@ravefox/space 0.2.1 → 0.3.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 (163) hide show
  1. package/LICENSE +21 -205
  2. package/README.md +80 -31
  3. package/bin/space.js +106 -0
  4. package/lib/agents.js +119 -0
  5. package/lib/credentials.js +95 -0
  6. package/lib/daemon.js +294 -0
  7. package/lib/harness.js +166 -0
  8. package/lib/http.js +101 -0
  9. package/lib/login.js +68 -0
  10. package/lib/mcp.js +34 -0
  11. package/lib/runtime.js +241 -0
  12. package/lib/store.js +96 -0
  13. package/lib/tools.js +93 -0
  14. package/package.json +27 -46
  15. package/NOTICE +0 -55
  16. package/assets/webhook-sdk/python/bot.py +0 -63
  17. package/assets/webhook-sdk/python/commonly.py +0 -232
  18. package/node_modules/@commonlyai/seats/README.md +0 -25
  19. package/node_modules/@commonlyai/seats/package.json +0 -22
  20. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
  21. package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
  22. package/node_modules/@commonlyai/seats/src/contract.js +0 -48
  23. package/node_modules/@commonlyai/seats/src/failures.js +0 -44
  24. package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
  25. package/node_modules/@commonlyai/seats/src/index.js +0 -22
  26. package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
  27. package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
  28. package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
  29. package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
  30. package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
  31. package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
  32. package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
  33. package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
  34. package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
  35. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
  36. package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
  37. package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
  38. package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
  39. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
  40. package/skills/commonly/SKILL.md +0 -233
  41. package/src/commands/agent.js +0 -6334
  42. package/src/commands/daemon.js +0 -933
  43. package/src/commands/dev.js +0 -153
  44. package/src/commands/doctor.js +0 -141
  45. package/src/commands/inbox.js +0 -330
  46. package/src/commands/login.js +0 -194
  47. package/src/commands/mcp.js +0 -56
  48. package/src/commands/pod.js +0 -120
  49. package/src/commands/task.js +0 -279
  50. package/src/commands/verify.js +0 -48
  51. package/src/index.js +0 -99
  52. package/src/lib/adapter-login.js +0 -76
  53. package/src/lib/adapters/broker-bridge-entry.js +0 -76
  54. package/src/lib/adapters/claude.js +0 -2207
  55. package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
  56. package/src/lib/adapters/codex.js +0 -1898
  57. package/src/lib/adapters/dsh.js +0 -513
  58. package/src/lib/adapters/goose.js +0 -510
  59. package/src/lib/adapters/hermes.js +0 -18
  60. package/src/lib/adapters/index.js +0 -68
  61. package/src/lib/adapters/opencode.js +0 -1681
  62. package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
  63. package/src/lib/adapters/pi-mcp-client.mjs +0 -617
  64. package/src/lib/adapters/pi-shell-env.mjs +0 -41
  65. package/src/lib/adapters/pi.js +0 -1025
  66. package/src/lib/adapters/qwen.js +0 -523
  67. package/src/lib/adapters/seat-model.js +0 -8
  68. package/src/lib/adapters/stub.js +0 -1
  69. package/src/lib/adapters/tested-version.js +0 -106
  70. package/src/lib/api.js +0 -205
  71. package/src/lib/claim-outcome.js +0 -128
  72. package/src/lib/claimed-work-gate.js +0 -73
  73. package/src/lib/cli-hint.js +0 -33
  74. package/src/lib/commit-attribution.js +0 -391
  75. package/src/lib/config.js +0 -155
  76. package/src/lib/content-refusal.js +0 -94
  77. package/src/lib/credential-file.js +0 -208
  78. package/src/lib/daemon-agent-states.js +0 -95
  79. package/src/lib/daemon-hint.js +0 -18
  80. package/src/lib/daemon-logs.js +0 -43
  81. package/src/lib/daemon-service-windows.js +0 -263
  82. package/src/lib/daemon-service.js +0 -486
  83. package/src/lib/daemon-state.js +0 -84
  84. package/src/lib/daemon-store.js +0 -119
  85. package/src/lib/daemon-supervisor.js +0 -629
  86. package/src/lib/daemon-watch.js +0 -115
  87. package/src/lib/declared-mcp-guard.js +0 -284
  88. package/src/lib/default-environment.js +0 -207
  89. package/src/lib/device-login.js +0 -126
  90. package/src/lib/digest-frame.js +0 -151
  91. package/src/lib/doctor.js +0 -291
  92. package/src/lib/egress-proxy.js +0 -160
  93. package/src/lib/enforcement.js +0 -946
  94. package/src/lib/environment.js +0 -681
  95. package/src/lib/first-output-watchdog.js +0 -191
  96. package/src/lib/git-readonly-server.js +0 -138
  97. package/src/lib/git-readonly.js +0 -463
  98. package/src/lib/git-remote-commonly.mjs +0 -100
  99. package/src/lib/grant-broker-guard.js +0 -345
  100. package/src/lib/hooks-config.js +0 -238
  101. package/src/lib/host-gh-broker.js +0 -152
  102. package/src/lib/host-gh-shim.mjs +0 -98
  103. package/src/lib/inbox.js +0 -483
  104. package/src/lib/instance-proxy.js +0 -76
  105. package/src/lib/machine-host.js +0 -29
  106. package/src/lib/mcp-credential-delivery.js +0 -121
  107. package/src/lib/mcp-first-run.js +0 -50
  108. package/src/lib/mcp-home.js +0 -644
  109. package/src/lib/mcp-ready.js +0 -68
  110. package/src/lib/mcp-server-version.js +0 -123
  111. package/src/lib/mcp-token.js +0 -36
  112. package/src/lib/mcp-warm-child.mjs +0 -412
  113. package/src/lib/memory-bridge.js +0 -191
  114. package/src/lib/memory-import.js +0 -156
  115. package/src/lib/missing-harness.js +0 -31
  116. package/src/lib/model-unavailable.js +0 -154
  117. package/src/lib/native-runtime-inventory.js +0 -40
  118. package/src/lib/outsider-grant-mcp.js +0 -82
  119. package/src/lib/own-key-launch.js +0 -42
  120. package/src/lib/pod-catch-up.js +0 -55
  121. package/src/lib/pod-focus.js +0 -224
  122. package/src/lib/pod-tail.js +0 -68
  123. package/src/lib/poll-liveness.js +0 -103
  124. package/src/lib/poll-retry.js +0 -81
  125. package/src/lib/poller.js +0 -200
  126. package/src/lib/provider-proxy.js +0 -42
  127. package/src/lib/public-seat-mcp-flag.js +0 -36
  128. package/src/lib/public-tool-error.js +0 -60
  129. package/src/lib/public-withheld-tools.js +0 -48
  130. package/src/lib/reply-receipt.js +0 -55
  131. package/src/lib/resolve-on-path.js +0 -22
  132. package/src/lib/runtime-inventory.js +0 -56
  133. package/src/lib/runtime-observation.js +0 -37
  134. package/src/lib/sandbox/bwrap.js +0 -275
  135. package/src/lib/sandbox/codex-perimeter.js +0 -16
  136. package/src/lib/sandbox/host-check.js +0 -268
  137. package/src/lib/sandbox/mode.js +0 -49
  138. package/src/lib/sandbox/opencode-loopback.mjs +0 -68
  139. package/src/lib/sandbox/seatbelt.js +0 -684
  140. package/src/lib/sandbox/turn-dir.js +0 -56
  141. package/src/lib/sandbox/windows-unsandboxed.js +0 -183
  142. package/src/lib/sandbox/workspace-git-config.js +0 -243
  143. package/src/lib/sandbox/workspace-pin.js +0 -158
  144. package/src/lib/seat-runtime-controls.js +0 -61
  145. package/src/lib/seat-stop.js +0 -97
  146. package/src/lib/server-skills.js +0 -32
  147. package/src/lib/session-recycle.js +0 -219
  148. package/src/lib/session-store.js +0 -215
  149. package/src/lib/skill-prompt.js +0 -74
  150. package/src/lib/skills-import.js +0 -113
  151. package/src/lib/spawn-credential.js +0 -402
  152. package/src/lib/spawn-retry.js +0 -311
  153. package/src/lib/stable-install.js +0 -176
  154. package/src/lib/supervised-turn.js +0 -352
  155. package/src/lib/task.js +0 -235
  156. package/src/lib/turn-failure-notice.js +0 -177
  157. package/src/lib/unconfined-seat-guard.js +0 -94
  158. package/src/lib/update-check.js +0 -192
  159. package/src/lib/upstream-refusal.js +0 -1
  160. package/src/lib/webhook-server.js +0 -109
  161. package/src/lib/windows-command.js +0 -393
  162. package/src/lib/workspace-git.js +0 -87
  163. package/src/lib/workspace-trust.js +0 -162
