@ravefox/space 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +21 -205
  2. package/README.md +104 -31
  3. package/bin/space.js +113 -0
  4. package/lib/agents.js +181 -0
  5. package/lib/boardTools.js +89 -0
  6. package/lib/credentials.js +95 -0
  7. package/lib/daemon.js +294 -0
  8. package/lib/harness.js +166 -0
  9. package/lib/http.js +101 -0
  10. package/lib/login.js +68 -0
  11. package/lib/mcp.js +39 -0
  12. package/lib/runtime.js +253 -0
  13. package/lib/store.js +96 -0
  14. package/lib/tools.js +93 -0
  15. package/lib/workspaceSandbox.js +77 -0
  16. package/lib/workspaceTools.js +160 -0
  17. package/lib/workspaceWorker.js +17 -0
  18. package/package.json +27 -46
  19. package/NOTICE +0 -55
  20. package/assets/webhook-sdk/python/bot.py +0 -63
  21. package/assets/webhook-sdk/python/commonly.py +0 -232
  22. package/node_modules/@commonlyai/seats/README.md +0 -25
  23. package/node_modules/@commonlyai/seats/package.json +0 -22
  24. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
  25. package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
  26. package/node_modules/@commonlyai/seats/src/contract.js +0 -48
  27. package/node_modules/@commonlyai/seats/src/failures.js +0 -44
  28. package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
  29. package/node_modules/@commonlyai/seats/src/index.js +0 -22
  30. package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
  31. package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
  32. package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
  33. package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
  34. package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
  35. package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
  36. package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
  37. package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
  38. package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
  39. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
  40. package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
  41. package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
  42. package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
  43. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
  44. package/skills/commonly/SKILL.md +0 -233
  45. package/src/commands/agent.js +0 -6334
  46. package/src/commands/daemon.js +0 -933
  47. package/src/commands/dev.js +0 -153
  48. package/src/commands/doctor.js +0 -141
  49. package/src/commands/inbox.js +0 -330
  50. package/src/commands/login.js +0 -194
  51. package/src/commands/mcp.js +0 -56
  52. package/src/commands/pod.js +0 -120
  53. package/src/commands/task.js +0 -279
  54. package/src/commands/verify.js +0 -48
  55. package/src/index.js +0 -99
  56. package/src/lib/adapter-login.js +0 -76
  57. package/src/lib/adapters/broker-bridge-entry.js +0 -76
  58. package/src/lib/adapters/claude.js +0 -2207
  59. package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
  60. package/src/lib/adapters/codex.js +0 -1898
  61. package/src/lib/adapters/dsh.js +0 -513
  62. package/src/lib/adapters/goose.js +0 -510
  63. package/src/lib/adapters/hermes.js +0 -18
  64. package/src/lib/adapters/index.js +0 -68
  65. package/src/lib/adapters/opencode.js +0 -1681
  66. package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
  67. package/src/lib/adapters/pi-mcp-client.mjs +0 -617
  68. package/src/lib/adapters/pi-shell-env.mjs +0 -41
  69. package/src/lib/adapters/pi.js +0 -1025
  70. package/src/lib/adapters/qwen.js +0 -523
  71. package/src/lib/adapters/seat-model.js +0 -8
  72. package/src/lib/adapters/stub.js +0 -1
  73. package/src/lib/adapters/tested-version.js +0 -106
  74. package/src/lib/api.js +0 -205
  75. package/src/lib/claim-outcome.js +0 -128
  76. package/src/lib/claimed-work-gate.js +0 -73
  77. package/src/lib/cli-hint.js +0 -33
  78. package/src/lib/commit-attribution.js +0 -391
  79. package/src/lib/config.js +0 -155
  80. package/src/lib/content-refusal.js +0 -94
  81. package/src/lib/credential-file.js +0 -208
  82. package/src/lib/daemon-agent-states.js +0 -95
  83. package/src/lib/daemon-hint.js +0 -18
  84. package/src/lib/daemon-logs.js +0 -43
  85. package/src/lib/daemon-service-windows.js +0 -263
  86. package/src/lib/daemon-service.js +0 -486
  87. package/src/lib/daemon-state.js +0 -84
  88. package/src/lib/daemon-store.js +0 -119
  89. package/src/lib/daemon-supervisor.js +0 -629
  90. package/src/lib/daemon-watch.js +0 -115
  91. package/src/lib/declared-mcp-guard.js +0 -284
  92. package/src/lib/default-environment.js +0 -207
  93. package/src/lib/device-login.js +0 -126
  94. package/src/lib/digest-frame.js +0 -151
  95. package/src/lib/doctor.js +0 -291
  96. package/src/lib/egress-proxy.js +0 -160
  97. package/src/lib/enforcement.js +0 -946
  98. package/src/lib/environment.js +0 -681
  99. package/src/lib/first-output-watchdog.js +0 -191
  100. package/src/lib/git-readonly-server.js +0 -138
  101. package/src/lib/git-readonly.js +0 -463
  102. package/src/lib/git-remote-commonly.mjs +0 -100
  103. package/src/lib/grant-broker-guard.js +0 -345
  104. package/src/lib/hooks-config.js +0 -238
  105. package/src/lib/host-gh-broker.js +0 -152
  106. package/src/lib/host-gh-shim.mjs +0 -98
  107. package/src/lib/inbox.js +0 -483
  108. package/src/lib/instance-proxy.js +0 -76
  109. package/src/lib/machine-host.js +0 -29
  110. package/src/lib/mcp-credential-delivery.js +0 -121
  111. package/src/lib/mcp-first-run.js +0 -50
  112. package/src/lib/mcp-home.js +0 -644
  113. package/src/lib/mcp-ready.js +0 -68
  114. package/src/lib/mcp-server-version.js +0 -123
  115. package/src/lib/mcp-token.js +0 -36
  116. package/src/lib/mcp-warm-child.mjs +0 -412
  117. package/src/lib/memory-bridge.js +0 -191
  118. package/src/lib/memory-import.js +0 -156
  119. package/src/lib/missing-harness.js +0 -31
  120. package/src/lib/model-unavailable.js +0 -154
  121. package/src/lib/native-runtime-inventory.js +0 -40
  122. package/src/lib/outsider-grant-mcp.js +0 -82
  123. package/src/lib/own-key-launch.js +0 -42
  124. package/src/lib/pod-catch-up.js +0 -55
  125. package/src/lib/pod-focus.js +0 -224
  126. package/src/lib/pod-tail.js +0 -68
  127. package/src/lib/poll-liveness.js +0 -103
  128. package/src/lib/poll-retry.js +0 -81
  129. package/src/lib/poller.js +0 -200
  130. package/src/lib/provider-proxy.js +0 -42
  131. package/src/lib/public-seat-mcp-flag.js +0 -36
  132. package/src/lib/public-tool-error.js +0 -60
  133. package/src/lib/public-withheld-tools.js +0 -48
  134. package/src/lib/reply-receipt.js +0 -55
  135. package/src/lib/resolve-on-path.js +0 -22
  136. package/src/lib/runtime-inventory.js +0 -56
  137. package/src/lib/runtime-observation.js +0 -37
  138. package/src/lib/sandbox/bwrap.js +0 -275
  139. package/src/lib/sandbox/codex-perimeter.js +0 -16
  140. package/src/lib/sandbox/host-check.js +0 -268
  141. package/src/lib/sandbox/mode.js +0 -49
  142. package/src/lib/sandbox/opencode-loopback.mjs +0 -68
  143. package/src/lib/sandbox/seatbelt.js +0 -684
  144. package/src/lib/sandbox/turn-dir.js +0 -56
  145. package/src/lib/sandbox/windows-unsandboxed.js +0 -183
  146. package/src/lib/sandbox/workspace-git-config.js +0 -243
  147. package/src/lib/sandbox/workspace-pin.js +0 -158
  148. package/src/lib/seat-runtime-controls.js +0 -61
  149. package/src/lib/seat-stop.js +0 -97
  150. package/src/lib/server-skills.js +0 -32
  151. package/src/lib/session-recycle.js +0 -219
  152. package/src/lib/session-store.js +0 -215
  153. package/src/lib/skill-prompt.js +0 -74
  154. package/src/lib/skills-import.js +0 -113
  155. package/src/lib/spawn-credential.js +0 -402
  156. package/src/lib/spawn-retry.js +0 -311
  157. package/src/lib/stable-install.js +0 -176
  158. package/src/lib/supervised-turn.js +0 -352
  159. package/src/lib/task.js +0 -235
  160. package/src/lib/turn-failure-notice.js +0 -177
  161. package/src/lib/unconfined-seat-guard.js +0 -94
  162. package/src/lib/update-check.js +0 -192
  163. package/src/lib/upstream-refusal.js +0 -1
  164. package/src/lib/webhook-server.js +0 -109
  165. package/src/lib/windows-command.js +0 -393
  166. package/src/lib/workspace-git.js +0 -87
  167. package/src/lib/workspace-trust.js +0 -162
