@crouton-kit/crouter 0.3.61 → 0.3.63

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 (114) hide show
  1. package/dist/builtin-views/canvas/core.mjs +2 -1
  2. package/dist/clients/attach/__tests__/context-message.test.d.ts +1 -0
  3. package/dist/clients/attach/__tests__/context-message.test.js +46 -0
  4. package/dist/clients/attach/__tests__/transport-relay.test.d.ts +1 -0
  5. package/dist/clients/attach/__tests__/transport-relay.test.js +176 -0
  6. package/dist/clients/attach/attach-cmd.js +747 -743
  7. package/dist/clients/attach/canvas-panels.d.ts +4 -4
  8. package/dist/clients/attach/canvas-panels.js +108 -37
  9. package/dist/clients/attach/chat-view.js +11 -0
  10. package/dist/clients/attach/context-message.d.ts +3 -1
  11. package/dist/clients/attach/context-message.js +30 -21
  12. package/dist/clients/attach/graph-overlay.d.ts +8 -1
  13. package/dist/clients/attach/graph-overlay.js +201 -22
  14. package/dist/clients/attach/transport-relay.d.ts +51 -0
  15. package/dist/clients/attach/transport-relay.js +196 -0
  16. package/dist/clients/attach/transport-socket.d.ts +29 -0
  17. package/dist/clients/attach/transport-socket.js +164 -0
  18. package/dist/clients/attach/transport.d.ts +32 -0
  19. package/dist/clients/attach/transport.js +26 -0
  20. package/dist/clients/attach/view-socket.d.ts +40 -44
  21. package/dist/clients/attach/view-socket.js +94 -197
  22. package/dist/commands/memory/delete.js +3 -3
  23. package/dist/commands/memory/origin.js +1 -1
  24. package/dist/commands/memory/read.js +3 -3
  25. package/dist/commands/memory/shared.d.ts +8 -6
  26. package/dist/commands/memory/shared.js +23 -9
  27. package/dist/commands/memory/write.js +3 -3
  28. package/dist/commands/node-worktree.js +9 -9
  29. package/dist/commands/node.js +71 -23
  30. package/dist/commands/sys/doctor.js +1 -0
  31. package/dist/core/__tests__/broker-fork-seam.test.js +17 -2
  32. package/dist/core/__tests__/broker-snapshot-history.test.js +12 -2
  33. package/dist/core/__tests__/canvas-inbox-watcher.test.js +79 -0
  34. package/dist/core/__tests__/context-intro.test.js +11 -0
  35. package/dist/core/__tests__/fixtures/fake-engine.d.ts +9 -4
  36. package/dist/core/__tests__/fixtures/fake-engine.js +12 -4
  37. package/dist/core/__tests__/full/broker-crash-teardown.test.js +17 -3
  38. package/dist/core/__tests__/kickoff.test.js +21 -0
  39. package/dist/core/__tests__/spawn-root.test.js +60 -0
  40. package/dist/core/__tests__/worktree.test.js +24 -1
  41. package/dist/core/canvas/browse/app.d.ts +2 -0
  42. package/dist/core/canvas/browse/app.js +83 -48
  43. package/dist/core/canvas/db.js +3 -2
  44. package/dist/core/canvas/index.d.ts +1 -0
  45. package/dist/core/canvas/index.js +1 -0
  46. package/dist/core/canvas/nav-model.d.ts +5 -3
  47. package/dist/core/canvas/nav-model.js +7 -6
  48. package/dist/core/canvas/paths.d.ts +8 -0
  49. package/dist/core/canvas/paths.js +10 -0
  50. package/dist/core/canvas/render.d.ts +6 -0
  51. package/dist/core/canvas/render.js +117 -20
  52. package/dist/core/canvas/source.d.ts +26 -0
  53. package/dist/core/canvas/source.js +29 -0
  54. package/dist/core/canvas/status-glyph.js +1 -0
  55. package/dist/core/canvas/types.d.ts +4 -3
  56. package/dist/core/config.d.ts +18 -2
  57. package/dist/core/config.js +88 -10
  58. package/dist/core/fault-classifier.d.ts +2 -2
  59. package/dist/core/memory-resolver.d.ts +9 -6
  60. package/dist/core/memory-resolver.js +28 -4
  61. package/dist/core/profiles/__tests__/root-profile.test.d.ts +1 -0
  62. package/dist/core/profiles/__tests__/root-profile.test.js +69 -0
  63. package/dist/core/profiles/manifest.d.ts +2 -0
  64. package/dist/core/profiles/manifest.js +40 -7
  65. package/dist/core/profiles/select.d.ts +4 -4
  66. package/dist/core/profiles/select.js +13 -13
  67. package/dist/core/runtime/__tests__/spawn-env.test.d.ts +1 -0
  68. package/dist/core/runtime/__tests__/spawn-env.test.js +268 -0
  69. package/dist/core/runtime/bearings.d.ts +5 -4
  70. package/dist/core/runtime/bearings.js +12 -6
  71. package/dist/core/runtime/broker.js +59 -7
  72. package/dist/core/runtime/fault-recovery.js +4 -0
  73. package/dist/core/runtime/front-door.js +1 -1
  74. package/dist/core/runtime/host.js +43 -3
  75. package/dist/core/runtime/kickoff.js +6 -0
  76. package/dist/core/runtime/launch.d.ts +6 -1
  77. package/dist/core/runtime/launch.js +7 -1
  78. package/dist/core/runtime/nodes.d.ts +7 -6
  79. package/dist/core/runtime/nodes.js +3 -3
  80. package/dist/core/runtime/situational-context.d.ts +12 -0
  81. package/dist/core/runtime/situational-context.js +48 -0
  82. package/dist/core/runtime/spawn-env.d.ts +27 -0
  83. package/dist/core/runtime/spawn-env.js +169 -0
  84. package/dist/core/runtime/spawn.d.ts +16 -7
  85. package/dist/core/runtime/spawn.js +8 -2
  86. package/dist/core/substrate/session-cache.js +7 -1
  87. package/dist/core/substrate/subject.d.ts +1 -1
  88. package/dist/core/view/remote-canvas-target.d.ts +3 -0
  89. package/dist/core/view/remote-canvas-target.js +25 -0
  90. package/dist/core/worktree.d.ts +4 -4
  91. package/dist/core/worktree.js +41 -19
  92. package/dist/daemon/crtrd.js +32 -0
  93. package/dist/daemon/predicate-eval.js +12 -1
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.js +4 -0
  96. package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
  97. package/dist/pi-extensions/canvas-bash-valve.js +238 -0
  98. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +12 -0
  99. package/dist/pi-extensions/canvas-inbox-watcher.js +41 -5
  100. package/dist/types.d.ts +32 -0
  101. package/dist/types.js +7 -0
  102. package/dist/web-client/assets/index-BnmSLNLa.css +2 -0
  103. package/dist/web-client/assets/{index-BQufdMhB.js → index-BvzxXXGU.js} +18 -18
  104. package/dist/web-client/index.html +2 -2
  105. package/dist/web-client/sw.js +1 -1
  106. package/docs/public-api.md +5 -3
  107. package/package.json +2 -2
  108. package/dist/web-client/assets/fraunces-latin-ext-wght-normal-Ca2vKHc0.woff2 +0 -0
  109. package/dist/web-client/assets/fraunces-latin-wght-normal-ukD16Tqj.woff2 +0 -0
  110. package/dist/web-client/assets/fraunces-vietnamese-wght-normal-CnvboYUG.woff2 +0 -0
  111. package/dist/web-client/assets/index-FjfN-jvN.css +0 -2
  112. package/dist/web-client/assets/instrument-sans-latin-ext-wght-normal-B5bTHO_g.woff2 +0 -0
  113. package/dist/web-client/assets/instrument-sans-latin-wght-normal-BbzFLZTg.woff2 +0 -0
  114. package/dist/web-client/workbox-98f7a950.js +0 -1
