@junghanacs/entwurf 0.12.10 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/AGENTS.md +2 -1
  2. package/BASELINE.md +45 -6
  3. package/CHANGELOG.md +16 -0
  4. package/CONTRIBUTING.md +4 -2
  5. package/DELIVERY.md +1 -1
  6. package/README.md +20 -5
  7. package/VERIFY.md +7 -4
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +148 -5
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -5
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
  13. package/package.json +4 -2
  14. package/pi/settings.reference.json +1 -1
  15. package/pi-extensions/acp-provider.ts +20 -10
  16. package/pi-extensions/lib/acp/augment.ts +60 -2
  17. package/pi-extensions/lib/acp/backend-adapter.ts +183 -8
  18. package/pi-extensions/lib/acp/backend.ts +5 -1
  19. package/pi-extensions/lib/acp/config.ts +19 -5
  20. package/pi-extensions/lib/acp/engraving.ts +3 -1
  21. package/pi-extensions/lib/acp/event-mapper.ts +10 -3
  22. package/pi-extensions/lib/acp/models.ts +69 -7
  23. package/pi-extensions/lib/acp/overlay.ts +234 -5
  24. package/pi-extensions/lib/acp/tool-surface.ts +12 -4
  25. package/run.sh +152 -21
  26. package/scripts/check-acp-cortex.ts +668 -0
  27. package/scripts/check-acp-provider-surface.ts +50 -6
  28. package/scripts/check-acp-session-reuse.ts +64 -1
  29. package/scripts/check-gate-qualification.ts +2 -0
  30. package/scripts/check-probe-cli-shim.ts +879 -0
  31. package/scripts/check-probe-ordering.ts +2450 -0
  32. package/scripts/check-shell-quote.ts +4 -4
  33. package/scripts/fixtures/probe-cli-shim +20 -0
  34. package/scripts/fixtures/probe-mcp-server.ts +168 -12
  35. package/scripts/lib/probe-acp-turn.ts +207 -0
  36. package/scripts/lib/probe-cli-shim.ts +464 -0
  37. package/scripts/lib/probe-cli-target.ts +165 -0
  38. package/scripts/lib/probe-event-log.ts +383 -0
  39. package/scripts/lib/probe-verdict.ts +1213 -0
  40. package/scripts/mutants/acp-cortex.json +196 -0
  41. package/scripts/mutants/probe-ordering.json +1032 -0
  42. package/scripts/smoke-acp-cortex-live.ts +392 -0
  43. package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
@@ -9,11 +9,15 @@
9
9
  // operator entries a backend needs (credentials, caches, built-in skills)
10
10
  // reachable through a TIGHT symlink whitelist — nothing else.
11
11
  //
12
- // Scope (NEXT §스코프 / §S2-scout 핀3): Claude-only on this lane. Claude is the one
13
- // shipped ACP backend, so this materializer is written for its config surface alone.
14
- // Codex/Gemini overlays (CODEX_HOME / admin.toml) are behavior-oracle territory and
15
- // out of scope here; a SECOND ACP backend enters only once the entry conditions in
16
- // docs/acp-backend-rail.md are met, and it brings its own overlay.
12
+ // Scope (NEXT §스코프 / §S2-scout 핀3): the block above describes the CLAUDE
13
+ // overlay `ensureClaudeConfigOverlay` and its symlink whitelist are written for
14
+ // claude-agent-acp's config surface alone. A second backend brings its OWN
15
+ // materializer, and cortex did: `ensureCortexDualHomeOverlay` (+ projectCortexMcpJson /
16
+ // sweepDeadCortexOverlays / cortexOverlayScopeId) lives further down this file with its
17
+ // own D-number rationale — an isolated HOME rather than a redirect knob, because cortex
18
+ // has no CLAUDE_CONFIG_DIR equivalent (§11-8 D2). The two share the module, never the
19
+ // strategy. Codex/Gemini overlays (CODEX_HOME / admin.toml) remain behavior-oracle
20
+ // territory and are out of scope here.
17
21
  //
18
22
  // Two deliberate divergences from the literal 0.11.0 illustrative comment block
19
23
  // (the 0.11.0 CODE already does both — only its top doc-comment drew projects/
@@ -28,6 +32,7 @@
28
32
  // answering — 0.11.0 LIVE probe). Keeping it `{}` inherits NO operator hook —
29
33
  // which is exactly the "mailbox absence by design" the plugin commits to
30
34
  // (no meta-bridge hook on this child's settings surface → no mailbox).
35
+ import { createHash } from "node:crypto";
31
36
  import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
32
37
  import { homedir } from "node:os";
33
38
  import { join } from "node:path";
@@ -194,3 +199,183 @@ export function ensureClaudeConfigOverlay(realDir = CLAUDE_REAL_CONFIG_DIR, over
194
199
  }
195
200
  }
196
201
  }
