@ctrl-spc/cs 0.7.15 → 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/dist/agents.js +175 -1
- package/dist/codex-home.js +14 -11
- package/dist/companion-ui.js +24 -4
- package/dist/companion.js +33 -25
- package/dist/config.js +147 -16
- package/dist/daemon-lifecycle.js +39 -12
- package/dist/daemon-processes.js +123 -19
- package/dist/failure-reason.js +76 -16
- package/dist/index.js +6 -3
- package/dist/login.js +6 -8
- package/dist/mcp.js +55 -56
- package/dist/orchestrator.js +322 -197
- package/dist/panel3/coordinator.js +3 -1
- package/dist/panel3/presence.js +1 -1
- package/dist/panel3/run.js +220 -84
- package/dist/panel3/spawn.js +26 -12
- package/dist/panel3/tools.js +5 -0
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +122 -145
- package/dist/supabase.js +141 -39
- package/package.json +1 -1
package/dist/agents.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { configDir } from './config.js';
|
|
4
|
+
import { userCodexHome, ensureCodexRunHome, removeCodexRunHome, CodexHomeFailure } from './codex-home.js';
|
|
5
|
+
import { windowsSafeSpawn, killTree } from './win-shell.js';
|
|
6
|
+
import { accessSync, constants, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
2
7
|
import { homedir } from 'node:os';
|
|
3
8
|
import { delimiter, join, win32 } from 'node:path';
|
|
4
9
|
const AGENTS = ['claude', 'codex'];
|
|
@@ -108,3 +113,172 @@ export function detectAgents() {
|
|
|
108
113
|
export function agentPath(agent) {
|
|
109
114
|
return resolve(agent);
|
|
110
115
|
}
|
|
116
|
+
/** Native status output only. Successful exit without a supported positive
|
|
117
|
+
* response is unknown; it may be a newer or unsupported CLI. */
|
|
118
|
+
export function parseHarnessStatus(agent, output, exitCode) {
|
|
119
|
+
if (agent === 'claude') {
|
|
120
|
+
try {
|
|
121
|
+
const value = JSON.parse(output);
|
|
122
|
+
if (value?.loggedIn === true && exitCode === 0)
|
|
123
|
+
return { state: 'authenticated', reason: 'native-status' };
|
|
124
|
+
if (value?.loggedIn === false)
|
|
125
|
+
return { state: 'sign-in-required', reason: 'provider-rejected' };
|
|
126
|
+
}
|
|
127
|
+
catch { /* Unsupported output never implies rejection. */ }
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
if (exitCode === 0 && /^Logged in using (ChatGPT|an API key)(?:\s|$)/m.test(output))
|
|
131
|
+
return { state: 'authenticated', reason: 'native-status' };
|
|
132
|
+
if (/^Not logged in\s*$/m.test(output))
|
|
133
|
+
return { state: 'sign-in-required', reason: 'provider-rejected' };
|
|
134
|
+
}
|
|
135
|
+
return { state: 'unknown', reason: 'unsupported' };
|
|
136
|
+
}
|
|
137
|
+
let evidenceDirectory = '';
|
|
138
|
+
let evidence = {};
|
|
139
|
+
const probeTimes = new Map();
|
|
140
|
+
const probes = new Map();
|
|
141
|
+
export function executionIdentity(agent) {
|
|
142
|
+
return createHash('sha256').update(JSON.stringify([agentPath(agent), agent === 'codex' ? userCodexHome() : process.env.CLAUDE_CONFIG_DIR ?? homedir(), process.platform])).digest('hex');
|
|
143
|
+
}
|
|
144
|
+
function loadHarnessEvidence() {
|
|
145
|
+
const directory = configDir();
|
|
146
|
+
if (evidenceDirectory === directory)
|
|
147
|
+
return;
|
|
148
|
+
evidenceDirectory = directory;
|
|
149
|
+
evidence = {};
|
|
150
|
+
probeTimes.clear();
|
|
151
|
+
try {
|
|
152
|
+
const saved = JSON.parse(readFileSync(join(directory, 'harness-auth.json'), 'utf8'));
|
|
153
|
+
for (const agent of AGENTS) {
|
|
154
|
+
const value = saved[agent];
|
|
155
|
+
if (!value)
|
|
156
|
+
continue;
|
|
157
|
+
const item = value.evidence;
|
|
158
|
+
if (value.identity !== executionIdentity(agent) || !item || !['authenticated', 'sign-in-required', 'unknown'].includes(item.state)
|
|
159
|
+
|| !['native-status', 'dispatch-success', 'provider-rejected', 'not-installed', 'preparation-unavailable', 'timeout', 'unsupported', 'check-failed'].includes(item.reason)
|
|
160
|
+
|| !Number.isFinite(Date.parse(item.observed_at))
|
|
161
|
+
|| ![null, 'authenticated', 'sign-in-required'].includes(item.last_result)
|
|
162
|
+
|| (item.last_result_at !== null && !Number.isFinite(Date.parse(item.last_result_at))))
|
|
163
|
+
continue;
|
|
164
|
+
evidence[agent] = { identity: value.identity, evidence: {
|
|
165
|
+
state: item.state === 'sign-in-required' ? item.state : 'unknown',
|
|
166
|
+
observed_at: item.observed_at, last_result: item.last_result, last_result_at: item.last_result_at,
|
|
167
|
+
reason: item.state === 'sign-in-required' ? 'provider-rejected' : 'check-failed',
|
|
168
|
+
} };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error.code !== 'ENOENT')
|
|
173
|
+
throw new Error('Saved provider status could not be read.', { cause: error });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export function recordHarnessObservation(agent, state, reason, now = Date.now(), expectedIdentity) {
|
|
177
|
+
loadHarnessEvidence();
|
|
178
|
+
const identity = executionIdentity(agent);
|
|
179
|
+
if (expectedIdentity !== undefined && expectedIdentity !== identity)
|
|
180
|
+
return;
|
|
181
|
+
const prior = evidence[agent]?.evidence;
|
|
182
|
+
if (prior && Date.parse(prior.observed_at) > now)
|
|
183
|
+
return;
|
|
184
|
+
const observed_at = new Date(now).toISOString();
|
|
185
|
+
const item = {
|
|
186
|
+
state, observed_at, reason,
|
|
187
|
+
last_result: state === 'unknown' ? prior?.last_result ?? null : state,
|
|
188
|
+
last_result_at: state === 'unknown' ? prior?.last_result_at ?? null : observed_at,
|
|
189
|
+
};
|
|
190
|
+
evidence[agent] = { identity: executionIdentity(agent), evidence: item };
|
|
191
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
192
|
+
const temporary = join(configDir(), `harness-auth.${randomUUID()}.tmp`);
|
|
193
|
+
try {
|
|
194
|
+
writeFileSync(temporary, JSON.stringify(evidence), { flag: 'wx', mode: 0o600 });
|
|
195
|
+
renameSync(temporary, join(configDir(), 'harness-auth.json'));
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
rmSync(temporary, { force: true });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export function harnessAuthEvidence(now = Date.now()) {
|
|
202
|
+
loadHarnessEvidence();
|
|
203
|
+
return Object.fromEntries(AGENTS.flatMap(agent => {
|
|
204
|
+
const saved = evidence[agent];
|
|
205
|
+
if (!saved)
|
|
206
|
+
return [];
|
|
207
|
+
const item = saved.evidence;
|
|
208
|
+
const age = now - Date.parse(item.observed_at);
|
|
209
|
+
const current = saved.identity === executionIdentity(agent) && age >= -5000 && age < 120_000;
|
|
210
|
+
return [[agent, { ...item, state: saved.identity !== executionIdentity(agent) || item.state === 'authenticated' && !current ? 'unknown' : item.state }]];
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
/** Called only by Presence. Same executable and seeded credential context as
|
|
214
|
+
* dispatch; no probe starts an agent conversation or carries product credentials. */
|
|
215
|
+
export async function probeHarnessAuth(agent, now = Date.now()) {
|
|
216
|
+
loadHarnessEvidence();
|
|
217
|
+
if (probes.has(agent))
|
|
218
|
+
return probes.get(agent);
|
|
219
|
+
if (now - (probeTimes.get(agent) ?? -Infinity) < 60_000)
|
|
220
|
+
return;
|
|
221
|
+
probeTimes.set(agent, now);
|
|
222
|
+
let closed = true;
|
|
223
|
+
let home = null;
|
|
224
|
+
const cleanup = () => { if (home) {
|
|
225
|
+
removeCodexRunHome(home);
|
|
226
|
+
home = null;
|
|
227
|
+
} ; probes.delete(agent); };
|
|
228
|
+
const pending = (async () => {
|
|
229
|
+
const bin = agentPath(agent);
|
|
230
|
+
if (!bin) {
|
|
231
|
+
recordHarnessObservation(agent, 'unknown', 'not-installed');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (agent === 'codex' && process.platform !== 'win32') {
|
|
235
|
+
const prepared = ensureCodexRunHome({ url: 'http://127.0.0.1:1/mcp' }, null, `status-${randomUUID()}`, false);
|
|
236
|
+
if (prepared instanceof CodexHomeFailure) {
|
|
237
|
+
recordHarnessObservation(agent, 'unknown', 'preparation-unavailable');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
home = prepared;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const args = agent === 'codex' ? ['login', 'status'] : ['auth', 'status'];
|
|
244
|
+
const launch = windowsSafeSpawn(bin, args);
|
|
245
|
+
const result = await new Promise((resolve) => {
|
|
246
|
+
const child = spawn(bin, launch.args, { shell: launch.shell, windowsHide: true, detached: process.platform !== 'win32',
|
|
247
|
+
env: home ? { ...process.env, CODEX_HOME: home } : process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
248
|
+
closed = false;
|
|
249
|
+
let output = '', timedOut = false;
|
|
250
|
+
const collect = (chunk) => { output = (output + chunk.toString()).slice(-16_384); };
|
|
251
|
+
child.stdout.on('data', collect);
|
|
252
|
+
child.stderr.on('data', collect);
|
|
253
|
+
const timer = setTimeout(() => {
|
|
254
|
+
timedOut = true;
|
|
255
|
+
if (process.platform !== 'win32' && child.pid) {
|
|
256
|
+
try {
|
|
257
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
killTree(child);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else
|
|
264
|
+
killTree(child);
|
|
265
|
+
resolve({ output: '', code: -1, timedOut: true });
|
|
266
|
+
}, 5000);
|
|
267
|
+
child.once('error', () => { closed = true; clearTimeout(timer); cleanup(); resolve({ output: '', code: -1, timedOut }); });
|
|
268
|
+
child.once('close', code => { closed = true; clearTimeout(timer); cleanup(); resolve({ output, code: code ?? -1, timedOut }); });
|
|
269
|
+
});
|
|
270
|
+
const resultState = result.timedOut ? { state: 'unknown', reason: 'timeout' } : parseHarnessStatus(agent, result.output, result.code);
|
|
271
|
+
recordHarnessObservation(agent, resultState.state, resultState.reason);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
recordHarnessObservation(agent, 'unknown', 'check-failed');
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
if (closed)
|
|
278
|
+
cleanup();
|
|
279
|
+
}
|
|
280
|
+
})().finally(() => { if (closed)
|
|
281
|
+
cleanup(); });
|
|
282
|
+
probes.set(agent, pending);
|
|
283
|
+
return pending;
|
|
284
|
+
}
|
package/dist/codex-home.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import { chmodSync, copyFileSync,
|
|
1
|
+
import { chmodSync, copyFileSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { configDir } from './config.js';
|
|
6
|
+
export class CodexHomeFailure {
|
|
7
|
+
cause;
|
|
8
|
+
kind = 'preparation-unavailable';
|
|
9
|
+
message = 'Codex credentials or its local execution configuration could not be prepared.';
|
|
10
|
+
constructor(cause) {
|
|
11
|
+
this.cause = cause;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
6
14
|
/**
|
|
7
15
|
* 18c SLICE 8 — A CODEX WORKER IS ISOLATED LIKE A CLAUDE WORKER.
|
|
8
16
|
*
|
|
@@ -319,8 +327,6 @@ subagentsEnabled = true, runtimePlatform = process.platform) {
|
|
|
319
327
|
const source = join(userCodexHome(), 'auth.json');
|
|
320
328
|
/* No credential, no isolated home. See the block comment above: this is the
|
|
321
329
|
exact shape that hangs, and hanging is worse than inheriting. */
|
|
322
|
-
if (!existsSync(source))
|
|
323
|
-
return null;
|
|
324
330
|
const home = codexRunHomePath(homeKey);
|
|
325
331
|
try {
|
|
326
332
|
/* FROM EMPTY. See above: a retry of the same request would otherwise start
|
|
@@ -352,10 +358,9 @@ subagentsEnabled = true, runtimePlatform = process.platform) {
|
|
|
352
358
|
writeFileSync(join(home, OWNER_FILE), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), { mode: 0o600 });
|
|
353
359
|
return home;
|
|
354
360
|
}
|
|
355
|
-
catch {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
return null;
|
|
361
|
+
catch (cause) {
|
|
362
|
+
removeCodexRunHome(home);
|
|
363
|
+
return new CodexHomeFailure(cause);
|
|
359
364
|
}
|
|
360
365
|
}
|
|
361
366
|
/**
|
|
@@ -366,8 +371,6 @@ subagentsEnabled = true, runtimePlatform = process.platform) {
|
|
|
366
371
|
*/
|
|
367
372
|
export function ensurePanel3CodexOwnerHome(server, ownerRunId, runtimePlatform = process.platform) {
|
|
368
373
|
const source = join(userCodexHome(), 'auth.json');
|
|
369
|
-
if (!existsSync(source))
|
|
370
|
-
return null;
|
|
371
374
|
const home = panel3CodexOwnerHomePath(ownerRunId);
|
|
372
375
|
try {
|
|
373
376
|
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
@@ -376,8 +379,8 @@ export function ensurePanel3CodexOwnerHome(server, ownerRunId, runtimePlatform =
|
|
|
376
379
|
writeFileSync(join(home, 'config.toml'), codexRunConfigToml(server, null, runtimePlatform, false, true), { mode: 0o600 });
|
|
377
380
|
return home;
|
|
378
381
|
}
|
|
379
|
-
catch {
|
|
380
|
-
return
|
|
382
|
+
catch (cause) {
|
|
383
|
+
return new CodexHomeFailure(cause);
|
|
381
384
|
}
|
|
382
385
|
}
|
|
383
386
|
/** Product-owned persistent state only. Windows owners use the installed Codex
|
package/dist/companion-ui.js
CHANGED
|
@@ -288,25 +288,42 @@ async function api(path, opts) {
|
|
|
288
288
|
async function boot() {
|
|
289
289
|
try {
|
|
290
290
|
const r = await api('/api/session');
|
|
291
|
-
if (!r.ok || r.data.signedIn === null) {
|
|
292
|
-
renderSessionUnavailable(r.data.sessionError || r.data.error);
|
|
291
|
+
if (!r.ok || r.data.signedIn === null || r.data.sessionRenewing || (r.data.signedIn && r.data.connectionState !== 'online')) {
|
|
292
|
+
renderSessionUnavailable(r.data.sessionError || r.data.error, r.data);
|
|
293
293
|
} else if (r.data.signedIn) renderHome(r.data);
|
|
294
294
|
else renderSignIn(r.data || {});
|
|
295
295
|
} catch (_) { renderSessionUnavailable(); }
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
function renderSessionUnavailable(message) {
|
|
298
|
+
function renderSessionUnavailable(message, session) {
|
|
299
|
+
session = session || {};
|
|
299
300
|
stopBadgePoll();
|
|
300
301
|
app.replaceChildren();
|
|
301
302
|
const wrap = el('div', 'auth');
|
|
302
303
|
const card = el('div', 'auth-card');
|
|
303
|
-
card.append(logo(), el('h1', 'auth-title', 'Connection unavailable'));
|
|
304
|
+
card.append(logo(), el('h1', 'auth-title', session.sessionRenewing ? 'Renewing session' : 'Connection unavailable'));
|
|
305
|
+
const status = el('p', 'auth-sub', session.sessionRenewing ? 'Your saved sign-in is being renewed. Your work remains saved.' : 'Your saved sign-in is preserved. Check the connection or start the local service.');
|
|
306
|
+
status.setAttribute('role', 'status'); card.append(status);
|
|
304
307
|
card.append(el('p', 'auth-sub', message || 'Sign-in could not be checked. Check your connection and try again.'));
|
|
305
308
|
const retry = el('button', 'btn btn-primary', 'Try again');
|
|
306
309
|
retry.addEventListener('click', boot);
|
|
307
310
|
card.append(retry);
|
|
311
|
+
if (session.connectionState === 'unknown') {
|
|
312
|
+
const start = el('button', 'btn', 'Start');
|
|
313
|
+
start.addEventListener('click', async function () {
|
|
314
|
+
if (start.disabled) return;
|
|
315
|
+
start.disabled = true; start.textContent = 'Starting…';
|
|
316
|
+
try { const result = await api('/api/start', {method:'POST'}); if (!result.ok) throw new Error(result.data.error || 'The local service could not start.'); await boot(); }
|
|
317
|
+
catch (error) { status.textContent = error.message || 'The local service could not start. Try again.'; start.disabled = false; start.textContent = 'Start'; }
|
|
318
|
+
});
|
|
319
|
+
card.append(start);
|
|
320
|
+
}
|
|
308
321
|
wrap.append(card);
|
|
309
322
|
app.append(wrap);
|
|
323
|
+
badgePollTimer = setInterval(function () { void api('/api/session').then(function (r) {
|
|
324
|
+
if (!card.isConnected || !r.ok || r.data.signedIn === null || r.data.sessionRenewing || (r.data.signedIn && r.data.connectionState !== 'online')) return;
|
|
325
|
+
if (r.data.signedIn) renderHome(r.data); else renderSignIn(r.data);
|
|
326
|
+
}).catch(function () {}); }, 3000);
|
|
310
327
|
}
|
|
311
328
|
|
|
312
329
|
/** The passive "Agent tools" badge (feature 05). Read-only: no button, no
|
|
@@ -370,6 +387,7 @@ async function pollBadgeOnce() {
|
|
|
370
387
|
// Home may have been torn down while the fetch was in flight.
|
|
371
388
|
if (!badge.isConnected) return;
|
|
372
389
|
if (r.ok && r.data.signedIn === false) { renderSignIn(r.data); return; }
|
|
390
|
+
if (r.ok && (r.data.sessionRenewing || (r.data.signedIn && r.data.connectionState !== 'online'))) { renderSessionUnavailable(r.data.sessionError, r.data); return; }
|
|
373
391
|
const fresh = agentToolsBadge(!r.ok || r.data.signedIn === null
|
|
374
392
|
? { reason: 'unavailable', message: r.data.sessionError }
|
|
375
393
|
: r.data.agentTools);
|
|
@@ -398,6 +416,8 @@ function renderSignIn(session) {
|
|
|
398
416
|
app.replaceChildren();
|
|
399
417
|
const wrap = el('div', 'auth');
|
|
400
418
|
const card = el('div', 'auth-card');
|
|
419
|
+
if (session.sessionState === 'signed-out') { const status = el('p', 'auth-sub', 'Signed out'); status.setAttribute('role', 'status'); card.append(status); }
|
|
420
|
+
if (session.sessionState === 'rejected') { const warning = el('p', 'auth-sub', 'CTRL+SPC sign-in was rejected. Sign in here to reconnect. Provider sign-in and stopped assignments remain separate.'); warning.setAttribute('role', 'status'); card.append(warning); }
|
|
401
421
|
card.append(logo());
|
|
402
422
|
card.append(el('h1', 'auth-title', 'Sign in'));
|
|
403
423
|
card.append(el('p', 'auth-sub', 'The board your agents report to. Sign in to link this computer to your account.'));
|
package/dist/companion.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import { isAuthSessionMissingError } from '@supabase/supabase-js';
|
|
1
2
|
import { createServer } from 'node:http';
|
|
2
3
|
import { timingSafeEqual } from 'node:crypto';
|
|
3
4
|
import { COMPANION_PORT } from './env.js';
|
|
4
5
|
import { openBrowser } from './browser.js';
|
|
5
|
-
import { companionToken, getMachineIdentity, readSession, clearSession, readCodebasePaths, writeCodebasePath } from './config.js';
|
|
6
|
+
import { companionToken, getMachineIdentity, readSession, readSessionRecord, clearSession, readCodebasePaths, writeCodebasePath } from './config.js';
|
|
6
7
|
import { getClient, signIn, NotLoggedIn, confirmedSessionRejection } from './supabase.js';
|
|
7
|
-
import {
|
|
8
|
-
import { startLocalRuntime, localRuntimeOwned, stopLocalOwnerForSignal, inspectLocalRuntime, checkLegacyUpgrade } from './daemon-lifecycle.js';
|
|
8
|
+
import { liveClient } from './presence.js';
|
|
9
|
+
import { startLocalRuntime, localRuntimeOwned, stopLocalOwnerForSignal, inspectLocalRuntime, checkLegacyUpgrade, notifySessionChanged } from './daemon-lifecycle.js';
|
|
9
10
|
import { CLI_VERSION } from './package-version.js';
|
|
10
11
|
import { detectAgents } from './agents.js';
|
|
11
12
|
import { agentToolsBadgeState, unregisterFromClaude, unregisterFromCodex, } from './mcp.js';
|
|
@@ -113,7 +114,10 @@ export async function serveCompanion({ open = false } = {}) {
|
|
|
113
114
|
}
|
|
114
115
|
closeCompanion = () => new Promise((resolve, reject) => {
|
|
115
116
|
server.close((error) => error ? reject(error) : resolve());
|
|
116
|
-
|
|
117
|
+
// An attached browser may still be awaiting a status request during a
|
|
118
|
+
// connection outage. Closing this GUI must also close its own active HTTP
|
|
119
|
+
// connections; they cannot keep the already-stopped runtime alive.
|
|
120
|
+
server.closeAllConnections();
|
|
117
121
|
});
|
|
118
122
|
// Local control starts before cloud sign-in. The GUI remains usable offline.
|
|
119
123
|
try {
|
|
@@ -244,7 +248,17 @@ async function handleApi(req, res, path, query) {
|
|
|
244
248
|
const machine = getMachineIdentity();
|
|
245
249
|
let email = null;
|
|
246
250
|
let sessionError = null;
|
|
247
|
-
|
|
251
|
+
let record = null;
|
|
252
|
+
try {
|
|
253
|
+
record = readSessionRecord();
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
sessionError = 'Local sign-in storage could not be read. Your service controls remain available.';
|
|
257
|
+
}
|
|
258
|
+
const local = await inspectLocalRuntime(Date.now() + 2000).catch(() => null);
|
|
259
|
+
const renewal = local?.status?.sessionRenewing === true;
|
|
260
|
+
const connectionState = local?.status?.cloud ?? 'unknown';
|
|
261
|
+
const session = record?.state === 'signed-in' ? record.tokens : null;
|
|
248
262
|
if (session) {
|
|
249
263
|
try {
|
|
250
264
|
const { data, error } = await (await requestClient()).auth.getUser(session.access_token);
|
|
@@ -256,9 +270,10 @@ async function handleApi(req, res, path, query) {
|
|
|
256
270
|
}
|
|
257
271
|
catch (error) {
|
|
258
272
|
if (error instanceof NotLoggedIn) {
|
|
259
|
-
sessionError = '
|
|
273
|
+
sessionError = 'The local service needs to check your sign-in. Use Start in Companion if the service is stopped, then check again.';
|
|
260
274
|
}
|
|
261
|
-
else if (!confirmedSessionRejection(error)) {
|
|
275
|
+
else if (!confirmedSessionRejection(error) && !isAuthSessionMissingError(error)) {
|
|
276
|
+
// getUser received an explicit stored JWT; Auth normalizes its typed session_not_found rejection to this class.
|
|
262
277
|
sessionError = 'Sign-in could not be checked. Check your connection and try again.';
|
|
263
278
|
}
|
|
264
279
|
}
|
|
@@ -266,6 +281,9 @@ async function handleApi(req, res, path, query) {
|
|
|
266
281
|
json(res, 200, {
|
|
267
282
|
signedIn: sessionError ? null : email !== null,
|
|
268
283
|
sessionError,
|
|
284
|
+
sessionRenewing: renewal,
|
|
285
|
+
connectionState,
|
|
286
|
+
sessionState: record?.state ?? 'signed-out',
|
|
269
287
|
email,
|
|
270
288
|
machineName: machine.name,
|
|
271
289
|
platform: process.platform,
|
|
@@ -284,6 +302,7 @@ async function handleApi(req, res, path, query) {
|
|
|
284
302
|
}
|
|
285
303
|
try {
|
|
286
304
|
const result = await signIn(email, password);
|
|
305
|
+
await notifySessionChanged();
|
|
287
306
|
await startPresenceUnlessDaemonServes((err) => {
|
|
288
307
|
console.warn(`Presence did not start after sign-in: ${err.message}`);
|
|
289
308
|
});
|
|
@@ -295,30 +314,19 @@ async function handleApi(req, res, path, query) {
|
|
|
295
314
|
return;
|
|
296
315
|
}
|
|
297
316
|
if (req.method === 'POST' && path === '/api/logout') {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
await stopPresence({ unregister: true });
|
|
302
|
-
}
|
|
303
|
-
else {
|
|
304
|
-
/* ═══ A COMPANION THAT DEFERRED TO A DAEMON STILL HAS TO UNREGISTER. ═══
|
|
305
|
-
`stopPresence` returns early when this process holds no presence
|
|
306
|
-
(`presence.ts:603-604`), so without this branch logout would delete
|
|
307
|
-
`session.json` and leave the `ctrl-spc` entry in Claude's and Codex's
|
|
308
|
-
configs pointing at a server whose session is gone.
|
|
309
|
-
|
|
310
|
-
Only the unregistration is copied, deliberately. The other two things
|
|
311
|
-
`stopPresence` does belong to a process that owns them, and this one
|
|
312
|
-
owns neither: the running tools server is the daemon's, and the daemon
|
|
313
|
-
still owns and heartbeats `cliv2_agents`. Stamping `stopped_at` from
|
|
314
|
-
here would mark a machine offline that is genuinely online. */
|
|
317
|
+
try {
|
|
318
|
+
await clearSession();
|
|
319
|
+
await notifySessionChanged();
|
|
315
320
|
const agents = detectAgents();
|
|
316
321
|
if (agents.includes('claude'))
|
|
317
322
|
void unregisterFromClaude();
|
|
318
323
|
if (agents.includes('codex'))
|
|
319
324
|
void unregisterFromCodex();
|
|
320
325
|
}
|
|
321
|
-
|
|
326
|
+
catch (error) {
|
|
327
|
+
json(res, 503, { ok: false, error: error instanceof Error ? error.message : 'Sign-out could not be confirmed.' });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
322
330
|
json(res, 200, { ok: true });
|
|
323
331
|
return;
|
|
324
332
|
}
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync, openSync, closeSync, fsyncSync, renameSync } from 'node:fs';
|
|
2
2
|
import { homedir, hostname, platform } from 'node:os';
|
|
3
|
-
import { randomBytes, createHash } from 'node:crypto';
|
|
3
|
+
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
4
4
|
import { execSync, execFileSync } from 'node:child_process';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
/** Own config dir, isolated from the v1 CLI: `~/.config/ctrl-spc-v2`. */
|
|
@@ -196,26 +196,157 @@ export function clearSupersededMachineIds() {
|
|
|
196
196
|
}
|
|
197
197
|
catch { /* leave as-is */ }
|
|
198
198
|
}
|
|
199
|
+
/** Missing, intentionally signed out, and unreadable storage are different facts. */
|
|
200
|
+
export function readSessionRecord() {
|
|
201
|
+
let text;
|
|
202
|
+
try {
|
|
203
|
+
text = readFileSync(filePath('session.json'), 'utf8');
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (error.code === 'ENOENT')
|
|
207
|
+
return null;
|
|
208
|
+
throw new Error('Local sign-in could not be read.', { cause: error });
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const value = JSON.parse(text);
|
|
212
|
+
if (!value || typeof value !== 'object')
|
|
213
|
+
throw new Error('Invalid record');
|
|
214
|
+
if (value.schema === undefined && typeof value.access_token === 'string' && value.access_token) {
|
|
215
|
+
if (value.refresh_token !== undefined && typeof value.refresh_token !== 'string')
|
|
216
|
+
throw new Error('Invalid credential');
|
|
217
|
+
// A stable legacy identity permits the first refresh to upgrade in place.
|
|
218
|
+
return { schema: 1, generation: createHash('sha256').update(text).digest('hex'), accountId: null,
|
|
219
|
+
state: 'signed-in', tokens: { access_token: value.access_token, refresh_token: value.refresh_token ?? '' } };
|
|
220
|
+
}
|
|
221
|
+
if (value.schema !== 1 || typeof value.generation !== 'string' || !value.generation
|
|
222
|
+
|| !(value.accountId === null || (typeof value.accountId === 'string' && value.accountId)))
|
|
223
|
+
throw new Error('Invalid identity');
|
|
224
|
+
if (value.state === 'signed-out' || value.state === 'rejected') {
|
|
225
|
+
if ('tokens' in value)
|
|
226
|
+
throw new Error('Inactive record contains credentials');
|
|
227
|
+
return { schema: 1, generation: value.generation, accountId: value.accountId, state: value.state };
|
|
228
|
+
}
|
|
229
|
+
if (value.state !== 'signed-in' || typeof value.tokens?.access_token !== 'string' || !value.tokens.access_token
|
|
230
|
+
|| typeof value.tokens.refresh_token !== 'string')
|
|
231
|
+
throw new Error('Invalid credentials');
|
|
232
|
+
return { schema: 1, generation: value.generation, accountId: value.accountId, state: 'signed-in',
|
|
233
|
+
tokens: { access_token: value.tokens.access_token, refresh_token: value.tokens.refresh_token } };
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
throw new Error('Local sign-in storage is incomplete or invalid.', { cause: error });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Compatibility for passive consumers: inactive records never expose tokens. */
|
|
199
240
|
export function readSession() {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
241
|
+
const record = readSessionRecord();
|
|
242
|
+
return record?.state === 'signed-in' ? record.tokens : null;
|
|
243
|
+
}
|
|
244
|
+
async function changeSession(change) {
|
|
245
|
+
// Dynamic import avoids config -> process helpers -> config initialization.
|
|
246
|
+
const { inspectProcess, processIdentityMatches } = await import('./win-shell.js');
|
|
247
|
+
const deadline = Date.now() + 5000;
|
|
248
|
+
const owner = await inspectProcess(process.pid, deadline);
|
|
249
|
+
if (!owner)
|
|
250
|
+
throw new Error('Cannot verify the sign-in writer process.');
|
|
251
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
252
|
+
const lock = filePath('session.lock');
|
|
253
|
+
const nonce = randomUUID();
|
|
254
|
+
let held = false;
|
|
255
|
+
while (Date.now() < deadline) {
|
|
256
|
+
try {
|
|
257
|
+
const fd = openSync(lock, 'wx', 0o600);
|
|
258
|
+
try {
|
|
259
|
+
writeFileSync(fd, JSON.stringify({ nonce, owner }));
|
|
260
|
+
fsyncSync(fd);
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
closeSync(fd);
|
|
264
|
+
}
|
|
265
|
+
held = true;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
if (error.code !== 'EEXIST')
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
let prior;
|
|
273
|
+
try {
|
|
274
|
+
prior = JSON.parse(readFileSync(lock, 'utf8'));
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (!(error instanceof SyntaxError) && error.code !== 'ENOENT')
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
if (prior && typeof prior.nonce === 'string' && Number.isInteger(prior.owner?.pid)) {
|
|
281
|
+
const live = await inspectProcess(prior.owner.pid, deadline);
|
|
282
|
+
if (!live || !processIdentityMatches(prior.owner, live)) {
|
|
283
|
+
// Recheck the resource identity after asynchronous process inspection.
|
|
284
|
+
try {
|
|
285
|
+
if (JSON.parse(readFileSync(lock, 'utf8')).nonce === prior.nonce)
|
|
286
|
+
rmSync(lock);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (error.code !== 'ENOENT')
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
295
|
+
}
|
|
296
|
+
if (!held)
|
|
297
|
+
throw new Error('Local sign-in is busy or its writer cannot be verified. Try again.');
|
|
298
|
+
const temp = filePath('session.' + nonce + '.tmp');
|
|
203
299
|
try {
|
|
204
|
-
|
|
300
|
+
const record = change(readSessionRecord());
|
|
301
|
+
if (record) {
|
|
302
|
+
const fd = openSync(temp, 'wx', 0o600);
|
|
303
|
+
try {
|
|
304
|
+
writeFileSync(fd, JSON.stringify(record));
|
|
305
|
+
fsyncSync(fd);
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
closeSync(fd);
|
|
309
|
+
}
|
|
310
|
+
renameSync(temp, filePath('session.json'));
|
|
311
|
+
}
|
|
312
|
+
return record;
|
|
205
313
|
}
|
|
206
|
-
|
|
207
|
-
|
|
314
|
+
finally {
|
|
315
|
+
rmSync(temp, { force: true });
|
|
316
|
+
if (JSON.parse(readFileSync(lock, 'utf8')).nonce === nonce)
|
|
317
|
+
rmSync(lock);
|
|
208
318
|
}
|
|
209
319
|
}
|
|
210
|
-
export function writeSession(session) {
|
|
211
|
-
|
|
320
|
+
export async function writeSession(session, accountId = null, expectedGeneration) {
|
|
321
|
+
if (!session.access_token || !session.refresh_token)
|
|
322
|
+
throw new Error('Sign-in did not return complete credentials.');
|
|
323
|
+
return (await changeSession(prior => {
|
|
324
|
+
if (expectedGeneration !== undefined && (prior?.generation ?? null) !== expectedGeneration)
|
|
325
|
+
throw new Error('The local sign-in changed while this request was being verified. Try again.');
|
|
326
|
+
return { schema: 1, generation: randomUUID(), accountId, state: 'signed-in', tokens: session };
|
|
327
|
+
}));
|
|
212
328
|
}
|
|
213
|
-
export function
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
329
|
+
export async function rotateSession(generation, session, accountId) {
|
|
330
|
+
let changed = false;
|
|
331
|
+
await changeSession(prior => {
|
|
332
|
+
if (prior?.state !== 'signed-in' || prior.generation !== generation || (prior.accountId !== null && prior.accountId !== accountId))
|
|
333
|
+
return null;
|
|
334
|
+
changed = true;
|
|
335
|
+
return { ...prior, accountId, tokens: session };
|
|
336
|
+
});
|
|
337
|
+
return changed;
|
|
338
|
+
}
|
|
339
|
+
export async function rejectSession(generation) {
|
|
340
|
+
await changeSession(prior => prior?.state === 'signed-in' && prior.generation === generation
|
|
341
|
+
? { schema: 1, generation: randomUUID(), accountId: prior.accountId, state: 'rejected' } : null);
|
|
342
|
+
}
|
|
343
|
+
export async function clearSession() {
|
|
344
|
+
let wasSignedIn = false;
|
|
345
|
+
await changeSession(prior => {
|
|
346
|
+
wasSignedIn = prior?.state === 'signed-in';
|
|
347
|
+
return { schema: 1, generation: randomUUID(), accountId: prior?.accountId ?? null, state: 'signed-out' };
|
|
348
|
+
});
|
|
349
|
+
return wasSignedIn;
|
|
219
350
|
}
|
|
220
351
|
/** This machine's project mappings, stored locally (not in the cloud) so
|
|
221
352
|
* absolute paths never leave the machine. Keyed by project id. */
|