Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
b58a138
feat(plugin): add `codework plugin add|remove` and declared plugin or…
sanchitrk Sep 17, 2026
62401fd
Merge branch 'dev' into feat-plugin-install
sanchitrk Sep 18, 2026
4585c70
feat(harness): discover settings from the session's host directory, n…
sanchitrk Sep 19, 2026
8cd0de7
feat(harness): install plugins through npm's own libraries instead of…
sanchitrk Sep 19, 2026
2072f45
feat(harness): give plugins a content-addressed store with immutable …
sanchitrk Sep 19, 2026
626f732
feat(harness): split plugin loading from plugin configuration
sanchitrk Sep 19, 2026
7cbb91d
feat(harness): let a session follow the store at each exchange boundary
sanchitrk Sep 19, 2026
d0dbf59
feat(cli): add plugin install, list, check and update
sanchitrk Sep 19, 2026
21bf41f
feat(harness): re-import plugins on demand with plugin reload
sanchitrk Sep 19, 2026
630ce95
feat(harness): keep the file that declared each plugin entry
sanchitrk Sep 19, 2026
e3b71ed
fix(harness): never install at boot, and name the file behind a plugi…
sanchitrk Sep 19, 2026
75c7985
feat(cli): link a session to the host directory its settings come from
sanchitrk Sep 19, 2026
a996367
feat(cli): add plugins to the project a session is linked to
sanchitrk Sep 19, 2026
377885c
fix(harness): read the .npmrc chain where the person is, not where th…
sanchitrk Sep 20, 2026
855bb43
test(harness): install against a registry and a git remote on this ma…
sanchitrk Sep 20, 2026
6a3d7c2
fix(cli): anchor a plugin reference to the session's project, not the…
sanchitrk Sep 20, 2026
a0384e0
fix(harness): refuse a relative directory where a plugin reference or…
sanchitrk Sep 20, 2026
e9e7172
fix(harness): refuse a relative host directory instead of resolving i…
sanchitrk Sep 20, 2026
f678a99
fix: make plugin manager updates atomic
sanchitrk Sep 20, 2026
33f135a
fix: address plugin manager review feedback
sanchitrk Sep 20, 2026
2524618
fix(harness): address plugin manager review issues
sanchitrk Sep 20, 2026
8c64e31
fix(harness): resolve plugin store entries under the registry their d…
sanchitrk Sep 20, 2026
e886eaa
fix(harness): drop retained plugin origins that no longer resolve
sanchitrk Sep 20, 2026
c949919
feat(harness): publish plugin.updated lifecycle events
sanchitrk Sep 20, 2026
e345ead
feat(harness): use session ID when available for plugin event updated
sanchitrk Sep 20, 2026
b8e8b07
tweaks
sanchitrk Sep 22, 2026
f530665
fix(harness): scope settings discovery to sessions; boot reads the us…
sanchitrk Sep 22, 2026
f1583cc
test: improve test coverage
sanchitrk Sep 22, 2026
52124ca
fix(harness): simplify absolute path check - regex checks are complic…
sanchitrk Sep 22, 2026
e3c8f2a
tweaks
sanchitrk Sep 22, 2026
9160d66
feat(plugin): add @codeworksh/plugin, the third-party plugin SDK
sanchitrk Sep 22, 2026
b74afbd
fix(harness): bind the sandbox barrel by name so the built drivers work
sanchitrk Sep 22, 2026
2a2917e
fix(harness): share effect and the plugin SDK with an installed plugin
sanchitrk Sep 22, 2026
294db88
fix(harness): call pacote through its default export so check and upd…
sanchitrk Sep 22, 2026
746500d
feat(harness): report a plugin that ships typescript as plugin-not-co…
sanchitrk Sep 22, 2026
7f970bf
feat(cli): add plugin check --refresh
sanchitrk Sep 22, 2026
d58036a
fix(scripts): drop the development condition from published manifests
sanchitrk Sep 22, 2026
ed63187
test(harness): cover plugins installed from git, end to end
sanchitrk Sep 22, 2026
69ca318
fix(cli): describe hostDir as an anchor, not as where settings come from
sanchitrk Sep 22, 2026
548ddea
fix(cli): name .codework in the link help so the directory is recogni…
sanchitrk Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(harness): scope settings discovery to sessions; boot reads the us…
…er layer only