202
+ // ============================================================================
203
+ // Cortex dual-HOME overlay — the as-measured containment for `cortex acp serve`
204
+ // (CP0 audit 2026-07-29, Cortex Code v1.1.52; docs/acp-backend-rail.md §4/§11-8).
205
+ // ============================================================================
206
+ //
207
+ // Why this is NOT the claude-shaped `SNOWFLAKE_HOME`-only overlay PR #40 shipped
208
+ // (each point is a measured defect, not a preference — §11-8 D-numbers):
209
+ //
210
+ // - D2: cortex reads `CONFIG_DIRS = [".claude", ".cortex"]` at `homedir()` and
211
+ // `~/.claude/skills` — a SNOWFLAKE_HOME redirect cannot move that axis. An
212
+ // overlay session carrying only auth symlinks still advertised the
213
+ // operator's 42 `~/.claude/skills` entries. Cortex has no
214
+ // `CLAUDE_CONFIG_DIR`-equivalent redirect knob (string occurs 0 times in the
215
+ // binary), so the only containment that closes the leak is an ISOLATED HOME:
216
+ // measured global-scope skill count 0, hook trace 0, bundled/project surface
217
+ // intact. Scope note: `homedir()`-anchored *operator-global* state is what
218
+ // the isolation closes; explicit cwd PROJECT scope (`<cwd>/.claude/*`) is
219
+ // retained by contract — a sibling working in a repo sees that repo's
220
+ // declared project surface.
221
+ // - D3: `CORTEX_HOME` beats `SNOWFLAKE_HOME` in cortex's own resolver, so one
222
+ // ambient operator variable would silently bypass the whole overlay. The
223
+ // adapter REFUSES to spawn when `CORTEX_HOME` is present at all (empty
224
+ // string included) — same presence-refusal family as the ordering probe's
225
+ // `CLAUDE_CODE_EXECUTABLE` precondition (backend-adapter.ts).
226
+ // - D9: cortex ACP `newSession` reads only `cwd` and `_meta` — the wire
227
+ // `mcpServers` param the backend-invariant turn loop passes is IGNORED. The
228
+ // explicit `entwurfProvider.mcpServers` (envelope-enriched) are therefore
229
+ // PROJECTED into the overlay-private `$SNOWFLAKE_HOME/cortex/mcp.json` (the
230
+ // door `cortex mcp add` writes). Exact-author every spawn; an entry type the
231
+ // file cannot represent fails loud BEFORE spawn (no silent drop).
232
+ // - D10: an isolated HOME also cuts `~/.pi/agent` (garden store, sockets,
233
+ // spawn surface) off the bundled entwurf-bridge — tools reach the model but
234
+ // see an EMPTY garden. Dual-HOME closes it: the `entwurf-bridge` mcp.json
235
+ // entry ALONE gets `HOME=<real operator home>` restored; every other MCP
236
+ // child stays in the isolated home. The real home is captured by the parent
237
+ // as an absolute path BEFORE spawn — never re-derived inside the child.
238
+ // - D4: cortex self-updates on launch by default, and `acp serve` accepts no
239
+ // `--no-auto-update` (the global flag position boots a TUI with exit 0 —
240
+ // protocol corruption, not a server). The one remaining door is the overlay
241
+ // writing `"autoUpdate": false` into its own `cortex/settings.json`. That is
242
+ // a mid-turn self-replacement OFF switch, not a version pin.
243
+ // - D5/F: auth passthrough is the measured MINIMUM: `connections.toml`,
244
+ // `config.toml` (optional — absent on the measured host),
245
+ // `cortex/cache/credential_cache` (auth succeeded with exactly this set).
246
+ // The WHOLE `cortex/cache` leaks operator tool_outputs/tip history; operator
247
+ // `cortex/skills` and operator `cortex/mcp.json` are denied outright.
248
+ // Symlink-through only — entwurf never copies/parses/mediates the Snowflake
249
+ // credential (AGENTS §ACP Plugin Boundary, Hard Rule 9).
250
+ //
251
+ // The overlay is SESSION/CHILD-SCOPED, never a static shared dir: two residents
252
+ // with different envelopes/configs would race one mcp.json. Scope id =
253
+ // `<host pid>-<sha256(scopeKey) 12 hex>`; the scope dir is torn down and
254
+ // exact-rewritten on every spawn (the prior child for the key is already dead —
255
+ // backend.ts tears it down before a "new" decision spawns), which is also the
256
+ // memory containment: nothing cortex wrote into the isolated home survives into
257
+ // the next session. Scope dirs whose host pid is gone are swept opportunistically.
258
+ /** Root under which per-session cortex dual-HOME overlays are materialized. */
259
+ export const CORTEX_OVERLAYS_ROOT = join(homedir(), ".pi", "agent", "cortex-overlays");
260
+ /** The name of the ONE mcp.json entry whose child gets the real operator HOME
261
+ * restored (D10 dual-HOME). Everything else stays in the isolated home. */
262
+ export const CORTEX_DUAL_HOME_BRIDGE_SERVER = "entwurf-bridge";
263
+ /** Deterministic per-(host process, session key) overlay dir name. */
264
+ export function cortexOverlayScopeId(scopeKey, pid = process.pid) {
265
+ const digest = createHash("sha256").update(scopeKey).digest("hex").slice(0, 12);
266
+ return `${pid}-${digest}`;
267
+ }
268
+ /** Overlay-authored `$SNOWFLAKE_HOME/cortex/settings.json` — D4's one door. */
269
+ export function cortexOverlaySettingsJson() {
270
+ return `${JSON.stringify({ autoUpdate: false }, null, "\t")}\n`;
271
+ }
272
+ /**
273
+ * Project the (envelope-enriched) explicit server list into cortex's
274
+ * `$SNOWFLAKE_HOME/cortex/mcp.json` shape (D9). Only stdio entries are measured
275
+ * through this door — an http/sse entry fails loud BEFORE spawn rather than
276
+ * being silently dropped. The `entwurf-bridge` entry alone carries
277
+ * `HOME=<realHome>` (D10); a bridge-declared HOME env is overridden, not merged.
278
+ */
279
+ export function projectCortexMcpJson(servers, realHome) {
280
+ const out = {};
281
+ for (const server of servers) {
282
+ if ("type" in server && (server.type === "http" || server.type === "sse")) {
283
+ throw new Error(`entwurf: cortex mcp.json projection cannot represent ${server.type} server ` +
284
+ `${JSON.stringify(server.name)} — only stdio entries are measured through ` +
285
+ `$SNOWFLAKE_HOME/cortex/mcp.json (CP0 D9). Remove it from entwurfProvider.mcpServers ` +
286
+ `for cortex models or front it with a stdio bridge.`);
287
+ }
288
+ const stdio = server;
289
+ const env = {};
290
+ for (const kv of stdio.env)
291
+ env[kv.name] = kv.value;
292
+ if (stdio.name === CORTEX_DUAL_HOME_BRIDGE_SERVER)
293
+ env.HOME = realHome;
294
+ out[stdio.name] = { type: "stdio", command: stdio.command, args: [...stdio.args], env };
295
+ }
296
+ return `${JSON.stringify({ mcpServers: out }, null, "\t")}\n`;
297
+ }
298
+ function cortexPidAlive(pid) {
299
+ try {
300
+ process.kill(pid, 0);
301
+ return true;
302
+ }
303
+ catch (error) {
304
+ // EPERM = alive but not ours; anything else (ESRCH) = gone.
305
+ return error.code === "EPERM";
306
+ }
307
+ }
308
+ /**
309
+ * Opportunistic GC of overlay scope dirs whose HOST pid is gone (process
310
+ * resources only — the overlay holds no records/transcripts worth preserving;
311
+ * conversations state is contained BY DESIGN, Hard Rule 8 does not apply to it).
312
+ * Entries not matching the scope-id grammar are left alone.
313
+ */
314
+ export function sweepDeadCortexOverlays(overlaysRoot, isPidAlive = cortexPidAlive) {
315
+ let entries;
316
+ try {
317
+ entries = readdirSync(overlaysRoot);
318
+ }
319
+ catch {
320
+ return; // root absent — nothing to sweep
321
+ }
322
+ for (const entry of entries) {
323
+ const match = /^(\d+)-[0-9a-f]{12}$/.exec(entry);
324
+ if (!match)
325
+ continue;
326
+ const pid = Number(match[1]);
327
+ if (pid === process.pid)
328
+ continue;
329
+ if (isPidAlive(pid))
330
+ continue;
331
+ try {
332
+ rmSync(join(overlaysRoot, entry), { recursive: true, force: true });
333
+ }
334
+ catch {
335
+ // Best-effort GC; a stuck dir is retried on the next spawn.
336
+ }
337
+ }
338
+ }
339
+ /** Symlink `realPath` at `overlayPath` when the operator actually has it.
340
+ * The scope dir is freshly rebuilt by the caller, so no stale-link repair. */
341
+ function cortexLinkIfExists(realPath, overlayPath) {
342
+ if (!existsSync(realPath))
343
+ return;
344
+ try {
345
+ symlinkSync(realPath, overlayPath);
346
+ }
347
+ catch (error) {
348
+ console.error(`[entwurf:cortex-overlay] symlink failed for ${overlayPath}: ${error instanceof Error ? error.message : String(error)}`);
349
+ }
350
+ }
351
+ /**
352
+ * Materialize the session-scoped cortex dual-HOME overlay: tear down the scope
353
+ * dir and exact-rewrite it (auth symlinks + authored settings.json/mcp.json).
354
+ * Returns the isolated HOME/SNOWFLAKE_HOME the spawn env must carry.
355
+ */
356
+ export function ensureCortexDualHomeOverlay(params) {
357
+ if (!params.realHome || !params.realHome.startsWith("/")) {
358
+ throw new Error(`entwurf: cortex dual-HOME overlay requires an absolute realHome captured by the parent (got ${JSON.stringify(params.realHome)})`);
359
+ }
360
+ const overlaysRoot = params.overlaysRoot ?? CORTEX_OVERLAYS_ROOT;
361
+ const realSnowflake = params.realSnowflakeHome ?? join(params.realHome, ".snowflake");
362
+ sweepDeadCortexOverlays(overlaysRoot, params.isPidAlive ?? cortexPidAlive);
363
+ const scopeDir = join(overlaysRoot, cortexOverlayScopeId(params.scopeKey));
364
+ // Exact rewrite (never merge): the prior child for this scope key is already
365
+ // torn down (backend.ts closes it before a "new" decision spawns), so
366
+ // everything it wrote — conversations, logs, $HOME dotfiles — is discarded
367
+ // here. rmSync does not follow symlinks, so the real auth files are untouched.
368
+ rmSync(scopeDir, { recursive: true, force: true });
369
+ const home = join(scopeDir, "home");
370
+ const snowflakeHome = join(home, ".snowflake");
371
+ const cortexDir = join(snowflakeHome, "cortex");
372
+ mkdirSync(join(cortexDir, "cache"), { recursive: true });
373
+ // D5/F — measured-minimum auth passthrough (symlink-through only).
374
+ cortexLinkIfExists(join(realSnowflake, "connections.toml"), join(snowflakeHome, "connections.toml"));
375
+ cortexLinkIfExists(join(realSnowflake, "config.toml"), join(snowflakeHome, "config.toml"));
376
+ cortexLinkIfExists(join(realSnowflake, "cortex", "cache", "credential_cache"), join(cortexDir, "cache", "credential_cache"));
377
+ // D4 — runtime self-replacement off; D9 — explicit-server projection.
378
+ writeFileSync(join(cortexDir, "settings.json"), cortexOverlaySettingsJson(), "utf8");
379
+ writeFileSync(join(cortexDir, "mcp.json"), projectCortexMcpJson(params.mcpServers, params.realHome), "utf8");
380
+ return { home, snowflakeHome, scopeDir };
381
+ }
@@ -10,10 +10,18 @@
10
10
  // if pi has excluded a built-in the backend will still expose, the declared
