@ctrl-spc/cs 0.7.14 → 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/README.md +50 -3
- package/dist/autostart.js +103 -122
- package/dist/companion-ui.js +34 -6
- package/dist/companion.js +59 -150
- package/dist/config.js +52 -1
- package/dist/daemon-lifecycle.js +548 -0
- package/dist/daemon-lock.js +149 -42
- package/dist/daemon-processes.js +756 -0
- package/dist/daemon.js +14 -46
- package/dist/darwin-coalition.js +340 -0
- package/dist/index.js +70 -74
- package/dist/login.js +5 -3
- 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 +620 -428
- package/dist/panel3/run.js +822 -520
- package/dist/panel3/spawn.js +59 -11
- package/dist/presence.js +183 -24
- package/dist/supabase.js +43 -9
- package/dist/win-shell.js +464 -1
- package/dist/windows-job.js +312 -0
- package/package.json +4 -3
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 {
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
246
|
+
let sessionError = null;
|
|
247
|
+
const session = readSession();
|
|
248
|
+
if (session) {
|
|
353
249
|
try {
|
|
354
|
-
|
|
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
|
}
|