@crouton-kit/crouter 0.3.60 → 0.3.62
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.
- package/dist/builtin-views/canvas/core.mjs +2 -1
- package/dist/clients/attach/__tests__/context-message.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/context-message.test.js +46 -0
- package/dist/clients/attach/__tests__/transport-relay.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/transport-relay.test.js +176 -0
- package/dist/clients/attach/attach-cmd.js +747 -743
- package/dist/clients/attach/canvas-panels.d.ts +4 -4
- package/dist/clients/attach/canvas-panels.js +108 -37
- package/dist/clients/attach/chat-view.js +11 -0
- package/dist/clients/attach/context-message.d.ts +3 -1
- package/dist/clients/attach/context-message.js +30 -21
- package/dist/clients/attach/graph-overlay.d.ts +8 -1
- package/dist/clients/attach/graph-overlay.js +201 -22
- package/dist/clients/attach/transport-relay.d.ts +51 -0
- package/dist/clients/attach/transport-relay.js +196 -0
- package/dist/clients/attach/transport-socket.d.ts +29 -0
- package/dist/clients/attach/transport-socket.js +164 -0
- package/dist/clients/attach/transport.d.ts +32 -0
- package/dist/clients/attach/transport.js +26 -0
- package/dist/clients/attach/view-socket.d.ts +40 -44
- package/dist/clients/attach/view-socket.js +94 -197
- package/dist/clients/web/server.js +1 -0
- package/dist/commands/memory/delete.js +3 -3
- package/dist/commands/memory/origin.js +1 -1
- package/dist/commands/memory/read.js +3 -3
- package/dist/commands/memory/shared.d.ts +8 -6
- package/dist/commands/memory/shared.js +23 -9
- package/dist/commands/memory/write.js +3 -3
- package/dist/commands/node-worktree.js +9 -9
- package/dist/commands/node.js +71 -23
- package/dist/commands/sys/doctor.js +1 -0
- package/dist/core/__tests__/broker-fork-seam.test.js +17 -2
- package/dist/core/__tests__/broker-snapshot-history.test.js +12 -2
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +79 -0
- package/dist/core/__tests__/context-intro.test.js +11 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +9 -4
- package/dist/core/__tests__/fixtures/fake-engine.js +12 -4
- package/dist/core/__tests__/kickoff.test.js +21 -0
- package/dist/core/__tests__/spawn-root.test.js +60 -0
- package/dist/core/__tests__/worktree.test.js +24 -1
- package/dist/core/canvas/browse/app.d.ts +2 -0
- package/dist/core/canvas/browse/app.js +83 -48
- package/dist/core/canvas/db.js +3 -2
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/canvas/nav-model.d.ts +5 -3
- package/dist/core/canvas/nav-model.js +7 -6
- package/dist/core/canvas/paths.d.ts +8 -0
- package/dist/core/canvas/paths.js +10 -0
- package/dist/core/canvas/render.d.ts +6 -0
- package/dist/core/canvas/render.js +117 -20
- package/dist/core/canvas/source.d.ts +26 -0
- package/dist/core/canvas/source.js +29 -0
- package/dist/core/canvas/status-glyph.js +1 -0
- package/dist/core/canvas/types.d.ts +4 -3
- package/dist/core/config.d.ts +18 -2
- package/dist/core/config.js +88 -10
- package/dist/core/fault-classifier.d.ts +2 -2
- package/dist/core/memory-resolver.d.ts +9 -6
- package/dist/core/memory-resolver.js +28 -4
- package/dist/core/profiles/__tests__/root-profile.test.d.ts +1 -0
- package/dist/core/profiles/__tests__/root-profile.test.js +69 -0
- package/dist/core/profiles/manifest.d.ts +2 -0
- package/dist/core/profiles/manifest.js +40 -7
- package/dist/core/profiles/select.d.ts +4 -4
- package/dist/core/profiles/select.js +13 -13
- package/dist/core/runtime/__tests__/spawn-env.test.d.ts +1 -0
- package/dist/core/runtime/__tests__/spawn-env.test.js +268 -0
- package/dist/core/runtime/bearings.d.ts +5 -4
- package/dist/core/runtime/bearings.js +12 -6
- package/dist/core/runtime/broker.js +59 -7
- package/dist/core/runtime/fault-recovery.js +4 -0
- package/dist/core/runtime/front-door.js +1 -1
- package/dist/core/runtime/host.js +43 -3
- package/dist/core/runtime/kickoff.js +6 -0
- package/dist/core/runtime/launch.d.ts +6 -1
- package/dist/core/runtime/launch.js +7 -1
- package/dist/core/runtime/nodes.d.ts +7 -6
- package/dist/core/runtime/nodes.js +3 -3
- package/dist/core/runtime/situational-context.d.ts +12 -0
- package/dist/core/runtime/situational-context.js +48 -0
- package/dist/core/runtime/spawn-env.d.ts +27 -0
- package/dist/core/runtime/spawn-env.js +169 -0
- package/dist/core/runtime/spawn.d.ts +16 -7
- package/dist/core/runtime/spawn.js +8 -2
- package/dist/core/substrate/session-cache.js +7 -1
- package/dist/core/substrate/subject.d.ts +1 -1
- package/dist/core/view/remote-canvas-target.d.ts +3 -0
- package/dist/core/view/remote-canvas-target.js +25 -0
- package/dist/core/worktree.d.ts +4 -4
- package/dist/core/worktree.js +41 -19
- package/dist/daemon/crtrd.js +32 -0
- package/dist/daemon/predicate-eval.js +12 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
- package/dist/pi-extensions/canvas-bash-valve.js +238 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +12 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +41 -5
- package/dist/types.d.ts +32 -0
- package/dist/types.js +7 -0
- package/dist/web-client/apple-touch-icon.png +0 -0
- package/dist/web-client/assets/index-BnmSLNLa.css +2 -0
- package/dist/web-client/assets/index-BvzxXXGU.js +77 -0
- package/dist/web-client/assets/workbox-window.prod.es5-Bd17z0YL.js +2 -0
- package/dist/web-client/icon-192.png +0 -0
- package/dist/web-client/icon-512.png +0 -0
- package/dist/web-client/icon-maskable-512.png +0 -0
- package/dist/web-client/index.html +6 -3
- package/dist/web-client/manifest.webmanifest +1 -0
- package/dist/web-client/sw.js +1 -0
- package/docs/public-api.md +5 -3
- package/package.json +4 -3
- package/dist/web-client/assets/fraunces-latin-ext-wght-normal-Ca2vKHc0.woff2 +0 -0
- package/dist/web-client/assets/fraunces-latin-wght-normal-ukD16Tqj.woff2 +0 -0
- package/dist/web-client/assets/fraunces-vietnamese-wght-normal-CnvboYUG.woff2 +0 -0
- package/dist/web-client/assets/index-72YQdji_.js +0 -77
- package/dist/web-client/assets/index-bDhImjJy.css +0 -2
- package/dist/web-client/assets/instrument-sans-latin-ext-wght-normal-B5bTHO_g.woff2 +0 -0
- package/dist/web-client/assets/instrument-sans-latin-wght-normal-BbzFLZTg.woff2 +0 -0
|
@@ -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
|
|
43
|
-
const
|
|
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
|
-
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null, forcePicker?: boolean): Promise<string
|
|
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
|
|
136
|
-
return (`crtr: no profile covers ${cwd}, and this session is non-interactive —
|
|
137
|
-
`Run \`crtr profile new --name <name> --project ${cwd}\` to create
|
|
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('
|
|
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
|
|
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
|
|
300
|
-
*
|
|
301
|
-
*
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
|
17
|
-
* never throws (mirrors
|
|
18
|
-
*
|
|
19
|
-
* know its full purview, not a
|
|
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('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>');
|
|
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
|
|
146
|
-
* never throws (mirrors
|
|
147
|
-
*
|
|
148
|
-
* know its full purview, not a
|
|
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
|
|
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
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
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
|
-
|
|
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
|