11
11
  // surface diverges from the actual one — we fail fast rather than lie.
12
12
  //
13
- // Claude-only scope (NEXT §스코프). The function keeps a `backend` field so the
14
- // honest-divergence logic stays explicit and the matrix gate can exercise both
15
- // the "claude narrows via tools" and "native always exposes" branches, but the
16
- // only backend this lane supplies is claude.
13
+ // The module's TWO jobs have different backend scopes do not collapse them:
14
+ // - `buildClaudeSessionMeta` is claude-only by CONSTRUCTION, with exactly one
15
+ // caller (`claudeAdapter.buildSessionMeta`). Cortex exposes no
16
+ // `_meta.systemPrompt` and no tools/permission declaration surface (native
17
+ // tools + its own overlay mcp.json projection), so its adapter returns
18
+ // `undefined` and that function is never reached for a cortex turn.
19
+ // - `assertExcludeToolsHonored` is BACKEND-INVARIANT: backend.ts runs it for
20
+ // every adapter before any spawn, passing `backend: adapter.backend` and the
21
+ // resolved `config.tools`. A cortex turn DOES enter this module through it.
22
+ // That split is why the `backend` field exists: the honest-divergence logic stays
23
+ // explicit and the matrix gate can exercise both the "claude narrows via tools"
24
+ // and "native always exposes" branches.
17
25
  //
