@ravefox/space 0.0.0-stage → 0.2.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 +203 -0
- package/NOTICE +44 -0
- package/README.md +49 -2
- package/assets/webhook-sdk/python/bot.py +63 -0
- package/assets/webhook-sdk/python/commonly.py +232 -0
- package/node_modules/@commonlyai/seats/README.md +25 -0
- package/node_modules/@commonlyai/seats/package.json +22 -0
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
- package/node_modules/@commonlyai/seats/src/contract.js +48 -0
- package/node_modules/@commonlyai/seats/src/failures.js +44 -0
- package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
- package/node_modules/@commonlyai/seats/src/index.js +22 -0
- package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
- package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
- package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
- package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
- package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
- package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
- package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
- package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
- package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
- package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
- package/package.json +56 -4
- package/skills/commonly/SKILL.md +233 -0
- package/src/commands/agent.js +6334 -0
- package/src/commands/daemon.js +933 -0
- package/src/commands/dev.js +153 -0
- package/src/commands/doctor.js +141 -0
- package/src/commands/inbox.js +330 -0
- package/src/commands/login.js +194 -0
- package/src/commands/mcp.js +56 -0
- package/src/commands/pod.js +120 -0
- package/src/commands/task.js +279 -0
- package/src/commands/verify.js +48 -0
- package/src/index.js +99 -0
- package/src/lib/adapter-login.js +76 -0
- package/src/lib/adapters/broker-bridge-entry.js +76 -0
- package/src/lib/adapters/claude.js +2207 -0
- package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
- package/src/lib/adapters/codex.js +1898 -0
- package/src/lib/adapters/dsh.js +513 -0
- package/src/lib/adapters/goose.js +510 -0
- package/src/lib/adapters/hermes.js +18 -0
- package/src/lib/adapters/index.js +68 -0
- package/src/lib/adapters/opencode.js +1681 -0
- package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
- package/src/lib/adapters/pi-mcp-client.mjs +617 -0
- package/src/lib/adapters/pi-shell-env.mjs +41 -0
- package/src/lib/adapters/pi.js +1025 -0
- package/src/lib/adapters/qwen.js +523 -0
- package/src/lib/adapters/seat-model.js +8 -0
- package/src/lib/adapters/stub.js +1 -0
- package/src/lib/adapters/tested-version.js +106 -0
- package/src/lib/api.js +205 -0
- package/src/lib/claim-outcome.js +128 -0
- package/src/lib/claimed-work-gate.js +73 -0
- package/src/lib/cli-hint.js +33 -0
- package/src/lib/commit-attribution.js +391 -0
- package/src/lib/config.js +155 -0
- package/src/lib/content-refusal.js +94 -0
- package/src/lib/credential-file.js +208 -0
- package/src/lib/daemon-agent-states.js +95 -0
- package/src/lib/daemon-hint.js +18 -0
- package/src/lib/daemon-logs.js +43 -0
- package/src/lib/daemon-service-windows.js +263 -0
- package/src/lib/daemon-service.js +486 -0
- package/src/lib/daemon-state.js +84 -0
- package/src/lib/daemon-store.js +119 -0
- package/src/lib/daemon-supervisor.js +629 -0
- package/src/lib/daemon-watch.js +115 -0
- package/src/lib/declared-mcp-guard.js +284 -0
- package/src/lib/default-environment.js +207 -0
- package/src/lib/device-login.js +126 -0
- package/src/lib/digest-frame.js +151 -0
- package/src/lib/doctor.js +291 -0
- package/src/lib/egress-proxy.js +160 -0
- package/src/lib/enforcement.js +946 -0
- package/src/lib/environment.js +681 -0
- package/src/lib/first-output-watchdog.js +191 -0
- package/src/lib/git-readonly-server.js +138 -0
- package/src/lib/git-readonly.js +463 -0
- package/src/lib/git-remote-commonly.mjs +100 -0
- package/src/lib/grant-broker-guard.js +345 -0
- package/src/lib/hooks-config.js +238 -0
- package/src/lib/host-gh-broker.js +152 -0
- package/src/lib/host-gh-shim.mjs +98 -0
- package/src/lib/inbox.js +483 -0
- package/src/lib/instance-proxy.js +76 -0
- package/src/lib/machine-host.js +29 -0
- package/src/lib/mcp-credential-delivery.js +121 -0
- package/src/lib/mcp-first-run.js +50 -0
- package/src/lib/mcp-home.js +644 -0
- package/src/lib/mcp-ready.js +68 -0
- package/src/lib/mcp-server-version.js +123 -0
- package/src/lib/mcp-token.js +36 -0
- package/src/lib/mcp-warm-child.mjs +412 -0
- package/src/lib/memory-bridge.js +191 -0
- package/src/lib/memory-import.js +156 -0
- package/src/lib/missing-harness.js +31 -0
- package/src/lib/model-unavailable.js +154 -0
- package/src/lib/native-runtime-inventory.js +40 -0
- package/src/lib/outsider-grant-mcp.js +82 -0
- package/src/lib/own-key-launch.js +42 -0
- package/src/lib/pod-catch-up.js +55 -0
- package/src/lib/pod-focus.js +224 -0
- package/src/lib/pod-tail.js +68 -0
- package/src/lib/poll-liveness.js +103 -0
- package/src/lib/poll-retry.js +81 -0
- package/src/lib/poller.js +200 -0
- package/src/lib/provider-proxy.js +42 -0
- package/src/lib/public-seat-mcp-flag.js +36 -0
- package/src/lib/public-tool-error.js +60 -0
- package/src/lib/public-withheld-tools.js +48 -0
- package/src/lib/reply-receipt.js +55 -0
- package/src/lib/resolve-on-path.js +22 -0
- package/src/lib/runtime-inventory.js +56 -0
- package/src/lib/runtime-observation.js +37 -0
- package/src/lib/sandbox/bwrap.js +275 -0
- package/src/lib/sandbox/codex-perimeter.js +16 -0
- package/src/lib/sandbox/host-check.js +268 -0
- package/src/lib/sandbox/mode.js +49 -0
- package/src/lib/sandbox/opencode-loopback.mjs +68 -0
- package/src/lib/sandbox/seatbelt.js +684 -0
- package/src/lib/sandbox/turn-dir.js +56 -0
- package/src/lib/sandbox/windows-unsandboxed.js +183 -0
- package/src/lib/sandbox/workspace-git-config.js +243 -0
- package/src/lib/sandbox/workspace-pin.js +158 -0
- package/src/lib/seat-runtime-controls.js +61 -0
- package/src/lib/seat-stop.js +97 -0
- package/src/lib/server-skills.js +32 -0
- package/src/lib/session-recycle.js +219 -0
- package/src/lib/session-store.js +215 -0
- package/src/lib/skill-prompt.js +74 -0
- package/src/lib/skills-import.js +113 -0
- package/src/lib/spawn-credential.js +402 -0
- package/src/lib/spawn-retry.js +311 -0
- package/src/lib/stable-install.js +176 -0
- package/src/lib/supervised-turn.js +352 -0
- package/src/lib/task.js +235 -0
- package/src/lib/turn-failure-notice.js +177 -0
- package/src/lib/unconfined-seat-guard.js +94 -0
- package/src/lib/update-check.js +192 -0
- package/src/lib/upstream-refusal.js +1 -0
- package/src/lib/webhook-server.js +109 -0
- package/src/lib/windows-command.js +393 -0
- package/src/lib/workspace-git.js +87 -0
- package/src/lib/workspace-trust.js +162 -0
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
import { collectRuntimeInventory, createInventoryCache } from '../lib/runtime-inventory.js';
|
|
2
|
+
/**
|
|
3
|
+
* commonly daemon <subcommand>
|
|
4
|
+
*
|
|
5
|
+
* Phase 2, slice 1: register one machine, persist its scoped daemon bearer,
|
|
6
|
+
* and report/send heartbeats. Adoption and supervision deliberately arrive in
|
|
7
|
+
* later slices; this command never reads an agent runtime credential.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { hostname, homedir } from 'os';
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
import {
|
|
13
|
+
chmodSync, closeSync, existsSync, mkdirSync, openSync, rmSync, watch,
|
|
14
|
+
} from 'fs';
|
|
15
|
+
import { createClient } from '../lib/api.js';
|
|
16
|
+
import { getToken, resolveInstance, resolveInstanceUrl } from '../lib/config.js';
|
|
17
|
+
import {
|
|
18
|
+
clearDaemonStop, loadDaemonRecord, loadDaemonStop, removeDaemonRecord,
|
|
19
|
+
saveDaemonRecord, saveDaemonStop,
|
|
20
|
+
} from '../lib/daemon-store.js';
|
|
21
|
+
import {
|
|
22
|
+
createDaemonSupervisor,
|
|
23
|
+
DEFAULT_HEARTBEAT_MS,
|
|
24
|
+
DEFAULT_POLL_MS,
|
|
25
|
+
} from '../lib/daemon-supervisor.js';
|
|
26
|
+
import { loadAgentToken, saveAgentToken } from './agent.js';
|
|
27
|
+
import { getLastTurn } from '../lib/session-store.js';
|
|
28
|
+
import { getAdapter, providerKeyEnvNames, seatProviderKeyEnv } from '../lib/adapters/index.js';
|
|
29
|
+
import { loginState, runLoginStatus, signedOutAdapters, signedOutHint } from '../lib/adapter-login.js';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
installDaemonService,
|
|
33
|
+
installSummary,
|
|
34
|
+
uninstallDaemonService,
|
|
35
|
+
startDaemonService,
|
|
36
|
+
stopDaemonService,
|
|
37
|
+
restartDaemonService,
|
|
38
|
+
daemonLogPath,
|
|
39
|
+
servicePaths,
|
|
40
|
+
serviceOwnerQuery,
|
|
41
|
+
loadedServiceFile,
|
|
42
|
+
foreignServiceRefusal,
|
|
43
|
+
decodeCmdOutput,
|
|
44
|
+
} from '../lib/daemon-service.js';
|
|
45
|
+
|
|
46
|
+
import { daemonLogsDir, daemonSeatLogPath, readLogTail } from '../lib/daemon-logs.js';
|
|
47
|
+
import { watchWindowsLauncher } from '../lib/daemon-service-windows.js';
|
|
48
|
+
import {
|
|
49
|
+
UNSANDBOXED_FLAG, clearUnsandboxedOptIn, hostSandboxReport, loadUnsandboxedOptIn, saveUnsandboxedOptIn, unsandboxedFlagRefusal,
|
|
50
|
+
} from '../lib/sandbox/windows-unsandboxed.js';
|
|
51
|
+
import { loadDaemonState, saveDaemonState } from '../lib/daemon-state.js';
|
|
52
|
+
import {
|
|
53
|
+
CLEAR_SCREEN, renderDaemonWatch, shouldRedraw, WATCH_INTERVAL_MS,
|
|
54
|
+
} from '../lib/daemon-watch.js';
|
|
55
|
+
import {
|
|
56
|
+
createReachCache, loadPodReach, readSessionLastTurn,
|
|
57
|
+
} from '../lib/daemon-agent-states.js';
|
|
58
|
+
import { installedCliVersion, startUpdateNotice, UPDATE_CHECK_OPT_OUT_ENV } from '../lib/update-check.js';
|
|
59
|
+
import { machineHostReport } from '../lib/machine-host.js';
|
|
60
|
+
import { orchestrateDaemonSetup } from '../lib/stable-install.js';
|
|
61
|
+
import { CLI } from '../lib/cli-hint.js';
|
|
62
|
+
|
|
63
|
+
// Set on the steps `daemon setup` runs, so a step can leave a line to setup.
|
|
64
|
+
const SETUP_CHILD_ENV = 'COMMONLY_DAEMON_SETUP_STEP';
|
|
65
|
+
|
|
66
|
+
// F459: login and register take --instance, so the first-timer walk tried it
|
|
67
|
+
// on run too and got "unknown option". Run serves the one registration this
|
|
68
|
+
// computer has, so --instance only confirms it: the same instance runs, any
|
|
69
|
+
// other (or an unknown saved key) is refused with the register line instead of
|
|
70
|
+
// quietly serving the registered one.
|
|
71
|
+
const instanceKey = (url) => String(url || '').replace(/\/+$/, '').toLowerCase();
|
|
72
|
+
export const daemonRunInstanceRefusal = (record, instanceArg, resolve = resolveInstance) => {
|
|
73
|
+
if (!instanceArg) return null;
|
|
74
|
+
const wanted = resolve(instanceArg)?.url;
|
|
75
|
+
if (wanted && instanceKey(wanted) === instanceKey(record?.instanceUrl)) return null;
|
|
76
|
+
if (!wanted) {
|
|
77
|
+
return `This computer is registered with ${record?.instanceUrl}, and no saved instance is named ${instanceArg}. `
|
|
78
|
+
+ `To use another instance, sign in to it first (${CLI} login --instance <url>), then ${CLI} daemon register --instance <url>`;
|
|
79
|
+
}
|
|
80
|
+
return `This computer is registered with ${record?.instanceUrl}, not ${instanceArg}. To run it for that instance instead: `
|
|
81
|
+
+ `${CLI} daemon register --instance ${instanceArg}`;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// launchd, systemd and Task Scheduler hold one Commonly daemon per computer
|
|
85
|
+
// account. Install refuses to stop another HOME's; this flag takes it over.
|
|
86
|
+
const REPLACE_FLAG = '--replace';
|
|
87
|
+
const REPLACE_HELP = 'Replace a Space daemon that another account (another HOME) runs on this computer account. Without it, install stops and names that daemon.';
|
|
88
|
+
|
|
89
|
+
// One supervised seat, as `commonly daemon status --verbose` names it. Two of these
|
|
90
|
+
// fields are worth reading closely: the seat's `instanceId` (the identity the
|
|
91
|
+
// platform routes by, so a seat attached to the wrong instance is otherwise
|
|
92
|
+
// invisible) and the supervisor's `restarts` flap counter — which is written,
|
|
93
|
+
// persisted and served, and was shown by no surface at all, so a seat that had
|
|
94
|
+
// been crash-looping looked exactly like a healthy one (TASK-072, 2026-09-19).
|
|
95
|
+
//
|
|
96
|
+
// `restarts` distinguishes 0 from absent on purpose: a state file written by an
|
|
97
|
+
// older daemon has no such field, and printing 0 for it would claim a reading the
|
|
98
|
+
// file does not carry.
|
|
99
|
+
export const formatSeatLine = (seat = {}) => {
|
|
100
|
+
const model = seat.model || 'default model';
|
|
101
|
+
const effort = seat.effort ? `/${seat.effort}` : '';
|
|
102
|
+
const error = seat.lastError ? ` error=${seat.lastError}` : '';
|
|
103
|
+
const restarts = seat.restarts === undefined || seat.restarts === null ? '-' : seat.restarts;
|
|
104
|
+
return ` ${seat.agentName}: ${seat.state} instance=${seat.instanceId || '-'} adapter=${seat.adapter || 'unknown'} model=${model}${effort} pid=${seat.pid || '-'} restarts=${restarts} lastTurn=${seat.lastTurnAt || 'never'}${error}`;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// `daemon status --watch`: one frame, then a redraw every 2 s on a terminal. Off a
|
|
108
|
+
// terminal, or with NO_COLOR set, it prints one frame and returns. A failed
|
|
109
|
+
// fetch is shown in the frame, so the loop keeps running through a brief outage.
|
|
110
|
+
const watchDaemonStatus = async () => {
|
|
111
|
+
const redraw = shouldRedraw({ isTTY: process.stdout.isTTY });
|
|
112
|
+
// Each agent's pod comes from its token record. The verdict for a pod is read
|
|
113
|
+
// through the operator's login and cached for 10 s, so the 2 s frame does not
|
|
114
|
+
// re-read it. A failed read is thrown, so the next frame retries.
|
|
115
|
+
const reachFor = createReachCache({
|
|
116
|
+
load: (podId) => loadPodReach({
|
|
117
|
+
client: createClient({ instance: requireDaemonRecord().instanceUrl }),
|
|
118
|
+
podId,
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
const frame = async () => {
|
|
122
|
+
try {
|
|
123
|
+
const record = requireDaemonRecord();
|
|
124
|
+
// A failed machine read still shows the local seats and logs, with the
|
|
125
|
+
// machine marked unreachable, so the operator can see what is running here.
|
|
126
|
+
let machine;
|
|
127
|
+
let machineNote = '';
|
|
128
|
+
try {
|
|
129
|
+
machine = await getDaemonMachineStatus({
|
|
130
|
+
client: createClient({ instance: record.instanceUrl, token: record.daemonToken }),
|
|
131
|
+
record,
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
machine = { name: record.machineName, status: 'unreachable', lastSeenAt: null };
|
|
135
|
+
machineNote = `Machine status unavailable (${error.message}): showing local seats only.`;
|
|
136
|
+
}
|
|
137
|
+
const seats = (loadDaemonState()?.seats || []).map((seat) => ({
|
|
138
|
+
...seat,
|
|
139
|
+
// The supervisor stamps spawn time; the session store holds real turns.
|
|
140
|
+
lastTurnAt: readSessionLastTurn(seat.recordKey || seat.agentName),
|
|
141
|
+
}));
|
|
142
|
+
// Keyed by the local record, so two instances of one agent never read
|
|
143
|
+
// each other's log, last turn or pod (F427).
|
|
144
|
+
const logTails = {};
|
|
145
|
+
for (const seat of seats) {
|
|
146
|
+
const key = seat.recordKey || seat.agentName;
|
|
147
|
+
const tail = readLogTail(daemonSeatLogPath(key), 3);
|
|
148
|
+
logTails[key] = tail ? tail.split('\n') : [];
|
|
149
|
+
}
|
|
150
|
+
let states = null;
|
|
151
|
+
let stateNote = '';
|
|
152
|
+
if (!getToken(record.instanceUrl)) {
|
|
153
|
+
stateNote = `Not logged in: showing local process state only. Run: ${CLI} login --instance ${record.instanceUrl}`;
|
|
154
|
+
} else {
|
|
155
|
+
const podIds = [...new Set(seats.map((seat) => loadAgentToken(seat.recordKey || seat.agentName)?.podId).filter(Boolean))];
|
|
156
|
+
try {
|
|
157
|
+
states = await reachFor(podIds);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
stateNote = `Agent states unavailable (${error.message}): showing local process state only`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return renderDaemonWatch({
|
|
163
|
+
machine, seats, logTails, states, stateNote: [machineNote, stateNote].filter(Boolean).join(' '), now: Date.now(), width: process.stdout.columns || 80, color: redraw,
|
|
164
|
+
});
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return daemonStatusErrorMessage(error);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
if (!redraw) {
|
|
170
|
+
console.log(await frame());
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
let busy = false;
|
|
174
|
+
const tick = async () => {
|
|
175
|
+
if (busy) return;
|
|
176
|
+
busy = true;
|
|
177
|
+
try {
|
|
178
|
+
process.stdout.write(`${CLEAR_SCREEN}${await frame()}\n`);
|
|
179
|
+
} finally {
|
|
180
|
+
busy = false;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
await tick();
|
|
184
|
+
const timer = setInterval(tick, WATCH_INTERVAL_MS);
|
|
185
|
+
const stop = () => {
|
|
186
|
+
clearInterval(timer);
|
|
187
|
+
process.stdout.write('\n');
|
|
188
|
+
process.exit(0);
|
|
189
|
+
};
|
|
190
|
+
process.once('SIGINT', stop);
|
|
191
|
+
process.once('SIGTERM', stop);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const requireDaemonRecord = () => {
|
|
195
|
+
const record = loadDaemonRecord();
|
|
196
|
+
if (!record) {
|
|
197
|
+
throw new Error(`No local daemon is registered. Run: ${CLI} daemon register`);
|
|
198
|
+
}
|
|
199
|
+
return record;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const requireUserToken = (instance) => {
|
|
203
|
+
const token = getToken(instance);
|
|
204
|
+
if (!token) throw new Error(`Not logged in to ${instance}. Run: ${CLI} login --instance ${instance}`);
|
|
205
|
+
return token;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const requireRegistrationResponse = (response) => {
|
|
209
|
+
const machine = response?.machine;
|
|
210
|
+
if (!machine?.id || !machine?.machineId || !machine?.name || !response?.daemonToken) {
|
|
211
|
+
throw new Error('Server returned an incomplete machine registration. No daemon credential was stored.');
|
|
212
|
+
}
|
|
213
|
+
return { machine, daemonToken: response.daemonToken };
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Exported for a service-level test: persistence failure must revoke the
|
|
217
|
+
// freshly-created machine because the raw bearer is not recoverable later.
|
|
218
|
+
export const registerDaemonMachine = async ({
|
|
219
|
+
client,
|
|
220
|
+
instanceUrl,
|
|
221
|
+
name,
|
|
222
|
+
persist = saveDaemonRecord,
|
|
223
|
+
host = machineHostReport(),
|
|
224
|
+
}) => {
|
|
225
|
+
// TASK-784: the host report is metadata only (platform, OS release major,
|
|
226
|
+
// arch, CLI version). An older server ignores it.
|
|
227
|
+
const { machine, daemonToken } = requireRegistrationResponse(
|
|
228
|
+
await client.post('/api/machines', { name, host }),
|
|
229
|
+
);
|
|
230
|
+
const record = {
|
|
231
|
+
machineDbId: machine.id,
|
|
232
|
+
machineId: machine.machineId,
|
|
233
|
+
machineName: machine.name,
|
|
234
|
+
instanceUrl,
|
|
235
|
+
daemonToken,
|
|
236
|
+
registeredAt: new Date().toISOString(),
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
persist(record);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// The token is a one-time response. If it cannot be secured locally, tear
|
|
243
|
+
// down the server row so it cannot remain a live, unrecoverable bearer.
|
|
244
|
+
try {
|
|
245
|
+
await client.del(`/api/machines/${machine.id}`);
|
|
246
|
+
} catch (revokeError) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`Could not store the daemon credential and could not revoke machine ${machine.name} (${machine.id}): ${revokeError.message}`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
throw new Error(`Could not store the daemon credential securely: ${error.message}. Machine registration was revoked.`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { machine, record };
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// What each supervisor check-in tells the server about this computer.
|
|
258
|
+
// Native Windows has no OS sandbox for any adapter, so it never reports
|
|
259
|
+
// 'default': Codex there runs on its own profiles only, and a removed marker
|
|
260
|
+
// must not read as "confined" while seats may still run. An older daemon sends
|
|
261
|
+
// neither field, which leaves the server's record. TASK-784: the host report
|
|
262
|
+
// rides along, so a machine an older CLI registered fills in on upgrade.
|
|
263
|
+
export const daemonMachineReport = () => ({ sandbox: hostSandboxReport(), host: machineHostReport() });
|
|
264
|
+
|
|
265
|
+
export const heartbeatDaemonMachine = async ({ client, record }) => (
|
|
266
|
+
client.post(`/api/machines/${record.machineDbId}/heartbeat`)
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
export const getDaemonMachineStatus = async ({ client, record }) => {
|
|
270
|
+
const response = await client.get('/api/machines/me');
|
|
271
|
+
const machine = response?.machine;
|
|
272
|
+
if (!machine || typeof machine !== 'object' || Array.isArray(machine)
|
|
273
|
+
|| !machine.id || !machine.machineId
|
|
274
|
+
|| (record && (machine.id !== record.machineDbId || machine.machineId !== record.machineId))) {
|
|
275
|
+
throw new Error('Unexpected daemon status response; local credential kept.');
|
|
276
|
+
}
|
|
277
|
+
return machine;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const isRevokedDaemonKey = (error) => error?.status === 401 && [
|
|
281
|
+
'Daemon token revoked or unknown',
|
|
282
|
+
'Machine access revoked',
|
|
283
|
+
].includes(error?.body?.message);
|
|
284
|
+
|
|
285
|
+
const isMissingDaemonMachine = (error) => error?.status === 404
|
|
286
|
+
&& error?.body?.message === 'Machine not found';
|
|
287
|
+
|
|
288
|
+
const storedDaemonState = async (client, record) => {
|
|
289
|
+
try {
|
|
290
|
+
await getDaemonMachineStatus({ client, record });
|
|
291
|
+
return 'active';
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (isRevokedDaemonKey(error)) return 'revoked';
|
|
294
|
+
if (isMissingDaemonMachine(error)) return 'missing';
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
export const daemonStatusErrorMessage = (error) => (isRevokedDaemonKey(error)
|
|
300
|
+
? `This computer's old key was revoked. Run: ${CLI} daemon register`
|
|
301
|
+
: isMissingDaemonMachine(error)
|
|
302
|
+
? `This computer is no longer registered. Run: ${CLI} daemon register`
|
|
303
|
+
: `Daemon status failed: ${error.message}`);
|
|
304
|
+
|
|
305
|
+
// `daemon setup` on a computer that is already registered for this instance
|
|
306
|
+
// skips login and register (it would stop at "already registered"). Only a
|
|
307
|
+
// stored key the server still accepts counts; a revoked or missing one goes
|
|
308
|
+
// through register, which replaces it.
|
|
309
|
+
export const setupFindsRegistration = async ({
|
|
310
|
+
instance, load = loadDaemonRecord, state = storedDaemonState, clientFor = createClient,
|
|
311
|
+
}) => {
|
|
312
|
+
const record = load();
|
|
313
|
+
if (!record || !instance) return false;
|
|
314
|
+
if (String(record.instanceUrl || '').replace(/\/+$/, '') !== instance) return false;
|
|
315
|
+
const client = clientFor({ instance: record.instanceUrl, token: record.daemonToken });
|
|
316
|
+
return (await state(client, record)) === 'active';
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Keep the old record until a new registration is securely persisted. A
|
|
320
|
+
// network failure or a still-live daemon key must never silently mint another.
|
|
321
|
+
export const allowRevokedDaemonReplacement = async ({ existing, client }) => {
|
|
322
|
+
if (!existing) return null;
|
|
323
|
+
const state = await storedDaemonState(client, existing);
|
|
324
|
+
if (state === 'active') {
|
|
325
|
+
throw new Error(`A local daemon is already registered for ${existing.machineName}. Run: ${CLI} daemon status`);
|
|
326
|
+
}
|
|
327
|
+
return state;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Revoke remotely before unlinking locally. An owner-session 401/403 is not
|
|
331
|
+
// proof of revocation: check the stored daemon key before discarding it.
|
|
332
|
+
export const unregisterDaemonMachine = async ({
|
|
333
|
+
client, daemonClient, record, remove = removeDaemonRecord,
|
|
334
|
+
}) => {
|
|
335
|
+
let removedRemotely = true;
|
|
336
|
+
try {
|
|
337
|
+
await client.del(`/api/machines/${record.machineDbId}`);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (isMissingDaemonMachine(error)) {
|
|
340
|
+
removedRemotely = false;
|
|
341
|
+
} else if ([401, 403].includes(error?.status) && daemonClient) {
|
|
342
|
+
if (await storedDaemonState(daemonClient, record) === 'active') throw error;
|
|
343
|
+
removedRemotely = false;
|
|
344
|
+
} else {
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
remove();
|
|
349
|
+
return { removedRemotely };
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// The adapter names a binary on THIS machine — the one fact the server cannot
|
|
353
|
+
// know (same reasoning as `agent run`'s env bootstrap). A server-declared
|
|
354
|
+
// preference is honored when that CLI is installed; otherwise probe only the
|
|
355
|
+
// established fallbacks. OpenCode must be named explicitly in runtime.adapter.
|
|
356
|
+
export const resolveAdapterForRuntime = async (runtime, registry = { getAdapter }) => {
|
|
357
|
+
const candidates = [runtime?.adapter, 'claude', 'codex'].filter(Boolean);
|
|
358
|
+
for (const name of candidates) {
|
|
359
|
+
const adapter = registry.getAdapter(name);
|
|
360
|
+
// eslint-disable-next-line no-await-in-loop
|
|
361
|
+
if (adapter && await adapter.detect()) return name;
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The daemon's first poll and heartbeat, with the Windows launcher watch armed
|
|
368
|
+
* BEFORE them (D245). Arming first puts the EPIPE guard on stdout and stderr
|
|
369
|
+
* before any supervision log line, and a launcher that is already gone stops
|
|
370
|
+
* the daemon here instead of after a tick logged into a dead pipe. Returns
|
|
371
|
+
* false when the daemon should not go on to its timers.
|
|
372
|
+
*/
|
|
373
|
+
export const firstSupervisionPass = async ({
|
|
374
|
+
supervisor,
|
|
375
|
+
onLauncherGone,
|
|
376
|
+
isEnded = () => false,
|
|
377
|
+
armLauncherWatch = watchWindowsLauncher,
|
|
378
|
+
}) => {
|
|
379
|
+
let launcherGone = false;
|
|
380
|
+
armLauncherWatch({
|
|
381
|
+
onGone: (pid) => {
|
|
382
|
+
launcherGone = true;
|
|
383
|
+
onLauncherGone(pid);
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
const stopped = () => launcherGone || isEnded();
|
|
387
|
+
if (stopped()) return false;
|
|
388
|
+
await supervisor.tick();
|
|
389
|
+
if (stopped()) return false;
|
|
390
|
+
await supervisor.heartbeat();
|
|
391
|
+
return !stopped();
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
export const registerDaemon = (program) => {
|
|
395
|
+
const daemon = program.command('daemon').description('Manage the local Space daemon');
|
|
396
|
+
const signedOutNotes = async () => (await signedOutAdapters({
|
|
397
|
+
detect: (name) => getAdapter(name)?.detect(),
|
|
398
|
+
runStatus: runLoginStatus,
|
|
399
|
+
})).map((name) => `Note: ${signedOutHint(name)}.`);
|
|
400
|
+
|
|
401
|
+
daemon.addHelpText('after', `
|
|
402
|
+
The daemon token is scoped to this machine and stored in a 0600 file under
|
|
403
|
+
~/.commonly/daemon/. It is never shown again after registration.
|
|
404
|
+
|
|
405
|
+
Examples:
|
|
406
|
+
$ ${CLI} daemon setup # install, log in, register, and start — one step
|
|
407
|
+
$ ${CLI} daemon register --name "Sam's MacBook"
|
|
408
|
+
$ ${CLI} daemon heartbeat
|
|
409
|
+
$ ${CLI} daemon status --verbose
|
|
410
|
+
$ ${CLI} daemon logs --seat my-agent -f
|
|
411
|
+
$ ${CLI} daemon start|stop|restart
|
|
412
|
+
$ ${CLI} daemon unregister
|
|
413
|
+
`);
|
|
414
|
+
|
|
415
|
+
daemon
|
|
416
|
+
.command('setup')
|
|
417
|
+
.description('From a fresh computer in one step: install the CLI under your home (no sudo, no global install), log in, register this computer, and start the daemon')
|
|
418
|
+
.option('--instance <url-or-key>', 'Target Space instance')
|
|
419
|
+
.option(UNSANDBOXED_FLAG, `Native Windows only: the agents this daemon runs have no sandbox (see \`${CLI} daemon install\`). Forwarded to the install step.`)
|
|
420
|
+
.option(REPLACE_FLAG, REPLACE_HELP)
|
|
421
|
+
.action(async (opts) => {
|
|
422
|
+
try {
|
|
423
|
+
const instanceUrl = resolveInstanceUrl(opts.instance);
|
|
424
|
+
await orchestrateDaemonSetup({
|
|
425
|
+
version: installedCliVersion(),
|
|
426
|
+
instanceUrl,
|
|
427
|
+
unsandboxed: Boolean(opts.unsandboxed),
|
|
428
|
+
alreadyRegistered: (instance) => setupFindsRegistration({ instance }),
|
|
429
|
+
replace: Boolean(opts.replace),
|
|
430
|
+
foreignDaemon: async () => {
|
|
431
|
+
const target = servicePaths(process.platform, homedir());
|
|
432
|
+
const answer = await readServiceCmd(serviceOwnerQuery(process.platform)).catch(() => '');
|
|
433
|
+
return foreignServiceRefusal({ target, loadedFile: loadedServiceFile(target.kind, answer) });
|
|
434
|
+
},
|
|
435
|
+
log: (line) => console.log(line),
|
|
436
|
+
// Run the stable CLI once with the user's terminal attached, so login
|
|
437
|
+
// device-auth and any prompts work; reject on a non-zero exit so a
|
|
438
|
+
// failed step stops the chain before the next one.
|
|
439
|
+
runCli: (entry, args) => new Promise((resolve, reject) => {
|
|
440
|
+
// SETUP_CHILD_ENV: register leaves the signed-out note to setup's last line.
|
|
441
|
+
const child = spawn(process.execPath, [entry, ...args], { stdio: 'inherit', env: { ...process.env, [SETUP_CHILD_ENV]: '1' } });
|
|
442
|
+
child.on('error', reject);
|
|
443
|
+
child.on('close', (code) => (code === 0
|
|
444
|
+
? resolve()
|
|
445
|
+
: reject(new Error(`\`commonly ${args.join(' ')}\` exited with code ${code}`))));
|
|
446
|
+
}),
|
|
447
|
+
});
|
|
448
|
+
// Growth's F370 walk: setup said all was well, and the first @mention
|
|
449
|
+
// then failed with "claude exited 1: Not logged in".
|
|
450
|
+
for (const line of await signedOutNotes()) console.log(line);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
console.error(`Daemon setup failed: ${error.message}`);
|
|
453
|
+
process.exitCode = 1;
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
daemon
|
|
458
|
+
.command('register')
|
|
459
|
+
.description('Register this machine and securely store its daemon credential')
|
|
460
|
+
.option('--name <name>', 'Machine name (default: this computer\'s hostname)')
|
|
461
|
+
.option('--instance <url-or-key>', 'Target Space instance')
|
|
462
|
+
.action(async (opts) => {
|
|
463
|
+
try {
|
|
464
|
+
const existing = loadDaemonRecord();
|
|
465
|
+
if (existing) {
|
|
466
|
+
const state = await allowRevokedDaemonReplacement({
|
|
467
|
+
existing,
|
|
468
|
+
client: createClient({ instance: existing.instanceUrl, token: existing.daemonToken }),
|
|
469
|
+
});
|
|
470
|
+
console.log(state === 'revoked'
|
|
471
|
+
? "This computer's old key was revoked; registering it again."
|
|
472
|
+
: 'This computer is no longer registered; registering it again.');
|
|
473
|
+
}
|
|
474
|
+
const instanceUrl = resolveInstanceUrl(opts.instance);
|
|
475
|
+
const client = createClient({ instance: instanceUrl, token: requireUserToken(opts.instance || instanceUrl) });
|
|
476
|
+
const { machine, record } = await registerDaemonMachine({
|
|
477
|
+
client,
|
|
478
|
+
instanceUrl,
|
|
479
|
+
name: String(opts.name || hostname()).trim(),
|
|
480
|
+
});
|
|
481
|
+
clearDaemonStop();
|
|
482
|
+
console.log(`Registered ${machine.name}. Daemon credential stored securely.`);
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
await heartbeatDaemonMachine({
|
|
486
|
+
client: createClient({ instance: record.instanceUrl, token: record.daemonToken }),
|
|
487
|
+
record,
|
|
488
|
+
});
|
|
489
|
+
console.log('Initial heartbeat accepted.');
|
|
490
|
+
} catch (error) {
|
|
491
|
+
console.error(`Machine is registered, but its initial heartbeat failed: ${error.message}`);
|
|
492
|
+
console.error(`Retry with: ${CLI} daemon heartbeat`);
|
|
493
|
+
process.exitCode = 1;
|
|
494
|
+
}
|
|
495
|
+
if (existing && existsSync(servicePaths(process.platform, homedir()).file)) {
|
|
496
|
+
console.log(`Restart the installed daemon to use the new key: ${CLI} daemon restart`);
|
|
497
|
+
}
|
|
498
|
+
// F458: register run by hand (the F454 walk) said nothing, and the
|
|
499
|
+
// agent looked healthy until its first message failed. Setup prints
|
|
500
|
+
// this itself, after the install step, so its own register doesn't.
|
|
501
|
+
if (!process.env[SETUP_CHILD_ENV]) {
|
|
502
|
+
for (const line of await signedOutNotes()) console.log(line);
|
|
503
|
+
}
|
|
504
|
+
} catch (error) {
|
|
505
|
+
console.error(`Daemon registration failed: ${error.message}`);
|
|
506
|
+
process.exitCode = 1;
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
daemon
|
|
511
|
+
.command('unregister')
|
|
512
|
+
.description('Revoke this machine on the server and remove its local daemon credential')
|
|
513
|
+
.action(async () => {
|
|
514
|
+
try {
|
|
515
|
+
const record = requireDaemonRecord();
|
|
516
|
+
const client = createClient({
|
|
517
|
+
instance: record.instanceUrl,
|
|
518
|
+
token: requireUserToken(record.instanceUrl),
|
|
519
|
+
});
|
|
520
|
+
const { removedRemotely } = await unregisterDaemonMachine({
|
|
521
|
+
client,
|
|
522
|
+
daemonClient: createClient({ instance: record.instanceUrl, token: record.daemonToken }),
|
|
523
|
+
record,
|
|
524
|
+
});
|
|
525
|
+
console.log(removedRemotely
|
|
526
|
+
? `Unregistered ${record.machineName} and removed its local daemon credential.`
|
|
527
|
+
: `Removed ${record.machineName}'s local daemon credential. Server access was already revoked or the machine was removed.`);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
console.error(`Daemon unregister failed: ${error.message}`);
|
|
530
|
+
process.exitCode = 1;
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
daemon
|
|
535
|
+
.command('heartbeat')
|
|
536
|
+
.description('Send a machine liveness heartbeat using the stored daemon credential')
|
|
537
|
+
.action(async () => {
|
|
538
|
+
try {
|
|
539
|
+
const record = requireDaemonRecord();
|
|
540
|
+
const response = await heartbeatDaemonMachine({
|
|
541
|
+
client: createClient({ instance: record.instanceUrl, token: record.daemonToken }),
|
|
542
|
+
record,
|
|
543
|
+
});
|
|
544
|
+
console.log(`Heartbeat accepted for ${response?.machine?.name || record.machineName}.`);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
console.error(`Daemon heartbeat failed: ${error.message}`);
|
|
547
|
+
process.exitCode = 1;
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// ── install / uninstall (ADR-026 D1) ──────────────────────────────────────
|
|
552
|
+
// The service manager's answer about the live daemon; rejects when it has none.
|
|
553
|
+
const readServiceCmd = (argv) => new Promise((resolvePromise, rejectPromise) => {
|
|
554
|
+
const child = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
555
|
+
const chunks = [];
|
|
556
|
+
child.stdout.on('data', (chunk) => { chunks.push(chunk); });
|
|
557
|
+
child.on('error', rejectPromise);
|
|
558
|
+
child.on('close', (code) => (code === 0
|
|
559
|
+
? resolvePromise(decodeCmdOutput(Buffer.concat(chunks)))
|
|
560
|
+
: rejectPromise(new Error(`${argv.join(' ')} exited ${code}`))));
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const serviceDeps = () => ({
|
|
564
|
+
// writeFile is deliberately NOT injected here: daemon-service.js's default
|
|
565
|
+
// writer sets the 0600 mode at CREATION, and a bare `writeFileSync(file,
|
|
566
|
+
// content, 'utf8')` dropped that — leaving the service file, which can hold
|
|
567
|
+
// a provider key, readable at umask until the chmod a moment later.
|
|
568
|
+
mkdirp: (dir) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); },
|
|
569
|
+
chmod: (path, mode) => chmodSync(path, mode),
|
|
570
|
+
ensureFile: (path) => { const fd = openSync(path, 'a', 0o600); closeSync(fd); },
|
|
571
|
+
existsFile: (file) => existsSync(file),
|
|
572
|
+
removeFile: (file) => rmSync(file),
|
|
573
|
+
readCmd: readServiceCmd,
|
|
574
|
+
execCmd: (argv) => new Promise((resolvePromise, rejectPromise) => {
|
|
575
|
+
const child = spawn(argv[0], argv.slice(1), { stdio: 'ignore' });
|
|
576
|
+
child.on('error', rejectPromise);
|
|
577
|
+
child.on('exit', (code) => (code === 0
|
|
578
|
+
? resolvePromise()
|
|
579
|
+
: rejectPromise(new Error(`${argv.join(' ')} exited ${code}`))));
|
|
580
|
+
}),
|
|
581
|
+
log: (line) => console.log(line),
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const runServiceAction = async (action, actionFn) => {
|
|
585
|
+
const deps = serviceDeps();
|
|
586
|
+
const target = servicePaths(process.platform, homedir());
|
|
587
|
+
if (!deps.existsFile(target.file)) {
|
|
588
|
+
throw new Error(`No installed daemon service found. Run: ${CLI} daemon install`);
|
|
589
|
+
}
|
|
590
|
+
await actionFn({ platform: process.platform, home: homedir(), execCmd: deps.execCmd });
|
|
591
|
+
console.log(`Daemon ${action} requested (${target.kind}).`);
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
daemon
|
|
595
|
+
.command('install')
|
|
596
|
+
.description('Register the daemon as a login service (launchd, systemd, or a Windows scheduled task); on Linux, also turn on lingering so it starts at boot')
|
|
597
|
+
.option(UNSANDBOXED_FLAG, 'Native Windows only: the agents this daemon runs have no sandbox, and run with your own access to files and commands. They get no connector grants. Codex can run commands in invite-only pods, where anyone in the pod, including people invited later, can direct it. Install again without it to turn it off.')
|
|
598
|
+
.option(REPLACE_FLAG, REPLACE_HELP)
|
|
599
|
+
.action(async (opts) => {
|
|
600
|
+
try {
|
|
601
|
+
if (opts.unsandboxed) {
|
|
602
|
+
const flagRefusal = unsandboxedFlagRefusal();
|
|
603
|
+
if (flagRefusal) throw new Error(flagRefusal);
|
|
604
|
+
}
|
|
605
|
+
// A service without a credential just crash-loops at boot.
|
|
606
|
+
requireDaemonRecord();
|
|
607
|
+
// D245: this command is the only writer of the opt-in, and installing
|
|
608
|
+
// without the flag is how it is turned off. Written before the task
|
|
609
|
+
// starts, so the first seats it spawns already read the answer.
|
|
610
|
+
// N3: the marker's state before this install, so a failed install can
|
|
611
|
+
// put it back rather than leave an opt-in with no daemon behind it.
|
|
612
|
+
const optedInBefore = loadUnsandboxedOptIn();
|
|
613
|
+
if (opts.unsandboxed) {
|
|
614
|
+
saveUnsandboxedOptIn();
|
|
615
|
+
console.log(`Agents this daemon runs will have no sandbox: they act with your own access to files and commands, and get no connector grants. Codex can run commands in invite-only pods, where anyone in the pod, people you invite later included, can direct it. To turn this off, run: ${CLI} daemon install`);
|
|
616
|
+
} else if (clearUnsandboxedOptIn()) {
|
|
617
|
+
console.log('Turned off running without a sandbox: on Windows, agents this daemon runs are refused again until you choose otherwise.');
|
|
618
|
+
}
|
|
619
|
+
// Same class one layer over (TASK-049): a service without the SEATS'
|
|
620
|
+
// provider key comes up clean, and every pi seat then dies inside its
|
|
621
|
+
// adapter. The keys are captured here because this is the only moment
|
|
622
|
+
// the operator's shell is in reach; the ones that are missing are named
|
|
623
|
+
// by installDaemonService rather than left to be discovered per seat.
|
|
624
|
+
let installed;
|
|
625
|
+
try {
|
|
626
|
+
installed = await installDaemonService({
|
|
627
|
+
...serviceDeps(),
|
|
628
|
+
replace: Boolean(opts.replace),
|
|
629
|
+
providerKeyEnvNames: providerKeyEnvNames(),
|
|
630
|
+
optionalEnvNames: [UPDATE_CHECK_OPT_OUT_ENV],
|
|
631
|
+
});
|
|
632
|
+
} catch (error) {
|
|
633
|
+
if (optedInBefore) saveUnsandboxedOptIn(); else clearUnsandboxedOptIn();
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
console.log(installSummary(installed));
|
|
637
|
+
} catch (error) {
|
|
638
|
+
console.error(`Daemon install failed: ${error.message}`);
|
|
639
|
+
process.exitCode = 1;
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
daemon
|
|
644
|
+
.command('uninstall')
|
|
645
|
+
.description('Remove the daemon login service (agents stop being supervised on this machine)')
|
|
646
|
+
.action(async () => {
|
|
647
|
+
try {
|
|
648
|
+
await uninstallDaemonService(serviceDeps());
|
|
649
|
+
} catch (error) {
|
|
650
|
+
console.error(`Daemon uninstall failed: ${error.message}`);
|
|
651
|
+
process.exitCode = 1;
|
|
652
|
+
} finally {
|
|
653
|
+
// N4: an uninstall that half-failed still turns the opt-in off; a
|
|
654
|
+
// later install has to ask for it again.
|
|
655
|
+
clearUnsandboxedOptIn();
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
daemon
|
|
660
|
+
.command('start')
|
|
661
|
+
.description('Start the installed daemon service and return to the terminal')
|
|
662
|
+
.action(async () => {
|
|
663
|
+
try {
|
|
664
|
+
await runServiceAction('start', startDaemonService);
|
|
665
|
+
} catch (error) {
|
|
666
|
+
console.error(`Daemon start failed: ${error.message}`);
|
|
667
|
+
process.exitCode = 1;
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
daemon
|
|
672
|
+
.command('stop')
|
|
673
|
+
.description('Stop the installed daemon service')
|
|
674
|
+
.action(async () => {
|
|
675
|
+
try {
|
|
676
|
+
await runServiceAction('stop', stopDaemonService);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
console.error(`Daemon stop failed: ${error.message}`);
|
|
679
|
+
process.exitCode = 1;
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
daemon
|
|
684
|
+
.command('restart')
|
|
685
|
+
.description('Restart the installed daemon service and return to the terminal')
|
|
686
|
+
.action(async () => {
|
|
687
|
+
try {
|
|
688
|
+
await runServiceAction('restart', restartDaemonService);
|
|
689
|
+
} catch (error) {
|
|
690
|
+
console.error(`Daemon restart failed: ${error.message}`);
|
|
691
|
+
process.exitCode = 1;
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// ── run (ADR-026 Phase 2, slice 2) ────────────────────────────────────────
|
|
696
|
+
daemon
|
|
697
|
+
.command('run')
|
|
698
|
+
.description('Run the resident supervisor: adopt requested agents, keep bound agents running, report per-agent state')
|
|
699
|
+
.option('--poll <ms>', 'Work-list poll interval in ms', String(DEFAULT_POLL_MS))
|
|
700
|
+
.option('--heartbeat <ms>', 'Heartbeat interval in ms', String(DEFAULT_HEARTBEAT_MS))
|
|
701
|
+
.option('--foreground', 'Keep the supervisor attached to this terminal (default for direct invocation)')
|
|
702
|
+
.option('--instance <url-or-key>', 'Confirm the instance this computer is registered with')
|
|
703
|
+
.action(async (opts) => {
|
|
704
|
+
let record;
|
|
705
|
+
try {
|
|
706
|
+
const terminal = loadDaemonStop();
|
|
707
|
+
if (terminal) {
|
|
708
|
+
console.error(`Daemon stopped: no valid local registration. Run: ${CLI} daemon register`);
|
|
709
|
+
process.exitCode = process.platform === 'darwin' ? 0 : terminal.exitCode;
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
record = requireDaemonRecord();
|
|
713
|
+
const instanceRefusal = daemonRunInstanceRefusal(record, opts.instance);
|
|
714
|
+
if (instanceRefusal) {
|
|
715
|
+
console.error(instanceRefusal);
|
|
716
|
+
process.exitCode = 1;
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const stopped = loadDaemonStop(record);
|
|
720
|
+
if (stopped) {
|
|
721
|
+
console.error(`Daemon stopped: this computer's key was revoked. Run: ${CLI} daemon register`);
|
|
722
|
+
// launchd restarts the first nonzero terminal exit once; a saved
|
|
723
|
+
// verdict makes that second invocation successful and ends the loop.
|
|
724
|
+
process.exitCode = process.platform === 'darwin' ? 0 : stopped.exitCode;
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const client = createClient({ instance: record.instanceUrl, token: record.daemonToken });
|
|
728
|
+
const logsDir = daemonLogsDir();
|
|
729
|
+
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
|
|
730
|
+
chmodSync(logsDir, 0o700);
|
|
731
|
+
const stampLog = (line) => console.log(`${new Date().toISOString()} ${line}`);
|
|
732
|
+
|
|
733
|
+
// TASK-049: fail loudly, once, naming the variable.
|
|
734
|
+
//
|
|
735
|
+
// A service-started daemon inherits a clean environment, so a provider
|
|
736
|
+
// key that lives only in the operator's shell is absent here; a pi seat
|
|
737
|
+
// then dies at adapter start with `COMMONLY_LITELLM_KEY is not set` and
|
|
738
|
+
// the operator sees crash loops with no cause at daemon level. The
|
|
739
|
+
// warning names the seat that needs the key, when that seat starts:
|
|
740
|
+
// at daemon start it went to every BYO user, who needs none of them
|
|
741
|
+
// (Growth's F370 walk). Not fatal: the daemon's other seats are
|
|
742
|
+
// unaffected.
|
|
743
|
+
const warnedKeys = new Set();
|
|
744
|
+
const warnMissingSeatKey = (agentName) => {
|
|
745
|
+
let saved = null;
|
|
746
|
+
try { saved = loadAgentToken(agentName); } catch { return; }
|
|
747
|
+
const name = seatProviderKeyEnv({ adapter: saved?.adapter, environment: saved?.environment });
|
|
748
|
+
if (!name || process.env[name] || warnedKeys.has(`${agentName}:${name}`)) return;
|
|
749
|
+
warnedKeys.add(`${agentName}:${name}`);
|
|
750
|
+
stampLog(`WARNING: [${agentName}] needs ${name}, which is not set in this environment, so it will fail to start (a provider.keyFile avoids this); export it and re-run: ${CLI} daemon install (docs/agents/daemon-service-environment.md)`);
|
|
751
|
+
};
|
|
752
|
+
// TASK-660: the daemon outlives many publishes, so say once at start-up
|
|
753
|
+
// when it is stale. Not awaited: supervision never waits on the registry.
|
|
754
|
+
startUpdateNotice({ installedVersion: installedCliVersion(), log: stampLog });
|
|
755
|
+
|
|
756
|
+
let pollTimer;
|
|
757
|
+
let heartbeatTimer;
|
|
758
|
+
let supervisor;
|
|
759
|
+
let terminalEnded = false;
|
|
760
|
+
const terminalAuth = (error) => isRevokedDaemonKey(error)
|
|
761
|
+
|| isMissingDaemonMachine(error);
|
|
762
|
+
const onTerminalAuthError = () => {
|
|
763
|
+
terminalEnded = true;
|
|
764
|
+
let saved = false;
|
|
765
|
+
try {
|
|
766
|
+
saveDaemonStop(record, 77);
|
|
767
|
+
saved = true;
|
|
768
|
+
} catch (error) {
|
|
769
|
+
stampLog(`could not save revoked-machine stop marker: ${error.message}`);
|
|
770
|
+
}
|
|
771
|
+
stampLog(`machine key revoked; stopping supervised agents. Run: ${CLI} daemon register`);
|
|
772
|
+
clearInterval(pollTimer);
|
|
773
|
+
clearInterval(heartbeatTimer);
|
|
774
|
+
supervisor.stop();
|
|
775
|
+
// Without the marker launchd would restart forever. A successful
|
|
776
|
+
// exit parks it on macOS; systemd honors 77 directly.
|
|
777
|
+
process.exitCode = saved || process.platform !== 'darwin' ? 77 : 0;
|
|
778
|
+
};
|
|
779
|
+
const runtimeInventory = createInventoryCache((harnesses) => collectRuntimeInventory(getAdapter, undefined, harnesses, { loginState }));
|
|
780
|
+
supervisor = createDaemonSupervisor({
|
|
781
|
+
record,
|
|
782
|
+
client,
|
|
783
|
+
isTerminalAuthError: terminalAuth,
|
|
784
|
+
onTerminalAuthError,
|
|
785
|
+
// One child per agent, logging to its own file. The child is the
|
|
786
|
+
// ordinary `commonly agent run <name>` — the daemon is its
|
|
787
|
+
// supervisor, never its replacement (D6).
|
|
788
|
+
// `recordKey` is the local record: `<name>`, or `<name>@<id>` for
|
|
789
|
+
// another instance, which `agent run` resolves the same way (F427).
|
|
790
|
+
spawnChild: (recordKey) => {
|
|
791
|
+
warnMissingSeatKey(recordKey);
|
|
792
|
+
const seatLog = daemonSeatLogPath(recordKey);
|
|
793
|
+
const out = openSync(seatLog, 'a', 0o600);
|
|
794
|
+
chmodSync(seatLog, 0o600);
|
|
795
|
+
return spawn(process.execPath, [process.argv[1], 'agent', 'run', recordKey], {
|
|
796
|
+
stdio: ['ignore', out, out],
|
|
797
|
+
// Windows: never flash a console window per seat (no-op elsewhere).
|
|
798
|
+
windowsHide: true,
|
|
799
|
+
env: { ...process.env, COMMONLY_MANAGED_RUNNER: '1' },
|
|
800
|
+
});
|
|
801
|
+
},
|
|
802
|
+
loadToken: loadAgentToken,
|
|
803
|
+
saveToken: saveAgentToken,
|
|
804
|
+
machineReport: daemonMachineReport,
|
|
805
|
+
runtimeInventory,
|
|
806
|
+
resolveAdapter: (runtime) => resolveAdapterForRuntime(runtime),
|
|
807
|
+
log: stampLog,
|
|
808
|
+
persistState: (seats) => saveDaemonState({
|
|
809
|
+
machineName: record.machineName,
|
|
810
|
+
machineDbId: record.machineDbId,
|
|
811
|
+
updatedAt: new Date().toISOString(),
|
|
812
|
+
seats: seats.map((seat) => ({
|
|
813
|
+
...seat,
|
|
814
|
+
lastTurnAt: getLastTurn(seat.recordKey || seat.agentName) || seat.lastTurnAt,
|
|
815
|
+
})),
|
|
816
|
+
}),
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
stampLog(`daemon supervising for ${record.machineName} — poll ${opts.poll}ms, heartbeat ${opts.heartbeat}ms (ctrl+c to stop)`);
|
|
820
|
+
const shutdown = () => {
|
|
821
|
+
stampLog('daemon stopping — terminating supervised agents');
|
|
822
|
+
clearInterval(pollTimer);
|
|
823
|
+
clearInterval(heartbeatTimer);
|
|
824
|
+
supervisor.stop();
|
|
825
|
+
process.exit(0);
|
|
826
|
+
};
|
|
827
|
+
// D245: under the Windows launcher, the launcher's exit is the stop
|
|
828
|
+
// signal. Armed before the first tick, so the EPIPE guard covers its
|
|
829
|
+
// log lines and a launcher already gone stops the daemon at once.
|
|
830
|
+
const started = await firstSupervisionPass({
|
|
831
|
+
supervisor,
|
|
832
|
+
onLauncherGone: (pid) => {
|
|
833
|
+
// stdout and stderr already swallow EPIPE (armed with the watch).
|
|
834
|
+
try { stampLog(`launcher ${pid} has exited`); } catch { /* stdout is gone */ }
|
|
835
|
+
shutdown();
|
|
836
|
+
},
|
|
837
|
+
isEnded: () => terminalEnded,
|
|
838
|
+
});
|
|
839
|
+
if (!started) return;
|
|
840
|
+
const runtimeFailure = (error) => {
|
|
841
|
+
if (terminalEnded) return;
|
|
842
|
+
terminalEnded = true;
|
|
843
|
+
stampLog(`daemon runtime failed: ${error.message}`);
|
|
844
|
+
clearInterval(pollTimer);
|
|
845
|
+
clearInterval(heartbeatTimer);
|
|
846
|
+
supervisor.stop();
|
|
847
|
+
process.exitCode = 75;
|
|
848
|
+
};
|
|
849
|
+
pollTimer = setInterval(() => { void supervisor.tick().catch(runtimeFailure); }, Number(opts.poll) || DEFAULT_POLL_MS);
|
|
850
|
+
heartbeatTimer = setInterval(() => { void supervisor.heartbeat().catch(runtimeFailure); }, Number(opts.heartbeat) || DEFAULT_HEARTBEAT_MS);
|
|
851
|
+
process.on('SIGINT', shutdown);
|
|
852
|
+
process.on('SIGTERM', shutdown);
|
|
853
|
+
} catch (error) {
|
|
854
|
+
console.error(`Daemon run failed: ${error.message}`);
|
|
855
|
+
const badConfig = !record || error.message.startsWith('Daemon credential file');
|
|
856
|
+
if (badConfig) {
|
|
857
|
+
try {
|
|
858
|
+
saveDaemonStop(record || { machineDbId: 'unregistered' }, 78);
|
|
859
|
+
} catch (markerError) {
|
|
860
|
+
console.error(`Could not save daemon stop marker: ${markerError.message}`);
|
|
861
|
+
if (process.platform === 'darwin') {
|
|
862
|
+
process.exitCode = 0;
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
process.exitCode = badConfig ? 78 : 75;
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
daemon
|
|
872
|
+
.command('status')
|
|
873
|
+
.description('Show the server-derived liveness of this machine')
|
|
874
|
+
.option('--verbose', 'Include locally supervised seat state and process details')
|
|
875
|
+
.option('--watch', 'Redraw the machine and agent view every 2 s until Ctrl-C')
|
|
876
|
+
.action(async (opts) => {
|
|
877
|
+
if (opts.watch) {
|
|
878
|
+
await watchDaemonStatus();
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
try {
|
|
882
|
+
const record = requireDaemonRecord();
|
|
883
|
+
const machine = await getDaemonMachineStatus({
|
|
884
|
+
client: createClient({ instance: record.instanceUrl, token: record.daemonToken }),
|
|
885
|
+
record,
|
|
886
|
+
});
|
|
887
|
+
const lastSeen = machine.lastSeenAt ? new Date(machine.lastSeenAt).toLocaleString() : 'never';
|
|
888
|
+
console.log(`${machine.name}: ${machine.status} (last heartbeat: ${lastSeen})`);
|
|
889
|
+
if (loadUnsandboxedOptIn()) console.log(`Sandbox: unsandboxed (opted in). Agents run with your own access and get no connector grants, and anyone in an invite-only pod can direct a Codex agent there. Turn off: ${CLI} daemon install`);
|
|
890
|
+
if (opts.verbose) {
|
|
891
|
+
const local = loadDaemonState();
|
|
892
|
+
if (!local) {
|
|
893
|
+
console.log('Local supervisor state: unavailable (daemon has not written state yet).');
|
|
894
|
+
} else if (!local.seats.length) {
|
|
895
|
+
console.log('Local supervisor state: no supervised seats.');
|
|
896
|
+
} else {
|
|
897
|
+
console.log('Local supervised seats:');
|
|
898
|
+
for (const seat of local.seats) console.log(formatSeatLine(seat));
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
} catch (error) {
|
|
902
|
+
console.error(daemonStatusErrorMessage(error));
|
|
903
|
+
process.exitCode = 1;
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
daemon
|
|
908
|
+
.command('logs')
|
|
909
|
+
.description('Show daemon or per-seat logs')
|
|
910
|
+
.option('--seat <name>', 'Show one supervised seat log instead of the daemon log')
|
|
911
|
+
.option('-f, --follow', 'Continue printing appended log output')
|
|
912
|
+
.option('--lines <n>', 'Number of trailing lines to show', '50')
|
|
913
|
+
.action(async (opts) => {
|
|
914
|
+
const path = opts.seat ? daemonSeatLogPath(opts.seat) : daemonLogPath();
|
|
915
|
+
const printTail = () => {
|
|
916
|
+
const output = readLogTail(path, opts.lines);
|
|
917
|
+
if (output === null) {
|
|
918
|
+
console.error(`No log found at ${path}`);
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
if (output) console.log(output);
|
|
922
|
+
return true;
|
|
923
|
+
};
|
|
924
|
+
if (!printTail() || !opts.follow) return;
|
|
925
|
+
const watcher = watch(path, () => printTail());
|
|
926
|
+
const close = () => {
|
|
927
|
+
watcher.close();
|
|
928
|
+
process.exit(0);
|
|
929
|
+
};
|
|
930
|
+
process.once('SIGINT', close);
|
|
931
|
+
process.once('SIGTERM', close);
|
|
932
|
+
});
|
|
933
|
+
};
|