@ravefox/space 0.0.0-stage → 0.2.1
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/LICENSE +205 -0
- package/NOTICE +55 -0
- package/README.md +63 -2
- package/assets/webhook-sdk/python/bot.py +63 -0
- package/assets/webhook-sdk/python/commonly.py +232 -0
- package/node_modules/@commonlyai/seats/README.md +25 -0
- package/node_modules/@commonlyai/seats/package.json +22 -0
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
- package/node_modules/@commonlyai/seats/src/contract.js +48 -0
- package/node_modules/@commonlyai/seats/src/failures.js +44 -0
- package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
- package/node_modules/@commonlyai/seats/src/index.js +22 -0
- package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
- package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
- package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
- package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
- package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
- package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
- package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
- package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
- package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
- package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
- package/package.json +63 -4
- package/skills/commonly/SKILL.md +233 -0
- package/src/commands/agent.js +6334 -0
- package/src/commands/daemon.js +933 -0
- package/src/commands/dev.js +153 -0
- package/src/commands/doctor.js +141 -0
- package/src/commands/inbox.js +330 -0
- package/src/commands/login.js +194 -0
- package/src/commands/mcp.js +56 -0
- package/src/commands/pod.js +120 -0
- package/src/commands/task.js +279 -0
- package/src/commands/verify.js +48 -0
- package/src/index.js +99 -0
- package/src/lib/adapter-login.js +76 -0
- package/src/lib/adapters/broker-bridge-entry.js +76 -0
- package/src/lib/adapters/claude.js +2207 -0
- package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
- package/src/lib/adapters/codex.js +1898 -0
- package/src/lib/adapters/dsh.js +513 -0
- package/src/lib/adapters/goose.js +510 -0
- package/src/lib/adapters/hermes.js +18 -0
- package/src/lib/adapters/index.js +68 -0
- package/src/lib/adapters/opencode.js +1681 -0
- package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
- package/src/lib/adapters/pi-mcp-client.mjs +617 -0
- package/src/lib/adapters/pi-shell-env.mjs +41 -0
- package/src/lib/adapters/pi.js +1025 -0
- package/src/lib/adapters/qwen.js +523 -0
- package/src/lib/adapters/seat-model.js +8 -0
- package/src/lib/adapters/stub.js +1 -0
- package/src/lib/adapters/tested-version.js +106 -0
- package/src/lib/api.js +205 -0
- package/src/lib/claim-outcome.js +128 -0
- package/src/lib/claimed-work-gate.js +73 -0
- package/src/lib/cli-hint.js +33 -0
- package/src/lib/commit-attribution.js +391 -0
- package/src/lib/config.js +155 -0
- package/src/lib/content-refusal.js +94 -0
- package/src/lib/credential-file.js +208 -0
- package/src/lib/daemon-agent-states.js +95 -0
- package/src/lib/daemon-hint.js +18 -0
- package/src/lib/daemon-logs.js +43 -0
- package/src/lib/daemon-service-windows.js +263 -0
- package/src/lib/daemon-service.js +486 -0
- package/src/lib/daemon-state.js +84 -0
- package/src/lib/daemon-store.js +119 -0
- package/src/lib/daemon-supervisor.js +629 -0
- package/src/lib/daemon-watch.js +115 -0
- package/src/lib/declared-mcp-guard.js +284 -0
- package/src/lib/default-environment.js +207 -0
- package/src/lib/device-login.js +126 -0
- package/src/lib/digest-frame.js +151 -0
- package/src/lib/doctor.js +291 -0
- package/src/lib/egress-proxy.js +160 -0
- package/src/lib/enforcement.js +946 -0
- package/src/lib/environment.js +681 -0
- package/src/lib/first-output-watchdog.js +191 -0
- package/src/lib/git-readonly-server.js +138 -0
- package/src/lib/git-readonly.js +463 -0
- package/src/lib/git-remote-commonly.mjs +100 -0
- package/src/lib/grant-broker-guard.js +345 -0
- package/src/lib/hooks-config.js +238 -0
- package/src/lib/host-gh-broker.js +152 -0
- package/src/lib/host-gh-shim.mjs +98 -0
- package/src/lib/inbox.js +483 -0
- package/src/lib/instance-proxy.js +76 -0
- package/src/lib/machine-host.js +29 -0
- package/src/lib/mcp-credential-delivery.js +121 -0
- package/src/lib/mcp-first-run.js +50 -0
- package/src/lib/mcp-home.js +644 -0
- package/src/lib/mcp-ready.js +68 -0
- package/src/lib/mcp-server-version.js +123 -0
- package/src/lib/mcp-token.js +36 -0
- package/src/lib/mcp-warm-child.mjs +412 -0
- package/src/lib/memory-bridge.js +191 -0
- package/src/lib/memory-import.js +156 -0
- package/src/lib/missing-harness.js +31 -0
- package/src/lib/model-unavailable.js +154 -0
- package/src/lib/native-runtime-inventory.js +40 -0
- package/src/lib/outsider-grant-mcp.js +82 -0
- package/src/lib/own-key-launch.js +42 -0
- package/src/lib/pod-catch-up.js +55 -0
- package/src/lib/pod-focus.js +224 -0
- package/src/lib/pod-tail.js +68 -0
- package/src/lib/poll-liveness.js +103 -0
- package/src/lib/poll-retry.js +81 -0
- package/src/lib/poller.js +200 -0
- package/src/lib/provider-proxy.js +42 -0
- package/src/lib/public-seat-mcp-flag.js +36 -0
- package/src/lib/public-tool-error.js +60 -0
- package/src/lib/public-withheld-tools.js +48 -0
- package/src/lib/reply-receipt.js +55 -0
- package/src/lib/resolve-on-path.js +22 -0
- package/src/lib/runtime-inventory.js +56 -0
- package/src/lib/runtime-observation.js +37 -0
- package/src/lib/sandbox/bwrap.js +275 -0
- package/src/lib/sandbox/codex-perimeter.js +16 -0
- package/src/lib/sandbox/host-check.js +268 -0
- package/src/lib/sandbox/mode.js +49 -0
- package/src/lib/sandbox/opencode-loopback.mjs +68 -0
- package/src/lib/sandbox/seatbelt.js +684 -0
- package/src/lib/sandbox/turn-dir.js +56 -0
- package/src/lib/sandbox/windows-unsandboxed.js +183 -0
- package/src/lib/sandbox/workspace-git-config.js +243 -0
- package/src/lib/sandbox/workspace-pin.js +158 -0
- package/src/lib/seat-runtime-controls.js +61 -0
- package/src/lib/seat-stop.js +97 -0
- package/src/lib/server-skills.js +32 -0
- package/src/lib/session-recycle.js +219 -0
- package/src/lib/session-store.js +215 -0
- package/src/lib/skill-prompt.js +74 -0
- package/src/lib/skills-import.js +113 -0
- package/src/lib/spawn-credential.js +402 -0
- package/src/lib/spawn-retry.js +311 -0
- package/src/lib/stable-install.js +176 -0
- package/src/lib/supervised-turn.js +352 -0
- package/src/lib/task.js +235 -0
- package/src/lib/turn-failure-notice.js +177 -0
- package/src/lib/unconfined-seat-guard.js +94 -0
- package/src/lib/update-check.js +192 -0
- package/src/lib/upstream-refusal.js +1 -0
- package/src/lib/webhook-server.js +109 -0
- package/src/lib/windows-command.js +393 -0
- package/src/lib/workspace-git.js +87 -0
- package/src/lib/workspace-trust.js +162 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory bridge — ADR-005 §Memory bridge, ADR-003 Phase 2.
|
|
3
|
+
*
|
|
4
|
+
* Two thin CAP shims the run loop calls around every spawn cycle:
|
|
5
|
+
*
|
|
6
|
+
* readLongTerm(client) — GET /api/agents/runtime/memory,
|
|
7
|
+
* returns sections.long_term.content,
|
|
8
|
+
* falling back to legacy content
|
|
9
|
+
* syncBack(client, { summary }) — POST /api/agents/runtime/memory/sync
|
|
10
|
+
* with mode:'patch', sourceRuntime:'local-cli'.
|
|
11
|
+
* No-op when summary is falsy.
|
|
12
|
+
*
|
|
13
|
+
* Identity (agentName, instanceId) is derived server-side from the runtime
|
|
14
|
+
* token (agentRuntimeAuth), so neither helper needs to pass it. Keeping it
|
|
15
|
+
* server-derived is invariant-preserving: a bug in the wrapper can't write
|
|
16
|
+
* to the wrong agent's memory.
|
|
17
|
+
*
|
|
18
|
+
* ADR-003 invariant #9: the wrapper supplies `content` + `visibility` ONLY.
|
|
19
|
+
* `byteSize`, `updatedAt`, and `schemaVersion` are server-stamped; supplying
|
|
20
|
+
* them from the client is wasted bytes and the kernel discards them.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { KEEP_PROCESS_ENV } from '@commonlyai/seats/turn-processes';
|
|
24
|
+
|
|
25
|
+
export const SOURCE_RUNTIME = 'local-cli';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Row slicing, the runtime-agnostic half of TASK-545. A row too large
|
|
29
|
+
* for one turn drifts when one seat carries it whole, so the seat splits it on
|
|
30
|
+
* the board before any code. It names the exact call and field for the reason
|
|
31
|
+
* the memory cue below names `long_term`: a seat adopts a habit through a
|
|
32
|
+
* surface it can see. Each adapter decides whether to pass it.
|
|
33
|
+
*/
|
|
34
|
+
export const ROW_SLICING_CUE = 'Slicing: when a row is too large for one turn, split it before '
|
|
35
|
+
+ 'writing any code. Post each slice as a child row (commonly_create_task with parentTask set '
|
|
36
|
+
+ "to the row's taskId) with one assignee, a written file or area boundary, and its own "
|
|
37
|
+
+ 'acceptance line.';
|
|
38
|
+
|
|
39
|
+
// The turn's leftovers are stopped when it ends (@commonlyai/seats/turn-processes),
|
|
40
|
+
// so a seat that means to reuse a server has to say so when it starts it.
|
|
41
|
+
export const BACKGROUND_PROCESS_CUE = 'Background processes: anything you start in the background is '
|
|
42
|
+
+ 'stopped when this turn ends, including a server that starts on first use, such as tmux, and an agent you '
|
|
43
|
+
+ 'restart with nohup. To keep one for a later turn, such as a dev server you will reuse, start it '
|
|
44
|
+
+ `with ${KEEP_PROCESS_ENV}=1 set, and stop it yourself when you are done with it.`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The turn preamble, shared by every adapter so the two cannot drift.
|
|
48
|
+
*
|
|
49
|
+
* Non-empty memory is prepended verbatim — that path is load-bearing and is
|
|
50
|
+
* not touched here.
|
|
51
|
+
*
|
|
52
|
+
* The EMPTY case used to return the bare prompt, which made two very different
|
|
53
|
+
* states byte-identical from inside the session: an agent that has never saved
|
|
54
|
+
* anything, and an agent on a runtime with no memory bridge at all. A seat
|
|
55
|
+
* cannot adopt a habit whose surface it has no evidence exists, so the empty
|
|
56
|
+
* case now says so.
|
|
57
|
+
*
|
|
58
|
+
* It names `long_term` specifically because that is the only v2 section read
|
|
59
|
+
* back. Legacy `content` is a fallback for records without that section. A write to `daily` —
|
|
60
|
+
* the section whose name most invites exactly this use — succeeds, returns 200,
|
|
61
|
+
* and is never seen again; the write and the silence are indistinguishable from
|
|
62
|
+
* a correct round trip. Naming the section is the whole point of the cue.
|
|
63
|
+
*
|
|
64
|
+
* `workCues` are working habits an adapter teaches on a fresh session, beside
|
|
65
|
+
* the memory cues and under the same once-per-session rule. An adapter passes
|
|
66
|
+
* only the habits its CLI can act on; passing none leaves the preamble as it
|
|
67
|
+
* was.
|
|
68
|
+
*
|
|
69
|
+
* `memoryReadOnly` is a public seat on a turn its owner didn't wake (F118, F145):
|
|
70
|
+
* it reads its memory and is shown the long_term it read, but it is not offered
|
|
71
|
+
* the save tool, so no cue names one, and it is told why. A resumed session may
|
|
72
|
+
* hold an earlier turn's offer, so that line goes in every turn, not just fresh
|
|
73
|
+
* ones. `memoryWithheld` wins over it: that turn reads nothing either.
|
|
74
|
+
*/
|
|
75
|
+
export const buildMemoryPreamble = (
|
|
76
|
+
prompt,
|
|
77
|
+
memoryLongTerm,
|
|
78
|
+
{ freshSession = false, workCues = [], memoryWithheld = false, memoryReadOnly = false } = {},
|
|
79
|
+
) => {
|
|
80
|
+
// `null` is the UNREADABLE signal, and it is deliberately not the same value
|
|
81
|
+
// as `''`. Telling a seat whose token was revoked that "nothing has ever been
|
|
82
|
+
// saved here" is a false claim about its own history, and it is the same
|
|
83
|
+
// defect this cue exists to fix — one state over. When we could not read, say
|
|
84
|
+
// that, and say nothing about what is stored.
|
|
85
|
+
const ownerOnly = 'You can read your memory on this turn but not change it: '
|
|
86
|
+
+ 'a memory write works only when your owner started the turn.';
|
|
87
|
+
let context;
|
|
88
|
+
if (memoryWithheld) {
|
|
89
|
+
// A workspace seat's turn that runs public: not unreadable and not empty,
|
|
90
|
+
// so neither of those cues is true. Its memory tools are off as well.
|
|
91
|
+
context = `=== Context (your persistent memory) ===\n`
|
|
92
|
+
+ `(withheld this turn — this turn runs as a public seat because not every `
|
|
93
|
+
+ `pod you answer in is confirmed private, so your memory stays out of it and `
|
|
94
|
+
+ `the memory tools are off. This says nothing about what you have saved.)`;
|
|
95
|
+
} else if (memoryLongTerm === null) {
|
|
96
|
+
context = `=== Context (your persistent memory) ===\n`
|
|
97
|
+
+ `(unreadable this turn — the memory read failed, so this says NOTHING `
|
|
98
|
+
+ `about what you have saved. Do not treat it as empty`
|
|
99
|
+
+ (memoryReadOnly ? `.)\n${ownerOnly}` : ` and do not re-save state you may already hold.)`);
|
|
100
|
+
} else if (memoryLongTerm) {
|
|
101
|
+
context = `=== Context (your persistent memory) ===\n${memoryLongTerm}`
|
|
102
|
+
+ (memoryReadOnly ? `\n(${ownerOnly})` : '');
|
|
103
|
+
} else if (memoryReadOnly) {
|
|
104
|
+
// F118: this turn isn't offered the save tool, so the cue that teaches it
|
|
105
|
+
// would name a tool the seat cannot call.
|
|
106
|
+
context = `=== Context (your persistent memory) ===\n`
|
|
107
|
+
+ `(empty — nothing has ever been saved here)\n${ownerOnly}`;
|
|
108
|
+
} else {
|
|
109
|
+
context = `=== Context (your persistent memory) ===\n`
|
|
110
|
+
+ `(empty — nothing has ever been saved here)\n`
|
|
111
|
+
+ `Only the \`long_term\` section is read back into this prompt. To make `
|
|
112
|
+
+ `something survive your next session, call commonly_save_my_memory({ `
|
|
113
|
+
+ `section: 'long_term', content: '...' }). A write to any other section `
|
|
114
|
+
+ `succeeds and is never shown to you again.`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
context = '=== Workspace identity ===\n'
|
|
118
|
+
+ 'This workspace is Space. Always call the product Space when explaining where you are. '
|
|
119
|
+
+ 'The upstream-compatible tools and environment variables keep their commonly_* and COMMONLY_* names.\n'
|
|
120
|
+
+ context;
|
|
121
|
+
|
|
122
|
+
if (!freshSession) return `${context}\n=== Current turn ===\n${prompt}`;
|
|
123
|
+
|
|
124
|
+
// This belongs on a fresh underlying CLI session, rather than every turn:
|
|
125
|
+
// a resumed session already carries the earlier instruction in its own
|
|
126
|
+
// transcript. Repeating it on each wake spends prompt budget while making
|
|
127
|
+
// the cue easier to ignore. The wrapper cannot know the final event of a
|
|
128
|
+
// session in advance, so it gives the end-of-session reminder while the
|
|
129
|
+
// agent can still act on it.
|
|
130
|
+
const freshReadCue = '=== Fresh session ===\n'
|
|
131
|
+
+ 'This is a fresh session. Read the persistent memory context above before acting; '
|
|
132
|
+
+ 'it carries durable state from prior sessions.\n';
|
|
133
|
+
let sessionEndCue = '=== Before this session ends ===\n';
|
|
134
|
+
if (memoryWithheld) {
|
|
135
|
+
sessionEndCue += 'Memory is withheld this turn, so there is nothing to save.';
|
|
136
|
+
} else if (memoryReadOnly) {
|
|
137
|
+
sessionEndCue += 'Memory is read-only on this turn, so there is nothing to save.';
|
|
138
|
+
} else if (memoryLongTerm === null) {
|
|
139
|
+
sessionEndCue += 'Memory was unreadable on this fresh session. Do not treat it as empty or '
|
|
140
|
+
+ 'write a replacement based on this cue.';
|
|
141
|
+
} else if (memoryLongTerm) {
|
|
142
|
+
sessionEndCue += 'At a natural end to meaningful work, save durable working state — '
|
|
143
|
+
+ 'gates held, decisions pending, and task context, not a transcript — with '
|
|
144
|
+
+ "commonly_save_my_memory({ section: 'long_term', content: '...' }).";
|
|
145
|
+
} else {
|
|
146
|
+
sessionEndCue += 'At a natural end to meaningful work, save durable working state — '
|
|
147
|
+
+ 'gates held, decisions pending, and task context, not a transcript — using '
|
|
148
|
+
+ 'the long_term write above.';
|
|
149
|
+
}
|
|
150
|
+
const workCue = workCues.length
|
|
151
|
+
? `\n=== Working a row ===\n${workCues.map((cue) => `- ${cue}`).join('\n')}\n`
|
|
152
|
+
: '';
|
|
153
|
+
return `${context}\n${freshReadCue}${workCue}\n=== Current turn ===\n${prompt}\n${sessionEndCue}`;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const readLongTerm = async (client, { onError } = {}) => {
|
|
157
|
+
try {
|
|
158
|
+
const body = await client.get('/api/agents/runtime/memory');
|
|
159
|
+
return body?.sections?.long_term?.content ?? body?.content ?? '';
|
|
160
|
+
} catch (err) {
|
|
161
|
+
// A 404 is the ONLY error that means what '' means: a fresh agent with no
|
|
162
|
+
// memory row yet. The kernel upserts on first write, so this is genuine
|
|
163
|
+
// absence and the empty cue is true.
|
|
164
|
+
if (err?.status === 404) return '';
|
|
165
|
+
// Everything else — auth revoked, 500, connection refused — is a failure to
|
|
166
|
+
// READ, which tells us nothing about what is stored. Returning '' here made
|
|
167
|
+
// the caller assert emptiness on no evidence.
|
|
168
|
+
//
|
|
169
|
+
// `err.status` is undefined for a transport failure, so the old guard
|
|
170
|
+
// (`err?.status && err.status !== 404`) skipped onError precisely when the
|
|
171
|
+
// backend was unreachable: the loudest condition was the silent one, and
|
|
172
|
+
// nothing contradicted the false cue.
|
|
173
|
+
onError?.(err);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export const syncBack = async (client, { summary } = {}) => {
|
|
179
|
+
if (!summary) return { skipped: true };
|
|
180
|
+
await client.post('/api/agents/runtime/memory/sync', {
|
|
181
|
+
mode: 'patch',
|
|
182
|
+
sourceRuntime: SOURCE_RUNTIME,
|
|
183
|
+
sections: {
|
|
184
|
+
long_term: {
|
|
185
|
+
content: summary,
|
|
186
|
+
visibility: 'private',
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
return { skipped: false };
|
|
191
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local memory import — Phase C of the retention plan ("your agent arrives
|
|
3
|
+
* whole"). Detects the memory a local agent already has on this machine
|
|
4
|
+
* (project CLAUDE.md / MEMORY.md, Claude Code's per-project auto-memory
|
|
5
|
+
* directory), composes it into one document, and promotes it into the
|
|
6
|
+
* kernel envelope's `long_term` section via POST /memory/sync.
|
|
7
|
+
*
|
|
8
|
+
* Design constraints:
|
|
9
|
+
* - Explicitly opt-in with preview (plan D3) — local memory files can
|
|
10
|
+
* contain private material; callers must confirm before anything is
|
|
11
|
+
* sent. This module only detects/composes/sends; the command layer
|
|
12
|
+
* owns the prompt.
|
|
13
|
+
* - APPEND, never clobber: `mode: 'patch'` replaces a section wholesale
|
|
14
|
+
* (mergePatchSections is section-level), so we read the existing
|
|
15
|
+
* long_term and append the import after it.
|
|
16
|
+
* - Server stamps byteSize/updatedAt/schemaVersion (ADR-003 invariant
|
|
17
|
+
* #9); we send content + visibility only.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'fs';
|
|
21
|
+
import { homedir } from 'os';
|
|
22
|
+
import { join, basename } from 'path';
|
|
23
|
+
|
|
24
|
+
export const SOURCE_RUNTIME = 'import-local';
|
|
25
|
+
|
|
26
|
+
// Envelope sections are curated context, not dumps. The auto-clearer story
|
|
27
|
+
// on the agent side starts caring around 400KB of session; a quarter of
|
|
28
|
+
// that is a generous ceiling for a one-shot import.
|
|
29
|
+
export const MAX_IMPORT_BYTES = 256 * 1024;
|
|
30
|
+
|
|
31
|
+
// Claude Code keeps per-project auto-memory under
|
|
32
|
+
// ~/.claude/projects/<slug>/memory where <slug> is the project path with
|
|
33
|
+
// every '/' replaced by '-'.
|
|
34
|
+
export const claudeProjectMemoryDir = (cwd, home) => join(
|
|
35
|
+
home,
|
|
36
|
+
'.claude',
|
|
37
|
+
'projects',
|
|
38
|
+
cwd.replaceAll('/', '-'),
|
|
39
|
+
'memory',
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const fileCandidate = (path, label) => {
|
|
43
|
+
if (!existsSync(path)) return null;
|
|
44
|
+
const stat = statSync(path);
|
|
45
|
+
if (!stat.isFile() || stat.size === 0) return null;
|
|
46
|
+
return { path, label, bytes: stat.size };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Find importable memory sources. An explicit path wins outright (file, or
|
|
51
|
+
* directory whose *.md files are all taken); otherwise auto-detect the
|
|
52
|
+
* well-known locations. Duplicates are collapsed by realpath — this repo,
|
|
53
|
+
* for instance, symlinks AGENTS.md → CLAUDE.md.
|
|
54
|
+
*/
|
|
55
|
+
export const detectMemorySources = ({
|
|
56
|
+
cwd = process.cwd(),
|
|
57
|
+
home = homedir(),
|
|
58
|
+
explicitPath = null,
|
|
59
|
+
} = {}) => {
|
|
60
|
+
const candidates = [];
|
|
61
|
+
|
|
62
|
+
if (explicitPath) {
|
|
63
|
+
if (!existsSync(explicitPath)) {
|
|
64
|
+
throw new Error(`No such file or directory: ${explicitPath}`);
|
|
65
|
+
}
|
|
66
|
+
if (statSync(explicitPath).isDirectory()) {
|
|
67
|
+
for (const f of readdirSync(explicitPath).filter((n) => n.endsWith('.md')).sort()) {
|
|
68
|
+
const c = fileCandidate(join(explicitPath, f), `from ${basename(explicitPath)}/`);
|
|
69
|
+
if (c) candidates.push(c);
|
|
70
|
+
}
|
|
71
|
+
if (!candidates.length) {
|
|
72
|
+
throw new Error(`No .md files found in ${explicitPath}`);
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
const c = fileCandidate(explicitPath, 'explicit');
|
|
76
|
+
if (!c) throw new Error(`${explicitPath} is empty or not a regular file`);
|
|
77
|
+
candidates.push(c);
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
const projectInstructions = fileCandidate(join(cwd, 'CLAUDE.md'), 'project instructions');
|
|
81
|
+
if (projectInstructions) candidates.push(projectInstructions);
|
|
82
|
+
const agentsMd = fileCandidate(join(cwd, 'AGENTS.md'), 'project instructions');
|
|
83
|
+
if (agentsMd) candidates.push(agentsMd);
|
|
84
|
+
const projectMemory = fileCandidate(join(cwd, 'MEMORY.md'), 'project memory');
|
|
85
|
+
if (projectMemory) candidates.push(projectMemory);
|
|
86
|
+
|
|
87
|
+
const memDir = claudeProjectMemoryDir(cwd, home);
|
|
88
|
+
if (existsSync(memDir) && statSync(memDir).isDirectory()) {
|
|
89
|
+
for (const f of readdirSync(memDir).filter((n) => n.endsWith('.md')).sort()) {
|
|
90
|
+
const c = fileCandidate(join(memDir, f), 'auto-memory');
|
|
91
|
+
if (c) candidates.push(c);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Collapse symlink duplicates (first mention wins its label).
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
return candidates.filter((c) => {
|
|
99
|
+
let real;
|
|
100
|
+
try {
|
|
101
|
+
real = realpathSync(c.path);
|
|
102
|
+
} catch {
|
|
103
|
+
real = c.path;
|
|
104
|
+
}
|
|
105
|
+
if (seen.has(real)) return false;
|
|
106
|
+
seen.add(real);
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Compose the sources into one markdown document with per-file provenance. */
|
|
112
|
+
export const composeImport = (sources) => {
|
|
113
|
+
if (!sources.length) throw new Error('Nothing to import');
|
|
114
|
+
const total = sources.reduce((n, s) => n + s.bytes, 0);
|
|
115
|
+
if (total > MAX_IMPORT_BYTES) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Import is ${Math.round(total / 1024)}KB — over the ${MAX_IMPORT_BYTES / 1024}KB ceiling. `
|
|
118
|
+
+ 'Pass an explicit --path to a smaller file, or trim the sources.',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const parts = sources.map((s) => {
|
|
122
|
+
const content = readFileSync(s.path, 'utf8').trim();
|
|
123
|
+
return `## Imported: ${basename(s.path)} (${s.label})\n\n${content}`;
|
|
124
|
+
});
|
|
125
|
+
return `# Imported local memory\n\n${parts.join('\n\n---\n\n')}`;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Promote the composed document into the agent's long_term section,
|
|
130
|
+
* appending after any existing content. `client` is an api.js client
|
|
131
|
+
* authenticated with the agent's runtime token.
|
|
132
|
+
*/
|
|
133
|
+
export const importMemory = async (client, { sources }) => {
|
|
134
|
+
const imported = composeImport(sources);
|
|
135
|
+
|
|
136
|
+
let existing = '';
|
|
137
|
+
try {
|
|
138
|
+
const body = await client.get('/api/agents/runtime/memory');
|
|
139
|
+
existing = body?.sections?.long_term?.content || '';
|
|
140
|
+
} catch (err) {
|
|
141
|
+
// Fresh agents 404 — that's the common case. Anything else should stop
|
|
142
|
+
// the import: appending to memory we couldn't read risks clobbering it.
|
|
143
|
+
if (err?.status && err.status !== 404) throw err;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const content = existing ? `${existing.trimEnd()}\n\n${imported}` : imported;
|
|
147
|
+
await client.post('/api/agents/runtime/memory/sync', {
|
|
148
|
+
mode: 'patch',
|
|
149
|
+
sourceRuntime: SOURCE_RUNTIME,
|
|
150
|
+
sections: {
|
|
151
|
+
long_term: { content, visibility: 'private' },
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return { files: sources.length, bytes: content.length, appended: Boolean(existing) };
|
|
156
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// F457: what a quick-try run says when the harness it needs isn't on PATH, and
|
|
2
|
+
// the report it leaves first. The run exits before it ever checks in, so
|
|
3
|
+
// without the report the connect page waits for it forever.
|
|
4
|
+
|
|
5
|
+
import { NPM } from './cli-hint.js';
|
|
6
|
+
|
|
7
|
+
// Only harnesses whose install command we have run ourselves get one here.
|
|
8
|
+
const INSTALL = {
|
|
9
|
+
claude: { label: 'Claude Code', command: `${NPM} i -g @anthropic-ai/claude-code`, signIn: 'claude' },
|
|
10
|
+
codex: { label: 'Codex', command: `${NPM} i -g @openai/codex`, signIn: 'codex' },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** One sentence on how to get the named harness, ending with "run this line again". */
|
|
14
|
+
export const missingHarnessHint = (name) => {
|
|
15
|
+
const known = INSTALL[name];
|
|
16
|
+
if (!known) return `Install ${name}, then run this line again.`;
|
|
17
|
+
return `Install ${known.label} (${known.command}), sign in once by running ${known.signIn}, then run this line again.`;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Tell the instance what this run looked for (a harness id, or null for none
|
|
22
|
+
* of the ones it probes). Best effort: an older instance without the route, or
|
|
23
|
+
* a failed call, must not hide the message the person needs in the terminal.
|
|
24
|
+
*/
|
|
25
|
+
export const reportMissingHarness = async (client, harness) => {
|
|
26
|
+
try {
|
|
27
|
+
await client?.post?.('/api/agents/runtime/harness-missing', { harness });
|
|
28
|
+
} catch {
|
|
29
|
+
// The terminal message below is the answer either way.
|
|
30
|
+
}
|
|
31
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F178 — a turn the model provider refused because the model itself is not
|
|
3
|
+
* available, and the one line the wrapper posts about it.
|
|
4
|
+
*
|
|
5
|
+
* Found on a Growth walk, 2026-11-05: a daemon seat with no server-side model
|
|
6
|
+
* ran claude with a model the person's Anthropic key was not scoped for. Every
|
|
7
|
+
* turn failed with "403 The requested model ... is not available for this API
|
|
8
|
+
* key", the seat log said so, and the pod said nothing. The person saw a silent
|
|
9
|
+
* agent and no way to fix it.
|
|
10
|
+
*
|
|
11
|
+
* The match is deliberately narrow. A 403 or 404 alone is not a model problem:
|
|
12
|
+
* a disabled organization, a revoked key and a wrong route all answer 403/404,
|
|
13
|
+
* and telling that person to change the model would send them the wrong way.
|
|
14
|
+
* So a failure counts only when it carries one of these shapes:
|
|
15
|
+
*
|
|
16
|
+
* 1. claude / Anthropic 403: "... model ... is not available for this API key",
|
|
17
|
+
* with the 403 attached as the status or on the phrase's own line
|
|
18
|
+
* 2. OpenAI-style error code `model_not_found` (codex, opencode, LiteLLM), as
|
|
19
|
+
* the structured `providerCode` adapterFailure keeps, not message text
|
|
20
|
+
* 3. a 404 whose text is model-shaped, on the same line or in one JSON
|
|
21
|
+
* error body: Anthropic's `not_found_error` body,
|
|
22
|
+
* kept as "model: <id>" by the adapter's keep-list, or "The model `<id>`
|
|
23
|
+
* does not exist", or "model <id> not found".
|
|
24
|
+
*
|
|
25
|
+
* A public seat's withheld-tool error quotes a TOOL's vendor text, not the
|
|
26
|
+
* turn's own provider, so it never counts (public-tool-error.js).
|
|
27
|
+
*/
|
|
28
|
+
import { PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from './public-tool-error.js';
|
|
29
|
+
import { CLI } from './cli-hint.js';
|
|
30
|
+
|
|
31
|
+
/** How long one model's hint stays posted before the seat may post it again. */
|
|
32
|
+
export const MODEL_UNAVAILABLE_HINT_WINDOW_MS = 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
const NOT_AVAILABLE_FOR_KEY_RE = /\bmodel\b[^\n]{0,160}?\bis not available for (?:this|your) API key\b/i;
|
|
35
|
+
// A status read out of text counts only where a status is written: at a line
|
|
36
|
+
// start, just inside a bracket, or after an explicit HTTP, HTTP/1.1, HTTP status,
|
|
37
|
+
// status, status_code, code, our own `upstream` label (adapterFailure's "(upstream 404)") or
|
|
38
|
+
// claude's `API Error`, each with an optional `:`, `=`, `_` or `-`. It is never
|
|
39
|
+
// followed by a word character, `.` or `-`. So `request_id=404`, `gpt-404-mini`
|
|
40
|
+
// and `claude-403x` are not statuses.
|
|
41
|
+
const statusRe = (code) => new RegExp('(?:^\\s*|[([{]\\s*|\\b(?:HTTP(?:/\\d(?:\\.\\d)?|\\s+status)?'
|
|
42
|
+
+ `|status(?:_?code)?|code|upstream|API\\s+Error)\\s*[:=_-]?\\s*)${code}(?![\\w.-])`, 'i');
|
|
43
|
+
const STATUS_403_RE = statusRe(403);
|
|
44
|
+
const MODEL_404_SHAPES = [
|
|
45
|
+
/\bmodel:\s*[`'"]?[\w.:/@-]+/i,
|
|
46
|
+
/\bthe model\b[^\n]{0,120}?\bdoes not exist\b/i,
|
|
47
|
+
/\bmodel\b[^\n]{0,120}?\bnot found\b/i,
|
|
48
|
+
];
|
|
49
|
+
const STATUS_404_RE = statusRe(404);
|
|
50
|
+
const modelShaped = (text) => MODEL_404_SHAPES.some((pattern) => pattern.test(text));
|
|
51
|
+
|
|
52
|
+
// A 404 counts only when the status and the model shape are one statement: the
|
|
53
|
+
// same line, or one JSON error body. An unrelated 404 on one stderr line must
|
|
54
|
+
// not be joined to a model mentioned on another.
|
|
55
|
+
const sameLine404 = (text) => text.split('\n').some((line) => STATUS_404_RE.test(line) && modelShaped(line));
|
|
56
|
+
|
|
57
|
+
const jsonBody404 = (text) => {
|
|
58
|
+
const start = text.indexOf('{');
|
|
59
|
+
const end = text.lastIndexOf('}');
|
|
60
|
+
if (start < 0 || end <= start) return false;
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(text.slice(start, end + 1));
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
|
68
|
+
const error = parsed.error && typeof parsed.error === 'object' ? parsed.error : parsed;
|
|
69
|
+
const statuses = [parsed.status, parsed.code, error.status, error.code];
|
|
70
|
+
if (statuses.some((value) => Number(value) >= 500)) return false;
|
|
71
|
+
const is404 = statuses.some((value) => String(value) === '404') || error.type === 'not_found_error';
|
|
72
|
+
const message = error.message ?? parsed.message;
|
|
73
|
+
return is404 && typeof message === 'string' && modelShaped(message);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// The model id, when the provider named it. Only this charset is ever echoed
|
|
77
|
+
// into a pod, so provider prose and anything redacted (`[redacted]`) cannot be.
|
|
78
|
+
const MODEL_NAME_RES = [
|
|
79
|
+
/\brequested model\s+[`'"]?([\w.:/@-]+?)[`'"]?\s+is not available\b/i,
|
|
80
|
+
/\bmodel:\s*[`'"]?([\w.:/@-]+)/i,
|
|
81
|
+
/\bmodel\s+[`'"]([\w.:/@-]+)[`'"]/i,
|
|
82
|
+
/\bmodel\s+([\w.:/@-]+)\s+(?:not found|does not exist)\b/i,
|
|
83
|
+
];
|
|
84
|
+
// An id read out of prose must look like one ("is", "not" never do); a seat's
|
|
85
|
+
// own configured model may be a bare alias such as `sonnet`.
|
|
86
|
+
const ID_LIKE_RE = /[\d.:/-]/;
|
|
87
|
+
const SAFE_MODEL_RE = /^(?=.*[A-Za-z])[\w.:/@-]{1,100}$/;
|
|
88
|
+
|
|
89
|
+
const failureText = (error) => [error?.message, error?.stderr, error?.body?.error, error?.body?.message]
|
|
90
|
+
.filter((value) => typeof value === 'string' && value)
|
|
91
|
+
.join('\n');
|
|
92
|
+
|
|
93
|
+
export const safeModelName = (value) => {
|
|
94
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
95
|
+
// A leading `@` would read as a mention in the pod, and `://` as a link.
|
|
96
|
+
if (text.startsWith('@') || text.includes('://')) return null;
|
|
97
|
+
return SAFE_MODEL_RE.test(text) ? text : null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `{ model }` when the failure is a provider saying the model is unavailable,
|
|
102
|
+
* else null. `model` is the id the provider named, then `fallbackModel` (the
|
|
103
|
+
* seat's own setting), then null when neither is known.
|
|
104
|
+
*/
|
|
105
|
+
export const modelUnavailableFailure = (error, { fallbackModel = null } = {}) => {
|
|
106
|
+
if (!error || error.code === PUBLIC_TOOL_FAILED_CODE || error.code === PUBLIC_MCP_NOT_READY_CODE) return null;
|
|
107
|
+
const text = failureText(error);
|
|
108
|
+
if (!text) return null;
|
|
109
|
+
// The key-scope phrase counts only on a 403: the attached status, or a 403 on
|
|
110
|
+
// the phrase's own line. The provider's code counts only as the structured
|
|
111
|
+
// field adapterFailure keeps, never as words in a message.
|
|
112
|
+
// An attached status is the adapter's own reading of the failure, so it wins:
|
|
113
|
+
// when it is set, only that status decides, and the text is not searched for
|
|
114
|
+
// another one. Without it, the status must be written on the same line.
|
|
115
|
+
const attached = typeof error.status === 'number' ? error.status : null;
|
|
116
|
+
const lines = text.split('\n');
|
|
117
|
+
const keyScope = NOT_AVAILABLE_FOR_KEY_RE.test(text) && (attached !== null ? attached === 403
|
|
118
|
+
: lines.some((line) => STATUS_403_RE.test(line) && NOT_AVAILABLE_FOR_KEY_RE.test(line)));
|
|
119
|
+
// The attached status reads the message, so its model shape must be on the
|
|
120
|
+
// message's own first line, not in what later stderr happens to mention.
|
|
121
|
+
const notFound = attached !== null
|
|
122
|
+
? attached === 404 && (modelShaped(String(error.message).split('\n')[0]) || jsonBody404(text))
|
|
123
|
+
: sameLine404(text) || jsonBody404(text);
|
|
124
|
+
const matched = keyScope
|
|
125
|
+
|| (error.providerCode === 'model_not_found' && !(attached >= 500))
|
|
126
|
+
|| notFound;
|
|
127
|
+
if (!matched) return null;
|
|
128
|
+
for (const pattern of MODEL_NAME_RES) {
|
|
129
|
+
const named = safeModelName(pattern.exec(text)?.[1]);
|
|
130
|
+
if (named && ID_LIKE_RE.test(named)) return { model: named };
|
|
131
|
+
}
|
|
132
|
+
return { model: safeModelName(fallbackModel) };
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const CLI_AGENT_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The one line posted in the pod. The model is set in the agent's Settings,
|
|
139
|
+
* under the "How it runs" section, which starts collapsed (F354: a walk missed
|
|
140
|
+
* it). `cliAgentName` is for a `commonly agent run` seat whose record has no
|
|
141
|
+
* saved environment: Settings doesn't reach that seat until `agent config`
|
|
142
|
+
* writes one, so the line names that command instead. A name that can't be
|
|
143
|
+
* printed safely becomes `<name>`: Settings still wouldn't reach that seat.
|
|
144
|
+
*/
|
|
145
|
+
export const modelUnavailableHint = (model, { cliAgentName = null } = {}) => {
|
|
146
|
+
const what = model
|
|
147
|
+
? `The model provider says ${model} isn't available for this agent.`
|
|
148
|
+
: "The model provider says this agent's model isn't available.";
|
|
149
|
+
if (typeof cliAgentName !== 'string' || !cliAgentName) {
|
|
150
|
+
return `${what} To change it, open the agent's Settings, then How it runs, then Model.`;
|
|
151
|
+
}
|
|
152
|
+
const name = CLI_AGENT_NAME_RE.test(cliAgentName) ? cliAgentName : '<name>';
|
|
153
|
+
return `${what} To change it, run ${CLI} agent config ${name} --model <model> on the computer it runs on, then restart ${CLI} agent run ${name}.`;
|
|
154
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { resolveWindowsCommandWithReason } from './windows-command.js';
|
|
6
|
+
|
|
7
|
+
export const claudeEffortsFromHelp = (help) => {
|
|
8
|
+
const section = String(help).match(/--effort\s+<[^>]+>([\s\S]*?)(?=\n\s+--|$)/)?.[1];
|
|
9
|
+
const declared = section?.match(/\((low(?:\s*,\s*(?:medium|high|xhigh|max))*)\)/)?.[1];
|
|
10
|
+
return declared ? declared.split(',').map((level) => level.trim()) : [];
|
|
11
|
+
};
|
|
12
|
+
export const codexModelsFromCache = (cache) => {
|
|
13
|
+
const models = Array.isArray(cache?.models) ? cache.models.filter((row) => typeof row.slug === 'string'
|
|
14
|
+
&& row.slug.length > 0 && row.slug.length <= 256 && row.visibility !== 'hidden' && row.visibility !== 'hide') : [];
|
|
15
|
+
const levels = models.map((row) => (Array.isArray(row.supported_reasoning_levels)
|
|
16
|
+
? row.supported_reasoning_levels.flatMap((level) => typeof level.effort === 'string' ? [level.effort] : []) : []));
|
|
17
|
+
return { models: models.length ? models.map((row) => row.slug) : null,
|
|
18
|
+
// Only levels supported by every advertised model are offered globally.
|
|
19
|
+
efforts: levels.length ? levels[0].filter((level) => levels.every((list) => list.includes(level))) : [] };
|
|
20
|
+
};
|
|
21
|
+
export const claudeRuntimeInventory = async ({
|
|
22
|
+
spawnSyncImpl = spawnSync, env = process.env, commandResolution = {},
|
|
23
|
+
} = {}) => {
|
|
24
|
+
// Windows: an npm `claude.cmd` is resolved to its target, never run through
|
|
25
|
+
// a shell (#2141). Elsewhere this is `claude --help`, unchanged.
|
|
26
|
+
const located = resolveWindowsCommandWithReason('claude', { env, ...commandResolution });
|
|
27
|
+
if (!located.command) return { models: null, efforts: [] };
|
|
28
|
+
const result = spawnSyncImpl(located.command, [...located.args, '--help'], {
|
|
29
|
+
encoding: 'utf8', timeout: 5000, maxBuffer: 128 * 1024, cwd: tmpdir(), env,
|
|
30
|
+
});
|
|
31
|
+
return { models: null, efforts: result.status === 0 ? claudeEffortsFromHelp(result.stdout) : [] };
|
|
32
|
+
};
|
|
33
|
+
export const codexRuntimeInventory = async ({ readFileImpl = readFile, statImpl = stat, env = process.env } = {}) => {
|
|
34
|
+
// Native cache only: no command that could execute workspace extensions.
|
|
35
|
+
const path = join(env.CODEX_HOME || join(homedir(), '.codex'), 'models_cache.json');
|
|
36
|
+
try {
|
|
37
|
+
if ((await statImpl(path)).size > 2 * 1024 * 1024) return { models: null, efforts: [] };
|
|
38
|
+
return codexModelsFromCache(JSON.parse(await readFileImpl(path, 'utf8')));
|
|
39
|
+
} catch { return { models: null, efforts: [] }; }
|
|
40
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A confined seat that an outsider-writable grant reaches runs with only the
|
|
3
|
+
* shipped MCP servers (docs/plans/hosted-mcp-connection-scope.md §12 C).
|
|
4
|
+
*
|
|
5
|
+
* While a grant whose source outsiders can write reaches one of a seat's pods,
|
|
6
|
+
* the server narrows what the seat's token can do (§12 A). It cannot see the
|
|
7
|
+
* MCP servers the seat loads on this machine, though. The daemon admits a
|
|
8
|
+
* declared stdio entry that equals one in the local token record
|
|
9
|
+
* (`auditDeclaredMcp`), so a server its owner installed locally can sit beside
|
|
10
|
+
* the grant's results, and content an outsider wrote can steer it. So before
|
|
11
|
+
* each turn, a confined seat that may be narrowed is refused while its MCP set
|
|
12
|
+
* holds anything but the shipped Commonly server or a shipped grant broker.
|
|
13
|
+
*
|
|
14
|
+
* The effective set is the declared one. A confined seat's adapter excludes
|
|
15
|
+
* user- and project-scope MCP config (claude `--strict-mcp-config`, codex
|
|
16
|
+
* `--ignore-user-config`, isolated homes for the others), and
|
|
17
|
+
* `outsider-grant-mcp.test.mjs` pins those flags, so a scope added to an
|
|
18
|
+
* adapter fails that test before it widens this set.
|
|
19
|
+
*
|
|
20
|
+
* Only confined seats (`sandbox.trust` public or workspace, with the legacy
|
|
21
|
+
* `internal` read as public) are checked. An
|
|
22
|
+
* unconfined seat named on a grant runs on its owner's machine with a shell,
|
|
23
|
+
* under the owner's consent that what it reads can leave, so a local MCP
|
|
24
|
+
* server adds nothing it lacks (§12, the F94 amendment).
|
|
25
|
+
*/
|
|
26
|
+
import { isShippedCommonlyMcpEntry, isShippedGrantBrokerEntry } from './declared-mcp-guard.js';
|
|
27
|
+
import { effectiveSandboxTrust } from './environment.js';
|
|
28
|
+
|
|
29
|
+
export const OUTSIDER_GRANT_MCP_UNCONFINED = 'outsider_grant_mcp_unconfined';
|
|
30
|
+
|
|
31
|
+
const CONFINED_TRUSTS = new Set(['public', 'workspace']);
|
|
32
|
+
|
|
33
|
+
// Read through the legacy table, as the broker guard and the adapters read it:
|
|
34
|
+
// a record still carrying `internal` is confined as `public`, and is handed
|
|
35
|
+
// the broker as a public seat, so it is checked as one.
|
|
36
|
+
export const seatIsConfined = (environment) => CONFINED_TRUSTS.has(
|
|
37
|
+
effectiveSandboxTrust(environment?.sandbox?.trust),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// The server flags a broker on a grant that names this seat (`byoNamed`). It is
|
|
41
|
+
// a marker for the guard, not something the client reads, so it is set aside
|
|
42
|
+
// before the whole-entry match, and only as exactly `true`.
|
|
43
|
+
const withoutByoNamedFlag = (server) => {
|
|
44
|
+
if (!server || typeof server !== 'object' || server.byoNamed !== true) return server;
|
|
45
|
+
const { byoNamed: _byoNamed, ...rest } = server;
|
|
46
|
+
return rest;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The entries of a turn's MCP set that are neither shipped server. */
|
|
50
|
+
export const unshippedMcpEntries = (environment, instanceUrl) => {
|
|
51
|
+
const servers = Array.isArray(environment?.mcp) ? environment.mcp : [];
|
|
52
|
+
return servers.filter((server) => !isShippedCommonlyMcpEntry(server)
|
|
53
|
+
&& !isShippedGrantBrokerEntry(withoutByoNamedFlag(server), instanceUrl));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether the server's answer leaves the seat possibly narrowed. Only an
|
|
58
|
+
* explicit `outsiderGrantNarrowed: false` clears it. A server from before the
|
|
59
|
+
* field, or an answer that lacks it, counts as narrowed, because the CLI
|
|
60
|
+
* publishes before the server change is deployed.
|
|
61
|
+
*/
|
|
62
|
+
export const mayBeNarrowed = (identity) => identity?.outsiderGrantNarrowed !== false;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @returns {null | { code: string, names: string[], message: string }}
|
|
66
|
+
*/
|
|
67
|
+
export const outsiderGrantMcpRefusal = ({ environment, identity, instanceUrl }) => {
|
|
68
|
+
if (!seatIsConfined(environment)) return null;
|
|
69
|
+
const extra = unshippedMcpEntries(environment, instanceUrl);
|
|
70
|
+
if (!extra.length || !mayBeNarrowed(identity)) return null;
|
|
71
|
+
const names = extra.map((server) => String(server?.name || '(unnamed)'));
|
|
72
|
+
const why = identity && 'outsiderGrantNarrowed' in identity
|
|
73
|
+
? 'a connector grant whose source outsiders can write reaches one of its pods'
|
|
74
|
+
: 'the server did not say whether a connector grant reaches its pods, so it is treated as if one does';
|
|
75
|
+
return {
|
|
76
|
+
code: OUTSIDER_GRANT_MCP_UNCONFINED,
|
|
77
|
+
names,
|
|
78
|
+
message: `${OUTSIDER_GRANT_MCP_UNCONFINED}: this seat loads MCP servers beyond the shipped Space server `
|
|
79
|
+
+ `and grant broker (${names.join(', ')}), and ${why}. Remove them from the seat's environment, `
|
|
80
|
+
+ 'or use a separate agent for the connector pod.',
|
|
81
|
+
};
|
|
82
|
+
};
|