@ctrl-spc/cs 0.7.13 → 0.7.15

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/companion.js CHANGED
@@ -3,70 +3,28 @@ import { timingSafeEqual } from 'node:crypto';
3
3
  import { COMPANION_PORT } from './env.js';
4
4
  import { openBrowser } from './browser.js';
5
5
  import { companionToken, getMachineIdentity, readSession, clearSession, readCodebasePaths, writeCodebasePath } from './config.js';
6
- import { getClient, signIn, NotLoggedIn } from './supabase.js';
7
- import { startPresence, stopPresence, liveClient, isPresenceRunning } from './presence.js';
6
+ 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';
9
+ import { CLI_VERSION } from './package-version.js';
8
10
  import { detectAgents } from './agents.js';
9
- import { agentToolsBadgeState, foreignToolsServerAlive, unregisterFromClaude, unregisterFromCodex, } from './mcp.js';
11
+ import { agentToolsBadgeState, unregisterFromClaude, unregisterFromCodex, } from './mcp.js';
10
12
  import { loadProjects, saveMapping } from './projects.js';
11
13
  import { listCodebases, addCodebase, reportLocated, removeCodebase, NotHostedRemoteError } from './codebases.js';
12
14
  import { hostedRemoteIdentity } from './git-remote.js';
13
15
  import { chooseFolder, detectGitRemote } from './folders.js';
14
16
  import { renderCompanionUi } from './companion-ui.js';
15
17
  const MAX_BODY_BYTES = 64 * 1024;
16
- const VERSION = '0.1.0';
17
- /** Whether THIS process is the one refreshing the machine's token. False once
18
- * the companion has found a daemon already serving and deferred to it, so
19
- * `requestClient`'s fallback stops building a rival refresher. Starts true
20
- * because a companion with no daemon beside it is the common case (bare `cs`
21
- * is `openCompanion()`, and `cs open` never installs autostart). */
22
- let companionRefreshing = true;
23
- /**
24
- * Resolves once `companionRefreshing` holds this boot's real decision.
25
- *
26
- * The server starts listening before that decision is taken: the probe inside
27
- * `startPresenceUnlessDaemonServes` costs up to a second, and the
28
- * `startPresence()` behind it costs seconds more. A browser tab left open by a
29
- * previous `cs open` polls `GET /api/session` every two seconds, so without
30
- * this it can be served during that window, while `companionRefreshing` is
31
- * still its `true` initializer, by a refreshing client on a machine where a
32
- * daemon is already rotating the token. That is the very collision the
33
- * deferral exists to prevent, so no authenticated request is served until the
34
- * decision is in.
35
- *
36
- * It is only ever a decision barrier. A failure to start presence is already
37
- * handled by the `onError` callback and must not become a permanently wedged
38
- * server, so the assignment below settles this promise on every path.
39
- */
18
+ const VERSION = CLI_VERSION;
40
19
  let refreshDecided = Promise.resolve();
20
+ let closeCompanion;
41
21
  function url(token = companionToken()) {
42
- return `http://127.0.0.1:${COMPANION_PORT}/?token=${encodeURIComponent(token)}`;
22
+ return 'http://127.0.0.1:' + COMPANION_PORT + '/?token=' + encodeURIComponent(token);
43
23
  }
