@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 CHANGED
@@ -1,4 +1,9 @@
1
- import { accessSync, constants, readdirSync, statSync } from 'node:fs';
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
+ }
@@ -1,8 +1,16 @@
1
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
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
- /* Same reasoning as the missing credential: an isolated home we cannot write
357
- is not a reason to fail the user's run. */
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 null;
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
@@ -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 { stopPresence, liveClient, isPresenceRunning } from './presence.js';
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
- server.closeIdleConnections();
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
- const session = readSession();
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 = 'Saved sign-in needs to be checked by the service. Run cs start on this computer, then try again.';
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
- // Logout also unregisters ctrl-spc from the detected agents (Phase 3), so no
299
- // dead server entry is left behind for the next agent run.
300
- if (isPresenceRunning()) {
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
- clearSession();
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 path = filePath('session.json');
201
- if (!existsSync(path))
202
- return null;
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
- return JSON.parse(readFileSync(path, 'utf8'));
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
- catch {
207
- return null;
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
- writeJson('session.json', session);
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 clearSession() {
214
- const path = filePath('session.json');
215
- if (!existsSync(path))
216
- return false;
217
- rmSync(path);
218
- return true;
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. */