@@ -1,418 +0,0 @@
1
- /**
2
- * TASK-096 — name a refused model route, instead of reporting the silence it
3
- * causes.
4
- *
5
- * Measured 2026-09-22 against pi 0.84.1 with a route answering 429 (the shape
6
- * that made a seat look broken for two and a half days): pi runs its own retry
7
- * ladder, reports the refusal on `message_end`/`auto_retry_end` JSON events, and
8
- * EXITS 0 with no assistant text. `extractReply` in the pi adapter collected only
9
- * `type: "error"` events — of which this produces none — so the wrapper logged
10
- * `no wrapper-post (empty output)` and the cause sat unread in the stream.
11
- *
12
- * Two rules govern the text that reaches a seat log:
13
- *
14
- * 1. KEEP-LIST, not strip-list (Vera 70876, Wren 70877). A strip-list removes
15
- * only the shapes we thought of, and a 401 can echo the key back. So a body
16
- * contributes one NAMED field or nothing at all.
17
- * 2. Exact-match refusal for credentials. If the kept text contains a value
18
- * this spawn was handed, the text is dropped — a known value, not a guessed
19
- * shape (Wren 70877).
20
- *
21
- * Two limits follow from rule 2. They are the accepted cost of refusing guessed
22
- * shapes, named here so they are not rediscovered as defects later (Vera 71082):
23
- *
24
- * a. The match is exact and against THIS spawn's values only, so a credential
25
- * that arrives transformed — base64 inside a proxy error, percent-encoded in
26
- * a URL echo — does not match and the text is kept. Matching transformed
27
- * shapes instead would reintroduce the class rule 1 exists to avoid.
28
- * b. The keep-list can still surface someone ELSE's secret. A third party's key
29
- * relayed in an upstream error is not detectable here, because the one
30
- * inventory this module has is the set of values it was itself handed.
31
- *
32
- * Scope: this reader was wired into the pi adapter only. TASK-103 extended it
33
- * to codex and claude, which are NOT silent the way pi was: the claude adapter
34
- * rejects on a non-zero exit and deliberately reports stdout with it, because a
35
- * `-p` run writes quota conditions there; the codex adapter rejects on
36
- * `turnFailedMessage` even at exit 0. Their gap was the opposite one — the text
37
- * they raised was the raw tail, unscrubbed by the rules above, and carried no
38
- * HTTP status for the run loop to classify on. `scrubAdapterFailure` below is
39
- * the shared reader they now both go through, so the two rules cannot drift
40
- * apart between three adapters.
41
- */
42
-
43
- /** The kept detail is truncated, ellipsis included, to this many characters. */
44
- export const MAX_REFUSAL_DETAIL = 256;
45
-
46
- /**
47
- * The status an adapter prefixes to its error text: `"429: {…}"` -> `429`.
48
- * Null for anything that is not a 4xx/5xx, so a message that merely starts with
49
- * a number ("4040 tokens over budget") is not read as a status.
50
- *
51
- * The separator pi uses is NOT fixed — measured 2026-09-22, the same pi 0.84.1
52
- * emits `"429: {"message":…}"` for a JSON body and `"502 <html>…"` for an
53
- * HTML one. Both forms are matched here; a parser that required the colon would
54
- * have kept `502 <html>…` as the whole "body" and then dropped it as non-JSON,
55
- * reporting the status alone with no sign that the body was never read.
56
- */
57
- const STATUS_PREFIX = /^\s*(\d{3})(?!\d)\s*:?\s*/;
58
-
59
- export const statusFromError = (value) => {
60
- const match = STATUS_PREFIX.exec(String(value ?? ''));
61
- if (!match) return null;
62
- const status = Number(match[1]);
63
- return status >= 400 && status <= 599 ? status : null;
64
- };
65
-
66
- /**
67
- * The body the adapter carried after its status prefix, or the whole text when
68
- * there is no status to strip. `"429: {"message":"a: b"}"` -> `{"message":"a: b"}`;
69
- * `"502 <html>"` -> `<html>`.
70
- */
71
- export const bodyFromError = (value) => {
72
- const text = String(value ?? '');
73
- if (statusFromError(text) === null) return text.trim();
74
- return text.replace(STATUS_PREFIX, '').trim();
75
- };
76
-
77
- /** The shortest value the refusal-text reader treats as a credential. */
78
- export const MIN_CREDENTIAL_LENGTH = 8;
79
-
80
- /**
81
- * Replace credential values this spawn was handed with a marker.
82
- *
83
- * This is the exact-match half, for text that is kept for its whole shape rather
84
- * than filtered to one field — an adapter's stderr tail, say, whose diagnostic
85
- * value is in the lines around the secret. Values shorter than eight characters
86
- * are ignored: redacting a one-character "credential" would corrupt ordinary
87
- * prose, and no issued credential is that short.
88
- *
89
- * The floor belongs to this reader only. The trace store scrubs every value
90
- * registered with it, whatever its length, and the valet refuses to grant a
91
- * credential shorter than this (credential_denied), so no granted value is
92
- * ever below the floor.
93
- */
94
- export const redactKnownCredentials = (text, credentials = []) => {
95
- let out = String(text ?? '');
96
- for (const value of credentials) {
97
- if (typeof value !== 'string' || value.length < MIN_CREDENTIAL_LENGTH) continue;
98
- out = out.split(value).join('[redacted]');
99
- }
100
- return out;
101
- };
102
-
103
- /**
104
- * The keep-list. Given the raw body an adapter captured, keep at most one named
105
- * field of it and only when that field is a string:
106
- *
107
- * `error.message` first (the OpenAI-shaped error), then a top-level `message`
108
- * (which is the shape LiteLLM's budget refusal actually uses — the motivating
109
- * case; a keep-list that kept only `error.message` would name the status and
110
- * still hide the budget line that started this row).
111
- *
112
- * Everything else — HTML, a non-JSON body, a JSON body with neither field, a
113
- * body echoing a known credential — contributes nothing. Returns null rather
114
- * than a partial guess.
115
- */
116
- export const keptRefusalDetail = (raw, { credentials = [] } = {}) => {
117
- let parsed;
118
- try {
119
- parsed = JSON.parse(String(raw ?? ''));
120
- } catch {
121
- return null;
122
- }
123
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
124
-
125
- const candidate = parsed?.error?.message ?? parsed?.message;
126
- if (typeof candidate !== 'string') return null;
127
-
128
- const text = candidate.replace(/\s+/g, ' ').trim();
129
- if (!text) return null;
130
-
131
- const redacted = redactKnownCredentials(text, credentials);
132
- // `[redacted]` appearing is the signal that the body echoed something this
133
- // spawn was handed. The status is still worth keeping; the text is not.
134
- if (redacted !== text) return null;
135
-
136
- return text.length > MAX_REFUSAL_DETAIL
137
- ? `${text.slice(0, MAX_REFUSAL_DETAIL - 1)}…`
138
- : text;
139
- };
140
-
141
- /**
142
- * Read a pi JSON stream and return the refusal it ended on, or null.
143
- *
144
- * `auto_retry_end` is the terminal word — it names the status and the body after
145
- * the ladder has finished — so it wins over the per-attempt `message_end`
146
- * events that precede it. A run that refused on its first attempt and never
147
- * retried still reports through `message_end`.
148
- *
149
- * A retry that answered ends the refusal. pi prints `auto_retry_end` with
150
- * `success: true` once a retried call answers (agent-session.js, pi 0.84.1), so
151
- * a refusal before it is one pi recovered from. Kept, it would name the cause
152
- * of a later failure that has none of its own, such as `fetch failed` (F320).
153
- */
154
- export const readUpstreamRefusal = (stdout, { credentials = [] } = {}) => {
155
- let status = null;
156
- let raw = null;
157
-
158
- for (const line of String(stdout ?? '').split('\n')) {
159
- if (!line.trim()) continue;
160
- let event;
161
- try {
162
- event = JSON.parse(line);
163
- } catch {
164
- continue;
165
- }
166
-
167
- // A compaction that continues the turn is pi recovering from an overflow,
168
- // so a refusal before it is one pi recovered from too. The turn's own cause
169
- // comes after (agent-session.js `compaction_end`, pi 0.84.1).
170
- if ((event?.type === 'auto_retry_end' && event.success === true)
171
- || (event?.type === 'compaction_end' && event.aborted === false && event.willRetry === true)) {
172
- status = null;
173
- raw = null;
174
- continue;
175
- }
176
-
177
- if (event?.type === 'auto_retry_end' && event.success === false) {
178
- const candidate = event.finalError ?? event.errorMessage;
179
- const attemptStatus = statusFromError(candidate);
180
- if (attemptStatus) {
181
- status = attemptStatus;
182
- raw = bodyFromError(candidate);
183
- } else if (typeof candidate === 'string' && candidate.trim()) {
184
- // F341: the retries ended on a failure that names no status ("fetch
185
- // failed"), so that is the turn's cause, not a 429 an earlier attempt
186
- // hit. Holding the old status named the turn refused and it went
187
- // unretried. An end with no error text at all says nothing, and keeps it.
188
- status = null;
189
- raw = null;
190
- }
191
- continue;
192
- }
193
-
194
- const message = event?.message;
195
- if (
196
- event?.type === 'message_end'
197
- && message?.role === 'assistant'
198
- && message.stopReason === 'error'
199
- && status === null
200
- ) {
201
- const attemptStatus = statusFromError(message.errorMessage);
202
- if (attemptStatus) {
203
- status = attemptStatus;
204
- raw = bodyFromError(message.errorMessage);
205
- }
206
- }
207
- }
208
-
209
- if (status === null) return null;
210
- return { status, detail: keptRefusalDetail(raw, { credentials }) };
211
- };
212
-
213
- /**
214
- * Is this text STRUCTURED upstream data — a JSON object or an array? The gate for
215
- * the whole-tail fallback below, deliberately NOT `kept === null`, which is true
216
- * for five different reasons (Vera 71350).
217
- *
218
- * An array counts, and that is a correction (Vera 71360, measured): `[{...}]` is
219
- * structured data with fields this code does not know, which is exactly what rule
220
- * 1 exists for, and treating it as prose shipped the whole body under exact-match
221
- * alone. A JSON *string* is the opposite case and does NOT count — `"Too many
222
- * requests"` is a whole-shape text with no fields to reduce, so it keeps its
223
- * wording like any other prose.
224
- */
225
- const isStructuredBody = (raw) => {
226
- try {
227
- const parsed = JSON.parse(String(raw ?? ''));
228
- return parsed !== null && typeof parsed === 'object';
229
- } catch {
230
- return false;
231
- }
232
- };
233
-
234
- /**
235
- * A 3-digit number is read as a status only when the tail NAMES it as one:
236
- * `status 429`, `HTTP 429`, `code: "429"` — or when it opens the tail or a ` | `
237
- * segment followed by a colon, which is the shape claude's `stderr | stdout`
238
- * join produces for a body that leads with its status.
239
- *
240
- * A bare `\b(\d{3})\b` scan is not enough, and the false positives are not
241
- * hypothetical: `500 tokens`, `"max_tokens": 500` and `retry in 500 seconds`
242
- * all read as 500, and `classifySpawnFailure` tests the status FIELD before the
243
- * message — so a count misread as a status stands the fleet down on a refusal
244
- * that never happened, which is worse than no status at all (Vera 71352).
245
- */
246
- const STATUS_NAMED = /\b(?:status(?:_?code)?|code|http)\b\W{0,4}(\d{3})(?!\d)/gi;
247
- // `HTTP/1.1 429 Too Many Requests` is the most canonical refusal shape there is,
248
- // and the `\W{0,4}` gap above handles `HTTP 429` but cannot reach a VERSIONED
249
- // line: the `1` in `/1.1` is a
250
- // WORD character, so `HTTP` and `429` are not within four non-word characters of
251
- // each other. It read as `status: null` — an unclassified runtime failure on the
252
- // single most recognisable shape, which is the misclassification this row exists
253
- // to remove (Vera 71359). Its own pattern, not a looser gap: widening the gap
254
- // would start reading `/v1/models 500` as a status.
255
- const STATUS_LINE = /\bHTTP\/\d(?:\.\d)?\s+(\d{3})(?!\d)/gi;
256
- const STATUS_COLON = /(?:^|\|\s*)(\d{3})(?!\d)\s*:/g;
257
-
258
- /** The first 4xx/5xx one of those shapes names, or null. */
259
- const scanStatuses = (text, pattern) => {
260
- for (const match of String(text ?? '').matchAll(pattern)) {
261
- const value = Number(match[1]);
262
- if (value >= 400 && value <= 599) return value;
263
- }
264
- return null;
265
- };
266
-
267
- /**
268
- * Find the status AND the body boundary, because they are the same question.
269
- *
270
- * Anchoring the status to a ` | ` segment start is not enough on its own: the
271
- * body has to be taken from just past that status too, or a mid-tail read
272
- * isolates the status and then hands the WHOLE joined tail to the body rules,
273
- * which no longer look like a JSON body and so fall through to "keep it all".
274
- * That is the difference between dropping a credential-echoing body and shipping
275
- * the part of it that exact-match cannot see.
276
- *
277
- * `STATUS_COLON` is the only shape that moves the boundary, because it is the
278
- * same shape as the anchored prefix (`429: {body}`), just found at a segment
279
- * boundary — claude's `stderr | stdout` join. A status that appears inside a
280
- * body (`{"error":{"code":"429"}}`) or as prose (`HTTP 503`) names the status
281
- * (`HTTP/1.1 429`) but marks no boundary, so the whole text stays the body.
282
- */
283
- const readStatusAndBody = (text) => {
284
- const raw = String(text ?? '');
285
- const prefixed = statusFromError(raw);
286
- if (prefixed !== null) return { status: prefixed, body: bodyFromError(raw) };
287
- // Only the colon shape moves the boundary — see the note above. A status LINE
288
- // names the status but its reason phrase is prose, so the body stays the whole
289
- // text and the exact-match rule protects it.
290
- for (const match of raw.matchAll(STATUS_COLON)) {
291
- const value = Number(match[1]);
292
- if (value >= 400 && value <= 599) {
293
- return { status: value, body: raw.slice(match.index + match[0].length).replace(/^\s*:?\s*/, '') };
294
- }
295
- }
296
- const named = scanStatuses(raw, STATUS_NAMED);
297
- return { status: named ?? scanStatuses(raw, STATUS_LINE), body: raw };
298
- };
299
-
300
- /**
301
- * The shared reader for an adapter's own failure tail (TASK-103).
302
- *
303
- * The two rules above are about a body an adapter captured, and they apply here
304
- * too — but the tail is a different shape from pi's refusal, and the difference
305
- * decides how each rule lands:
306
- *
307
- * - The tail is often NOT structured. Claude's `-p` mode writes its terminal
308
- * condition as prose (`Claude usage limit reached. Your limit will reset at
309
- * 11:40pm.`), and that line is the whole diagnostic value of reporting
310
- * stdout at all — 361 consecutive failures on 2026-08-03 carried no reason
311
- * because of it. So the keep-list applies only when the tail parses as a
312
- * body; when it does not, the text is KEPT as a whole shape and the
313
- * exact-match rule is what protects it (`redactKnownCredentials`, which
314
- * substitutes rather than drops, precisely because a whole-shape text has no
315
- * second field to fall back to).
316
- *
317
- * - A body that IS structured (a JSON object or array) is reduced to one named
318
- * field, and a body whose field echoed a value this spawn was handed is
319
- * DROPPED WHOLE — the same trade rule 2 makes everywhere. The fallback to the
320
- * whole tail is gated on the SHAPE (is it structured at all), never on
321
- * "the keep-list returned null": that null has
322
- * four other causes, and one of them is the credential echo this line exists
323
- * to drop. Gating on `kept === null` would emit the whole body for that case,
324
- * which is precisely what rule 2 forbids — and exact-match cannot see a
325
- * transformed shape (limit (a) in the header), so a key that arrived
326
- * base64'd or percent-encoded would ship (Vera 71350/71351).
327
- *
328
- * Returns `{ status, detail }`: `status` is the 4xx/5xx the tail named, or null.
329
- * The caller decides where the status goes; `classifySpawnFailure` reads it off
330
- * `error.status` or out of the message text, so both a leading `429: …` prefix
331
- * and an attached field classify the same way. `detail` is `''` when nothing of
332
- * the body may be shown — the status is then the whole diagnostic.
333
- */
334
- export const scrubAdapterFailure = (text, { credentials = [], limit = MAX_REFUSAL_DETAIL } = {}) => {
335
- const raw = String(text ?? '').trim();
336
- const { status, body } = readStatusAndBody(raw);
337
- const kept = keptRefusalDetail(body, { credentials });
338
- const detail = kept ?? (isStructuredBody(body) ? '' : redactKnownCredentials(body, credentials));
339
- return { status, detail: detail.slice(0, limit) };
340
- };
341
-
342
- /**
343
- * The Error an adapter raises, built from its own failure tail.
344
- *
345
- * `status` is attached as a field, not only written into the text: the run
346
- * loop's classifier tests `error.status === 429` before it looks at the
347
- * message, and a refusal that is only readable by regex is one wording away
348
- * from the misclassification that cost an hour on 2026-08-18 (see
349
- * spawn-retry.js). The message still names the status so a human log says the
350
- * same thing as the classifier's decision.
351
- */
352
- /**
353
- * The provider's own error code (`error.code`, then a top-level `code`), read
354
- * from the body a tail carries: the structured body after a status, else the
355
- * one JSON object embedded in prose (`unexpected status 404 Not Found: {…}`).
356
- * It is a keep-list of one more named field, and narrower than the message:
357
- * only a short identifier is kept, and one that echoes a credential this spawn
358
- * was handed is dropped. Null when there is none (F178: the keep-list reduced
359
- * `{"code":"model_not_found"}` to its message, so the code was lost).
360
- */
361
- const PROVIDER_CODE_RE = /^[A-Za-z][\w.-]{0,63}$/;
362
- export const providerCodeFrom = (text, { credentials = [] } = {}) => {
363
- const raw = String(text ?? '');
364
- const { body } = readStatusAndBody(raw.trim());
365
- const candidates = [body];
366
- const start = raw.indexOf('{');
367
- const end = raw.lastIndexOf('}');
368
- if (start >= 0 && end > start) candidates.push(raw.slice(start, end + 1));
369
- for (const candidate of candidates) {
370
- let parsed;
371
- try {
372
- parsed = JSON.parse(candidate);
373
- } catch {
374
- continue;
375
- }
376
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
377
- const code = parsed.error?.code ?? parsed.code;
378
- if (typeof code !== 'string' || !PROVIDER_CODE_RE.test(code)) return null;
379
- return redactKnownCredentials(code, credentials) === code ? code : null;
380
- }
381
- return null;
382
- };
383
-
384
- export const adapterFailure = (label, text, { credentials = [], exitCode = null, limit } = {}) => {
385
- const { status, detail } = scrubAdapterFailure(text, { credentials, limit });
386
- const code = exitCode === null ? '' : ` exited with code ${exitCode}`;
387
- const named = status === null ? '' : ` (upstream ${status})`;
388
- const error = new Error(`${label}${code}${named}${detail ? `: ${detail}` : ''}`);
389
- if (status !== null) error.status = status;
390
- const providerCode = providerCodeFrom(text, { credentials });
391
- if (providerCode !== null) error.providerCode = providerCode;
392
- return error;
393
- };
394
-
395
- /**
396
- * The seat-log clause. The status is always present; the detail only when the
397
- * keep-list kept one. `grep 'upstream refused'` finds every refusal, which is
398
- * the property the old `no wrapper-post (empty output)` line did not have.
399
- */
400
- export const describeUpstreamRefusal = (refusal) => {
401
- if (!refusal || typeof refusal.status !== 'number') return null;
402
- return refusal.detail
403
- ? `upstream refused ${refusal.status}: ${refusal.detail}`
404
- : `upstream refused ${refusal.status}`;
405
- };
406
-
407
- /**
408
- * The credential values this spawn was handed, for the exact-match check in
409
- * `redactKnownCredentials`. `envNames` are the adapter's own provider keys; the
410
- * caller supplies them because only the adapter knows which key its route uses.
411
- */
412
- export const spawnCredentials = (ctx = {}, envNames = []) => {
413
- const env = ctx.env || process.env || {};
414
- return [
415
- ctx.runtimeToken,
416
- ...envNames.map((name) => env[name]),
417
- ].filter((value) => typeof value === 'string' && value.length >= MIN_CREDENTIAL_LENGTH);
418
- };
@@ -1,201 +0,0 @@
1
- /**
2
- * Build the env and HOME one run executes in. The run starts from nothing the
3
- * user's login can be found through: a short allowlist of locale/terminal
4
- * variables, a fresh HOME whose XDG dirs sit inside it, and only the
5
- * credentials its grant names.
6
- *
7
- * What this is NOT: a filesystem sandbox. A process that already knows an
8
- * absolute path (/Users/<you>/.claude) can still open it, and macOS keychain
9
- * items are reached through the user's security session, not through HOME.
10
- * This removes the paths a CLI discovers its login by; an OS sandbox
11
- * (Seatbelt, bwrap) is what stops a process that goes looking.
12
- */
13
- import { randomUUID } from 'node:crypto';
14
- import { realpathSync } from 'node:fs';
15
- import { mkdtemp, mkdir, chmod, readFile, rm } from 'node:fs/promises';
16
- import { homedir, tmpdir } from 'node:os';
17
- import { delimiter, join, resolve, sep } from 'node:path';
18
- import { traceOf } from '../trace/events.js';
19
- import { runFailure } from '../failures.js';
20
- import { MIN_CREDENTIAL_LENGTH } from '../upstream-refusal.js';
21
- import { validateGrant } from './grant.js';
22
- import { startProviderProxy } from '../wire/provider-proxy.js';
23
-
24
- // Same spirit as the goose adapter's SAFE_ENV: locale, terminal and TLS trust
25
- // only. USER/LOGNAME are left out on purpose; they name the account whose
26
- // login this boundary exists to hide.
27
- export const BASE_ENV_ALLOWLIST = /^(?:PATH|LANG|LC_[A-Z0-9_]+|TERM|TZ|SSL_CERT_FILE|SSL_CERT_DIR|NO_COLOR|CI)$/;
28
-
29
- const isInside = (path, dir) => {
30
- const a = resolve(path);
31
- const b = resolve(dir);
32
- return a === b || a.startsWith(b.endsWith(sep) ? b : b + sep);
33
- };
34
-
35
- /**
36
- * PATH entries under the real home both name it and often hold shims that
37
- * read the user's login (~/.local/bin/claude). Adapters run their CLI by
38
- * absolute path, so the run loses nothing it needs.
39
- */
40
- // A HOME reached through a symlink (macOS /var -> /private/var) shows up in
41
- // PATH under its resolved path too, e.g. npm's cwd-derived node_modules/.bin.
42
- const spellings = (dir) => { try { return [dir, realpathSync(dir)]; } catch { return [dir]; } };
43
-
44
- const scrubPath = (value, realHomes) => value.split(delimiter)
45
- .filter((entry) => entry && !realHomes.some((home) => isInside(entry, home)))
46
- .join(delimiter);
47
-
48
- const pickBaseEnv = (baseEnv, realHomes) => {
49
- const env = {};
50
- for (const [name, value] of Object.entries(baseEnv)) {
51
- if (typeof value === 'string' && BASE_ENV_ALLOWLIST.test(name)) env[name] = value;
52
- }
53
- if (env.PATH !== undefined) env.PATH = scrubPath(env.PATH, realHomes);
54
- return env;
55
- };
56
-
57
- const makeHome = async (root) => {
58
- const home = await mkdtemp(join(root || tmpdir(), 'seat-home-'));
59
- // Conventional layout, so a CLI that ignores XDG_* and defaults to
60
- // ~/.config still lands inside the throwaway HOME.
61
- const dirs = {
62
- XDG_CONFIG_HOME: join(home, '.config'),
63
- XDG_DATA_HOME: join(home, '.local', 'share'),
64
- XDG_STATE_HOME: join(home, '.local', 'state'),
65
- XDG_CACHE_HOME: join(home, '.cache'),
66
- TMPDIR: join(home, 'tmp'),
67
- };
68
- await chmod(home, 0o700);
69
- for (const dir of Object.values(dirs)) await mkdir(dir, { recursive: true, mode: 0o700 });
70
- // mkdir's mode is masked by umask; set it explicitly on every level made.
71
- for (const dir of [join(home, '.local'), ...Object.values(dirs)]) await chmod(dir, 0o700);
72
- return { home, dirs };
73
- };
74
-
75
- /** A value that cannot ride an env var is the same as no value. */
76
- const usable = (value) => typeof value === 'string' && value.length > 0 && !value.includes('\0');
77
-
78
- const resolveOne = async (spec, { baseEnv, resolveSecret }) => {
79
- if (spec.from === 'env') return baseEnv[spec.ref || spec.name];
80
- if (spec.from === 'file') return (await readFile(spec.ref, 'utf8')).replace(/\r?\n$/, '');
81
- if (typeof resolveSecret !== 'function') return undefined;
82
- return resolveSecret(spec.name, spec);
83
- };
84
-
85
- /**
86
- * The grant.issued entry for grantMeta. Its scope is the room grant this run
87
- * acts under (exact tool names, a connection id), as the host knows it; the
88
- * run grant's own credential names and patterns show up in credential.* and
89
- * in each permission.decided rule. `taskId` + `podId` bind the grant to that
90
- * board task, ending when it is done unless `endsOn` says otherwise.
91
- */
92
- const grantIssued = (meta) => {
93
- const {
94
- grantId = randomUUID(), parentGrantId, scope = {}, target, issuedBy, expiresAt, taskId, podId, endsOn = 'task.done',
95
- } = meta;
96
- return {
97
- grantId,
98
- ...(parentGrantId === undefined ? {} : { parentGrantId }),
99
- scope: { tools: [], audience: [], ...scope },
100
- ...(target ? { target } : {}),
101
- ...(issuedBy ? { issuedBy } : {}),
102
- ...(expiresAt ? { expiresAt: expiresAt instanceof Date ? expiresAt.toISOString() : expiresAt } : {}),
103
- ...(taskId ? { binding: { taskId, podId, endsOn } } : {}),
104
- };
105
- };
106
-
107
- /**
108
- * prepareRunEnv({ grant, baseEnv, resolveSecret, root?, require?, trace?, ctx?, grantMeta?, upstreams? })
109
- * → { env, home, cleanup, granted: [names], injected: { NAME: { baseURL, wire } }, grantId? }
110
- *
111
- * `require` lists credential names the adapter cannot run without; one the
112
- * grant does not name fails the run as credential_denied before any spawn.
113
- * A named credential that cannot be resolved, or that resolves to fewer than
114
- * MIN_CREDENTIAL_LENGTH characters, fails it the same way: a run that
115
- * silently lacks its key fails later and less legibly, and a value that short
116
- * would slip past the refusal-text reader's redaction.
117
- *
118
- * A credential the grant marks `inject: true` never reaches the env. The host
119
- * names its provider in `upstreams[NAME]` ({ baseURL, wire }, wire defaulting
120
- * to openai-chat); the run gets a loopback provider proxy holding the value in
121
- * memory, env[NAME] becomes that proxy's token, and `injected[NAME].baseURL`
122
- * is where the host points the CLI. cleanup() closes the proxies. One with no
123
- * upstream fails as credential_denied (no_upstream) before it is resolved.
124
- * The proxy listens on 127.0.0.1, so a run without the host's network
125
- * (bwrap --unshare-net) cannot reach it yet.
126
- *
127
- * Events go to `trace`, else ctx.trace: grant.issued (only with grantMeta,
128
- * first), then credential.granted {names, injected?} or credential.denied
129
- * {name, reason}. `injected` lists the names held by a proxy; the rest of
130
- * `names` are held by the run.
131
- * Values never enter an event or a message, and a hook that offers
132
- * redact(values) is handed the resolved values and each proxy token, so the
133
- * record can scrub them out of agent text too.
134
- */
135
- export const prepareRunEnv = async ({
136
- grant, baseEnv = {}, resolveSecret, root, require: required = [], trace, ctx, grantMeta, upstreams = {},
137
- } = {}) => {
138
- const valid = validateGrant(grant);
139
- const hook = trace && typeof trace.emit === 'function' ? trace : traceOf(ctx);
140
- const issued = grantMeta ? grantIssued(grantMeta) : null;
141
- if (issued) hook.emit('grant.issued', issued);
142
- const realHomes = [...new Set([baseEnv.HOME, homedir()].filter(Boolean).flatMap(spellings))];
143
- const DENIED = {
144
- not_granted: 'is not in the run grant',
145
- unresolved: 'could not be resolved',
146
- too_short: `resolved to a value shorter than ${MIN_CREDENTIAL_LENGTH} characters`,
147
- no_upstream: 'is marked inject, but the host named no upstream for it',
148
- };
149
- const deny = (name, reason, values) => {
150
- hook.emit('credential.denied', { name, reason });
151
- return runFailure('credential_denied', `${name} ${DENIED[reason]}`, { credentials: values });
152
- };
153
-
154
- const granted = new Set(valid.credentials.map((c) => c.name));
155
- const missing = required.find((name) => !granted.has(name));
156
- if (missing) throw deny(missing, 'not_granted', []);
157
-
158
- const { home, dirs } = await makeHome(root);
159
- const proxies = [];
160
- const cleanup = async () => {
161
- await Promise.all(proxies.splice(0).map((proxy) => proxy.close()));
162
- await rm(home, { recursive: true, force: true });
163
- };
164
- const env = { ...pickBaseEnv(baseEnv, realHomes), HOME: home, ...dirs };
165
- const values = [];
166
- const injected = {};
167
- try {
168
- for (const spec of valid.credentials) {
169
- const upstream = spec.inject ? upstreams[spec.name] : undefined;
170
- if (spec.inject && !upstream) throw deny(spec.name, 'no_upstream', values);
171
- let value;
172
- // A resolver's error text may quote the secret it failed on; drop it.
173
- try { value = await resolveOne(spec, { baseEnv, resolveSecret }); } catch { value = undefined; }
174
- if (!usable(value)) throw deny(spec.name, 'unresolved', values);
175
- // A value this short cannot be told apart from ordinary text, so the
176
- // refusal-text reader would not redact it. Refuse it here, before spawn.
177
- if (value.length < MIN_CREDENTIAL_LENGTH) throw deny(spec.name, 'too_short', values);
178
- values.push(value);
179
- if (upstream) {
180
- const wire = upstream.wire ?? 'openai-chat';
181
- const proxy = await startProviderProxy({
182
- provider: { baseURL: upstream.baseURL }, keyMaterial: value, wire, adapterLabel: 'Valet',
183
- });
184
- proxies.push(proxy);
185
- env[spec.name] = proxy.token;
186
- injected[spec.name] = { baseURL: proxy.baseURL, wire };
187
- } else {
188
- env[spec.name] = value;
189
- }
190
- }
191
- } catch (error) {
192
- await cleanup();
193
- throw error;
194
- }
195
- // A proxy token authorizes provider calls until cleanup, so the record scrubs it too.
196
- if (typeof hook.redact === 'function') hook.redact([...values, ...proxies.map((proxy) => proxy.token)]);
197
- const names = valid.credentials.map((c) => c.name);
198
- const proxied = Object.keys(injected);
199
- hook.emit('credential.granted', proxied.length ? { names, injected: proxied } : { names });
200
- return { env, home, cleanup, granted: names, injected, ...(issued ? { grantId: issued.grantId } : {}) };
201
- };