@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.
- package/LICENSE +21 -205
- package/README.md +104 -31
- package/bin/space.js +113 -0
- package/lib/agents.js +181 -0
- package/lib/boardTools.js +89 -0
- package/lib/credentials.js +95 -0
- package/lib/daemon.js +294 -0
- package/lib/harness.js +166 -0
- package/lib/http.js +101 -0
- package/lib/login.js +68 -0
- package/lib/mcp.js +39 -0
- package/lib/runtime.js +253 -0
- package/lib/store.js +96 -0
- package/lib/tools.js +93 -0
- package/lib/workspaceSandbox.js +77 -0
- package/lib/workspaceTools.js +160 -0
- package/lib/workspaceWorker.js +17 -0
- package/package.json +27 -46
- package/NOTICE +0 -55
- package/assets/webhook-sdk/python/bot.py +0 -63
- package/assets/webhook-sdk/python/commonly.py +0 -232
- package/node_modules/@commonlyai/seats/README.md +0 -25
- package/node_modules/@commonlyai/seats/package.json +0 -22
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
- package/node_modules/@commonlyai/seats/src/contract.js +0 -48
- package/node_modules/@commonlyai/seats/src/failures.js +0 -44
- package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
- package/node_modules/@commonlyai/seats/src/index.js +0 -22
- package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
- package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
- package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
- package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
- package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
- package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
- package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
- package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
- package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
- package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
- package/skills/commonly/SKILL.md +0 -233
- package/src/commands/agent.js +0 -6334
- package/src/commands/daemon.js +0 -933
- package/src/commands/dev.js +0 -153
- package/src/commands/doctor.js +0 -141
- package/src/commands/inbox.js +0 -330
- package/src/commands/login.js +0 -194
- package/src/commands/mcp.js +0 -56
- package/src/commands/pod.js +0 -120
- package/src/commands/task.js +0 -279
- package/src/commands/verify.js +0 -48
- package/src/index.js +0 -99
- package/src/lib/adapter-login.js +0 -76
- package/src/lib/adapters/broker-bridge-entry.js +0 -76
- package/src/lib/adapters/claude.js +0 -2207
- package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
- package/src/lib/adapters/codex.js +0 -1898
- package/src/lib/adapters/dsh.js +0 -513
- package/src/lib/adapters/goose.js +0 -510
- package/src/lib/adapters/hermes.js +0 -18
- package/src/lib/adapters/index.js +0 -68
- package/src/lib/adapters/opencode.js +0 -1681
- package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
- package/src/lib/adapters/pi-mcp-client.mjs +0 -617
- package/src/lib/adapters/pi-shell-env.mjs +0 -41
- package/src/lib/adapters/pi.js +0 -1025
- package/src/lib/adapters/qwen.js +0 -523
- package/src/lib/adapters/seat-model.js +0 -8
- package/src/lib/adapters/stub.js +0 -1
- package/src/lib/adapters/tested-version.js +0 -106
- package/src/lib/api.js +0 -205
- package/src/lib/claim-outcome.js +0 -128
- package/src/lib/claimed-work-gate.js +0 -73
- package/src/lib/cli-hint.js +0 -33
- package/src/lib/commit-attribution.js +0 -391
- package/src/lib/config.js +0 -155
- package/src/lib/content-refusal.js +0 -94
- package/src/lib/credential-file.js +0 -208
- package/src/lib/daemon-agent-states.js +0 -95
- package/src/lib/daemon-hint.js +0 -18
- package/src/lib/daemon-logs.js +0 -43
- package/src/lib/daemon-service-windows.js +0 -263
- package/src/lib/daemon-service.js +0 -486
- package/src/lib/daemon-state.js +0 -84
- package/src/lib/daemon-store.js +0 -119
- package/src/lib/daemon-supervisor.js +0 -629
- package/src/lib/daemon-watch.js +0 -115
- package/src/lib/declared-mcp-guard.js +0 -284
- package/src/lib/default-environment.js +0 -207
- package/src/lib/device-login.js +0 -126
- package/src/lib/digest-frame.js +0 -151
- package/src/lib/doctor.js +0 -291
- package/src/lib/egress-proxy.js +0 -160
- package/src/lib/enforcement.js +0 -946
- package/src/lib/environment.js +0 -681
- package/src/lib/first-output-watchdog.js +0 -191
- package/src/lib/git-readonly-server.js +0 -138
- package/src/lib/git-readonly.js +0 -463
- package/src/lib/git-remote-commonly.mjs +0 -100
- package/src/lib/grant-broker-guard.js +0 -345
- package/src/lib/hooks-config.js +0 -238
- package/src/lib/host-gh-broker.js +0 -152
- package/src/lib/host-gh-shim.mjs +0 -98
- package/src/lib/inbox.js +0 -483
- package/src/lib/instance-proxy.js +0 -76
- package/src/lib/machine-host.js +0 -29
- package/src/lib/mcp-credential-delivery.js +0 -121
- package/src/lib/mcp-first-run.js +0 -50
- package/src/lib/mcp-home.js +0 -644
- package/src/lib/mcp-ready.js +0 -68
- package/src/lib/mcp-server-version.js +0 -123
- package/src/lib/mcp-token.js +0 -36
- package/src/lib/mcp-warm-child.mjs +0 -412
- package/src/lib/memory-bridge.js +0 -191
- package/src/lib/memory-import.js +0 -156
- package/src/lib/missing-harness.js +0 -31
- package/src/lib/model-unavailable.js +0 -154
- package/src/lib/native-runtime-inventory.js +0 -40
- package/src/lib/outsider-grant-mcp.js +0 -82
- package/src/lib/own-key-launch.js +0 -42
- package/src/lib/pod-catch-up.js +0 -55
- package/src/lib/pod-focus.js +0 -224
- package/src/lib/pod-tail.js +0 -68
- package/src/lib/poll-liveness.js +0 -103
- package/src/lib/poll-retry.js +0 -81
- package/src/lib/poller.js +0 -200
- package/src/lib/provider-proxy.js +0 -42
- package/src/lib/public-seat-mcp-flag.js +0 -36
- package/src/lib/public-tool-error.js +0 -60
- package/src/lib/public-withheld-tools.js +0 -48
- package/src/lib/reply-receipt.js +0 -55
- package/src/lib/resolve-on-path.js +0 -22
- package/src/lib/runtime-inventory.js +0 -56
- package/src/lib/runtime-observation.js +0 -37
- package/src/lib/sandbox/bwrap.js +0 -275
- package/src/lib/sandbox/codex-perimeter.js +0 -16
- package/src/lib/sandbox/host-check.js +0 -268
- package/src/lib/sandbox/mode.js +0 -49
- package/src/lib/sandbox/opencode-loopback.mjs +0 -68
- package/src/lib/sandbox/seatbelt.js +0 -684
- package/src/lib/sandbox/turn-dir.js +0 -56
- package/src/lib/sandbox/windows-unsandboxed.js +0 -183
- package/src/lib/sandbox/workspace-git-config.js +0 -243
- package/src/lib/sandbox/workspace-pin.js +0 -158
- package/src/lib/seat-runtime-controls.js +0 -61
- package/src/lib/seat-stop.js +0 -97
- package/src/lib/server-skills.js +0 -32
- package/src/lib/session-recycle.js +0 -219
- package/src/lib/session-store.js +0 -215
- package/src/lib/skill-prompt.js +0 -74
- package/src/lib/skills-import.js +0 -113
- package/src/lib/spawn-credential.js +0 -402
- package/src/lib/spawn-retry.js +0 -311
- package/src/lib/stable-install.js +0 -176
- package/src/lib/supervised-turn.js +0 -352
- package/src/lib/task.js +0 -235
- package/src/lib/turn-failure-notice.js +0 -177
- package/src/lib/unconfined-seat-guard.js +0 -94
- package/src/lib/update-check.js +0 -192
- package/src/lib/upstream-refusal.js +0 -1
- package/src/lib/webhook-server.js +0 -109
- package/src/lib/windows-command.js +0 -393
- package/src/lib/workspace-git.js +0 -87
- 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
|
-
};
|