44
- /**
45
- * Come online, unless a daemon on this machine already has.
46
- *
47
- * `foreignToolsServerAlive()` probes the fixed tools-server port over loopback
48
- * and does NOT exclude the calling process, so this must be asked before
49
- * anything in this process could bind that port. Both call sites (boot and
50
- * `POST /api/login`) ask exactly once, at a moment where only another `cs` could
51
- * be answering.
52
- *
53
- * When one is: presence, the tools server, agent registration, the orchestrator
54
- * and the recovery sweeps are all the daemon's job, and running a second copy
55
- * here duplicates them on the one rotating refresh token this machine has.
56
- *
57
- * `companionRefreshing` is assigned on BOTH branches rather than left at its
58
- * initializer, because login runs this again: a companion that deferred to a
59
- * daemon and later signs in with that daemon gone must stop reading as
60
- * non-refreshing.
61
- */
24
+ /** Only explicit open/login/start intent starts the shared local owner. */
62
25
  async function startPresenceUnlessDaemonServes(onError) {
63
- if (await foreignToolsServerAlive()) {
64
- companionRefreshing = false;
65
- return;
66
- }
67
- companionRefreshing = true;
68
26
  try {
69
- await startPresence();
27
+ await startLocalRuntime({ onStop: closeCompanion });
70
28
  }
71
29
  catch (err) {
72
30
  onError(err);
@@ -90,8 +48,7 @@ async function isOurCompanion(token) {
90
48
  }
91
49
  }
92
50
  /**
93
- * `cs open`: opens the Companion GUI. If a companion is already resident, just
94
- * points the browser at it; otherwise becomes the resident server itself.
51
+ * `cs open` explicitly starts the shared service and opens the resident GUI.
95
52
  */
96
53
  export async function openCompanion() {
97
54
  if (!readSession())
@@ -134,6 +91,13 @@ export async function serveCompanion({ open = false } = {}) {
134
91
  // the token via the browser) once a health probe confirms it — otherwise
135
92
  // some unrelated process holds the port and must not receive the token.
136
93
  if (await isOurCompanion(token)) {
94
+ if (!await inspectLocalRuntime())
95
+ await checkLegacyUpgrade();
96
+ const start = await fetch('http://127.0.0.1:' + COMPANION_PORT + '/api/start', { method: 'POST', headers: { 'x-ctrl-spc-token': token }, signal: AbortSignal.timeout(30000) });
97
+ if (!start.ok) {
98
+ const result = await start.json().catch(() => null);
99
+ throw new Error(result?.error ?? 'The resident Companion cannot start this service. Run cs restart on this computer, then cs open.');
100
+ }
137
101
  console.log(`Companion already running — ${url(token)}`);
138
102
  if (open)
139
103
  openBrowser(url(token));
@@ -147,15 +111,13 @@ export async function serveCompanion({ open = false } = {}) {
147
111
  }
148
112
  throw err;
149
113
  }
150
- // Come online immediately if already signed in (terminal `cs login` or a
151
- // prior GUI sign-in). Not signed in is fine — the GUI shows the sign-in screen.
114
+ closeCompanion = () => new Promise((resolve, reject) => {
115
+ server.close((error) => error ? reject(error) : resolve());
116
+ server.closeIdleConnections();
117
+ });
118
+ // Local control starts before cloud sign-in. The GUI remains usable offline.
152
119
  try {
153
- if (readSession()) {
154
- await startPresenceUnlessDaemonServes((err) => {
155
- if (!(err instanceof NotLoggedIn))
156
- console.warn(`Presence did not start: ${err.message}`);
157
- });
158
- }
120
+ await startPresenceUnlessDaemonServes((err) => console.warn(`Local service did not start: ${err.message}`));
159
121
  }
160
122
  finally {
161
123
  // Signed out, presence started, or the probe itself threw: the decision is
@@ -165,20 +127,14 @@ export async function serveCompanion({ open = false } = {}) {
165
127
  console.log(`Companion running — ${url(token)}`);
166
128
  if (open)
167
129
  openBrowser(url(token));
168
- await new Promise((resolve) => {
169
- let closing = false;
170
- const shutdown = () => {
171
- if (closing)
172
- return;
173
- closing = true;
174
- void stopPresence().finally(() => {
175
- server.close(() => resolve());
176
- process.exit(0);
177
- });
178
- };
179
- process.on('SIGINT', shutdown);
180
- process.on('SIGTERM', shutdown);
181
- });
130
+ const shutdown = () => {
131
+ if (localRuntimeOwned())
132
+ void stopLocalOwnerForSignal().catch((error) => console.error(error.message));
133
+ else
134
+ void closeCompanion?.().then(() => process.exit(0));
135
+ };
136
+ process.on('SIGINT', shutdown);
137
+ process.on('SIGTERM', shutdown);
182
138
  }
183
139
  // --- request handling --------------------------------------------------------
184
140
  /** Two legitimate Host values for a loopback service. Anything else is a
@@ -236,74 +192,9 @@ function agentToolsState(signedIn) {
236
192
  const installed = detectAgents().filter((a) => a === 'claude' || a === 'codex');
237
193
  return agentToolsBadgeState({ signedIn, installed });
238
194
  }
239
- /**
240
- * The client an authenticated API request is served through.
241
- *
242
- * ═══ IT REUSES PRESENCE'S CLIENT, AND THAT IS THE WHOLE POINT OF THIS
243
- * FUNCTION. ═══ `getClient()` builds a BRAND NEW client every call, with
244
- * `autoRefreshToken: true` and an `onAuthStateChange` that writes
245
- * `session.json` back (`supabase.ts:61-89`). Calling it per HTTP request meant
246
- * `cs open` ran a whole family of refreshing, disk-writing clients beside the
247
- * daemon's, all on the ONE rotating refresh token the machine has. Supabase
248
- * rotates that token on every refresh and revokes the previous family member
249
- * outside a 10-second reuse window (`supabase/config.toml:171-175`), so two
250
- * independent refreshers race: one wins, the loser replays a superseded token,
251
- * GoTrue revokes the family, and the token on disk is PERMANENTLY dead rather
252
- * than merely stale. Investigation
253
- * `.bugs/20260831-supabase-health/investigations/04-refresh-token-loop.md` §3
254
- * names this row 6 of its rotation-collision map and calls it the most likely
255
- * precipitating cause of the 2026-08-30 incident, after which the daemon's
256
- * heartbeat spun on `400 refresh_token_not_found` roughly 360 times an hour
257
- * with no way to self-heal.
258
- *
259
- * So the rule `daemon.ts` and `panel3/run.ts` already state in their own
260
- * comments now holds here too: ONE REFRESH LOOP PER PROCESS, and one writer of
261
- * `session.json`. When presence is running, its client is the one that owns
262
- * refreshing, and the companion reads it rather than standing up a rival.
263
- *
264
- * Per process was never enough on its own, because `cs start` and `cs open` are
265
- * two processes over one `session.json`: the rule is ONE REFRESH LOOP PER
266
- * MACHINE. A companion that boots (or signs in) while a daemon is already
267
- * serving defers to it entirely, starting no presence of its own, and everything
268
- * below follows from that decision.
269
- *
270
- * ═══ AND `getClient()` REMAINS THE FALLBACK, BECAUSE NULL HERE MEANS "NO
271
- * PRESENCE", NOT "SIGNED OUT". ═══ `liveClient()` is null before the first
272
- * sign-in, and after `stopPresence()`, and in a companion serving on a machine
273
- * whose presence failed to start, and in one that found a daemon already
274
- * serving and deferred to it. Only `getClient()` can answer the signed-in
275
- * question, by reading the session off disk and throwing `NotLoggedIn` when
276
- * there is none, so the fallback is what every caller's 401 path still hangs
277
- * off.
278
- *
279
- * What the fallback may NOT do is refresh while another `cs` process on this
280
- * machine is refreshing, which is the deferring case above: `cs start` is
281
- * already rotating the one token family, and a client built here with
282
- * `refreshing: true` would race it exactly as the per-request family did.
283
- * `companionRefreshing` carries the decision this process took at boot or at
284
- * sign-in, and `getClient({ refreshing: false })` then returns a client that was
285
- * never handed the refresh token, so it cannot rotate anything. It still reads
286
- * the session off disk and still throws `NotLoggedIn`, including when the stored
287
- * access token has already expired, so the 401 boundary is unchanged. A single
288
- * companion request building a single client is not the defect; a client per
289
- * request forever, beside the daemon's, is.
290
- *
291
- * ═══ READ AT CALL TIME, NEVER CACHED. ═══ `POST /api/login` signs in and then
292
- * takes the deferral decision above, which on the no-daemon branch starts
293
- * presence. A companion that resolved this once at startup would hold the
294
- * pre-login null for the life of the process and go on building its own clients
295
- * after the very moment presence became available to share. The login path also
296
- * reassigns `companionRefreshing`, so caching would freeze the boot decision
297
- * too, past a sign-in that legitimately changed it.
298
- *
299
- * Exported so the rule above is what a test holds: with no presence running it
300
- * falls through to `getClient()`, refreshing only when no other `cs` process on
301
- * this machine is, and lets `NotLoggedIn` out rather than softening it into a
302
- * null, which is the half of the behaviour reachable without a real session and
303
- * a live network.
304
- */
195
+ /** Requests borrow the owner's client or read credentials without refreshing. */
305
196
  export async function requestClient() {
306
- return liveClient() ?? (await getClient({ refreshing: companionRefreshing }));
197
+ return liveClient() ?? (await getClient({ refreshing: false }));
307
198
  }
308
199
  async function handle(req, res, token) {
309
200
  if (!allowedHost(req.headers.host)) {
@@ -342,21 +233,39 @@ async function handle(req, res, token) {
342
233
  res.writeHead(404, { 'Content-Type': 'text/plain' }).end('Not found');
343
234
  }
344
235
  async function handleApi(req, res, path, query) {
345
- // Nothing here is served until this boot's refresh decision is in, so no
346
- // request can be answered by a client built from the `true` initializer on a
347
- // machine where a daemon is the one refreshing. See `refreshDecided`.
236
+ // Requests wait for the explicit startup attempt; reads never start a service.
348
237
  await refreshDecided;
238
+ if (req.method === 'POST' && path === '/api/start') {
239
+ await startLocalRuntime({ onStop: closeCompanion });
240
+ json(res, 200, { ok: true });
241
+ return;
242
+ }
349
243
  if (req.method === 'GET' && path === '/api/session') {
350
244
  const machine = getMachineIdentity();
351
245
  let email = null;
352
- if (readSession()) {
246
+ let sessionError = null;
247
+ const session = readSession();
248
+ if (session) {
353
249
  try {
354
- email = (await (await requestClient()).auth.getUser()).data.user?.email ?? null;
250
+ const { data, error } = await (await requestClient()).auth.getUser(session.access_token);
251
+ if (error)
252
+ throw error;
253
+ email = data.user?.email ?? null;
254
+ if (!email)
255
+ throw new Error('Sign-in verification returned no account.');
256
+ }
257
+ catch (error) {
258
+ 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.';
260
+ }
261
+ else if (!confirmedSessionRejection(error)) {
262
+ sessionError = 'Sign-in could not be checked. Check your connection and try again.';
263
+ }
355
264
  }
356
- catch { /* stale/expired session reads as signed out */ }
357
265
  }
358
266
  json(res, 200, {
359
- signedIn: email !== null,
267
+ signedIn: sessionError ? null : email !== null,
268
+ sessionError,
360
269
  email,
361
270
  machineName: machine.name,
362
271
  platform: process.platform,
package/dist/config.js CHANGED
@@ -1,12 +1,63 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
2
2
  import { homedir, hostname, platform } from 'node:os';
3
3
  import { randomBytes, createHash } from 'node:crypto';
4
- import { execSync } from 'node:child_process';
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`. */
7
7
  export function configDir() {
8
8
  return process.env.CTRL_SPC_V2_CONFIG_DIR || join(homedir(), '.config', 'ctrl-spc-v2');
9
9
  }
10
+ /** Local control and process records are never hosted or shared with harness MCP. */
11
+ export function lifecycleDir() {
12
+ return join(configDir(), 'lifecycle');
13
+ }
14
+ let securedLifecycleDir = null;
15
+ export function ensureLifecycleDir() {
16
+ const path = lifecycleDir();
17
+ if (securedLifecycleDir === path && existsSync(path))
18
+ return path;
19
+ mkdirSync(path, { recursive: true, mode: 0o700 });
20
+ if (process.platform === 'win32') {
21
+ // Set an exact current-user ACL; removing inheritance alone leaves any
22
+ // explicit grants from an existing directory in place.
23
+ const script = "$ErrorActionPreference='Stop'; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; $acl=New-Object System.Security.AccessControl.DirectorySecurity; $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false); $rule=New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow'); $acl.AddAccessRule($rule); Set-Acl -LiteralPath $env:CTRL_SPC_LIFECYCLE_ACL_PATH -AclObject $acl";
24
+ execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
25
+ stdio: 'ignore', windowsHide: true, timeout: 3000, env: { ...process.env, CTRL_SPC_LIFECYCLE_ACL_PATH: path },
26
+ });
27
+ }
28
+ else
29
+ chmodSync(path, 0o700);
30
+ securedLifecycleDir = path;
31
+ return path;
32
+ }
33
+ export function readLifecycleToken() {
34
+ try {
35
+ return readFileSync(join(lifecycleDir(), 'token'), 'utf8').trim() || null;
36
+ }
37
+ catch (error) {
38
+ if (error.code === 'ENOENT')
39
+ return null;
40
+ throw error;
41
+ }
42
+ }
43
+ export function lifecycleToken() {
44
+ const prior = readLifecycleToken();
45
+ if (prior)
46
+ return prior;
47
+ const path = join(ensureLifecycleDir(), 'token');
48
+ const token = randomBytes(32).toString('hex');
49
+ try {
50
+ writeFileSync(path, token, { flag: 'wx', mode: 0o600 });
51
+ }
52
+ catch (error) {
53
+ if (error.code !== 'EEXIST')
54
+ throw error;
55
+ }
56
+ const result = readLifecycleToken();
57
+ if (!result)
58
+ throw new Error('Local service credential could not be created.');
59
+ return result;
60
+ }
10
61
  function filePath(name) {
11
62
  return join(configDir(), name);
12
63
  }