`Settings.load`'s `hostDir` is a session property by contract, but boot and
`reload` passed the process's own `hostCwd` to it. That made the process
behave like a session anchored at its launch directory: the project-layer
discovery walk climbs ancestors, so a `.codework/` found anywhere above the
launch directory silently configured the process -- a server started in
/var/usr could pick up a stranger's project settings.

- Boot and `rebuild` now load the user layer and the explicit override
  only; project plugins still arrive at a session's first exchange through
  `follow`'s union, and survive reloads through retained origins.
- `--user-config-dir` is pre-resolved at the harness boundary: `~` expands
  to home and a relative spelling resolves against `hostCwd`, so a session
  `hostDir` can never move the override's anchor.
- `hostCwd` doc corrected: it is the process-level directory (sandbox
  default, `.npmrc`/parse fallback, override base), not a project-settings
  root.
- The resolve-only boot invariant moves to the user layer: an
  unresolvable user-layer entry still fails startup rather than fetching;
  a project-layer entry is now its session's drift report, not a
  process-level failure.
- Tests pin a launch inside a project's subtree never importing its
  plugin (import side-effect sentinel), project entries surfacing as
  session drift, and relative `--user-config-dir` resolving against
  `hostCwd` with a different session `hostDir`.
  • Loading branch information
sanchitrk committed Sep 22, 2026
commit f53066598931936d0284502b40887d26e198f111
39 changes: 25 additions & 14 deletions packages/harness/src/effect/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Control } from "../control.ts";
import { Database } from "../db/db.ts";
import { Event } from "../event/event.ts";
import { Global } from "../global.ts";
import { fileSystem } from "../host.ts";
import { fileSystem, hostPath } from "../host.ts";
import { EventRegistry } from "../event/registry.ts";
import { follow, load, type Origin, type PluginRef, type Pool } from "../plugin/catalog.ts";
import { anchor } from "../plugin/loader.ts";
Expand All @@ -25,6 +25,7 @@ import { SessionRuntime } from "../session/runtime.ts";
import type { Info as SettingsInfo } from "../settings/schema.ts";
import { Settings } from "../settings/settings.ts";
import { State } from "../state/state.ts";
import { expandTilde } from "../util/home.ts";

