@ctrl-spc/cs 0.7.14 → 0.7.16
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/README.md +50 -3
- package/dist/agents.js +175 -1
- package/dist/autostart.js +103 -122
- package/dist/codex-home.js +14 -11
- package/dist/companion-ui.js +54 -6
- package/dist/companion.js +86 -169
- package/dist/config.js +199 -17
- package/dist/daemon-lifecycle.js +575 -0
- package/dist/daemon-lock.js +149 -42
- package/dist/daemon-processes.js +860 -0
- package/dist/daemon.js +14 -46
- package/dist/darwin-coalition.js +340 -0
- package/dist/failure-reason.js +76 -16
- package/dist/index.js +75 -76
- package/dist/login.js +9 -9
- package/dist/mcp.js +55 -56
- package/dist/native/darwin-coalition +0 -0
- package/dist/native/darwin-coalition.build.json +1 -0
- package/dist/native/darwin-coalition.c +145 -0
- package/dist/orchestrator.js +892 -575
- package/dist/panel3/coordinator.js +3 -1
- package/dist/panel3/presence.js +1 -1
- package/dist/panel3/run.js +996 -558
- package/dist/panel3/spawn.js +85 -23
- package/dist/panel3/tools.js +5 -0
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +271 -135
- package/dist/supabase.js +173 -37
- package/dist/win-shell.js +464 -1
- package/dist/windows-job.js +312 -0
- package/package.json +4 -3
package/dist/daemon.js
CHANGED
|
@@ -1,49 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* the process is signalled. Under launchd/KeepAlive (autostart) a hard crash is
|
|
6
|
-
* restarted by the OS. The companion server (`cs open`) shares the same presence
|
|
7
|
-
* loop via presence.ts, so the two front-ends never diverge.
|
|
8
|
-
*
|
|
9
|
-
* ═══ AND SINCE recovery-1 SLICE 4 IT ALSO ANSWERS AGENT PANEL CARDS. ═══ Lane's
|
|
10
|
-
* ruling (2026-08-21): *the user experience must never require them to launch or
|
|
11
|
-
* authenticate multiple CLIs. They launch the CLI with `cs start`.* Before this
|
|
12
|
-
* the panel was a second binary with a second sign-in, so a stranded card could
|
|
13
|
-
* name the right machine and print a command that started the wrong daemon.
|
|
14
|
-
*
|
|
15
|
-
* THE PANEL GETS THE SESSION THIS ALREADY HOLDS, never one of its own. Two
|
|
16
|
-
* clients in one process is two refresh loops on one rotating refresh token,
|
|
17
|
-
* both writing `session.json`.
|
|
18
|
-
*
|
|
19
|
-
* Companion and the terminal daemon both use the same presence lifecycle,
|
|
20
|
-
* which owns the card worker, session and per-machine lock.
|
|
21
|
-
*/
|
|
1
|
+
import { inspectLocalRuntime, startLocalRuntime, stopLocalOwnerForSignal } from './daemon-lifecycle.js';
|
|
2
|
+
import { CLI_VERSION } from './package-version.js';
|
|
3
|
+
import { machineHostname } from './config.js';
|
|
4
|
+
/** Foreground entry; local lifecycle owns service control and cloud reconnects. */
|
|
22
5
|
export async function runDaemon() {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
async function shutdown(code = 0) {
|
|
30
|
-
if (stopping)
|
|
31
|
-
return;
|
|
32
|
-
stopping = true;
|
|
33
|
-
// Shared presence stops the card worker and both readiness records.
|
|
34
|
-
await stopPresence();
|
|
35
|
-
process.exit(code);
|
|
6
|
+
if (!await startLocalRuntime()) {
|
|
7
|
+
const current = await inspectLocalRuntime();
|
|
8
|
+
console.log(`CTRL+SPC is already running on ${machineHostname()} (pid ${current?.record.process.pid ?? 'unknown'}).`);
|
|
9
|
+
console.log(`Running version: ${current?.status?.version ?? 'unavailable'}. Installed version: ${CLI_VERSION}.`);
|
|
10
|
+
console.log('Use cs status to check it, cs stop to stop it, or cs restart to load this installed version.');
|
|
11
|
+
return;
|
|
36
12
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (!live)
|
|
42
|
-
throw new Error('the presence loop is not running, so there is no session to watch');
|
|
43
|
-
live.auth.onAuthStateChange((event) => {
|
|
44
|
-
if (event === 'SIGNED_OUT')
|
|
45
|
-
void shutdown(1);
|
|
46
|
-
});
|
|
47
|
-
// Keep the event loop alive.
|
|
48
|
-
await new Promise(() => { });
|
|
13
|
+
console.log(`CTRL+SPC is running on ${machineHostname()}. Loaded version: ${CLI_VERSION}.`);
|
|
14
|
+
console.log('Cloud connection is being checked. Run cs status for readiness. Use cs stop or cs restart to manage this service.');
|
|
15
|
+
process.on('SIGINT', () => void stopLocalOwnerForSignal().catch((error) => console.error(error.message)));
|
|
16
|
+
process.on('SIGTERM', () => void stopLocalOwnerForSignal().catch((error) => console.error(error.message)));
|
|
49
17
|
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { spawn, execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { chmodSync, existsSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { createServer, createConnection } from 'node:net';
|
|
6
|
+
import { join, dirname } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
const execute = promisify(execFile);
|
|
9
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
10
|
+
const executable = join(dirname(modulePath), 'native', 'darwin-coalition');
|
|
11
|
+
export function validMacCoalition(value) {
|
|
12
|
+
const v = value;
|
|
13
|
+
return !!v && /^\d+$/.test(v.id) && /^[\da-f-]{36}$/i.test(v.boot) && /^com\.ctrl-spc\.execution\.[\da-f-]{36}$/i.test(v.label)
|
|
14
|
+
&& (v.domain === undefined || v.domain === 'gui' || v.domain === 'user');
|
|
15
|
+
}
|
|
16
|
+
function timeout(deadline) {
|
|
17
|
+
if (Date.now() >= deadline)
|
|
18
|
+
throw new Error('Service operation exceeded its deadline.');
|
|
19
|
+
return Math.max(1, Math.min(5000, deadline - Date.now()));
|
|
20
|
+
}
|
|
21
|
+
async function native(args, deadline = Date.now() + 5000) {
|
|
22
|
+
for (;;) {
|
|
23
|
+
try {
|
|
24
|
+
const { stdout } = await execute(executable, args, { timeout: timeout(deadline), maxBuffer: 8 * 1024 * 1024 });
|
|
25
|
+
return stdout.trim() ? JSON.parse(stdout) : null;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
// A fork/exit between enumeration and the atomic count is a known,
|
|
29
|
+
// read-only race. Retry only this result within the caller's deadline.
|
|
30
|
+
if (error.code !== 75 || Date.now() + 25 >= deadline) {
|
|
31
|
+
// Preparation failures can become cloud-visible text. execFile's own
|
|
32
|
+
// message embeds local executable paths; the helper's fixed messages
|
|
33
|
+
// contain only the operation and OS error number.
|
|
34
|
+
const detail = error.stderr;
|
|
35
|
+
throw new Error(typeof detail === 'string' && detail.trim() ? detail.trim().slice(0, 500)
|
|
36
|
+
: 'Mac execution ownership could not be verified. Reinstall CTRL+SPC or retry the command.', { cause: error });
|
|
37
|
+
}
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function processCoalition(pid, deadline) {
|
|
43
|
+
const value = await native(['process', String(pid)], deadline);
|
|
44
|
+
if (value === null)
|
|
45
|
+
return null;
|
|
46
|
+
if (!value || typeof value.id !== 'string' || !/^\d+$/.test(value.id) || typeof value.boot !== 'string' || !/^[\da-f-]{36}$/i.test(value.boot)
|
|
47
|
+
|| value.pid !== pid || value.owner !== process.getuid())
|
|
48
|
+
throw new Error('Mac execution identity is unavailable.');
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
export async function inspectMacCoalition(coalition, deadline = Date.now() + 5000) {
|
|
52
|
+
if (!validMacCoalition(coalition))
|
|
53
|
+
throw new Error('Mac execution identity is invalid.');
|
|
54
|
+
const result = await native(['inspect', coalition.id, coalition.boot], deadline);
|
|
55
|
+
if (!result || typeof result.exists !== 'boolean' || !Number.isSafeInteger(result.active) || result.active < 0 || !Array.isArray(result.members))
|
|
56
|
+
throw new Error('Mac execution membership is unavailable.');
|
|
57
|
+
const rows = result.members.map((row) => nativeIdentity(row, coalition));
|
|
58
|
+
if (result.active > rows.length || (result.active === 0 && rows.length))
|
|
59
|
+
throw new Error('Mac execution membership changed. Retry the command.');
|
|
60
|
+
return rows;
|
|
61
|
+
}
|
|
62
|
+
function nativeIdentity(row, coalition) {
|
|
63
|
+
if (!row || !Number.isInteger(row.pid) || row.pid <= 0 || !Number.isInteger(row.ppid) || !Number.isInteger(row.pgid)
|
|
64
|
+
|| row.owner !== String(process.getuid()) || typeof row.birth !== 'string' || !Number.isFinite(Date.parse(row.birth))
|
|
65
|
+
|| !Number.isInteger(row.version) || row.version <= 0)
|
|
66
|
+
throw new Error('Mac process identity is invalid.');
|
|
67
|
+
return { pid: row.pid, ppid: row.ppid, pgid: row.pgid, owner: row.owner, birth: row.birth,
|
|
68
|
+
commandHash: createHash('sha256').update(`${row.pid}:${row.version}`).digest('hex'), macProcessVersion: row.version, ...(coalition ? { macCoalition: coalition } : {}) };
|
|
69
|
+
}
|
|
70
|
+
export async function inspectMacProcess(pid, deadline) {
|
|
71
|
+
const result = await native(['process', String(pid)], deadline);
|
|
72
|
+
if (result === null)
|
|
73
|
+
return null;
|
|
74
|
+
return nativeIdentity({ ...result, owner: String(result.owner) });
|
|
75
|
+
}
|
|
76
|
+
export async function inspectMacProcesses(deadline) {
|
|
77
|
+
const result = await native(['all'], deadline);
|
|
78
|
+
if (!result || !Array.isArray(result.members))
|
|
79
|
+
throw new Error('Mac process enumeration is unavailable.');
|
|
80
|
+
return result.members.map((row) => nativeIdentity(row));
|
|
81
|
+
}
|
|
82
|
+
export async function signalMacMember(identity, deadline, signal = 'KILL') {
|
|
83
|
+
if (!identity.macCoalition || !identity.macProcessVersion)
|
|
84
|
+
throw new Error('Mac process ownership is unavailable.');
|
|
85
|
+
await native(['signal', identity.macCoalition.id, identity.macCoalition.boot, String(identity.pid), String(identity.macProcessVersion), signal], deadline);
|
|
86
|
+
}
|
|
87
|
+
export async function signalMacProcess(identity, deadline) {
|
|
88
|
+
if (identity.owner !== String(process.getuid()))
|
|
89
|
+
throw new Error('Mac process belongs to another user.');
|
|
90
|
+
const own = await processCoalition(process.pid, deadline);
|
|
91
|
+
if (!own)
|
|
92
|
+
throw new Error('Mac process signaling is unavailable.');
|
|
93
|
+
await native(['signal-process', own.boot, String(identity.pid), identity.birth], deadline);
|
|
94
|
+
}
|
|
95
|
+
export async function unloadMacExecution(coalition, deadline) {
|
|
96
|
+
if (!validMacCoalition(coalition))
|
|
97
|
+
throw new Error('Mac execution identity is invalid.');
|
|
98
|
+
// Only the first unpublished candidate omitted this field and used gui.
|
|
99
|
+
// Never try both namespaces: the recorded one is the only owned job.
|
|
100
|
+
try {
|
|
101
|
+
await execute('/bin/launchctl', ['bootout', `${coalition.domain ?? 'gui'}/${process.getuid()}/${coalition.label}`], { timeout: timeout(deadline) });
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (![3, 113].includes(error.code ?? -1))
|
|
105
|
+
throw new Error('The owned Mac execution job could not be unloaded.', { cause: error });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function spawnMacExecution(bin, args, options, reservationId, file) {
|
|
109
|
+
if (!existsSync(executable))
|
|
110
|
+
throw new Error('The Mac execution helper is missing. Reinstall CTRL+SPC before starting work.');
|
|
111
|
+
if (!/^[\da-f-]{36}$/i.test(reservationId))
|
|
112
|
+
throw new Error('Invalid execution reservation.');
|
|
113
|
+
// A private short path avoids sockaddr_un's path limit for long config dirs.
|
|
114
|
+
const directory = mkdtempSync(`/tmp/ctrl-spc-execution-${process.getuid()}-`);
|
|
115
|
+
chmodSync(directory, 0o700);
|
|
116
|
+
const access = statSync(directory);
|
|
117
|
+
if (access.uid !== process.getuid() || (access.mode & 0o777) !== 0o700)
|
|
118
|
+
throw new Error('Mac execution transport could not be made private.');
|
|
119
|
+
const ready = join(directory, 'ready.json');
|
|
120
|
+
const launch = { bin, args, cwd: options.cwd?.toString() ?? process.cwd(), env: options.env ?? process.env,
|
|
121
|
+
shell: options.shell ?? false, label: `com.ctrl-spc.execution.${reservationId}`, domain: 'user', directory, ready };
|
|
122
|
+
try {
|
|
123
|
+
writeFileSync(file, JSON.stringify(launch), { flag: 'wx', mode: 0o600 });
|
|
124
|
+
return { child: spawn(process.execPath, [modulePath, '--bridge', file], { ...options, shell: false, detached: true }), ready };
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
rmSync(directory, { recursive: true, force: true });
|
|
128
|
+
rmSync(file, { force: true });
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export function readMacExecution(ready) {
|
|
133
|
+
let text;
|
|
134
|
+
try {
|
|
135
|
+
text = readFileSync(ready, 'utf8');
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (error.code === 'ENOENT')
|
|
139
|
+
return null;
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
const result = JSON.parse(text);
|
|
143
|
+
if (!validMacCoalition(result))
|
|
144
|
+
throw new Error('Mac execution identity is invalid.');
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
export function removeMacExecutionFiles(ready) { rmSync(dirname(ready), { recursive: true, force: true }); }
|
|
148
|
+
function send(socket, frame, source) {
|
|
149
|
+
if (!socket.write(JSON.stringify(frame) + '\n') && source) {
|
|
150
|
+
source.pause();
|
|
151
|
+
socket.once('drain', () => source.resume());
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function frames(socket, receive) {
|
|
155
|
+
let pending = '';
|
|
156
|
+
socket.setEncoding('utf8');
|
|
157
|
+
socket.on('data', (chunk) => {
|
|
158
|
+
pending += chunk;
|
|
159
|
+
if (pending.length > 2 * 1024 * 1024) {
|
|
160
|
+
socket.destroy(new Error('Mac execution transport exceeded its frame limit.'));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
let end;
|
|
164
|
+
while ((end = pending.indexOf('\n')) >= 0) {
|
|
165
|
+
const line = pending.slice(0, end);
|
|
166
|
+
pending = pending.slice(end + 1);
|
|
167
|
+
try {
|
|
168
|
+
receive(JSON.parse(line));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
socket.destroy(new Error('Mac execution transport is invalid.'));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function xml(text) { return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
178
|
+
async function bridge(file) {
|
|
179
|
+
const launch = JSON.parse(readFileSync(file, 'utf8'));
|
|
180
|
+
const own = await processCoalition(process.pid);
|
|
181
|
+
if (!own)
|
|
182
|
+
throw new Error('Mac execution capability is unavailable; no agent was started.');
|
|
183
|
+
const socketPath = join(launch.directory, 'io.sock'), plist = join(launch.directory, 'job.plist');
|
|
184
|
+
let socket = null, connected = false, ended = false, exitReceived = false;
|
|
185
|
+
let jobIdentity = null;
|
|
186
|
+
process.stdin.pause();
|
|
187
|
+
const server = createServer((peer) => {
|
|
188
|
+
if (connected) {
|
|
189
|
+
peer.destroy();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
connected = true;
|
|
193
|
+
socket = peer;
|
|
194
|
+
peer.on('error', () => { process.exitCode = 1; });
|
|
195
|
+
peer.on('close', () => {
|
|
196
|
+
if (!exitReceived)
|
|
197
|
+
process.exitCode = 1;
|
|
198
|
+
process.stdin.destroy();
|
|
199
|
+
if (!exitReceived)
|
|
200
|
+
server.close();
|
|
201
|
+
});
|
|
202
|
+
frames(peer, (frame) => {
|
|
203
|
+
if (frame.kind === 'hello' && typeof frame.pid === 'number') {
|
|
204
|
+
void (async () => {
|
|
205
|
+
const identity = await processCoalition(frame.pid);
|
|
206
|
+
if (!identity || identity.id === own.id)
|
|
207
|
+
throw new Error('The Mac execution job is not isolated.');
|
|
208
|
+
const coalition = { id: identity.id, boot: identity.boot, label: launch.label, domain: launch.domain };
|
|
209
|
+
const members = await inspectMacCoalition(coalition);
|
|
210
|
+
if (members.length !== 1 || members[0].pid !== frame.pid)
|
|
211
|
+
throw new Error('The Mac execution job is not exclusively owned.');
|
|
212
|
+
jobIdentity = members[0];
|
|
213
|
+
const temp = launch.ready + '.tmp';
|
|
214
|
+
writeFileSync(temp, JSON.stringify(coalition), { flag: 'wx', mode: 0o600 });
|
|
215
|
+
renameSync(temp, launch.ready);
|
|
216
|
+
process.stdin.on('data', (chunk) => send(peer, { kind: 'stdin', data: chunk.toString('base64') }, process.stdin));
|
|
217
|
+
process.stdin.on('end', () => { ended = true; send(peer, { kind: 'end' }); });
|
|
218
|
+
process.stdin.resume();
|
|
219
|
+
})().catch(() => { console.error('The Mac execution job could not be safely registered.'); process.exitCode = 1; peer.destroy(); });
|
|
220
|
+
}
|
|
221
|
+
else if ((frame.kind === 'stdout' || frame.kind === 'stderr') && typeof frame.data === 'string') {
|
|
222
|
+
const output = frame.kind === 'stdout' ? process.stdout : process.stderr;
|
|
223
|
+
if (!output.write(Buffer.from(frame.data, 'base64'))) {
|
|
224
|
+
peer.pause();
|
|
225
|
+
output.once('drain', () => peer.resume());
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else if (frame.kind === 'exit' && typeof frame.code === 'number') {
|
|
229
|
+
exitReceived = true;
|
|
230
|
+
process.exitCode = frame.code;
|
|
231
|
+
peer.end();
|
|
232
|
+
process.stdin.destroy();
|
|
233
|
+
void (async () => {
|
|
234
|
+
const deadline = Date.now() + 5000;
|
|
235
|
+
while (jobIdentity && Date.now() < deadline) {
|
|
236
|
+
const current = await inspectMacProcess(jobIdentity.pid, deadline);
|
|
237
|
+
if (!current || current.birth !== jobIdentity.birth)
|
|
238
|
+
return;
|
|
239
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
240
|
+
}
|
|
241
|
+
if (jobIdentity)
|
|
242
|
+
throw new Error('Mac execution helper did not close.');
|
|
243
|
+
})().catch(() => { process.exitCode = 1; }).finally(() => server.close());
|
|
244
|
+
}
|
|
245
|
+
else
|
|
246
|
+
throw new Error('Invalid execution transport frame.');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); });
|
|
250
|
+
const content = '<?xml version="1.0"?><plist version="1.0"><dict><key>Label</key><string>' + launch.label + '</string>'
|
|
251
|
+
+ '<key>ProgramArguments</key><array>' + [process.execPath, modulePath, '--job', file, launch.domain].map((value) => '<string>' + xml(value) + '</string>').join('') + '</array>'
|
|
252
|
+
+ '<key>LimitLoadToSessionType</key><string>Background</string><key>RunAtLoad</key><true/><key>KeepAlive</key><false/><key>AbandonProcessGroup</key><true/></dict></plist>';
|
|
253
|
+
writeFileSync(plist, content, { flag: 'wx', mode: 0o600 });
|
|
254
|
+
try {
|
|
255
|
+
await execute('/bin/launchctl', ['bootstrap', `${launch.domain}/${process.getuid()}`, plist], { timeout: 5000 });
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
socket?.destroy();
|
|
259
|
+
server.close();
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
// Only bootstrap/registration is bounded. Running agents have no timer.
|
|
263
|
+
const timer = setTimeout(() => { if (!existsSync(launch.ready)) {
|
|
264
|
+
console.error('The Mac execution job did not become ready.');
|
|
265
|
+
process.exitCode = 1;
|
|
266
|
+
socket?.destroy();
|
|
267
|
+
server.close();
|
|
268
|
+
} }, 5000);
|
|
269
|
+
timer.unref();
|
|
270
|
+
process.on('exit', () => { if (!ended)
|
|
271
|
+
process.stdin.destroy(); });
|
|
272
|
+
}
|
|
273
|
+
function job(file) {
|
|
274
|
+
const closeUnpromptedJob = (label, domain = process.argv[4] === 'user' ? 'user' : 'gui') => {
|
|
275
|
+
if (!label || !/^com\.ctrl-spc\.execution\.[\da-f-]{36}$/i.test(label))
|
|
276
|
+
return;
|
|
277
|
+
// The creator can disappear before this queued job even reads its launch
|
|
278
|
+
// file. No grant can then arrive; remove this exact one-shot job as well.
|
|
279
|
+
void execute('/bin/launchctl', ['bootout', `${domain}/${process.getuid()}/${label}`], { timeout: 5000 }).catch(() => { process.exitCode = 1; });
|
|
280
|
+
};
|
|
281
|
+
let launch;
|
|
282
|
+
try {
|
|
283
|
+
launch = JSON.parse(readFileSync(file, 'utf8'));
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
closeUnpromptedJob(process.env.XPC_SERVICE_NAME);
|
|
287
|
+
process.exitCode = 1;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const socket = createConnection(join(launch.directory, 'io.sock'));
|
|
291
|
+
let provider = null, admitted = false;
|
|
292
|
+
socket.on('connect', () => send(socket, { kind: 'hello', pid: process.pid }));
|
|
293
|
+
socket.on('error', () => { if (!admitted)
|
|
294
|
+
process.exitCode = 1; });
|
|
295
|
+
socket.on('close', () => { if (!admitted)
|
|
296
|
+
closeUnpromptedJob(launch.label, launch.domain ?? 'gui'); });
|
|
297
|
+
frames(socket, (frame) => {
|
|
298
|
+
if (frame.kind === 'stdin' && typeof frame.data === 'string') {
|
|
299
|
+
let bytes = Buffer.from(frame.data, 'base64');
|
|
300
|
+
if (!admitted) {
|
|
301
|
+
if (!bytes.length || bytes[0] !== 1)
|
|
302
|
+
throw new Error('Invalid execution grant.');
|
|
303
|
+
admitted = true;
|
|
304
|
+
bytes = bytes.subarray(1);
|
|
305
|
+
provider = spawn(launch.bin, launch.args, { cwd: launch.cwd, env: launch.env, shell: launch.shell, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
306
|
+
provider.stdin?.on('error', () => { });
|
|
307
|
+
provider.stdout?.on('data', (chunk) => { if (!socket.destroyed)
|
|
308
|
+
send(socket, { kind: 'stdout', data: chunk.toString('base64') }, provider.stdout); });
|
|
309
|
+
provider.stderr?.on('data', (chunk) => { if (!socket.destroyed)
|
|
310
|
+
send(socket, { kind: 'stderr', data: chunk.toString('base64') }, provider.stderr); });
|
|
311
|
+
provider.on('error', () => { if (!socket.destroyed)
|
|
312
|
+
send(socket, { kind: 'stderr', data: Buffer.from('The harness could not start.').toString('base64') }); });
|
|
313
|
+
provider.on('close', (code, signal) => { if (!socket.destroyed) {
|
|
314
|
+
send(socket, { kind: 'exit', code: code ?? 1, signal });
|
|
315
|
+
socket.end();
|
|
316
|
+
} });
|
|
317
|
+
}
|
|
318
|
+
if (bytes.length && !provider?.stdin?.write(bytes)) {
|
|
319
|
+
socket.pause();
|
|
320
|
+
provider?.stdin?.once('drain', () => socket.resume());
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
else if (frame.kind === 'end') {
|
|
324
|
+
if (provider)
|
|
325
|
+
provider.stdin?.end();
|
|
326
|
+
else {
|
|
327
|
+
send(socket, { kind: 'exit', code: 0 });
|
|
328
|
+
socket.end();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
else
|
|
332
|
+
throw new Error('Invalid execution transport frame.');
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
if (process.argv[1] === modulePath) {
|
|
336
|
+
if (process.argv[2] === '--bridge')
|
|
337
|
+
void bridge(process.argv[3]).catch(() => { console.error('Mac safe execution is unavailable; no agent was started.'); process.exitCode = 1; });
|
|
338
|
+
else if (process.argv[2] === '--job')
|
|
339
|
+
job(process.argv[3]);
|
|
340
|
+
}
|
package/dist/failure-reason.js
CHANGED
|
@@ -64,22 +64,8 @@ export function plainFailureReason(agent, error, stderr = '') {
|
|
|
64
64
|
|| haystack.includes('429') || haystack.includes('overloaded')) {
|
|
65
65
|
return `${name}'s usage limit is in effect on this account, so the run could not start.`;
|
|
66
66
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
18a SLICE 7 ADDED THE OAUTH AND 401 SIGNATURES, from the real thing rather
|
|
71
|
-
than from imagination. The Windows machine's Claude Code had a stale token
|
|
72
|
-
and said, on STDOUT as a stream-json result: `"error":
|
|
73
|
-
"authentication_failed"` and `API Error: 401 OAuth access token has
|
|
74
|
-
expired.` The word "authentication" above would have matched that, but only
|
|
75
|
-
once stdout was being read at all, which is the other half of the fix. `401`
|
|
76
|
-
and `oauth` are here so the same failure is caught when it arrives in
|
|
77
|
-
shorter words. */
|
|
78
|
-
if (haystack.includes('unauthorized') || haystack.includes('not logged in')
|
|
79
|
-
|| haystack.includes('authentication') || haystack.includes('oauth')
|
|
80
|
-
|| haystack.includes('401')) {
|
|
81
|
-
return `${name} is not signed in on this machine.`;
|
|
82
|
-
}
|
|
67
|
+
if (nativeFailureKind(stderr, error) === 'authentication')
|
|
68
|
+
return failureMessage(agent, 'authentication');
|
|
83
69
|
/* THE RUN WAS KILLED, by the timeout or by a signal from outside. Not the
|
|
84
70
|
user's Stop, which never reaches here: a stopped run is settled with its own
|
|
85
71
|
flag well before this. */
|
|
@@ -96,3 +82,77 @@ export function plainFailureReason(agent, error, stderr = '') {
|
|
|
96
82
|
}
|
|
97
83
|
return `${name} stopped without finishing.`;
|
|
98
84
|
}
|
|
85
|
+
/** Accept only native error records and verified diagnostic lines. Assistant
|
|
86
|
+
* prose, a quoted status code and credential-file presence prove nothing. */
|
|
87
|
+
export function nativeFailureKind(output, diagnostics = '') {
|
|
88
|
+
const classify = (value) => {
|
|
89
|
+
if (!value || typeof value !== 'object')
|
|
90
|
+
return 'unknown';
|
|
91
|
+
const record = value;
|
|
92
|
+
const nested = record.error;
|
|
93
|
+
const error = nested && typeof nested === 'object' ? nested : record;
|
|
94
|
+
const code = typeof error.code === 'string' ? error.code : typeof nested === 'string' ? nested : typeof error.type === 'string' ? error.type : '';
|
|
95
|
+
if (['authentication_error', 'authentication_failed', 'invalid_api_key', 'invalid_authentication', 'token_expired', 'refresh_token_expired', 'refresh_token_reused', 'refresh_token_invalidated'].includes(code))
|
|
96
|
+
return 'authentication';
|
|
97
|
+
if (['rate_limit_error', 'rate_limit_exceeded', 'usage_limit_reached', 'insufficient_quota'].includes(code))
|
|
98
|
+
return 'usage-limit';
|
|
99
|
+
if (code === 'invalid_request_error' && record.status === 400 && typeof error.message === 'string' && /^The requested model is not supported for this account\.?$/.test(error.message))
|
|
100
|
+
return 'invalid-model';
|
|
101
|
+
if (['model_not_found', 'unsupported_model', 'unrecognized_model'].includes(code))
|
|
102
|
+
return 'invalid-model';
|
|
103
|
+
if (['overloaded_error', 'server_error', 'temporarily_unavailable', 'connection_error'].includes(code))
|
|
104
|
+
return 'transient';
|
|
105
|
+
if (typeof error.message === 'string') {
|
|
106
|
+
if (error.message === 'Your access token could not be refreshed. Please log out and sign in again.')
|
|
107
|
+
return 'authentication';
|
|
108
|
+
try {
|
|
109
|
+
return classify(JSON.parse(error.message));
|
|
110
|
+
}
|
|
111
|
+
catch { /* Not a nested native record. */ }
|
|
112
|
+
}
|
|
113
|
+
return 'unknown';
|
|
114
|
+
};
|
|
115
|
+
for (const line of output.split('\n')) {
|
|
116
|
+
const text = line.trim();
|
|
117
|
+
try {
|
|
118
|
+
const event = JSON.parse(text);
|
|
119
|
+
// Ignore native assistant messages, tool results and their quoted prose.
|
|
120
|
+
if (['turn.failed', 'error'].includes(event?.type) || (event?.type === 'result' && event.is_error === true)) {
|
|
121
|
+
if (event.type === 'result' && (event.api_error_status === undefined || event.api_error_status === 401)
|
|
122
|
+
&& event.result === 'Failed to authenticate. API Error: 401 Invalid bearer token')
|
|
123
|
+
return 'authentication';
|
|
124
|
+
if (event.type === 'result' && event.api_error_status === 401 && typeof event.result === 'string'
|
|
125
|
+
&& /^Failed to authenticate\. API Error: 401 OAuth access token (?:has (?:expired|been revoked)|is invalid)\./.test(event.result))
|
|
126
|
+
return 'authentication';
|
|
127
|
+
const kind = classify(event);
|
|
128
|
+
if (kind !== 'unknown')
|
|
129
|
+
return kind;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch { /* Native diagnostics may be non-JSON. */ }
|
|
133
|
+
}
|
|
134
|
+
for (const text of diagnostics.split('\n').map(line => line.trim())) {
|
|
135
|
+
if (/^API Error: (529|503) (Overloaded|Service unavailable)$/.test(text))
|
|
136
|
+
return 'transient';
|
|
137
|
+
if (/^API Error: 401 (?:OAuth access token (?:has (?:expired|been revoked)|is invalid)\.|.*"type"\s*:\s*"authentication_error")/.test(text)
|
|
138
|
+
|| /^\[claude-code:authentication_failed\]/.test(text)
|
|
139
|
+
|| /^ERROR: Your access token could not be refreshed because your refresh token was already used\./.test(text))
|
|
140
|
+
return 'authentication';
|
|
141
|
+
if (/^\[claude-code:unrecognized_model\]/.test(text))
|
|
142
|
+
return 'invalid-model';
|
|
143
|
+
}
|
|
144
|
+
return 'unknown';
|
|
145
|
+
}
|
|
146
|
+
export function failureMessage(agent, kind) {
|
|
147
|
+
const name = agentDisplayName(agent);
|
|
148
|
+
switch (kind) {
|
|
149
|
+
case 'authentication': return `${name} needs sign-in on its assigned computer. Saved work is waiting for you to restart this card.`;
|
|
150
|
+
case 'preparation-unavailable': return `${name}'s local credentials or execution configuration could not be checked.`;
|
|
151
|
+
case 'missing-binary': return `${name} is not installed on this machine, or is not on its PATH.`;
|
|
152
|
+
case 'timeout': return 'The run was stopped for taking too long.';
|
|
153
|
+
case 'usage-limit': return `${name}'s usage limit is in effect on this account.`;
|
|
154
|
+
case 'invalid-model': return `${name} rejected the selected model. Choose an available model and retry.`;
|
|
155
|
+
case 'transient': return `${name}'s service is temporarily unavailable.`;
|
|
156
|
+
default: return `${name} stopped without finishing.`;
|
|
157
|
+
}
|
|
158
|
+
}
|