@@ -1,629 +0,0 @@
1
- import { isDeepStrictEqual } from 'node:util';
2
- import { homedir } from 'node:os';
3
- import { isAbsolute, resolve as pathResolve } from 'node:path';
4
- import {
5
- auditDeclaredMcp,
6
- installedStdioEntries,
7
- } from './declared-mcp-guard.js';
8
-
9
- import { seatBaseline } from './default-environment.js';
10
- import { getAdapter } from './adapters/index.js';
11
- import { providerRefusal } from './environment.js';
12
- import { withholdGrantBroker } from './grant-broker-guard.js';
13
-
14
- /**
15
- * ADR-026 Phase 2, slice 2: the resident supervision loop behind
16
- * `commonly daemon run`.
17
- *
18
- * The server's work list (GET /api/agent-binding/assigned) is the source of
19
- * truth (D2): a row `requested` gets adopted (the D3 CAS — the server refuses
20
- * the loser of a race cleanly), a row `bound` gets provisioned (token file)
21
- * and supervised (a `commonly agent run <name>` child), and a supervised
22
- * agent that leaves the list gets stopped. Per-agent state rides every
23
- * machine heartbeat (D5).
24
- *
25
- * D6 discipline: a replacement child is only ever scheduled from the previous
26
- * child's 'exit' event — there is no code path that spawns a second runner
27
- * for an agent whose child has not exited.
28
- *
29
- * All side effects (client, spawn, token file I/O, adapter detection, timers)
30
- * are injected so the loop's decisions are testable without processes.
31
- */
32
-
33
- export const DEFAULT_POLL_MS = 30_000;
34
- export const DEFAULT_HEARTBEAT_MS = 30_000;
35
- export const BACKOFF_BASE_MS = 5_000;
36
- export const BACKOFF_MAX_MS = 60_000;
37
- export const MAX_CONCURRENT_AGENT_STARTS = 3;
38
- export const AGENT_START_WINDOW_MS = 5_000;
39
- export const RECONNECT_MAX_MS = 60_000;
40
-
41
- export const reconnectDelayMs = (failures, random = Math.random) => {
42
- const ceiling = Math.min(RECONNECT_MAX_MS, 1_000 * 2 ** Math.min(failures, 6));
43
- return Math.floor(1_000 + random() * (ceiling - 1_000));
44
- };
45
-
46
- const workspacePathFor = (environment) => {
47
- const declared = environment?.workspace?.path;
48
- if (typeof declared !== 'string' || !declared.trim()) return null;
49
- const expanded = declared === '~'
50
- ? homedir()
51
- : (declared.startsWith('~/') ? `${homedir()}/${declared.slice(2)}` : declared);
52
- return isAbsolute(expanded) ? expanded : pathResolve(expanded);
53
- };
54
-
55
- export const backoffMs = (restarts) => Math.min(
56
- BACKOFF_MAX_MS,
57
- BACKOFF_BASE_MS * 2 ** Math.max(0, Math.min(restarts, 10)),
58
- );
59
-
60
- const identityKey = (agentName, instanceId) => `${agentName} ${instanceId || 'default'}`;
61
-
62
- // An instance id becomes part of a local file name.
63
- const INSTANCE_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
64
- const isDefaultInstance = (instanceId) => String(instanceId || 'default').toLowerCase() === 'default';
65
- // Instance ids compare case-insensitively, as `agent run` and the server do.
66
- const sameInstance = (a, b) => String(a || 'default').toLowerCase() === String(b || 'default').toLowerCase();
67
-
68
- /**
69
- * The local token record a seat lives in, as `agent run <key>` resolves it
70
- * (F306, F427): the default instance is `<name>`, another is `<name>@<id>`,
71
- * except a key an older CLI saved for that instance under the bare name, which
72
- * is used where it is. So a second instance placed on a computer that already
73
- * runs the default gets its own record, and neither overwrites the other.
74
- * Null for an instance id that can't be a file name.
75
- */
76
- export const recordKeyFor = (row, loadToken) => {
77
- const { agentName } = row;
78
- const instanceId = row.instanceId || 'default';
79
- if (isDefaultInstance(instanceId)) return agentName;
80
- if (!INSTANCE_ID_PATTERN.test(instanceId)) return null;
81
- const scoped = `${agentName}@${instanceId}`;
82
- if (loadToken(scoped)) return scoped;
83
- const legacy = loadToken(agentName);
84
- if (legacy && sameInstance(legacy.instanceId, instanceId)) return agentName;
85
- return scoped;
86
- };
87
-
88
- export const createDaemonSupervisor = ({
89
- record,
90
- client,
91
- spawnChild, // (agentName) => child emitting 'exit'; must expose .kill()
92
- loadToken, // (agentName) => token record | null
93
- saveToken, // (agentName, record) => void
94
- runtimeInventory = async () => undefined,
95
- // Machine-level facts for the check-in, read locally each beat (D245: the
96
- // Windows unsandboxed opt-in). Never derived from anything the server sent.
97
- machineReport = () => ({}),
98
- resolveAdapter, // async (runtime) => adapter name for THIS machine
99
- persistState = () => {}, // (agentStates) => void; must not persist secrets
100
- log = () => {},
101
- isTerminalAuthError = () => false,
102
- onTerminalAuthError = () => {},
103
- now = Date.now,
104
- random = Math.random,
105
- setTimeoutFn = setTimeout,
106
- clearTimeoutFn = clearTimeout,
107
- }) => {
108
- // key → { agentName, instanceId, child, state, restarts, backoffTimer, desired }
109
- const seats = new Map();
110
- let stopped = false;
111
- let reconnectFailures = 0;
112
- let reconnectAfter = 0;
113
- const recentStarts = [];
114
-
115
- const connectionFailure = (error, label) => {
116
- if (isTerminalAuthError(error)) {
117
- stopped = true;
118
- onTerminalAuthError(error);
119
- return;
120
- }
121
- reconnectFailures += 1;
122
- reconnectAfter = now() + reconnectDelayMs(reconnectFailures, random);
123
- log(`${label}: ${error.message}; reconnect after ${Math.round((reconnectAfter - now()) / 1000)}s`);
124
- };
125
-
126
- const connectionRestored = () => {
127
- reconnectFailures = 0;
128
- reconnectAfter = 0;
129
- };
130
-
131
- const persist = () => {
132
- try {
133
- // The local record key too, for this host's own readouts (the last turn
134
- // is kept per record); the heartbeat reports agentStates() without it.
135
- const keys = Array.from(seats.values()).map((s) => s.recordKey || s.agentName);
136
- persistState(agentStates().map((state, i) => ({ ...state, recordKey: keys[i] })));
137
- } catch (error) {
138
- // A diagnostic state file must never take the daemon down. The log is
139
- // still useful, and the next state transition retries the write.
140
- log(`could not persist local daemon state: ${error.message}`);
141
- }
142
- };
143
-
144
- const agentStates = () => Array.from(seats.values()).map((s) => ({
145
- agentName: s.agentName,
146
- instanceId: s.instanceId,
147
- state: s.state,
148
- restarts: s.restarts,
149
- adapter: s.adapter || null,
150
- model: s.model || null,
151
- effort: s.effort || null,
152
- pid: Number.isInteger(s.pid) ? s.pid : null,
153
- lastTurnAt: s.lastTurnAt || null,
154
- lastError: s.lastError || null,
155
- }));
156
-
157
- const startChild = (seat) => {
158
- if (stopped || !seat.desired || seat.child || seat.startDelayTimer) return;
159
- const at = now();
160
- while (recentStarts.length && recentStarts[0] + AGENT_START_WINDOW_MS <= at) {
161
- recentStarts.shift();
162
- }
163
- if (recentStarts.length >= MAX_CONCURRENT_AGENT_STARTS) {
164
- seat.startDelayTimer = setTimeoutFn(() => {
165
- seat.startDelayTimer = null;
166
- startChild(seat);
167
- }, Math.max(1, recentStarts[0] + AGENT_START_WINDOW_MS - at));
168
- return;
169
- }
170
- recentStarts.push(at);
171
- seat.child = spawnChild(seat.recordKey || seat.agentName);
172
- seat.state = 'running';
173
- seat.pid = Number.isInteger(seat.child?.pid) ? seat.child.pid : null;
174
- seat.lastTurnAt = new Date().toISOString();
175
- seat.lastError = null;
176
- persist();
177
- log(`[${seat.recordKey || seat.agentName}] supervising (restarts so far: ${seat.restarts})`);
178
- seat.child.on('exit', (code) => {
179
- seat.child = null;
180
- seat.pid = null;
181
- if (stopped || !seat.desired) {
182
- seat.state = 'stopped';
183
- persist();
184
- return;
185
- }
186
- seat.state = code === 0 ? 'stopped' : 'crashed';
187
- seat.lastError = code === 0 ? null : `child exited with code ${code}`;
188
- seat.restarts += 1;
189
- const delay = backoffMs(seat.restarts - 1);
190
- log(`[${seat.recordKey || seat.agentName}] exited (code ${code}) — respawn in ${Math.round(delay / 1000)}s`);
191
- seat.backoffTimer = setTimeoutFn(() => {
192
- seat.backoffTimer = null;
193
- startChild(seat);
194
- }, delay);
195
- persist();
196
- });
197
- };
198
-
199
- const stopSeat = (seat) => {
200
- seat.desired = false;
201
- if (seat.startDelayTimer) {
202
- clearTimeoutFn(seat.startDelayTimer);
203
- seat.startDelayTimer = null;
204
- }
205
- if (seat.backoffTimer) {
206
- clearTimeoutFn(seat.backoffTimer);
207
- seat.backoffTimer = null;
208
- }
209
- if (seat.child) {
210
- log(`[${seat.recordKey || seat.agentName}] no longer assigned here — stopping`);
211
- seat.child.kill('SIGTERM');
212
- } else {
213
- seat.state = 'stopped';
214
- seat.pid = null;
215
- persist();
216
- }
217
- };
218
-
219
- // Preserve the complete ADR-008 environment when the daemon receives it.
220
- // Older installs only expose runtime.model/effort; those fields are a
221
- // compatibility overlay and must merge into an existing local environment
222
- // rather than erasing its workspace, skills, or MCP declarations.
223
- const environmentFor = (row) => {
224
- const declared = row.environment && typeof row.environment === 'object'
225
- && !Array.isArray(row.environment) ? { ...row.environment } : null;
226
- const runtime = row.runtime && typeof row.runtime === 'object' ? row.runtime : {};
227
- if (declared) {
228
- if (runtime.model && declared.model === undefined) declared.model = String(runtime.model);
229
- if (runtime.effort && declared.effort === undefined) declared.effort = String(runtime.effort);
230
- return { value: declared, declared: true };
231
- }
232
- const fallback = {};
233
- if (runtime.model) fallback.model = String(runtime.model);
234
- if (runtime.effort) fallback.effort = String(runtime.effort);
235
- return Object.keys(fallback).length ? { value: fallback, declared: false } : null;
236
- };
237
-
238
- // `provider` is host-local authority: /assigned intentionally omits it.
239
- // Ignore a provider field even if a future or malformed row carries one,
240
- // then restore only the local token record's value during adoption.
241
- const withoutServerProvider = (environment) => {
242
- const safe = { ...(environment || {}) };
243
- delete safe.provider;
244
- return safe;
245
- };
246
-
247
- // A declared environment runs on THIS machine as the operator. Refuse any
248
- // declared stdio command that is not the shipped commonly MCP server or one
249
- // the operator already installed here, and any http server that would
250
- // receive the seat token off-origin. Provider blocks are ignored from rows;
251
- // the host-local token record is their only source.
252
- const admitDeclared = (row, environment, existing) => {
253
- const mcpAudit = auditDeclaredMcp(environment, {
254
- instanceUrl: existing?.instanceUrl || record.instanceUrl,
255
- allowedStdioEntries: installedStdioEntries(existing),
256
- });
257
- if (mcpAudit.refusals.length === 0) return true;
258
- log(`[${row.agentName}] refusing the declared environment — it would not stay on this machine's terms:`);
259
- for (const refusal of mcpAudit.refusals) log(`[${row.agentName}] ${refusal}`);
260
- return false;
261
- };
262
-
263
- // The mint refuses to clobber an existing token (409 token_exists); the
264
- // binding to THIS machine is the owner's explicit takeover choice (D3), so
265
- // that refusal is answered with rotate:true — loudly.
266
- // Returns 'ready' | 'changed' (record updated — the seat must restart to
267
- // load it) | false.
268
- const ensureToken = async (row, recordKey) => {
269
- // One derive for this seat, so the broker refusal cannot be applied at three
270
- // of four sites: `seatBaseline` is where an environment becomes the one the
271
- // seat RUNS under, and `withholdGrantBroker` asks the same question the
272
- // server asks (`can this seat confine a granter's authority?`) for the cases
273
- // the server cannot see — a row naming no adapter, a backend older than the
274
- // refusal, a record written by hand. Entry-level: the broker
275
- // entry is withheld and the seat still starts, because it was never promised
276
- // confinement. `record.instanceUrl` is what makes the injected url
277
- // identifiable at all — the record holds the UNRESOLVED
278
- // `${COMMONLY_API_URL}/api/mcp/grants/<id>` placeholder.
279
- const derive = (environment, adapter, options) => withholdGrantBroker(
280
- seatBaseline(environment, adapter, options),
281
- adapter,
282
- {
283
- instanceUrl: record.instanceUrl,
284
- onRefuse: (refusal, names) => log(
285
- `[${recordKey}] ${refusal.code} (${refusal.reason}) — withholding ${names.join(', ')}: ${refusal.detail}`,
286
- ),
287
- },
288
- );
289
-
290
- const existing = loadToken(recordKey);
291
- if (existing) {
292
- // recordKeyFor gives each instance its own record; a record that still
293
- // names another instance (written by hand, or the bare name holding a
294
- // non-default instance when the default is placed) is never rotated or
295
- // launched as this one, which would overwrite that seat's bearer.
296
- if (!sameInstance(existing.instanceId, row.instanceId)) {
297
- log(`[${recordKey}] local token belongs to instance '${existing.instanceId || 'default'}'; refusing '${row.instanceId || 'default'}'`);
298
- return false;
299
- }
300
- // A human owner can request a fresh key from Settings after revocation.
301
- // The daemon owns its local token file, so the browser only stamps the
302
- // directive; this host rotates the key and restarts its child once.
303
- const requestedAt = row.reconnectRequestedAt && new Date(row.reconnectRequestedAt).getTime();
304
- const appliedAt = existing.reconnectRequestAppliedAt
305
- && new Date(existing.reconnectRequestAppliedAt).getTime();
306
- if (Number.isFinite(requestedAt) && requestedAt > (appliedAt || 0)) {
307
- try {
308
- const minted = await client.post('/api/agent-binding/runtime-token', {
309
- agentName: row.agentName, instanceId: row.instanceId, rotate: true,
310
- });
311
- if (!minted?.token) throw new Error('mint returned no token');
312
- saveToken(recordKey, {
313
- ...existing,
314
- runtimeToken: minted.token,
315
- reconnectRequestAppliedAt: row.reconnectRequestedAt,
316
- });
317
- log(`[${recordKey}] owner-requested key rotation applied; restarting the seat`);
318
- return 'changed';
319
- } catch (error) {
320
- log(`[${recordKey}] owner-requested key rotation failed: ${error.message}`);
321
- return false;
322
- }
323
- }
324
- // A model changed in the UI reaches the seat here: update the record,
325
- // and let the caller restart the child (`agent run` reads its record
326
- // once at boot). A row with NO declared model leaves the record alone —
327
- // never strip an operator's hand-set environment.
328
- const wanted = environmentFor(row);
329
- if (wanted?.declared && !admitDeclared(row, wanted.value, existing)) return false;
330
- const declaredAdapter = row.runtime && typeof row.runtime === 'object'
331
- && typeof row.runtime.adapter === 'string'
332
- ? row.runtime.adapter.trim().toLowerCase()
333
- : null;
334
- let adapterChanged = false;
335
- let nextAdapter = existing.adapter;
336
- if (declaredAdapter) {
337
- const detectedAdapter = await resolveAdapter(row.runtime || null);
338
- // resolveAdapterForRuntime historically probes fallbacks when a
339
- // declared adapter is absent. A configuration edit must never accept
340
- // that fallback: it would report claude while running codex (or vice
341
- // versa). Keep the existing child/token untouched until the exact
342
- // requested adapter is detected locally.
343
- if (detectedAdapter !== declaredAdapter) {
344
- log(`[${recordKey}] requested adapter '${declaredAdapter}' is not available on this machine — keeping the current seat`);
345
- return false;
346
- }
347
- nextAdapter = declaredAdapter;
348
- adapterChanged = existing.adapter !== nextAdapter;
349
- }
350
- const providerRefused = existing.environment?.provider
351
- ? providerRefusal(existing.environment, getAdapter(nextAdapter)) : null;
352
- if (providerRefused) {
353
- // The record stays as it is, and the seat is not started: a retained
354
- // adapter that needs a server binding (opencode) refuses to run under
355
- // the server's new one, so starting it would only crash-loop. The
356
- // owner sets the adapter back, or moves the provider to one it speaks.
357
- log(`[${recordKey}] host-local provider: ${providerRefused} — keeping the current record and not starting it; `
358
- + `set the adapter back to ${existing.adapter || 'the previous one'} in Space, or change the provider`);
359
- return false;
360
- }
361
- if (wanted) {
362
- const serverEnvironment = withoutServerProvider(wanted.value);
363
- const merged = wanted.declared
364
- ? serverEnvironment
365
- : { ...(existing.environment || {}), ...serverEnvironment };
366
- // The provider chooses a network endpoint and reads a host key file,
367
- // so it is authored on this host. /assigned omits it; rehydrate the
368
- // local block after applying the server's declared environment.
369
- if (existing.environment?.provider) {
370
- merged.provider = existing.environment.provider;
371
- }
372
- // A server-declared environment replaces the local one, so a seat whose
373
- // declaration carries no mcp[] would come back tool-less. Re-apply the
374
- // shipped default here and below, or the seat silently loses every
375
- // commonly_* tool the next time the UI edits its model (TASK-048).
376
- //
377
- // A DECLARED environment also gets the sandbox default when it names
378
- // none: the declaration is not the operator's, and `sandbox.mode`
379
- // defaults to 'none' in the adapters, so an omitted block is an
380
- // unconfined seat (TASK-052). A local record with no environment at
381
- // all is in the same position — nobody has authored anything.
382
- const nextEnvironment = derive(merged, nextAdapter, {
383
- sandbox: wanted.declared || !existing.environment,
384
- });
385
- const workspacePath = workspacePathFor(nextEnvironment);
386
- const nextRecord = {
387
- ...existing,
388
- machineId: record.machineId,
389
- ...(adapterChanged ? { adapter: nextAdapter } : {}),
390
- environment: nextEnvironment,
391
- ...(workspacePath ? { workspacePath } : {}),
392
- };
393
- if (adapterChanged
394
- || !isDeepStrictEqual(existing.environment || null, nextEnvironment)
395
- || (workspacePath && existing.workspacePath !== workspacePath)) {
396
- saveToken(recordKey, nextRecord);
397
- log('runtime config changed — restarting the seat to load it');
398
- return 'changed';
399
- }
400
- }
401
- if (adapterChanged) {
402
- // An adapter that consumes mcp[] must not be started on a record that
403
- // declares none, even when only the adapter itself changed.
404
- const nextEnvironment = derive(existing.environment, nextAdapter, {
405
- sandbox: !existing.environment,
406
- });
407
- saveToken(recordKey, {
408
- ...existing,
409
- adapter: nextAdapter,
410
- ...(nextEnvironment ? { environment: nextEnvironment } : {}),
411
- });
412
- log('runtime adapter changed — restarting the seat to load it');
413
- return 'changed';
414
- }
415
- if (!wanted) {
416
- // A record written by a CLI older than the shipped default carries no
417
- // mcp[] — the c4-smoke record was exactly this, and the operator had to
418
- // hand-add the entry. Behind a row that declares nothing there is no
419
- // other write path: the record is never touched again, so the seat
420
- // stays tool-less for as long as it runs. Heal it here, and only when
421
- // the environment actually changed — otherwise every tick rewrites the
422
- // file and restarts the seat forever.
423
- const nextEnvironment = derive(existing.environment, existing.adapter, {
424
- sandbox: !existing.environment,
425
- });
426
- if (!isDeepStrictEqual(existing.environment || null, nextEnvironment || null)) {
427
- saveToken(recordKey, { ...existing, environment: nextEnvironment });
428
- log('record predates the commonly MCP baseline — restarting the seat to load it');
429
- return 'changed';
430
- }
431
- }
432
- return 'ready';
433
- }
434
- const requestedAdapter = row.runtime && typeof row.runtime === 'object'
435
- && typeof row.runtime.adapter === 'string'
436
- ? row.runtime.adapter.trim().toLowerCase()
437
- : null;
438
- let adapter = null;
439
- if (requestedAdapter) {
440
- adapter = await resolveAdapter(row.runtime || null);
441
- if (adapter !== requestedAdapter) {
442
- log(`[${recordKey}] requested adapter '${requestedAdapter}' is not available on this machine — skipping token mint`);
443
- return false;
444
- }
445
- }
446
- const declaredAtMint = environmentFor(row);
447
- if (declaredAtMint?.declared && !admitDeclared(row, declaredAtMint.value, null)) return false;
448
- const body = { agentName: row.agentName, instanceId: row.instanceId };
449
- let minted;
450
- try {
451
- minted = await client.post('/api/agent-binding/runtime-token', body);
452
- } catch (error) {
453
- if (error?.status === 409 && error?.body?.code === 'token_exists') {
454
- log(`[${recordKey}] a runtime token exists elsewhere — rotating it to this machine (the old token stops working)`);
455
- try {
456
- minted = await client.post('/api/agent-binding/runtime-token', { ...body, rotate: true });
457
- } catch (rotateError) {
458
- log(`[${recordKey}] token rotation failed: ${rotateError.message}`);
459
- return false;
460
- }
461
- } else {
462
- log(`[${recordKey}] token mint failed: ${error.message}`);
463
- return false;
464
- }
465
- }
466
- if (!minted?.token) {
467
- log(`[${recordKey}] mint returned no token — skipping`);
468
- return false;
469
- }
470
- if (!adapter) adapter = await resolveAdapter(row.runtime || null);
471
- if (!adapter) {
472
- log(`[${recordKey}] no usable CLI adapter on this machine — install claude, codex, opencode, qwen, or dsh, or attach manually`);
473
- return false;
474
- }
475
- const environment = environmentFor(row);
476
- // A seat installed server-side (no local `agent attach`) arrives with no
477
- // mcp[] at all: without the default it spawns a CLI that has no commonly_*
478
- // tools and cannot post. See lib/default-environment.js. Nothing here is
479
- // operator-authored, so this seat also gets the sandbox default — the
480
- // self-serve install's seat used to be born unconfined (TASK-052).
481
- const recordEnvironment = derive(
482
- environment ? withoutServerProvider(environment.value) : null,
483
- adapter,
484
- { sandbox: true },
485
- );
486
- saveToken(recordKey, {
487
- agentName: row.agentName,
488
- instanceId: row.instanceId,
489
- runtimeToken: minted.token,
490
- instanceUrl: record.instanceUrl,
491
- machineId: record.machineId,
492
- podId: row.podIds?.[0] || null,
493
- adapter,
494
- ...(recordEnvironment ? { environment: recordEnvironment } : {}),
495
- ...(recordEnvironment ? (() => {
496
- const workspacePath = workspacePathFor(recordEnvironment);
497
- return workspacePath ? { workspacePath } : {};
498
- })() : {}),
499
- });
500
- log(`[${recordKey}] provisioned runtime token (adapter: ${adapter}${environment?.value?.model ? `, model: ${environment.value.model}` : ''})`);
501
- return 'ready';
502
- };
503
-
504
- const tick = async () => {
505
- if (stopped || now() < reconnectAfter) return;
506
- let assigned;
507
- try {
508
- assigned = await client.get('/api/agent-binding/assigned');
509
- } catch (error) {
510
- connectionFailure(error, 'work-list fetch failed');
511
- return;
512
- }
513
- connectionRestored();
514
- const rows = Array.isArray(assigned?.agents) ? assigned.agents : [];
515
-
516
- const bound = [];
517
- for (const row of rows) {
518
- if (row.state === 'requested') {
519
- try {
520
- // eslint-disable-next-line no-await-in-loop
521
- await client.post('/api/agent-binding/adopt', {
522
- agentName: row.agentName, instanceId: row.instanceId,
523
- });
524
- log(`[${row.agentName}] adopted onto this machine`);
525
- bound.push(row);
526
- } catch (error) {
527
- // A clean CAS refusal (409) means another machine won — drop it.
528
- log(`[${row.agentName}] adopt refused: ${error.message}`);
529
- }
530
- } else {
531
- bound.push(row);
532
- }
533
- }
534
-
535
- const desiredKeys = new Set();
536
- for (const row of bound) {
537
- const key = identityKey(row.agentName, row.instanceId);
538
- desiredKeys.add(key);
539
- let seat = seats.get(key);
540
- if (!seat) {
541
- seat = {
542
- agentName: row.agentName,
543
- instanceId: row.instanceId || 'default',
544
- child: null,
545
- state: 'stopped',
546
- restarts: 0,
547
- backoffTimer: null,
548
- startDelayTimer: null,
549
- desired: true,
550
- adapter: null,
551
- model: null,
552
- effort: null,
553
- pid: null,
554
- lastTurnAt: null,
555
- lastError: null,
556
- };
557
- seats.set(key, seat);
558
- }
559
- seat.desired = true;
560
- const recordKey = recordKeyFor(row, loadToken);
561
- if (!recordKey) {
562
- log(`[${row.agentName}] instance id '${row.instanceId}' is not a plain id (letters, digits, '.', '_' or '-'); not starting it`);
563
- continue;
564
- }
565
- seat.recordKey = recordKey;
566
- // eslint-disable-next-line no-await-in-loop
567
- const ready = await ensureToken(row, recordKey);
568
- // Report what the seat actually RUNS, which is the record: `agent run`
569
- // reads it once at boot, and ensureToken above may have just rewritten it
570
- // (a mint, or a config change). Reading it AFTER ensureToken is the point
571
- // — the same tick's write is the record the seat starts from, so the very
572
- // first heartbeat is already about the running seat.
573
- //
574
- // The row's runtime is a COMPATIBILITY OVERLAY for a seat with no record
575
- // at all, never an override of a declared environment (see the overlay
576
- // comment above ensureToken): leading with it here reported a model the
577
- // seat was not running, because the spawn path gives the declared
578
- // environment precedence over runtime.model/effort (environmentFor, and
579
- // the TASK-065 measurement). ensureToken also REFUSES a change it cannot
580
- // apply — a requested adapter this machine does not have — so the record
581
- // is the honest answer there too: it names the adapter that kept running.
582
- const current = loadToken(recordKey);
583
- seat.adapter = current ? (current.adapter || null) : (row.runtime?.adapter || null);
584
- seat.model = current ? (current.environment?.model || null) : (row.runtime?.model || null);
585
- seat.effort = current ? (current.environment?.effort || null) : (row.runtime?.effort || null);
586
- if (ready === 'changed' && seat.child) {
587
- // desired stays true, so the exit handler respawns with the updated
588
- // record — the restart path IS the D6 path, no second spawner.
589
- seat.child.kill('SIGTERM');
590
- } else if (ready && !seat.child && !seat.backoffTimer) {
591
- startChild(seat);
592
- }
593
- persist();
594
- }
595
-
596
- for (const [key, seat] of seats) {
597
- if (!desiredKeys.has(key) && seat.desired) stopSeat(seat);
598
- }
599
- persist();
600
- };
601
-
602
- const heartbeat = async () => {
603
- if (stopped || now() < reconnectAfter) return;
604
- try {
605
- await client.post(`/api/machines/${record.machineDbId}/heartbeat`, {
606
- ...machineReport(),
607
- agents: agentStates(),
608
- runtimeInventory: await runtimeInventory(),
609
- });
610
- connectionRestored();
611
- } catch (error) {
612
- connectionFailure(error, 'heartbeat failed');
613
- }
614
- };
615
-
616
- const stop = () => {
617
- stopped = true;
618
- for (const seat of seats.values()) {
619
- stopSeat(seat);
620
- seat.state = 'stopped';
621
- seat.pid = null;
622
- }
623
- persist();
624
- };
625
-
626
- return {
627
- tick, heartbeat, stop, agentStates,
628
- };
629
- };