@@ -7,7 +7,7 @@
7
7
  // straight into `join(profilesRoot(), value)`.
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
10
- import { dirname, join, resolve as resolvePath, sep } from 'node:path';
10
+ import { dirname, join, relative, resolve as resolvePath, sep } from 'node:path';
11
11
  import { SCHEMA_VERSION, PROFILE_DIR } from '../../types.js';
12
12
  import { userScopeRoot, resetScopeCache } from '../scope.js';
13
13
  import { ensureDir, nowIso } from '../fs-utils.js';
@@ -21,6 +21,14 @@ import { usage, notFound, ambiguous, general } from '../errors.js';
21
21
  * chars. The char class alone rules out `/`, `\`, `.`, and `..` segments —
22
22
  * this is the "generated id shape" check every exact-id lookup requires. */
23
23
  const ID_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{8}$/;
24
+ export const ROOT_PROFILE_ID = 'root-00000000';
25
+ const ROOT_PROFILE_NAME = 'root';
26
+ function scaffoldProfile(profileId, manifest) {
27
+ const root = profileRoot(profileId);
28
+ ensureDir(root);
29
+ ensureDir(join(root, 'memory'));
30
+ writeManifestAtomic(profileManifestPath(profileId), manifest);
31
+ }
24
32
  export function profilesRoot() {
25
33
  return join(userScopeRoot(), PROFILE_DIR);
26
34
  }
@@ -39,8 +47,11 @@ function assertSafeProfileId(profileId) {
39
47
  * guard, and is what makes an unexpected symlink under a profile dir inert. */
40
48
  function assertInsideProfilesRoot(path) {
41
49
  const root = profilesRoot();
42
- const resolvedRoot = existsSync(root) ? realpathSync(root) : resolvePath(root);
43
- const resolvedPath = existsSync(path) ? realpathSync(path) : resolvePath(path);
50
+ const absRoot = resolvePath(root);
51
+ const resolvedRoot = existsSync(root) ? realpathSync(root) : absRoot;
52
+ const resolvedPath = existsSync(path)
53
+ ? realpathSync(path)
54
+ : resolvePath(resolvedRoot, relative(absRoot, resolvePath(path)));
44
55
  if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(resolvedRoot + sep)) {
45
56
  throw usage(`profile path escapes the profiles root: ${path}`);
46
57
  }
@@ -233,6 +244,31 @@ function generateProfileId(name) {
233
244
  // resetScopeCache() when they can change project dirs or the profile name
234
245
  // (never for a bare last-used-at touch).
235
246
  // ---------------------------------------------------------------------------
247
+ export function ensureRootProfile() {
248
+ return withProfileManifestLock(ROOT_PROFILE_ID, () => {
249
+ const manifestPath = profileManifestPath(ROOT_PROFILE_ID);
250
+ const manifestExists = existsSync(manifestPath);
251
+ const manifest = manifestExists ? readManifestFile(manifestPath) : null;
252
+ if (manifestExists && manifest === null) {
253
+ throw general(`root profile manifest is unreadable: ${manifestPath}`);
254
+ }
255
+ if (manifest !== null) {
256
+ ensureDir(profileRoot(ROOT_PROFILE_ID));
257
+ ensureDir(profileMemoryDir(ROOT_PROFILE_ID));
258
+ return { profileId: ROOT_PROFILE_ID, manifest };
259
+ }
260
+ const next = {
261
+ schema_version: SCHEMA_VERSION,
262
+ name: ROOT_PROFILE_NAME,
263
+ projects: [],
264
+ created_at: nowIso(),
265
+ last_used_at: nowIso(),
266
+ };
267
+ scaffoldProfile(ROOT_PROFILE_ID, next);
268
+ resetScopeCache();
269
+ return { profileId: ROOT_PROFILE_ID, manifest: next };
270
+ });
271
+ }
236
272
  export function createProfile(name, projects = []) {
237
273
  const trimmed = name.trim();
238
274
  if (trimmed === '')
@@ -240,9 +276,6 @@ export function createProfile(name, projects = []) {
240
276
  const resolvedProjects = dedupeOrdered(projects.map(resolveExistingProjectDir));
241
277
  const profileId = generateProfileId(trimmed);
242
278
  return withProfileManifestLock(profileId, () => {
243
- const root = profileRoot(profileId);
244
- ensureDir(root);
245
- ensureDir(join(root, 'memory'));
246
279
  const manifest = {
247
280
  schema_version: SCHEMA_VERSION,
248
281
  name: trimmed,
@@ -250,7 +283,7 @@ export function createProfile(name, projects = []) {
250
283
  created_at: nowIso(),
251
284
  last_used_at: null,
252
285
  };
253
- writeManifestAtomic(profileManifestPath(profileId), manifest);
286
+ scaffoldProfile(profileId, manifest);
254
287
  resetScopeCache();
255
288
  return { profileId, manifest };
256
289
  });
@@ -27,7 +27,7 @@ export declare function profileCoversCwd(entry: ProfileEntry, cwd: string): bool
27
27
  * is covered). Default is the MRU covering profile.
28
28
  * - Non-interactive: auto-pick the MRU covering profile, no prompt.
29
29
  * 4. Else — nothing covers cwd walking all the way up. Interactive: prompt to
30
- * create a profile here or proceed as root (root lives ONLY here). Non-
31
- * interactive (no TTY): default to root (null) and print the recovery
32
- * instruction to STDERR — never stdout, which the caller may be piping. */
33
- export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null, forcePicker?: boolean): Promise<string | null>;
30
+ * create a profile here or use the root profile. Non-interactive (no TTY):
31
+ * default to the root profile and print the recovery instruction to STDERR —
32
+ * never stdout, which the caller may be piping. */
33
+ export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null, forcePicker?: boolean): Promise<string>;
@@ -10,7 +10,7 @@ import { existsSync, realpathSync } from 'node:fs';
10
10
  import { homedir } from 'node:os';
11
11
  import { basename, resolve as resolvePath, sep } from 'node:path';
12
12
  import { createInterface } from 'node:readline/promises';
13
- import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, addProfileProject, } from './manifest.js';
13
+ import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, addProfileProject, ensureRootProfile, } from './manifest.js';
14
14
  import { getDefaultProfileId, setDefaultProfileId, clearDefaultProfile, } from './default-binding.js';
15
15
  import { stdoutColor } from '../output.js';
16
16
  /** Resolve cwd to an absolute, realpath'd form so it compares against the
@@ -132,9 +132,9 @@ function optionRow(k, label, detail, isDefault, defaultText = 'default') {
132
132
  function caret(hint) {
133
133
  return ` ${accent('\u203a')} ${dim(hint)} `;
134
134
  }
135
- function recoveryMessage(cwd) {
136
- return (`crtr: no profile covers ${cwd}, and this session is non-interactive — starting without a profile (root).\n` +
137
- `Run \`crtr profile new --name <name> --project ${cwd}\` to create one, or pass --profile <id-or-name> next time.\n`);
135
+ function rootProfileMessage(cwd) {
136
+ return (`crtr: no profile covers ${cwd}, and this session is non-interactive — using the root profile.\n` +
137
+ `Run \`crtr profile new --name <name> --project ${cwd}\` to create a dedicated profile, or pass --profile <id-or-name> next time.\n`);
138
138
  }