export interface Options {
/**
Expand All @@ -35,9 +36,10 @@ export interface Options {
readonly plugins?: ReadonlyArray<PluginRef>;
readonly database?: string;
/**
* The host directory the project settings layer is discovered from. Defaults to the OS
* process's directory; a test or an embedder that runs somewhere other than where it wants
* settings read from passes its own, rather than inheriting whatever launched the process.
* The process-level host directory: the default sandbox mount, the `.npmrc`/parse fallback
* for plugin references no file declared, and the base a relative `--user-config-dir`
* resolves against. Defaults to the OS process's directory. It is never a project-settings
* root -- that discovery belongs to a session's `hostDir`.
*/
readonly hostCwd?: string;
readonly home?: string;
Expand All @@ -56,15 +58,23 @@ export const layer = (options: Options = {}) =>
// can quietly fall back to the OS process's directory when it meant a session's mount.
const hostCwd = options.hostCwd ?? process.cwd();
const global = Global.layerWith(paths);
const settingsOptions = options.userConfigDir === undefined ? {} : { userConfigDir: options.userConfigDir };
// `--user-config-dir` is a process-level flag: `~` expands to the user's home, and a
// relative spelling resolves against the process's own directory. A session's
// `hostDir` has no claim on it.
const settingsOptions =
options.userConfigDir === undefined
? {}
: { userConfigDir: hostPath.resolve(hostCwd, expandTilde(options.userConfigDir, hostPath)) };
// Settings are read once here because the plugin selection has to be prepared before
// any layer that depends on it; every later read goes through `Settings.Service`,
// which discovers from the session's own `hostDir` rather than from this one.
// any layer that depends on it; every later read goes through `Settings.Service`.
//
// This read happens before any session exists, so the process's directory is the only
// root available -- and the right one: it is what decides which plugins this process
// loads at all. Which of them a given session *runs* is the per-session question.
const config = yield* Settings.load({ ...settingsOptions, home: paths.home, hostDir: hostCwd });
// No `hostDir` is passed: that parameter is a property of a session, and a process
// has none. Boot reads the user layer and the explicit override only -- a server can
// start anywhere, so it must never walk ancestors of the directory it happened to
// launch in for a `.codework/`, which would hand it a stranger's project settings.
// A project's plugins still load: lazily, at the session's first exchange, through
// `follow`.
const config = yield* Settings.load({ ...settingsOptions, home: paths.home });
/*
* Which entries this process configures from, per exchange.
*
Expand Down Expand Up @@ -148,9 +158,10 @@ export const layer = (options: Options = {}) =>
const rebuild = () =>
Effect.gen(function* () {
reloads += 1;
// Re-read from the same root this process booted with, so the rebuilt pool
// holds what the old one did plus whatever was added since.
const current = yield* Settings.load({ ...settingsOptions, home: paths.home, hostDir: hostCwd });
// Re-read the process's layers -- the user layer and the explicit override, as
// at boot. Project layers belong to sessions and come back through the
// retained origins below, never through a process-level discovery walk.
const current = yield* Settings.load({ ...settingsOptions, home: paths.home });
const loaded = yield* Ref.get(pool);
// Reload is process-wide. Include modules discovered lazily from every linked
// session, not only the project the server happened to start in.
Expand Down
180 changes: 167 additions & 13 deletions packages/harness/test/plugin.passes.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import "./utils/env.ts";
import { Effect, Option } from "effect";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vite-plus/test";
Expand Down Expand Up @@ -465,10 +465,12 @@ describe("a running session and the store", () => {
});

describe("boot", () => {
it("reports a configured plugin whose bytes are missing, rather than fetching it", () =>
it("fails on a user-layer plugin whose bytes are missing, rather than fetching it", () =>
withProject(async ({ root, project }) => {
// The user layer is the process's own selection: an entry it cannot resolve fails the
// boot, never a fetch.
await writeFile(
join(project, ".codework", "settings.jsonc"),
join(root, "home", "settings.jsonc"),
JSON.stringify({ plugins: ["@acme/never-published-anywhere"] }),
);

Expand All @@ -489,6 +491,155 @@ describe("boot", () => {
// that puts bytes on disk, and this is the error that names it.
expect(failure).toMatchObject({ _tag: "PluginStoreError", reason: "plugin-not-installed" });
}));

it("leaves a project-layer entry to its session -- boot does not see it", () =>
withProject(async ({ root, project }) => {
await writeFile(
join(project, ".codework", "settings.jsonc"),
JSON.stringify({ plugins: ["@acme/never-published-anywhere"] }),
);

// The process starts in the project but reads no project layer: boot succeeds, and the
// entry is the session's drift report -- a warning at its exchange, not a startup
// failure and never a fetch.
const prompts: string[] = [];
const open = immediateOpen();
await Effect.runPromise(
Effect.gen(function* () {
const session = yield* Session.create({ directory: project, hostDir: project });
yield* session.prompt({ text: "go", delivery: "followUp" });
yield* session.resume();
yield* session.wait();
}).pipe(
Effect.provide(
Harness.layer({
home: join(root, "home"),
hostCwd: project,
database: ":memory:",
llm: (request, signal) => {
prompts.push(request.context.systemPrompt ?? "");
return open(request, signal);
},
}),
),
Effect.scoped,
Effect.timeout("20 seconds"),
Effect.orDie,
),
);
expect(prompts).toHaveLength(1);
}));

it("does not read a `.codework` found above the directory the process started in", () =>
withProject(async ({ root, project }) => {
// `project` holds the marker; the process launches from deep inside it, the way an
// installed binary in /var/usr would. Its ancestors must not configure the process.
const bin = join(project, "var", "usr", "bin");
await mkdir(bin, { recursive: true });
const sentinel = join(root, "imported.sentinel");
const module = join(project, "marker.mjs");
// A prompt marker alone would not prove the point: a boot that loaded the module into
// the process pool would still leave it out of this session's selection. The import
// side effect is the observation that matters -- if boot ever evaluates the module,
// the sentinel exists.
await writeFile(
module,
[
`import { writeFileSync } from "node:fs";`,
`writeFileSync(${JSON.stringify(sentinel)}, "loaded");`,
"export default {",
" id: 'acme.prompt.marker',",
" kind: 'prompt',",
" setup: (ctx) => ctx.plugin.prompt.set(`${ctx.plugin.prompt.get() ?? ''}marker`),",
"};",
].join("\n"),
);
await writeFile(join(project, ".codework", "settings.jsonc"), JSON.stringify({ plugins: [module] }));

// A session in a plain directory gets only the user layer: nothing of the startup
// directory's project leaks into either the boot pool or the exchange.
const plain = join(root, "plain");
await mkdir(plain);
const prompts: string[] = [];
const open = immediateOpen();
await Effect.runPromise(
Effect.gen(function* () {
const session = yield* Session.create({ directory: plain, hostDir: plain });
yield* session.prompt({ text: "go", delivery: "followUp" });
yield* session.resume();
yield* session.wait();
}).pipe(
Effect.provide(
Harness.layer({
home: join(root, "home"),
hostCwd: bin,
database: ":memory:",
llm: (request, signal) => {
prompts.push(request.context.systemPrompt ?? "");
return open(request, signal);
},
}),
),
Effect.scoped,
Effect.timeout("20 seconds"),
Effect.orDie,
),
);
expect(prompts).toHaveLength(1);
expect(prompts[0]).not.toContain("marker");
await expect(access(sentinel)).rejects.toThrow();
}));

it("resolves a relative --user-config-dir against the process directory, not the session's", () =>
withProject(async ({ root, project }) => {
// The process launches from `launch`; the override is `<launch>/myconf`. The session
// anchors at `project`, a different directory entirely -- if the flag resolved
// against the session's host, `project/myconf` would be read instead and the plugin
// would never appear.
const launch = join(root, "launch");
const custom = join(launch, "myconf");
await mkdir(custom, { recursive: true });
const module = join(root, "confplugin.mjs");
await writeFile(
module,
[
"export default {",
" id: 'acme.prompt.conf',",
" kind: 'prompt',",
" setup: (ctx) => ctx.plugin.prompt.set(`${ctx.plugin.prompt.get() ?? ''}conf`),",
"};",
].join("\n"),
);
await writeFile(join(custom, "settings.jsonc"), JSON.stringify({ plugins: [module] }));

const prompts: string[] = [];
const open = immediateOpen();
await Effect.runPromise(
Effect.gen(function* () {
const session = yield* Session.create({ directory: project, hostDir: project });
yield* session.prompt({ text: "go", delivery: "followUp" });
yield* session.resume();
yield* session.wait();
}).pipe(
Effect.provide(
Harness.layer({
home: join(root, "home"),
hostCwd: launch,
userConfigDir: "myconf",
database: ":memory:",
llm: (request, signal) => {
prompts.push(request.context.systemPrompt ?? "");
return open(request, signal);
},
}),
),
Effect.scoped,
Effect.timeout("20 seconds"),
Effect.orDie,
),
);
expect(prompts[0]?.endsWith("conf")).toBe(true);
}));
});

describe("reload", () => {
Expand Down Expand Up @@ -698,23 +849,25 @@ describe("reload", () => {
: Effect.void,
);

// The boot load ran before `listen` attached, so the first notice is the reload's
// re-import -- a changed module instance is what `loaded` reports.
yield* state.reload;
// A project plugin is not a boot plugin: it loads at the session's first
// exchange, through `follow`'s union -- that is the `loaded` notice.
yield* run();
// The plugin leaves settings and its file is gone: the next rebuild drops the
// retained origin rather than failing on it.
yield* Effect.promise(() => writeFile(settings, JSON.stringify({ plugins: [] })));
yield* Effect.promise(() => rm(file));
yield* state.reload;
// Back in settings, but the module cannot be imported now.
yield* Effect.promise(() => writeFile(settings, JSON.stringify({ plugins: [file] })));
yield* Effect.promise(() => writeFile(file, "export default { nope: true };"));
// A process-level failure names no session: the broken entry sits in the user
// layer, which is the only layer `reload` reads.
const broken = join(project, "broken.mjs");
yield* Effect.promise(() =>
writeFile(join(root, "home", "settings.jsonc"), JSON.stringify({ plugins: [broken] })),
);
yield* Effect.promise(() => writeFile(broken, "export default { nope: true };"));
yield* state.reload;
// An exchange failure on a path the module registry has never seen names
// the session that ran it; a `reload` failure belongs to no session.
const broken = join(project, "broken.mjs");
// the session that ran it.
yield* Effect.promise(() => writeFile(settings, JSON.stringify({ plugins: [broken] })));
yield* Effect.promise(() => writeFile(broken, "export default { nope: true };"));
yield* run().pipe(Effect.ignore);

expect(seen.map((event) => event.status)).toEqual(["loaded", "dropped", "failed", "failed"]);
Expand All @@ -724,7 +877,8 @@ describe("reload", () => {
expect(seen[3]?.sessionId).toBeDefined();
}),
});
expect(prompts.length).toBe(0);
// The first exchange's lazy load ran the plugin: its marker made the prompt.
expect(prompts[0]?.endsWith("before")).toBe(true);
}));
});

Expand Down