18
26
  // Carrier guard (NEXT §S2-scout 핀1): `buildClaudeSessionMeta` only attaches
19
27
  // `_meta.systemPrompt` when a caller passes one. The shipped S2d path passes the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.12.10",
3
+ "version": "0.13.0",
4
4
  "description": "Garden-citizen dispatch substrate and meta-bridge for Claude Code, Codex, Antigravity, and pi harnesses.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -121,6 +121,8 @@
121
121
  "check-acp-carrier-augment": "./run.sh check-acp-carrier-augment",
122
122
  "check-agy-permission-matrix": "./run.sh check-agy-permission-matrix",
123
123
  "check-gate-qualification": "./run.sh check-gate-qualification",
124
+ "check-probe-ordering": "./run.sh check-probe-ordering",
125
+ "check-probe-cli-shim": "./run.sh check-probe-cli-shim",
124
126
  "check-pack": "./run.sh check-pack",
125
127
  "check-pack-install": "./run.sh check-pack-install",
126
128
  "check-install-container": "./run.sh check-install-container",
@@ -130,7 +132,7 @@
130
132
  "format": "biome check --write .",
131
133
  "check-meta-manifest-schema": "./run.sh check-meta-manifest-schema",
132
134
  "check-package-source-routing": "./run.sh check-package-source-routing",
133
- "check": "pnpm lint && pnpm typecheck && ./run.sh check-shell-quote && ./run.sh check-install-surface && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-capability-bundle-reach && ./run.sh check-bridge-delivery && ./run.sh smoke-pi-attach && ./run.sh check-fresh-cut-gate && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-hook-launch-topology && ./run.sh check-meta-identity-consumers && ./run.sh check-meta-capability-source && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh smoke-meta-install-state && ./run.sh check-meta-doctor-oracle && ./run.sh smoke-agy-install-state && ./run.sh check-agy-permission-matrix && ./run.sh smoke-agy-statusline-state && ./run.sh smoke-agy-hooks-state && ./run.sh smoke-pi-provider-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-package-source-routing && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-install-preflight && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-lock && ./run.sh check-entwurf-v2-decider && ./run.sh check-entwurf-v2-matrix && ./run.sh check-entwurf-v2-release && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-send-fallback && ./run.sh check-entwurf-v2-mailbox && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-runner && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-boot && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-entwurf-v2-spawn && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-spawn-production && ./run.sh check-entwurf-facts && ./run.sh check-control-socket-path && ./run.sh check-socket-discovery && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-agy-sender-identity && ./run.sh check-auth-boundary && ./run.sh check-acp-provider-surface && ./run.sh check-acp-sdk-surface && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-acp-carrier-augment && ./run.sh check-gate-qualification && ./run.sh check-pack"
135
+ "check": "pnpm lint && pnpm typecheck && ./run.sh check-shell-quote && ./run.sh check-install-surface && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-capability-bundle-reach && ./run.sh check-bridge-delivery && ./run.sh smoke-pi-attach && ./run.sh check-fresh-cut-gate && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-hook-launch-topology && ./run.sh check-meta-identity-consumers && ./run.sh check-meta-capability-source && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh smoke-meta-install-state && ./run.sh check-meta-doctor-oracle && ./run.sh smoke-agy-install-state && ./run.sh check-agy-permission-matrix && ./run.sh smoke-agy-statusline-state && ./run.sh smoke-agy-hooks-state && ./run.sh smoke-pi-provider-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-package-source-routing && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-install-preflight && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-lock && ./run.sh check-entwurf-v2-decider && ./run.sh check-entwurf-v2-matrix && ./run.sh check-entwurf-v2-release && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-send-fallback && ./run.sh check-entwurf-v2-mailbox && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-runner && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-boot && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-entwurf-v2-spawn && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-spawn-production && ./run.sh check-entwurf-facts && ./run.sh check-control-socket-path && ./run.sh check-socket-discovery && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-agy-sender-identity && ./run.sh check-auth-boundary && ./run.sh check-acp-provider-surface && ./run.sh check-acp-sdk-surface && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-acp-carrier-augment && ./run.sh check-probe-ordering && ./run.sh check-probe-cli-shim && ./run.sh check-acp-cortex && ./run.sh check-gate-qualification && ./run.sh check-pack"
134
136
  },