139
139
  /** The one synchronous (pre-boot) decision point: no profile covers cwd, and
140
140
  * the session IS interactive. Ask on the controlling terminal — create a
@@ -184,11 +184,11 @@ async function promptCreateOrRoot(cwd) {
184
184
  try {
185
185
  writeHeader('No profile covers this directory');
186
186
  process.stdout.write(optionRow('c', 'create a profile here', '', false));
187
- process.stdout.write(optionRow('r', dim('run without a profile (root)'), '', true));
187
+ process.stdout.write(optionRow('r', dim('use the root profile'), '', true));
188
188
  process.stdout.write('\n');
189
189
  const choice = (await rl.question(caret('[c/r]'))).trim().toLowerCase();
190
190
  if (choice !== 'c' && choice !== 'create')
191
- return null;
191
+ return ensureRootProfile().profileId;
192
192
  return await createProfileHere(rl, cwd);
193
193
  }
194
194
  finally {
@@ -296,9 +296,9 @@ async function promptPickProfileOrCreate(candidates, cwd, exact, pinnedId) {
296
296
  * is covered). Default is the MRU covering profile.
297
297
  * - Non-interactive: auto-pick the MRU covering profile, no prompt.
298
298
  * 4. Else — nothing covers cwd walking all the way up. Interactive: prompt to
299
- * create a profile here or proceed as root (root lives ONLY here). Non-
300
- * interactive (no TTY): default to root (null) and print the recovery
301
- * instruction to STDERR — never stdout, which the caller may be piping. */
299
+ * create a profile here or use the root profile. Non-interactive (no TTY):
300
+ * default to the root profile and print the recovery instruction to STDERR —
301
+ * never stdout, which the caller may be piping. */
302
302
  export async function selectProfileForCwd(cwd, explicitProfile, forcePicker = false) {
303
303
  const resolvedCwd = resolveCwd(cwd);
304
304
  if (explicitProfile !== undefined && explicitProfile !== null && explicitProfile !== '') {
@@ -349,8 +349,9 @@ export async function selectProfileForCwd(cwd, explicitProfile, forcePicker = fa
349
349
  updateProfileLastUsed(winner.profileId);
350
350
  return winner.profileId;
351
351
  }
352
- process.stderr.write(recoveryMessage(resolvedCwd));
353
- return null;
352
+ const root = ensureRootProfile();
353
+ process.stderr.write(rootProfileMessage(resolvedCwd));
354
+ return root.profileId;
354
355
  }
355
356
  // Interactive. Several exact claimants → choose among them. Else covered from
356
357
  // a parent/workspace dir → menu of covering profiles. Else nothing covers →
@@ -361,7 +362,6 @@ export async function selectProfileForCwd(cwd, explicitProfile, forcePicker = fa
361
362
  : covering.length > 0
362
363
  ? await promptPickProfileOrCreate(covering, resolvedCwd, false, validPinnedId)
363
364
  : await promptCreateOrRoot(resolvedCwd);
364
- if (chosen !== null)
365
- updateProfileLastUsed(chosen);
365
+ updateProfileLastUsed(chosen);
366
366
  return chosen;
367
367
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,268 @@
1
+ // Run with: node --import tsx/esm --test src/core/runtime/__tests__/spawn-env.test.ts
2
+ //
3
+ // Regression lock for the C-1 spawn-env boundary (`../spawn-env.ts`): the
4
+ // broker child env is CONSTRUCTED from a default-deny allowlist, never
5
+ // inherited via `{ ...process.env }`. Each case below reproduces one clause of
6
+ // the contract so a future edit that re-opens the leak (or re-admits `GIT_*`
7
+ // / a credentialed proxy, or resolves `spawnEnv.allow` from the wrong scope)
8
+ // fails loud here instead of silently in prod.
9
+ import { test, before, after, beforeEach, afterEach } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { resetScopeCache } from '../../scope.js';
15
+ import { readMergedLaunchConfig } from '../../config.js';
16
+ import { buildOperationalEnvBase, buildBrokerEnv } from '../spawn-env.js';
17
+ const LEAK_CLASS_HOST_ENV = {
18
+ HOME: '/Users/tester',
19
+ PATH: '/usr/bin:/bin',
20
+ LANG: 'en_US.UTF-8',
21
+ XDG_CONFIG_HOME: '/Users/tester/.config',
22
+ PI_CODING_AGENT_DIR: '/Users/tester/.pi',
23
+ // The C-1 leak class: ambient third-party credentials that must never cross.
24
+ MISTRAL_API_KEY: 'fake-m',
25
+ GOOGLE_GENERATIVE_AI_API_KEY: 'fake-g',
26
+ GITHUB_TOKEN: 'fake-gh',
27
+ AWS_ACCESS_KEY_ID: 'fake-aws-id',
28
+ AWS_SECRET_ACCESS_KEY: 'fake-aws-secret',
29
+ // The GIT_* injection surface.
30
+ GIT_CONFIG_KEY_0: 'http.https://x.insteadof',
31
+ GIT_ASKPASS: '/tmp/fake-askpass',
32
+ // Proxy: one credentialed (must drop), one plain (must keep).
33
+ HTTP_PROXY: 'http://baduser:badpass@proxy.corp:3128',
34
+ HTTPS_PROXY: 'http://proxy.corp:3128',
35
+ NO_PROXY: 'localhost,127.0.0.1',
36
+ // Configured model-provider keys (source B).
37
+ ANTHROPIC_API_KEY: 'fake-anthropic',
38
+ OPENAI_API_KEY: 'fake-openai',
39
+ };
40
+ /** Ambient-scope helper: most cases below don't care about `spawnEnv.allow`
41
+ * scoping (source C), so they resolve against whatever cwd/profile is live
42
+ * when they run (an isolated empty scope by construction of `beforeEach`
43
+ * below) rather than repeating `{ targetCwd, targetProfileId }` at each
44
+ * call site. */
45
+ function base(host) {
46
+ return buildOperationalEnvBase({ targetCwd: process.cwd(), targetProfileId: null, host });
47
+ }
48
+ // --- every case needs an isolated scope root so the repo's own
49
+ // .crouter/config.json (which declares no spawnEnv) never masks a test, and
50
+ // so `spawnEnv.allow` config can be planted per-test without cross-talk.
51
+ let isolatedHome;
52
+ let isolatedCwd;
53
+ let prevHome;
54
+ let prevCwd;
55
+ before(() => {
56
+ prevHome = process.env.HOME;
57
+ prevCwd = process.cwd();
58
+ });
59
+ beforeEach(() => {
60
+ isolatedHome = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-spawn-env-home-')));
61
+ isolatedCwd = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-spawn-env-cwd-')));
62
+ process.env.HOME = isolatedHome;
63
+ process.chdir(isolatedCwd);
64
+ resetScopeCache();
65
+ });
66
+ afterEach(() => {
67
+ rmSync(isolatedHome, { recursive: true, force: true });
68
+ rmSync(isolatedCwd, { recursive: true, force: true });
69
+ });
70
+ after(() => {
71
+ process.chdir(prevCwd);
72
+ if (prevHome === undefined)
73
+ delete process.env.HOME;
74
+ else
75
+ process.env.HOME = prevHome;
76
+ resetScopeCache();
77
+ });
78
+ test('A1: literal operational names and non-secret prefixes cross verbatim', () => {
79
+ const env = base(LEAK_CLASS_HOST_ENV);
80
+ assert.equal(env.HOME, '/Users/tester');
81
+ assert.equal(env.PATH, '/usr/bin:/bin');
82
+ assert.equal(env.LANG, 'en_US.UTF-8');
83
+ assert.equal(env.XDG_CONFIG_HOME, '/Users/tester/.config');
84
+ assert.equal(env.PI_CODING_AGENT_DIR, '/Users/tester/.pi');
85
+ });
86
+ test('leak-class ambient credentials are dropped', () => {
87
+ const env = base(LEAK_CLASS_HOST_ENV);
88
+ assert.equal(env.MISTRAL_API_KEY, undefined);
89
+ assert.equal(env.GOOGLE_GENERATIVE_AI_API_KEY, undefined);
90
+ assert.equal(env.GITHUB_TOKEN, undefined);
91
+ assert.equal(env.AWS_ACCESS_KEY_ID, undefined);
92
+ assert.equal(env.AWS_SECRET_ACCESS_KEY, undefined);
93
+ });
94
+ test('GIT_* is dropped in full — no default admits any GIT_ variable', () => {
95
+ const env = base(LEAK_CLASS_HOST_ENV);
96
+ assert.equal(env.GIT_CONFIG_KEY_0, undefined);
97
+ assert.equal(env.GIT_ASKPASS, undefined);
98
+ assert.equal(Object.keys(env).some((k) => k.startsWith('GIT_')), false);
99
+ });
100
+ test('B: configured model-provider keys cross by exact name', () => {
101
+ const env = base(LEAK_CLASS_HOST_ENV);
102
+ assert.equal(env.ANTHROPIC_API_KEY, 'fake-anthropic');
103
+ assert.equal(env.OPENAI_API_KEY, 'fake-openai');
104
+ });
105
+ // ---------------------------------------------------------------------------
106
+ // A2 — proxy userinfo guard (Major 1 fix): must reject username-only,
107
+ // password-only, and scheme-less userinfo, for BOTH uppercase and lowercase
108
+ // var names; must keep a plain proxy URL, for both cases; NO_PROXY always
109
+ // keeps.
110
+ // ---------------------------------------------------------------------------
111
+ test('A2: a credentialed proxy URL is dropped, a plain one crosses (uppercase)', () => {
112
+ const env = base(LEAK_CLASS_HOST_ENV);
113
+ assert.equal(env.HTTP_PROXY, undefined, 'userinfo-bearing proxy must not cross');
114
+ assert.equal(env.HTTPS_PROXY, 'http://proxy.corp:3128', 'plain proxy crosses untouched');
115
+ assert.equal(env.NO_PROXY, 'localhost,127.0.0.1');
116
+ });
117
+ test('A2: password-only proxy authority is dropped (http://:pass@host)', () => {
118
+ const env = base({ HTTP_PROXY: 'http://:badpass@proxy.corp:3128' });
119
+ assert.equal(env.HTTP_PROXY, undefined, 'a naive `username === \'\'` check would wrongly admit this');
120
+ });
121
+ test('A2: username-only proxy authority is dropped (http://user@host)', () => {
122
+ const env = base({ HTTP_PROXY: 'http://baduser@proxy.corp:3128' });
123
+ assert.equal(env.HTTP_PROXY, undefined);
124
+ });
125
+ test('A2: scheme-less userinfo is dropped, not silently normalized then admitted', () => {
126
+ // `new URL('user:pass@proxy.corp:3128')` parses "user" as a CUSTOM scheme
127
+ // with empty username/password — the exact hole the naive check missed.
128
+ const env = base({ HTTP_PROXY: 'user:pass@proxy.corp:3128' });
129
+ assert.equal(env.HTTP_PROXY, undefined);
130
+ });
131
+ test('A2: lowercase proxy vars get the same guard — credentialed drops, plain keeps', () => {
132
+ const env = base({
133
+ http_proxy: 'http://baduser:badpass@proxy.corp:3128',
134
+ https_proxy: 'http://proxy.corp:3128',
135
+ no_proxy: 'localhost',
136
+ });
137
+ assert.equal(env.http_proxy, undefined, 'lowercase credentialed proxy must not cross');
138
+ assert.equal(env.https_proxy, 'http://proxy.corp:3128', 'lowercase plain proxy crosses');
139
+ assert.equal(env.no_proxy, 'localhost');
140
+ });
141
+ test('A2: a non-http(s) scheme or hostless value is dropped, never admitted', () => {
142
+ const env = base({ HTTP_PROXY: 'not a url at all' });
143
+ assert.equal(env.HTTP_PROXY, undefined);
144
+ });
145
+ // ---------------------------------------------------------------------------
146
+ // buildBrokerEnv: overlay + drop the leak class through the real process.env
147
+ // path.
148
+ // ---------------------------------------------------------------------------
149
+ test('buildBrokerEnv overlays inv.env + FRONT_DOOR_ENV on top of the sanitized base, and drops the leak class', () => {
150
+ const inv = {
151
+ argv: [],
152
+ env: { CRTR_NODE_ID: 'n1', CRTR_CONTEXT_DIR: '/ctx', CRTR_NODE_CWD: isolatedCwd, CRTR_PROFILE_ID: '' },
153
+ };
154
+ // Mutate individual keys and delete them back out afterward — NEVER
155
+ // reassign `process.env` wholesale (`process.env = {...}`), which detaches
156
+ // it from the real OS environment binding (e.g. breaks `os.homedir()` for
157
+ // every test that runs after it in this same process).
158
+ try {
159
+ process.env.MISTRAL_API_KEY = 'fake-m';
160
+ process.env.GIT_ASKPASS = '/tmp/fake-askpass';
161
+ const env = buildBrokerEnv(inv);
162
+ assert.equal(env.CRTR_NODE_ID, 'n1', 'trusted inv.env overlay present');
163
+ assert.equal(env.CRTR_CONTEXT_DIR, '/ctx');
164
+ assert.equal(env.CRTR_FRONT_DOOR, '1', 'fork-bomb recursion guard set');
165
+ assert.equal(env.MISTRAL_API_KEY, undefined, 'ambient leak class still absent through the real process.env path');
166
+ assert.equal(env.GIT_ASKPASS, undefined, 'GIT_* still absent through the real process.env path');
167
+ }
168
+ finally {
169
+ delete process.env.MISTRAL_API_KEY;
170
+ delete process.env.GIT_ASKPASS;
171
+ }
172
+ });
173
+ // ---------------------------------------------------------------------------
174
+ // C — explicit spawnEnv.allow escape hatch: literal names + globs.
175
+ // ---------------------------------------------------------------------------
176
+ test('C: an explicit spawnEnv.allow literal name admits a var that would otherwise be dropped', () => {
177
+ mkdirSync(join(isolatedHome, '.crouter'), { recursive: true });
178
+ writeFileSync(join(isolatedHome, '.crouter', 'config.json'), JSON.stringify({ spawnEnv: { allow: ['GOOGLE_GENERATIVE_AI_API_KEY'] } }));
179
+ resetScopeCache();
180
+ const env = base(LEAK_CLASS_HOST_ENV);
181
+ assert.equal(env.GOOGLE_GENERATIVE_AI_API_KEY, 'fake-g', 'explicitly allowlisted name now crosses');
182
+ assert.equal(env.MISTRAL_API_KEY, undefined, 'every other leak-class name stays absent — the hatch is scoped, not a blanket reopen');
183
+ assert.equal(env.GIT_ASKPASS, undefined);
184
+ });
185
+ test('C (glob, Minor fix): AWS_* admits only AWS_-prefixed names, nothing else', () => {
186
+ mkdirSync(join(isolatedHome, '.crouter'), { recursive: true });
187
+ writeFileSync(join(isolatedHome, '.crouter', 'config.json'), JSON.stringify({ spawnEnv: { allow: ['AWS_*'] } }));
188
+ resetScopeCache();
189
+ const env = base(LEAK_CLASS_HOST_ENV);
190
+ assert.equal(env.AWS_ACCESS_KEY_ID, 'fake-aws-id', 'AWS_-prefixed name admitted by the glob');
191
+ assert.equal(env.AWS_SECRET_ACCESS_KEY, 'fake-aws-secret', 'a second AWS_-prefixed name is also admitted');
192
+ assert.equal(env.MISTRAL_API_KEY, undefined, 'the glob must not admit unrelated names');
193
+ assert.equal(env.GIT_ASKPASS, undefined);
194
+ });
195
+ test('C (glob, Minor fix): a bare "*" (inherit-all) is rejected loud at config read, not silently accepted or dropped', () => {
196
+ mkdirSync(join(isolatedCwd, '.crouter'), { recursive: true });
197
+ writeFileSync(join(isolatedCwd, '.crouter', 'config.json'), JSON.stringify({ spawnEnv: { allow: ['*'] } }));
198
+ resetScopeCache();
199
+ assert.throws(() => readMergedLaunchConfig(isolatedCwd, null), /matches every env var name/, 'a bare "*" must fail loud at config read, per cto-rejects-fallback-hedges — never silently literal, never silently dropped');
200
+ });
201
+ // ---------------------------------------------------------------------------
202
+ // Major 2 fix — spawnEnv.allow resolves from the TARGET's own cwd/profile,
203
+ // never the calling/launcher process's ambient scope.
204
+ // ---------------------------------------------------------------------------
205
+ test('target-scope: spawnEnv.allow is read from the TARGET cwd, not process.cwd() (the ambient/launcher cwd)', () => {
206
+ const launcherCwd = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-spawn-env-launcher-')));
207
+ const targetCwd = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-spawn-env-target-')));
208
+ try {
209
+ // The LAUNCHER's own cwd declares an allowlist entry — this must NOT leak
210
+ // into a target with a different cwd (the verified C-1 follow-up leak:
211
+ // "a broker for target cwd B admitted AWS_ACCESS_KEY_ID solely because
212
+ // the launcher cwd A had it in spawnEnv.allow").
213
+ mkdirSync(join(launcherCwd, '.crouter'), { recursive: true });
214
+ writeFileSync(join(launcherCwd, '.crouter', 'config.json'), JSON.stringify({ spawnEnv: { allow: ['AWS_ACCESS_KEY_ID'] } }));
215
+ process.chdir(launcherCwd); // simulate: ambient process.cwd() is the LAUNCHER's, not the target's
216
+ resetScopeCache();
217
+ const envAgainstUnrelatedTarget = buildOperationalEnvBase({ targetCwd, targetProfileId: null, host: LEAK_CLASS_HOST_ENV });
218
+ assert.equal(envAgainstUnrelatedTarget.AWS_ACCESS_KEY_ID, undefined, "the launcher's own cwd allowlist must not admit into an unrelated target");
219
+ // Now give the TARGET's own cwd the allow entry instead (ambient
220
+ // process.cwd() is STILL the launcher's — unchanged) and confirm it takes
221
+ // effect purely because it is the passed target scope.
222
+ mkdirSync(join(targetCwd, '.crouter'), { recursive: true });
223
+ writeFileSync(join(targetCwd, '.crouter', 'config.json'), JSON.stringify({ spawnEnv: { allow: ['AWS_ACCESS_KEY_ID'] } }));
224
+ resetScopeCache();
225
+ const envAgainstOwnTarget = buildOperationalEnvBase({ targetCwd, targetProfileId: null, host: LEAK_CLASS_HOST_ENV });
226
+ assert.equal(envAgainstOwnTarget.AWS_ACCESS_KEY_ID, 'fake-aws-id', "the TARGET's own cwd allowlist takes effect regardless of the ambient/launcher cwd");
227
+ }
228
+ finally {
229
+ rmSync(launcherCwd, { recursive: true, force: true });
230
+ rmSync(targetCwd, { recursive: true, force: true });
231
+ resetScopeCache();
232
+ }
233
+ });
234
+ test('target-scope: buildBrokerEnv resolves spawnEnv.allow from inv.env.CRTR_NODE_CWD (the revive/spawn target), not this process\'s ambient cwd', () => {
235
+ const launcherCwd = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-spawn-env-launcher2-')));
236
+ const targetCwd = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-spawn-env-target2-')));
237
+ try {
238
+ mkdirSync(join(targetCwd, '.crouter'), { recursive: true });
239
+ writeFileSync(join(targetCwd, '.crouter', 'config.json'), JSON.stringify({ spawnEnv: { allow: ['MISTRAL_API_KEY'] } }));
240
+ process.chdir(launcherCwd); // this process's ambient cwd is unrelated to the target — mirrors the daemon reviving a node from its OWN cwd
241
+ resetScopeCache();
242
+ process.env.MISTRAL_API_KEY = 'fake-m';
243
+ try {
244
+ const inv = { argv: [], env: { CRTR_NODE_ID: 'n2', CRTR_NODE_CWD: targetCwd, CRTR_PROFILE_ID: '' } };
245
+ const env = buildBrokerEnv(inv);
246
+ assert.equal(env.MISTRAL_API_KEY, 'fake-m', "the TARGET node's own cwd allowlist (from inv.env.CRTR_NODE_CWD) takes effect on a broker launch — this is what revive/triggered spawn goes through");
247
+ }
248
+ finally {
249
+ delete process.env.MISTRAL_API_KEY;
250
+ }
251
+ }
252
+ finally {
253
+ rmSync(launcherCwd, { recursive: true, force: true });
254
+ rmSync(targetCwd, { recursive: true, force: true });
255
+ resetScopeCache();
256
+ }
257
+ });
258
+ test('target-scope: spawnEnv.allow is read from the TARGET profile id, not this process\'s ambient CRTR_PROFILE_ID', () => {
259
+ const profileId = 'testprof-aaaaaaaa'; // must match the profile-id shape (<slug>-<8-hex>)
260
+ const profileDir = join(isolatedHome, '.crouter', 'profiles', profileId);
261
+ mkdirSync(profileDir, { recursive: true });
262
+ writeFileSync(join(profileDir, 'config.json'), JSON.stringify({ spawnEnv: { allow: ['AWS_ACCESS_KEY_ID'] } }));
263
+ resetScopeCache();
264
+ const withoutTargetProfile = buildOperationalEnvBase({ targetCwd: isolatedCwd, targetProfileId: null, host: LEAK_CLASS_HOST_ENV });
265
+ assert.equal(withoutTargetProfile.AWS_ACCESS_KEY_ID, undefined, 'no profile passed — the profile-scoped allow must not apply');
266
+ const withTargetProfile = buildOperationalEnvBase({ targetCwd: isolatedCwd, targetProfileId: profileId, host: LEAK_CLASS_HOST_ENV });
267
+ assert.equal(withTargetProfile.AWS_ACCESS_KEY_ID, 'fake-aws-id', "passing the TARGET's own profile id resolves that profile's allowlist");
268
+ });
@@ -13,10 +13,11 @@ export declare function contextDirNote(nodeId: string): string;
13
13
  * manifest that names at least one project dir. Phrased as PURVIEW, not a cwd
14
14
  * replacement: the node's cwd is still its one working dir (bash/edits run
15
15
  * there), the profile just widens what it may ALSO read/act on. Omitted
16
- * entirely for a root/no-profile node or a deleted/unreadable manifest
17
- * never throws (mirrors `assembleNodeSubject`'s profile resolution). This
18
- * absolute-path disclosure is intentional (spec-approved): the agent must
19
- * know its full purview, not a redacted count. */
16
+ * entirely for a profile with no project purview (including the stable root
17
+ * profile) or a deleted/unreadable manifest — never throws (mirrors
18
+ * `assembleNodeSubject`'s profile resolution). This absolute-path disclosure
19
+ * is intentional (spec-approved): the agent must know its full purview, not a
20
+ * redacted count. */
20
21
  export declare function profileNote(nodeId: string): string;
21
22
  /** Orchestrator-only framing: a resident orchestrator survives refresh cycles,
22
23
  * so its context dir is also where a future cycle of itself resumes the work.
@@ -43,6 +43,7 @@ import { readdirSync } from 'node:fs';
43
43
  import { cadenceDisplay } from '../wake.js';
44
44
  import { renderKnowledgeBlock } from '../substrate/index.js';
45
45
  import { loadProfileManifest } from '../profiles/manifest.js';
46
+ import { situationalContextBlock } from './situational-context.js';
46
47
  function escapeAttribute(value) {
47
48
  return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
48
49
  }
@@ -142,10 +143,11 @@ export function contextDirNote(nodeId) {
142
143
  * manifest that names at least one project dir. Phrased as PURVIEW, not a cwd
143
144
  * replacement: the node's cwd is still its one working dir (bash/edits run
144
145
  * there), the profile just widens what it may ALSO read/act on. Omitted
145
- * entirely for a root/no-profile node or a deleted/unreadable manifest
146
- * never throws (mirrors `assembleNodeSubject`'s profile resolution). This
147
- * absolute-path disclosure is intentional (spec-approved): the agent must
148
- * know its full purview, not a redacted count. */
146
+ * entirely for a profile with no project purview (including the stable root
147
+ * profile) or a deleted/unreadable manifest — never throws (mirrors
148
+ * `assembleNodeSubject`'s profile resolution). This absolute-path disclosure
149
+ * is intentional (spec-approved): the agent must know its full purview, not a
150
+ * redacted count. */
149
151
  export function profileNote(nodeId) {
150
152
  const meta = getNode(nodeId);
151
153
  const profileId = meta?.profile_id;
@@ -376,7 +378,7 @@ export function buildContextBearings(nodeId) {
376
378
  if (map !== '')
377
379
  bearings.push('', '## Your place in the canvas', map);
378
380
  bearings.push('', '## Your context directory', contextDirNote(nodeId));
379
- // Profile purview — omitted entirely for a root/no-profile node (see profileNote).
381
+ // Profile purview — omitted entirely when there is no project purview (see profileNote).
380
382
  const profileSection = profileNote(nodeId);
381
383
  if (profileSection !== '')
382
384
  bearings.push('', profileSection);
@@ -390,6 +392,10 @@ export function buildContextBearings(nodeId) {
390
392
  bearings.push('</crtr-bearings>');
391
393
  // The consultable catalog rides BESIDE the bearings, not inside them.
392
394
  const knowledge = renderKnowledgeBlock(nodeId);
395
+ // Hidden applet/situation-origin ambient context (see situational-context.ts)
396
+ // — a SIBLING block, never nested under <crtr-bearings>, and never routed
397
+ // through the visible chat/digest path. '' when nothing was ever set.
398
+ const situational = situationalContextBlock(nodeId);
393
399
  const projectCtx = node?.cwd !== undefined ? buildProjectContextBlock(node.cwd) : '';
394
- return [bearings.join('\n'), knowledge, projectCtx].filter((s) => s !== '').join('\n');
400
+ return [bearings.join('\n'), knowledge, situational, projectCtx].filter((s) => s !== '').join('\n');
395
401
  }
@@ -28,7 +28,7 @@ import { FRONT_DOOR_ENV } from './front-door.js';
28
28
  import { equivalentOtherProviderModel, normalizeModel, piInvocationToSdkConfig, } from './launch.js';
29
29
  import { assertEngineVersion, loadBrokerEngine } from './broker-sdk.js';
30
30
  import { classify } from '../fault-classifier.js';
31
- import { isModelNotFoundError, readFault, recordFault } from './fault.js';
31
+ import { isModelNotFoundError, readFault, recordFault, recordFaultForCurrentNode } from './fault.js';
32
32
  import { CONNECTION_FAULT_RECOVERY_BODY, PROVIDER_FAULT_RECOVERY_BODY } from './fault-recovery-nudge.js';
33
33
  import { markFatalOnExhaust, nextFaultRetry, policyFor } from './fault-recovery.js';
34
34
  import { probeOnline as probeOnlineReal } from './connectivity.js';
@@ -1717,6 +1717,27 @@ export async function buildBrokerSession(engine, cfg) {
1717
1717
  // 3. Resolve the model spec (`anthropic/sonnet` → a Model) against the
1718
1718
  // SERVICES registry — which has any extension-provided providers (C3).
1719
1719
  // Undefined ⇒ the SDK picks the settings default.
1720
+ // M-9: describe the AUTHENTICATED set for a fault message, and a helper to
1721
+ // record a durable fault + abort the session build — never a silent
1722
+ // SDK-default fallback. The throw propagates out of buildBrokerSession to
1723
+ // runBroker's caller (broker-cli), which logs it and exits non-zero BEFORE
1724
+ // session_start; crtrd's boot-failure grace then transitions the node to
1725
+ // 'dead' (transition(id, 'crash')) — an operator-visible fault, not a
1726
+ // warning buried in broker.log.
1727
+ const describeAvailableModels = () => {
1728
+ const available = services.modelRegistry.getAvailable().map((m) => `${m.provider}/${m.id}`);
1729
+ return available.length > 0 ? available.join(', ') : '(none — no provider has valid credentials)';
1730
+ };
1731
+ const faultAndDie = (message) => {
1732
+ recordFaultForCurrentNode({
1733
+ link: 'crtr→pi',
1734
+ op: 'model resolution at launch',
1735
+ kind: 'model-not-found',
1736
+ retry: { disposition: 'fatal' },
1737
+ message,
1738
+ });
1739
+ throw new Error(`[broker] FATAL: ${message}`);
1740
+ };
1720
1741
  let model;
1721
1742
  let thinkingLevel;
1722
1743
  if (cfg.model !== undefined && cfg.model !== '') {
@@ -1724,15 +1745,46 @@ export async function buildBrokerSession(engine, cfg) {
1724
1745
  thinkingLevel = parsedModel.thinkingLevel;
1725
1746
  const slash = parsedModel.modelSpec.indexOf('/');
1726
1747
  if (slash > 0) {
1727
- model = services.modelRegistry.find(parsedModel.modelSpec.slice(0, slash), parsedModel.modelSpec.slice(slash + 1));
1728
- if (model === undefined) {
1729
- process.stderr.write(`[broker] WARNING: model '${cfg.model}' not found in registry ` +
1730
- `falling back to the SDK default model.\n`);
1748
+ const provider = parsedModel.modelSpec.slice(0, slash);
1749
+ const id = parsedModel.modelSpec.slice(slash + 1);
1750
+ // `find()` is NOT auth-filtered it will happily return a model
1751
+ // whose provider has no credentials configured (e.g. a registered
1752
+ // OpenAI model on an Anthropic-OAuth-only box). Use it only as an
1753
+ // existence check, then gate on `hasConfiguredAuth()` before ever
1754
+ // handing the model to the SDK — otherwise launch "succeeds" and the
1755
+ // node only fails on its first real provider call.
1756
+ const candidate = services.modelRegistry.find(provider, id);
1757
+ if (candidate === undefined) {
1758
+ faultAndDie(`model '${cfg.model}' not registered. ` +
1759
+ `Available (authenticated): ${describeAvailableModels()}`);
1760
+ }
1761
+ else if (!services.modelRegistry.hasConfiguredAuth(candidate)) {
1762
+ faultAndDie(`model '${cfg.model}' is registered but its provider '${provider}' has no configured credentials. ` +
1763
+ `Available (authenticated): ${describeAvailableModels()}`);
1731
1764
  }
1765
+ else {
1766
+ model = candidate;
1767
+ }
1768
+ }
1769
+ else {
1770
+ faultAndDie(`model '${cfg.model}' has no 'provider/id' form and cannot be resolved. ` +
1771
+ `Available: ${describeAvailableModels()}`);
1772
+ }
1773
+ }
1774
+ else {
1775
+ // M-9 criterion 1: no explicit/kind-default model was resolved by
1776
+ // launch.ts. Left as `undefined`, the SDK's own findInitialModel would try
1777
+ // a *settings* default next (a provider/id saved in settings.json) via a
1778
+ // plain registry `find()` that does NOT check auth — an unauthenticated
1779
+ // settings default would silently become the session's model and only
1780
+ // fail on the first real request. Skip that gap entirely: select
1781
+ // explicitly from the AUTHENTICATED set ourselves, or fault.
1782
+ const available = services.modelRegistry.getAvailable();
1783
+ if (available.length === 0) {
1784
+ faultAndDie('no model specified and no authenticated model is available (no provider has valid credentials)');
1732
1785
  }
1733
1786
  else {
1734
- process.stderr.write(`[broker] WARNING: model '${cfg.model}' has no 'provider/id' form — ` +
1735
- `using the SDK default model.\n`);
1787
+ model = available[0];
1736
1788
  }
1737
1789
  }
1738
1790
  const created = await engine.createAgentSessionFromServices({
@@ -9,6 +9,10 @@ const POLICIES = {
9
9
  // deliberate agent/operator decision (kill the runaway subprocess), not a
10
10
  // provider back-off.
11
11
  wedged: { kind: 'wedged', gate: 'none', max: 0, baseDelayMs: 0, capDelayMs: 0, fatal: true },
12
+ // A launch-time model resolution failure (unregistered/unauthenticated spec) —
13
+ // never auto-retried, since retrying with the same unresolved model would just
14
+ // fail again identically. Fixing it requires a new --model or new credentials.
15
+ 'model-not-found': { kind: 'model-not-found', gate: 'none', max: 0, baseDelayMs: 0, capDelayMs: 0, fatal: true },
12
16
  };
13
17
  export function policyFor(kind) {
14
18
  return POLICIES[kind];
@@ -7,7 +7,7 @@
7
7
  // crtr --profile <id-or-name> → root under an explicit profile (else the
8
8
  // startup selector: a per-cwd pinned default,
9
9
  // else MRU covering cwd, else a synchronous
10
- // create-or-root prompt)
10
+ // create-or-root-profile prompt)
11
11
  // crtr --pick-profile → force the profile chooser open even when a
12
12
  // per-cwd default is pinned (to re-pin/unpin)
13
13
  // crtr <subcommand> ... → falls through to the normal dispatcher