135
137
  "pi": {
136
138
  "extensions": [
@@ -32,7 +32,7 @@
32
32
  "WebFetch",
33
33
  "WebSearch"
34
34
  ],
35
- "codexDisabledFeatures": ["image_generation", "tool_suggest", "tool_search", "multi_agent", "apps", "memories"],
35
+ "cortexConnection": "MY_SNOWFLAKE_CONNECTION",
36
36
  "mcpServers": {
37
37
  "entwurf-bridge": {
38
38
  "command": "/path/to/entwurf/mcp/entwurf-bridge/start.sh",
@@ -2,11 +2,19 @@
2
2
  //
3
3
  // This is the pi-extension entry point that registers `entwurf` as a pi
4
4
  // session provider/model. It is intentionally THIN: it stands up the provider
5
- // surface (curated Claude anchor + no-auth sentinel) and wires streamSimple to
6
- // the real ACP backend (lib/acp/backend.ts — spawn-per-turn claude-agent-acp).
7
- // It does NOT build a socket/peers/citizen protocol or touch the v2 core —
8
- // socket-citizenship is supplied by the host `--entwurf-control` pi session
9
- // (AGENTS §ACP Plugin Boundary).
5
+ // surface (every registered adapter's curated rows + the no-auth sentinel) and
6
+ // wires streamSimple to the real ACP backend (lib/acp/backend.ts — a
7
+ // spawn-per-turn ACP child, `claude-agent-acp` or `cortex acp serve` depending
8
+ // on which adapter the model id routes to). It does NOT build a
9
+ // socket/peers/citizen protocol or touch the v2 core — socket-citizenship is
10
+ // supplied by the host `--entwurf-control` pi session (AGENTS §ACP Plugin
11
+ // Boundary).
12
+ //
13
+ // The model set comes from `allCuratedModels()` — the union across the adapter
14
+ // registry (backend-adapter.ts), NOT a per-backend list spelled here. Adding a
15
+ // backend therefore never edits this file. check-acp-provider-surface pins that
16
+ // the compiled entry really registers the EXACT union
17
+ // ([QK:CORTEX-PROVIDER-SIX-ROW-SURFACE]).
10
18
  //
11
19
  // Fence: this entry rides the emit-capable root tsconfig (it is not in the root
12
20
  // `exclude` list); its lib modules are imported with `.js` suffixes (the root
@@ -49,14 +57,16 @@ export default function (pi: ExtensionAPI) {
49
57
  apiKey: ENTWURF_ACP_NO_AUTH_SENTINEL,
50
58
  api: "entwurf",
51
59
  models: allCuratedModels(),
52
- // S2c: real ACP backend. Spawn-per-turn claude-agent-acp drive + event
53
- // mapping (lib/acp/backend.ts). The S0 fail-loud stub is gone the
54
- // provider path is open. Backend auth still belongs to the operator's own
55
- // Claude CLI child (no-auth sentinel above); this plugin only orchestrates.
60
+ // S2c: real ACP backend. Spawn-per-turn ACP child + event mapping
61
+ // (lib/acp/backend.ts); the routed adapter decides WHICH child. The S0
62
+ // fail-loud stub is gone — the provider path is open. Backend auth still
63
+ // belongs to the operator's own backend CLI child (no-auth sentinel above);
64
+ // this plugin only orchestrates.
56
65
  streamSimple: streamShellAcp,
57
66
  });
58
67
 
59
- // Mark only AFTER a successful registration. If curatedClaudeModels() (a
68
+ // Mark only AFTER a successful registration. If allCuratedModels() (which
69
+ // fail-fasts on an unowned/duplicate curated id, and whose claude rows carry a
60
70
  // fail-loud anchor check) or registerProvider throws, the runtime is not left
61
71
  // poisoned with a "registered" marker — a retry can register cleanly.
62
72
  markRegisteredOnRuntime(pi);
@@ -30,14 +30,64 @@
30
30
 
31
31
  import { existsSync, readFileSync } from "node:fs";
32
32
  import { homedir } from "node:os";
33
- import { join } from "node:path";
33
+ import { join, resolve } from "node:path";
34
34
  import { ENTWURF_PROJECT_CONTEXT_OPEN_TAG } from "../../../protocol.js";
35
35
  import type { AcpTextBlock } from "./context.js";
36
36
 
37
37
  const MAX_AUGMENT_BYTES = 50 * 1024;
38
38
 
39
+ // Carrier-less backends expose no `_meta.systemPrompt` (docs/acp-backend-rail.md
40
+ // §9-4). claude folds the operator engraving into that carrier; a carrier-less
41
+ // backend (cortex, the first — precisely: SYSTEM-PROMPT-carrier-less, since
42
+ // cortex does read `_meta` for an unpromoted caller-session-id seam, §11-8)
43
+ // has nowhere to put it — so its operator engraving
44
+ // rides HERE, prepended as the LEADING section of the first-user augment. It
45
+ // travels on the WIRE only (new-only, never the config signature), so an
46
+ // engraving change applies to the NEXT new cortex session rather than
47
+ // invalidating a live reused one — the documented asymmetry vs claude, where the
48
+ // carrier feeds bridgeConfigSignature.
49
+ const CARRIER_LESS_BACKENDS: ReadonlySet<string> = new Set(["cortex"]);
50
+
51
+ // The env var an operator points at a personal engraving file. Shared surface
52
+ // with the claude carrier (engraving.ts) BY NAME, but read INLINE here (not
53
+ // imported): augment.ts is loaded raw by the strip-types carrier-augment gate,
54
+ // which cannot resolve a `.js` VALUE import of the sibling `.ts` engraving
55
+ // module (augment.ts only value-imports real `.js` files). So the carrier-less
56
+ // override read is self-contained. Unlike engraving.ts's shipped-default path,
57
+ // this ONLY ever reads the operator OVERRIDE file: the shipped claude default
58
+ // (a preset-replacement / auto-memory lever) is meaningless for a non-claude
59
+ // backend and is never injected into a carrier-less augment.
60
+ const ENGRAVING_OVERRIDE_ENV = "ENTWURF_ACP_ENGRAVING_PATH";
61
+
62
+ /**
63
+ * Render the operator engraving OVERRIDE for a carrier-less backend, or null
64
+ * when unset / empty / whitespace / unreadable. Pure w.r.t. (override file
65
+ * contents, backend, sorted mcpServerNames). Mirrors engraving.ts's `{{backend}}`
66
+ * / `{{mcp_servers}}` token substitution so the operator's file behaves the same
67
+ * on the augment as it would on the claude carrier.
68
+ */
69
+ function loadCarrierlessOperatorEngraving(backend: string, mcpServerNames: readonly string[]): string | null {
70
+ const envPath = process.env[ENGRAVING_OVERRIDE_ENV]?.trim();
71
+ if (!envPath) return null;
72
+ let source: string;
73
+ try {
74
+ source = readFileSync(resolve(envPath), "utf8");
75
+ } catch {
76
+ return null;
77
+ }
78
+ const names = [...mcpServerNames].sort();
79
+ const mcpList = names.length > 0 ? names.join(", ") : "(none registered)";
80
+ const rendered = source
81
+ .replace(/\{\{backend\}\}/g, backend)
82
+ .replace(/\{\{mcp_servers\}\}/g, mcpList)
83
+ .trim();
84
+ return rendered.length > 0 ? rendered : null;
85
+ }
86
+
39
87
  export interface PiContextAugmentParams {
40
- /** Claude-only this cut; kept as a field so the narrative names the backend. */
88
+ /** The routed backend ("claude" | "cortex"). Load-bearing, not decorative: the
89
+ * narrative names it AND `CARRIER_LESS_BACKENDS` keys off it to decide whether
90
+ * the operator engraving leads the augment. */
41
91
  backend: string;
42
92
  cwd: string;
43
93
  mcpServerNames: readonly string[];
@@ -58,6 +108,14 @@ export function buildPiContextAugment(params: PiContextAugmentParams): string {
58
108
 
59
109
  const sections: string[] = [];
60
110
 
111
+ // System-prompt-carrier-less backend (cortex): the operator engraving leads the augment,
112
+ // since there is no `_meta.systemPrompt` carrier to hold it. null when no
113
+ // override is configured — the shipped claude default is never injected here.
114
+ if (CARRIER_LESS_BACKENDS.has(params.backend)) {
115
+ const engraving = loadCarrierlessOperatorEngraving(params.backend, params.mcpServerNames);
116
+ if (engraving) sections.push(engraving);
117
+ }
118
+
61
119
  // Bridge identity + caller-side sibling stance. The closing line is a
62
120
  // caller-perspective statement: when THIS session throws an entwurf, the peer
63
121
  // on the other end is a sibling, not a worker.
@@ -19,15 +19,32 @@
19
19
 
20
20
  import { readFileSync } from "node:fs";
21
21
  import { createRequire } from "node:module";
22
+ import { homedir } from "node:os";
22
23
  import { dirname, join } from "node:path";
23
24
 
24
25
  import type { AcpConnectionLike } from "./acp-client.js";
25
- import type { ResolvedAcpConfig } from "./config.js";
26
+ import { enrichMcpServersWithEnvelope, type ResolvedAcpConfig } from "./config.js";
26
27
  import { loadEngraving } from "./engraving.js";
27
- import { curatedClaudeModels, SUPPORTED_ANTHROPIC_MODEL_IDS } from "./models.js";
28
- import { claudeLaunchEnvDefaults, ensureClaudeConfigOverlay } from "./overlay.js";
28
+ import {
29
+ CORTEX_MODEL_PREFIX,
30
+ curatedClaudeModels,
31
+ curatedCortexModels,
32
+ SUPPORTED_ANTHROPIC_MODEL_IDS,
33
+ SUPPORTED_CORTEX_MODEL_IDS,
34
+ } from "./models.js";
35
+ import { claudeLaunchEnvDefaults, ensureClaudeConfigOverlay, ensureCortexDualHomeOverlay } from "./overlay.js";
29
36
  import { buildClaudeSessionMeta } from "./tool-surface.js";
30
37
 
38
+ // POSIX-safe single-quote wrapper for shell arg interpolation. Byte-for-byte
39
+ // identical to the reference in entwurf-core.ts; PARITY-PINNED by
40
+ // scripts/check-shell-quote.ts (SOURCE_SITES). Used only by the cortex override
41
+ // path below, where operator-configured connection/model tokens are appended to
42
+ // an operator `bash -lc` string — quoting keeps a connection name with shell
43
+ // metacharacters from being reinterpreted by the shell.
44
+ function shellQuote(value: string): string {
45
+ return `'${value.replace(/'/g, `'\\''`)}'`;
46
+ }
47
+
31
48
  // ---------------------------------------------------------------------------
32
49
  // Shared shapes
33
50
  // ---------------------------------------------------------------------------
@@ -64,13 +81,19 @@ export interface AcpCarrierParams {
64
81
 
65
82
  /** ensureOverlay input — cwd + (native) model id + the resolved config. A backend
66
83
  * whose overlay/env depends on its OWN settings reads them off `config.adapterSettings`
67
- * here; backend.ts never inspects config. Same shape as AcpLaunchParams (overlay and
68
- * launch are distinct phases, so they keep distinct names). */
84
+ * here; backend.ts never inspects config. Same shape as AcpLaunchParams plus the
85
+ * session key (overlay and launch are distinct phases, so they keep distinct names). */
69
86
  export interface AcpOverlayParams {
70
87
  cwd: string;
71
88
  modelId: string;
72
89
  nativeModelId: string;
73
90
  config: ResolvedAcpConfig;
91
+ /** The AUTHORITATIVE per-session key backend.ts already computed
92
+ * (resolveSessionKey: opts.sessionId → PI_SESSION_ID → cwd). A session-scoped
93
+ * overlay MUST scope on this value, never on an ambient re-derivation — the
94
+ * re-derived form drops `opts.sessionId`, so two sessions in one process/cwd
95
+ * would alias one overlay (GPT review 2026-07-29, cortex P0-1). */
96
+ sessionKey: string;
74
97
  }
75
98
 
76
99
  /** buildSessionMeta input — mirrors the newSession `_meta` inputs. */
@@ -270,9 +293,161 @@ export const claudeAdapter: AcpBackendAdapter = {
270
293
  // ---------------------------------------------------------------------------
271
294
 
272
295
  /** Registered adapters. Order carries NO routing authority — routeModel decides.
273
- * Step A: claude only. A second backend appends here with its reserved prefix
274
- * (e.g. `<backend>-*`), and the fail-fast below proves no two adapters claim one id. */
275
- const ADAPTERS: readonly AcpBackendAdapter[] = [claudeAdapter];
296
+ * claude (unprefixed ids) + cortex (the `cortex-` prefix). A further backend
297
+ * appends here with its own reserved prefix; the fail-fast below proves no two
298
+ * adapters claim one id. */
299
+ // ---------------------------------------------------------------------------
300
+ // cortex adapter — Snowflake Cortex Code, the first non-claude backend on the
301
+ // rail (docs/acp-backend-rail.md §4/§6). It adds ZERO to the common layer:
302
+ // everything cortex-specific lives here + models.ts + overlay.ts + the gates.
303
+ // ---------------------------------------------------------------------------
304
+
305
+ const SUPPORTED_CORTEX_IDS: ReadonlySet<string> = new Set(SUPPORTED_CORTEX_MODEL_IDS);
306
+
307
+ /** Cortex's OWN settings (§10 B): a Snowflake connection name, or null. Opaque to
308
+ * config.ts / backend.ts — only cortexAdapter reads it (casting back). */
309
+ export interface CortexAdapterSettings {
310
+ cortexConnection: string | null;
311
+ }
312
+
313
+ /** The env var an operator sets to pin a Snowflake connection per-shell without
314
+ * editing settings.json. Wins over `entwurfProvider.cortexConnection`. The
315
+ * ENTWURF_ACP_* convention (PR #40's legacy PI_SHELL_ACP* cortex-connection var renamed). */
316
+ export const CORTEX_CONNECTION_ENV = "ENTWURF_ACP_CORTEX_CONNECTION";
317
+
318
+ export const cortexAdapter: AcpBackendAdapter = {
319
+ backend: "cortex",
320
+
321
+ // Cortex owns the reserved `cortex-` prefix (§9-1). routeModel strips it to the
322
+ // native id: `cortex-auto` → "auto", `cortex-claude-sonnet-5` → "claude-sonnet-5".
323
+ routeModel(modelId) {
324
+ if (!SUPPORTED_CORTEX_IDS.has(modelId)) return undefined;
325
+ return { nativeModelId: modelId.slice(CORTEX_MODEL_PREFIX.length) };
326
+ },
327
+
328
+ curatedModels() {
329
+ return curatedCortexModels();
330
+ },
331
+
332
+ // Cortex's ONLY own setting is the connection name. env override wins over
333
+ // settings (per-shell pin); empty/whitespace → null (Cortex falls back to its
334
+ // own default connection). A non-string settings value fails loud.
335
+ resolveAdapterSettings({ mergedBlock, projectBlock, globalPath, projectPath }): CortexAdapterSettings {
336
+ const raw = mergedBlock.cortexConnection;
337
+ if (raw !== undefined && typeof raw !== "string") {
338
+ const offending = projectBlock.cortexConnection !== undefined ? projectPath : globalPath;
339
+ throw new Error(`${offending}: invalid entwurfProvider settings: cortexConnection must be a string`);
340
+ }
341
+ const envConn = process.env[CORTEX_CONNECTION_ENV]?.trim();
342
+ const settingsConn = raw?.trim();
343
+ const cortexConnection = envConn || settingsConn || null;
344
+ return { cortexConnection };
345
+ },
346
+
347
+ // `cortex acp serve` resolved from PATH (the CLI itself IS the ACP server — no
348
+ // `*-acp` npm package, unlike claude). `-c <conn>` appended when a connection
349
+ // is pinned. NO `-m`: the model is enforced per-turn via
350
+ // session/set_config_option (enforceModel below, CP0-M measured GO) and a
351
+ // launch pin would be a SECOND model authority that drifts from it.
352
+ // CORTEX_ACP_COMMAND override runs via `bash -lc` with the selection flags
353
+ // appended so the bridge's choice wins (later yargs args override earlier ones).
354
+ resolveLaunch({ config }) {
355
+ const settings = config.adapterSettings as CortexAdapterSettings | undefined;
356
+ const connection = settings?.cortexConnection?.trim() || undefined;
357
+ const selectionArgs: string[] = [];
358
+ if (connection) selectionArgs.push("-c", connection);
359
+ const override = process.env.CORTEX_ACP_COMMAND?.trim();
360
+ if (override) {
361
+ const command = selectionArgs.length > 0 ? `${override} ${selectionArgs.map(shellQuote).join(" ")}` : override;
362
+ return { command: "bash", args: ["-lc", command] };
363
+ }
364
+ return { command: "cortex", args: ["acp", "serve", ...selectionArgs] };
365
+ },
366
+
367
+ // The overlay location is SESSION-SCOPED (never static), so the spawn env
368
+ // rides ensureOverlay(...).envOverrides; there is no static launch env. The
369
+ // v1.1.8-era CORTEX_DISABLE_AUTO_APPLY_PROFILES knob was retired with the
370
+ // dual-HOME redesign: profiles now live inside the overlay-owned isolated
371
+ // home (empty by construction), and the knob is unmeasured on v1.1.52.
372
+ launchEnvDefaults() {
373
+ return {};
374
+ },
375
+
376
+ // Dual-HOME containment (CP0 D2/D3/D9/D10 — see the overlay module header):
377
+ // refuse an ambient CORTEX_HOME outright, then materialize the session-scoped
378
+ // isolated HOME with auth symlinks, `autoUpdate:false`, and the mcp.json
379
+ // projection of the envelope-enriched explicit servers (cortex ignores the
380
+ // wire mcpServers param, so this file IS how tools reach a cortex session).
381
+ ensureOverlay({ modelId, config, sessionKey }) {
382
+ // D3 — presence refusal, empty string included: upstream's resolver treats
383
+ // a set-but-empty CORTEX_HOME differently from unset, and one ambient value
384
+ // would silently bypass SNOWFLAKE_HOME (the probe's CLAUDE_CODE_EXECUTABLE
385
+ // precondition is the same family). Refuse the ambiguity; never pick a side.
386
+ if ("CORTEX_HOME" in process.env) {
387
+ throw new Error(
388
+ "entwurf: CORTEX_HOME is present in the environment (empty string included) — it overrides " +
389
+ "SNOWFLAKE_HOME inside cortex and would bypass the dual-HOME overlay entirely (CP0 D3). " +
390
+ "Unset it to run a cortex ACP turn.",
391
+ );
392
+ }
393
+ // The scope authority is the AUTHORITATIVE params.sessionKey backend.ts
394
+ // computed — never an ambient re-derivation, which would drop opts.sessionId
395
+ // and alias two same-process/cwd sessions onto one overlay (P0-1). The
396
+ // envelope below still reads PI_SESSION_ID: that is the identity CARRIER for
397
+ // the bridge child (the same source the turn loop's wire enrichment uses),
398
+ // a different axis from overlay-dir scoping.
399
+ const piSessionId = process.env.PI_SESSION_ID?.trim() || undefined;
400
+ const enriched = enrichMcpServersWithEnvelope(config.mcpServers, { modelId, piSessionId });
401
+ const overlay = ensureCortexDualHomeOverlay({
402
+ scopeKey: sessionKey,
403
+ mcpServers: enriched,
404
+ realHome: homedir(),
405
+ });
406
+ return { envOverrides: { HOME: overlay.home, SNOWFLAKE_HOME: overlay.snowflakeHome } };
407
+ },
408
+
409
+ // System-prompt-carrier-less (§9-4/§11-8): Cortex ACP exposes no
410
+ // `_meta.systemPrompt` and has no developer_instructions / GEMINI_SYSTEM_MD
411
+ // equivalent. (It does READ `_meta` — a caller-session-id seam, measured but
412
+ // unexplored and deliberately not part of this contract.) loadCarrier returns
413
+ // null WITHOUT calling loadEngraving, so the cortex turn never touches the
414
+ // shipped-engraving / appendSystemPrompt signature; buildSessionMeta returns
415
+ // undefined so backend.ts omits the `_meta` key entirely. The operator
416
+ // engraving instead rides the first-user augment (augment.ts).
417
+ loadCarrier() {
418
+ return null;
419
+ },
420
+
421
+ buildSessionMeta() {
422
+ return undefined;
423
+ },
424
+
425
+ // Per-turn enforcement via session/set_config_option — the SAME wire call the
426
+ // claude adapter makes, measured live against cortex v1.1.52 (CP0-M): the
427
+ // option id is "model", accepted values are the NATIVE ids (`auto`,
428
+ // `claude-sonnet-5`, `openai-gpt-5.4`, …), and a value cortex no longer
429
+ // serves fails loud BEFORE the prompt (`Unsupported model: …`). PR #40's
430
+ // launch-time `-m` pin was retired for this: set-model is the single model
431
+ // authority (resolveLaunch never passes `-m`), and "auto" is set explicitly
432
+ // rather than treated as an unspoken default.
433
+ async enforceModel({ connection, acpSessionId, nativeModelId, modelId }) {
434
+ const setConfig = connection.setSessionConfigOption;
435
+ if (typeof setConfig !== "function") {
436
+ throw new Error(`setSessionConfigOption unsupported — cannot enforce model ${modelId}`);
437
+ }
438
+ await setConfig.call(connection, { sessionId: acpSessionId, configId: "model", value: nativeModelId });
439
+ },
440
+
441
+ // A connection change must invalidate a reused session (§4/§7). Flat,
442
+ // sorted-stable primitive map; reads ONLY the opaque adapterSettings. `backend`
443
+ // + `nativeModelId` are added by backend.ts.
444
+ configSignatureFields(adapterSettings) {
445
+ const settings = adapterSettings as CortexAdapterSettings | undefined;
446
+ return { cortexConnection: settings?.cortexConnection ?? null };
447
+ },
448
+ };
449
+
450
+ const ADAPTERS: readonly AcpBackendAdapter[] = [claudeAdapter, cortexAdapter];
276
451
 
277
452
  /**
278
453
  * Resolve the backend adapter that owns `modelId`.