@ctrl-spc/cs 0.7.14 → 0.7.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -3
- package/dist/agents.js +175 -1
- package/dist/autostart.js +103 -122
- package/dist/codex-home.js +14 -11
- package/dist/companion-ui.js +54 -6
- package/dist/companion.js +86 -169
- package/dist/config.js +199 -17
- package/dist/daemon-lifecycle.js +575 -0
- package/dist/daemon-lock.js +149 -42
- package/dist/daemon-processes.js +860 -0
- package/dist/daemon.js +14 -46
- package/dist/darwin-coalition.js +340 -0
- package/dist/failure-reason.js +76 -16
- package/dist/index.js +75 -76
- package/dist/login.js +9 -9
- package/dist/mcp.js +55 -56
- package/dist/native/darwin-coalition +0 -0
- package/dist/native/darwin-coalition.build.json +1 -0
- package/dist/native/darwin-coalition.c +145 -0
- package/dist/orchestrator.js +892 -575
- package/dist/panel3/coordinator.js +3 -1
- package/dist/panel3/presence.js +1 -1
- package/dist/panel3/run.js +996 -558
- package/dist/panel3/spawn.js +85 -23
- package/dist/panel3/tools.js +5 -0
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +271 -135
- package/dist/supabase.js +173 -37
- package/dist/win-shell.js +464 -1
- package/dist/windows-job.js +312 -0
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -4,9 +4,11 @@ import { runDaemon } from './daemon.js';
|
|
|
4
4
|
import { openCompanion } from './companion.js';
|
|
5
5
|
import { autostartOn, autostartOff } from './autostart.js';
|
|
6
6
|
import { detectAgents } from './agents.js';
|
|
7
|
-
import { getMachineIdentity, clearSession, readSession } from './config.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { getMachineIdentity, clearSession, readSession, machineHostname } from './config.js';
|
|
8
|
+
import { CLI_VERSION } from './package-version.js';
|
|
9
|
+
import { inspectLocalRuntime, runLifecycleCommand, notifySessionChanged } from './daemon-lifecycle.js';
|
|
10
|
+
import { readMigration, readRuntime } from './daemon-lock.js';
|
|
11
|
+
import { claudeRegisteredOnDisk, codexRegisteredOnDisk } from './mcp.js';
|
|
10
12
|
import { panelCommand } from './panel3/cli.js';
|
|
11
13
|
const HELP = `cs — CTRL+SPC
|
|
12
14
|
|
|
@@ -14,7 +16,11 @@ const HELP = `cs — CTRL+SPC
|
|
|
14
16
|
cs open Open the Companion app in your browser
|
|
15
17
|
cs login Sign in from the terminal and link this computer
|
|
16
18
|
cs start Come online and answer cards, no window (used by auto-start)
|
|
17
|
-
cs
|
|
19
|
+
cs stop Stop this service; refuses while work is active
|
|
20
|
+
cs restart Load this installed version and keep it running in background
|
|
21
|
+
cs stop --force Interrupt owned work, save recovery, then stop
|
|
22
|
+
cs restart --force Interrupt owned work, then load this installed version
|
|
23
|
+
cs status Show local service, cloud readiness and running version
|
|
18
24
|
cs autostart on Come online automatically at login
|
|
19
25
|
cs autostart off Stop coming online at login
|
|
20
26
|
cs logout Sign this computer out
|
|
@@ -28,80 +34,62 @@ const HELP = `cs — CTRL+SPC
|
|
|
28
34
|
|
|
29
35
|
cs help Show this help
|
|
30
36
|
`;
|
|
31
|
-
/**
|
|
32
|
-
* The whole setup state, and the ONE next step to take, for an agent — the
|
|
33
|
-
* reader of this command in every story is Claude Code or Codex, not a person
|
|
34
|
-
* at a prompt, so every step below addresses the agent and names the user only
|
|
35
|
-
* for sign-in, which is the one step only the user can take.
|
|
36
|
-
*
|
|
37
|
-
* A non-zero exit means "not fully set up", NEVER "a command failed". A machine
|
|
38
|
-
* mid-setup exits 1 while everything is working correctly.
|
|
39
|
-
*/
|
|
37
|
+
/** Inspection never creates a competing cloud refresh owner. */
|
|
40
38
|
async function status() {
|
|
41
|
-
const
|
|
39
|
+
const machine = getMachineIdentity();
|
|
42
40
|
const installed = detectAgents();
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
41
|
+
console.log('Computer: ' + machine.name);
|
|
42
|
+
console.log('Installed CLI version: ' + CLI_VERSION);
|
|
43
|
+
console.log('Harnesses installed: ' + (installed.join(', ') || 'none'));
|
|
44
|
+
console.log('Harness sign-in: not checked by this command; each work request checks its selected harness.');
|
|
45
|
+
const inspection = await inspectLocalRuntime();
|
|
46
|
+
if (inspection?.status) {
|
|
47
|
+
const current = inspection.status;
|
|
48
|
+
console.log('Local service: ' + current.local + ' (pid ' + current.pid + ')');
|
|
49
|
+
console.log('Running version: ' + current.version + ' — verified');
|
|
50
|
+
console.log('Cloud connection: ' + current.cloud);
|
|
51
|
+
const work = [...current.work.active, ...current.work.pending, ...current.work.unknown];
|
|
52
|
+
console.log('Owned work: ' + work.length + (current.work.unknown.length ? ' (includes unverified execution)' : ''));
|
|
53
|
+
for (const item of work)
|
|
54
|
+
console.log(' ' + (item.cardId ?? item.workId ?? 'Card identity unavailable') + ' — ' + (item.harness ?? 'harness unknown'));
|
|
55
|
+
if (current.work.receipts || current.work.legacyHeldTodoIds.length)
|
|
56
|
+
console.log('Saved interruptions are waiting for reconciliation or explicit continuation.');
|
|
57
|
+
if (current.cloud === 'sign-in-required')
|
|
58
|
+
console.log('Run cs login on ' + machine.name + '. Local stop and restart remain available.');
|
|
59
|
+
else if (current.cloud !== 'online')
|
|
60
|
+
console.log('Cloud readiness is not confirmed. Check the connection; cs status will show when it is ready.');
|
|
61
|
+
if (current.version !== CLI_VERSION)
|
|
62
|
+
console.log('Run cs restart to activate the installed CLI version.');
|
|
63
|
+
else
|
|
64
|
+
console.log('Stop: cs stop. Restart this installed version: cs restart.');
|
|
65
|
+
console.log('Explicit interruption: cs stop --force or cs restart --force. Interrupted cards need a new message to continue.');
|
|
66
|
+
if (current.cloud === 'online') {
|
|
67
|
+
for (const agent of installed)
|
|
68
|
+
console.log(' ' + agent + ': ' + ((agent === 'claude' ? claudeRegisteredOnDisk() : codexRegisteredOnDisk()) ? 'registered' : 'not registered'));
|
|
58
69
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const raw = err instanceof Error ? err.message : String(err);
|
|
63
|
-
sessionProblem = raw.replace(/\s*Run `cs login` again\.?\s*$/, '').replace(/\.$/, '');
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
const serving = await foreignToolsServerAlive();
|
|
67
|
-
/* Gated on `serving` because a plain shutdown DELIBERATELY leaves the agent
|
|
68
|
-
config entries in place (presence.ts), so an ungated read prints
|
|
69
|
-
"registered" against a daemon that is not there. */
|
|
70
|
-
const registered = {
|
|
71
|
-
claude: serving && claudeRegisteredOnDisk(),
|
|
72
|
-
codex: serving && codexRegisteredOnDisk(),
|
|
73
|
-
};
|
|
74
|
-
console.log(`Computer: ${id.name}`);
|
|
75
|
-
console.log(`Signed in: ${signedIn ? (email ?? 'unknown') : stored ? 'saved sign-in unusable' : 'Not signed in'}`);
|
|
76
|
-
console.log(`Daemon: ${serving ? 'running' : 'not running'}`);
|
|
77
|
-
console.log(`Agents: ${installed.length ? installed.join(', ') : 'none installed'}`);
|
|
78
|
-
for (const agent of installed) {
|
|
79
|
-
console.log(` ${agent.padEnd(8)} ${registered[agent] ? 'registered' : 'not registered'}`);
|
|
80
|
-
}
|
|
81
|
-
console.log('');
|
|
82
|
-
const unregistered = installed.filter((a) => !registered[a]);
|
|
83
|
-
if (!stored) {
|
|
84
|
-
console.log('Nobody is signed in. Ask the user to run `cs login` themselves: it opens a');
|
|
85
|
-
console.log('browser and waits up to five minutes, so do not run it yourself. They sign in');
|
|
86
|
-
console.log('with the same email and password they use on ctrl-spc.com. Then run `cs status`');
|
|
87
|
-
console.log('again.');
|
|
88
|
-
}
|
|
89
|
-
else if (!signedIn) {
|
|
90
|
-
console.log(`The saved sign-in could not be used: ${sessionProblem ?? 'unknown'}. Ask the user to run`);
|
|
91
|
-
console.log('`cs login` again.');
|
|
92
|
-
}
|
|
93
|
-
else if (!serving) {
|
|
94
|
-
console.log('Nothing is serving the tools. `cs start` never exits on its own, so start it in');
|
|
95
|
-
console.log('the background and leave it running, then run `cs status` again.');
|
|
70
|
+
if (current.cloud !== 'online' || current.work.unknown.length)
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
return;
|
|
96
73
|
}
|
|
97
|
-
|
|
98
|
-
console.log(
|
|
99
|
-
console.log('
|
|
74
|
+
if (inspection) {
|
|
75
|
+
console.log('Local service: alive; control is unavailable (pid ' + inspection.record.process.pid + ')');
|
|
76
|
+
console.log('Running version: unavailable. Last recorded version: ' + inspection.record.version);
|
|
77
|
+
console.log('Cloud connection and owned work: unknown');
|
|
78
|
+
console.log('Run cs restart --force to recover verified owned execution. This interrupts work; saved cards need a new message to continue.');
|
|
100
79
|
}
|
|
101
80
|
else {
|
|
102
|
-
|
|
103
|
-
console.log('
|
|
104
|
-
|
|
81
|
+
const migration = readMigration();
|
|
82
|
+
console.log('Local service: stopped or not yet managed by this release');
|
|
83
|
+
console.log('Running version: unavailable');
|
|
84
|
+
console.log('Cloud connection: unknown; local service is not responding');
|
|
85
|
+
if (migration && !migration.completed)
|
|
86
|
+
console.log('One-time upgrade is pending. Run cs restart to check whether this computer still needs a restart.');
|
|
87
|
+
else if (readRuntime())
|
|
88
|
+
console.log('A previous service exited. Run cs restart to recover this instance.');
|
|
89
|
+
else
|
|
90
|
+
console.log('Run cs start. If an older service needs a one-time computer restart, the command will explain it.');
|
|
91
|
+
if (!readSession())
|
|
92
|
+
console.log('No saved sign-in. Run cs login on ' + machine.name + '. Local service controls work without sign-in.');
|
|
105
93
|
}
|
|
106
94
|
process.exitCode = 1;
|
|
107
95
|
}
|
|
@@ -112,11 +100,22 @@ async function main() {
|
|
|
112
100
|
case undefined: return openCompanion();
|
|
113
101
|
case 'open': return openCompanion();
|
|
114
102
|
case 'login': return login();
|
|
115
|
-
case 'start':
|
|
103
|
+
case 'start':
|
|
104
|
+
if (arg && !(arg.startsWith('--lifecycle-handover=') && process.env.CTRL_SPC_LIFECYCLE_HANDOVER === arg.slice('--lifecycle-handover='.length)))
|
|
105
|
+
throw new Error('Usage: cs start');
|
|
106
|
+
return runDaemon();
|
|
107
|
+
case 'stop':
|
|
108
|
+
case 'restart':
|
|
109
|
+
if (process.argv.length > 4 || (arg !== undefined && arg !== '--force'))
|
|
110
|
+
throw new Error('Usage: cs ' + cmd + ' [--force]');
|
|
111
|
+
return runLifecycleCommand(cmd, arg === '--force');
|
|
116
112
|
case 'status': return status();
|
|
117
|
-
case 'logout':
|
|
118
|
-
|
|
113
|
+
case 'logout': {
|
|
114
|
+
const signedIn = await clearSession();
|
|
115
|
+
await notifySessionChanged();
|
|
116
|
+
console.log(signedIn ? 'Signed out.' : 'Was not signed in.');
|
|
119
117
|
return;
|
|
118
|
+
}
|
|
120
119
|
/* THE PANEL'S OWN COMMANDS, ROUTED WHOLE. The person types one CLI, so the
|
|
121
120
|
card commands are `cs` subcommands; the argument handling stays inside
|
|
122
121
|
`panel3/`, which is the one import this file makes into it (named in
|
|
@@ -145,6 +144,6 @@ async function main() {
|
|
|
145
144
|
}
|
|
146
145
|
}
|
|
147
146
|
main().catch((err) => {
|
|
148
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
147
|
+
console.error('CTRL+SPC on ' + machineHostname() + ': ' + (err instanceof Error ? err.message : String(err)));
|
|
149
148
|
process.exit(1);
|
|
150
149
|
});
|
package/dist/login.js
CHANGED
|
@@ -2,8 +2,9 @@ import { createServer } from 'node:http';
|
|
|
2
2
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { SUPABASE_URL, SUPABASE_KEY } from './env.js';
|
|
4
4
|
import { openBrowser } from './browser.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { getMachineIdentity } from './config.js';
|
|
6
|
+
import { acceptLogin } from './supabase.js';
|
|
7
|
+
import { notifySessionChanged } from './daemon-lifecycle.js';
|
|
7
8
|
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
8
9
|
const MAX_BODY_BYTES = 64 * 1024;
|
|
9
10
|
export async function login() {
|
|
@@ -80,15 +81,12 @@ async function handleCallback(req, res, state, finish) {
|
|
|
80
81
|
res.end(JSON.stringify({ ok: false, error: 'Invalid or missing state' }));
|
|
81
82
|
return;
|
|
82
83
|
}
|
|
83
|
-
writeSession({ access_token, refresh_token });
|
|
84
84
|
try {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
if (error || !data.user?.email)
|
|
88
|
-
throw new Error(error?.message ?? 'No user for this session.');
|
|
85
|
+
const email = await acceptLogin({ access_token, refresh_token });
|
|
86
|
+
await notifySessionChanged();
|
|
89
87
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
90
88
|
res.end(JSON.stringify({ ok: true }));
|
|
91
|
-
finish({ ok: true, email
|
|
89
|
+
finish({ ok: true, email });
|
|
92
90
|
}
|
|
93
91
|
catch (err) {
|
|
94
92
|
const message = `Session could not be verified: ${err.message}`;
|
|
@@ -169,7 +167,9 @@ function renderPage(state) {
|
|
|
169
167
|
tin.onclick=()=>{tin.classList.add('active');tup.classList.remove('active');fin.classList.add('active');fup.classList.remove('active')}
|
|
170
168
|
tup.onclick=()=>{tup.classList.add('active');tin.classList.remove('active');fup.classList.add('active');fin.classList.remove('active')}
|
|
171
169
|
const { createClient } = await import('https://esm.sh/@supabase/supabase-js@2')
|
|
172
|
-
const sb = createClient(${JSON.stringify(SUPABASE_URL)}, ${JSON.stringify(SUPABASE_KEY)}
|
|
170
|
+
const sb = createClient(${JSON.stringify(SUPABASE_URL)}, ${JSON.stringify(SUPABASE_KEY)}, {
|
|
171
|
+
auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false }
|
|
172
|
+
})
|
|
173
173
|
async function done(session){
|
|
174
174
|
const r = await fetch('/callback',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
175
175
|
body:JSON.stringify({access_token:session.access_token,refresh_token:session.refresh_token,state:STATE})})
|
package/dist/mcp.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deferMcpCleanup, pendingMcpCleanup, acknowledgeMcpCleanup } from './daemon-processes.js';
|
|
1
2
|
import { listStructureArtifactsHandler, getStructureArtifactHandler, searchAgentCardsHandler, createStructureArtifactHandler, updateStructureArtifactHandler } from './product-tools.js';
|
|
2
3
|
import { createEpicHandler, createSprintHandler, listStructureHandler, updateStructureHandler } from './product-tools.js';
|
|
3
4
|
export { createEpicHandler, createSprintHandler, listStructureHandler, updateStructureHandler } from './product-tools.js';
|
|
@@ -20,9 +21,10 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
|
20
21
|
import { z } from 'zod';
|
|
21
22
|
import { workflowToolPermission, validateWorkflowToolTarget, WORKFLOW_TOOL_TEACHING, WORKFLOW_AUTHORING_TEACHING, authorToolMentions } from './workflows.js';
|
|
22
23
|
import { workflowToolForName, validateApprovalAction } from './workflow-tool-mentions.js';
|
|
24
|
+
import { clientSessionCurrent } from './supabase.js';
|
|
23
25
|
import { TOOLS_SERVER_PORT, SESSION_TTL_MS } from './env.js';
|
|
24
26
|
import { agentPath } from './agents.js';
|
|
25
|
-
import { mcpToken, readMcpToken
|
|
27
|
+
import { mcpToken, readMcpToken } from './config.js';
|
|
26
28
|
import { FIREWALL_WRITING_RULE } from './firewall.js';
|
|
27
29
|
/* The path rule itself, moved out so `workflows.ts` can ask the same question
|
|
28
30
|
before it builds a workflow. `refuseAbsolutePaths` below is still v2's own
|
|
@@ -9237,6 +9239,7 @@ runTodoIdSource = null) {
|
|
|
9237
9239
|
await releaseSessionReservations([prevTodo.sessionId], client);
|
|
9238
9240
|
}
|
|
9239
9241
|
openSessions.set(connectionId, {
|
|
9242
|
+
accountId: userId,
|
|
9240
9243
|
sessionId: opened.id,
|
|
9241
9244
|
taskId: null,
|
|
9242
9245
|
todoId: args.todo_id,
|
|
@@ -9380,6 +9383,7 @@ runTodoIdSource = null) {
|
|
|
9380
9383
|
// Record the new session in the registry keyed by connectionId, with a
|
|
9381
9384
|
// fresh activity-TTL (extended by every subsequent tool call).
|
|
9382
9385
|
openSessions.set(connectionId, {
|
|
9386
|
+
accountId: userId,
|
|
9383
9387
|
sessionId: session.id,
|
|
9384
9388
|
taskId: session.task_id,
|
|
9385
9389
|
expiresAt: Date.now() + SESSION_TTL_MS,
|
|
@@ -11034,7 +11038,7 @@ export async function releaseSessionReservations(sessionIds,
|
|
|
11034
11038
|
/** The client to use. The lifecycle helpers hold `toolsClient`; the tool
|
|
11035
11039
|
* handlers hold their own per-connection client and pass it, matching what
|
|
11036
11040
|
* they use for the session-status write immediately above each call. */
|
|
11037
|
-
client = toolsClient) {
|
|
11041
|
+
client = toolsClient, strict = false) {
|
|
11038
11042
|
if (!client || sessionIds.length === 0)
|
|
11039
11043
|
return;
|
|
11040
11044
|
try {
|
|
@@ -11049,6 +11053,8 @@ client = toolsClient) {
|
|
|
11049
11053
|
throw new Error(error.message);
|
|
11050
11054
|
}
|
|
11051
11055
|
catch (err) {
|
|
11056
|
+
if (strict)
|
|
11057
|
+
throw err;
|
|
11052
11058
|
/* NOTHING ABOVE THIS, following `noteDroppedActivity` and the session
|
|
11053
11059
|
helpers. The session is already ended, which is the fact the product
|
|
11054
11060
|
reads; an unreleased lease degrades to exactly the pre-slice behaviour
|
|
@@ -11366,64 +11372,49 @@ export async function noteDroppedActivity(todoId) {
|
|
|
11366
11372
|
console.warn(`recording a dropped activity write failed: ${errorMessage(err)}`);
|
|
11367
11373
|
}
|
|
11368
11374
|
}
|
|
11369
|
-
/**
|
|
11370
|
-
export async function
|
|
11371
|
-
const
|
|
11372
|
-
|
|
11373
|
-
it is scoped to the CONNECTION rather than to the session. The connection
|
|
11374
|
-
is gone, so nothing can produce anything else under it. */
|
|
11375
|
-
lastSessionByConnection.delete(connectionId);
|
|
11376
|
-
if (!toolsClient || !s) {
|
|
11377
|
-
openSessions.delete(connectionId);
|
|
11375
|
+
/** Flush only the authenticated account's durable cleanup, using its captured client. */
|
|
11376
|
+
export async function reconcileMcpCleanup(client, accountId) {
|
|
11377
|
+
const sessionIds = pendingMcpCleanup(accountId);
|
|
11378
|
+
if (!sessionIds.length)
|
|
11378
11379
|
return;
|
|
11379
|
-
}
|
|
11380
|
+
const { error } = await client.from('cliv2_agent_sessions').update({ status: 'ended' }).in('id', sessionIds);
|
|
11381
|
+
if (error)
|
|
11382
|
+
throw error;
|
|
11383
|
+
await releaseSessionReservations(sessionIds, client, true);
|
|
11384
|
+
acknowledgeMcpCleanup(accountId, sessionIds);
|
|
11385
|
+
}
|
|
11386
|
+
/** Retire the local connection immediately; cloud cleanup survives authentication loss. */
|
|
11387
|
+
export async function endConnectionSession(connectionId) {
|
|
11388
|
+
const client = toolsClient;
|
|
11389
|
+
const session = openSessions.get(connectionId);
|
|
11390
|
+
if (session)
|
|
11391
|
+
deferMcpCleanup([session]);
|
|
11380
11392
|
openSessions.delete(connectionId);
|
|
11381
|
-
|
|
11382
|
-
|
|
11383
|
-
|
|
11384
|
-
|
|
11385
|
-
|
|
11386
|
-
|
|
11387
|
-
|
|
11388
|
-
|
|
11389
|
-
catch (err) {
|
|
11390
|
-
console.warn(`ending session on connection close failed: ${err.message}`);
|
|
11391
|
-
}
|
|
11392
|
-
/* 18c SLICE 9 (GAP 15), ROUTE 4a. THE CONNECTION IS GONE, so nothing can
|
|
11393
|
-
ever release these leases by asking. This is what covers every orchestrator
|
|
11394
|
-
finish path: they all end the agent PROCESS, and a dead process's MCP
|
|
11395
|
-
connection closes here. See `releaseSessionReservations` for why the fix
|
|
11396
|
-
hangs off the session rather than off those ten callers. */
|
|
11397
|
-
await releaseSessionReservations([s.sessionId]);
|
|
11398
|
-
}
|
|
11399
|
-
/** Mark ALL open sessions ended and clear the registry (server stop / logout). */
|
|
11400
|
-
export async function endAllOpenSessions() {
|
|
11401
|
-
if (openSessions.size === 0) {
|
|
11402
|
-
return;
|
|
11393
|
+
lastSessionByConnection.delete(connectionId);
|
|
11394
|
+
if (client && session) {
|
|
11395
|
+
try {
|
|
11396
|
+
await reconcileMcpCleanup(client, session.accountId);
|
|
11397
|
+
}
|
|
11398
|
+
catch {
|
|
11399
|
+
console.warn('Session cleanup is waiting for its original account to reconnect.');
|
|
11400
|
+
}
|
|
11403
11401
|
}
|
|
11404
|
-
|
|
11402
|
+
}
|
|
11403
|
+
export async function endAllOpenSessions(client = toolsClient) {
|
|
11404
|
+
const sessions = [...openSessions.values()];
|
|
11405
|
+
deferMcpCleanup(sessions);
|
|
11405
11406
|
openSessions.clear();
|
|
11406
|
-
/* 18c Slice 6: server stop or logout. Every connection is going away, so no
|
|
11407
|
-
later write can legitimately attribute to any of these. */
|
|
11408
11407
|
lastSessionByConnection.clear();
|
|
11409
|
-
if (!
|
|
11408
|
+
if (!client)
|
|
11410
11409
|
return;
|
|
11411
|
-
|
|
11412
|
-
|
|
11413
|
-
|
|
11414
|
-
|
|
11415
|
-
|
|
11416
|
-
|
|
11417
|
-
|
|
11418
|
-
}
|
|
11419
|
-
catch (err) {
|
|
11420
|
-
console.warn(`ending all open sessions failed: ${err.message}`);
|
|
11410
|
+
for (const accountId of new Set(sessions.map(session => session.accountId))) {
|
|
11411
|
+
try {
|
|
11412
|
+
await reconcileMcpCleanup(client, accountId);
|
|
11413
|
+
}
|
|
11414
|
+
catch {
|
|
11415
|
+
console.warn('Session cleanup is waiting for its original account to reconnect.');
|
|
11416
|
+
}
|
|
11421
11417
|
}
|
|
11422
|
-
/* 18c SLICE 9 (GAP 15), ROUTE 4b. Server stop or logout: every session is
|
|
11423
|
-
over at once, so every lease any of them holds goes with them. On logout in
|
|
11424
|
-
particular, leaving them active would have the next sign-in's first run
|
|
11425
|
-
collide with an account that is no longer even connected. */
|
|
11426
|
-
await releaseSessionReservations(sessionIds);
|
|
11427
11418
|
}
|
|
11428
11419
|
async function readJsonBody(req) {
|
|
11429
11420
|
const chunks = [];
|
|
@@ -11537,7 +11528,7 @@ export async function startToolsServer(deps) {
|
|
|
11537
11528
|
// clear session.json — it can't reach this server's in-memory session. So
|
|
11538
11529
|
// every request (including ones on an already-open MCP connection) re-checks
|
|
11539
11530
|
// the on-disk session; once it's gone, nothing is served for the old account.
|
|
11540
|
-
if (!
|
|
11531
|
+
if (!clientSessionCurrent(deps.client)) {
|
|
11541
11532
|
res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Logged out');
|
|
11542
11533
|
return;
|
|
11543
11534
|
}
|
|
@@ -11563,6 +11554,12 @@ export async function startToolsServer(deps) {
|
|
|
11563
11554
|
await existing.handleRequest(req, res);
|
|
11564
11555
|
return;
|
|
11565
11556
|
}
|
|
11557
|
+
if (sessionId) {
|
|
11558
|
+
// MCP clients reinitialize after a lost session only when it returns 404.
|
|
11559
|
+
sessionTodoIds.delete(sessionId);
|
|
11560
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' }).end('Session not found');
|
|
11561
|
+
return;
|
|
11562
|
+
}
|
|
11566
11563
|
if (req.method !== 'POST') {
|
|
11567
11564
|
res.writeHead(400, { 'Content-Type': 'text/plain' }).end('Bad Request: no valid session ID provided');
|
|
11568
11565
|
return;
|
|
@@ -11668,9 +11665,11 @@ export async function stopToolsServer() {
|
|
|
11668
11665
|
// D2b: flip every open work session to ended (this needs toolsClient) BEFORE
|
|
11669
11666
|
// dropping the client reference, so the board's "working" chips clear on server
|
|
11670
11667
|
// stop / logout; then release the client alongside the reg-status resets.
|
|
11671
|
-
|
|
11672
|
-
toolsClient = null;
|
|
11668
|
+
const client = toolsClient;
|
|
11673
11669
|
await h.close();
|
|
11670
|
+
void endAllOpenSessions(client).catch(error => console.warn(`Session cleanup remains incomplete: ${String(error)}`));
|
|
11671
|
+
if (toolsClient === client)
|
|
11672
|
+
toolsClient = null;
|
|
11674
11673
|
}
|
|
11675
11674
|
/** Whether the tools server is currently listening, and its fixed port. */
|
|
11676
11675
|
export function toolsServerStatus() {
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"sourceHash":"e817b52cb4cf445046d83c9d4a6a7f8e79624f498564a3e40cb66d575ed4d63a","compiler":"Apple clang version 21.0.0 (clang-2100.1.1.101)\nTarget: arm64-apple-darwin25.3.0\nThread model: posix\nInstalledDir: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin\n","binaryHash":"25d9d65d0ee5718ddb8f54a08fa5000b9c73508f6eb532f085e812b249adf9bb"}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/* The launchd job is the execution owner; this helper only reads and signals
|
|
2
|
+
* its kernel resource coalition. No privilege or runtime compiler is required.
|
|
3
|
+
* Apple exports these SPI but omits their structures from the public SDK.
|
|
4
|
+
* Check every ABI size/capability, and fail closed when unavailable.
|
|
5
|
+
* Sources: apple-oss-distributions/xnu: bsd/sys/proc_info_private.h,
|
|
6
|
+
* osfmk/mach/coalition.h, osfmk/kern/coalition.c, bsd/kern/sys_coalition.c.
|
|
7
|
+
*/
|
|
8
|
+
#include <libproc.h>
|
|
9
|
+
#include <sys/sysctl.h>
|
|
10
|
+
#include <dlfcn.h>
|
|
11
|
+
#include <errno.h>
|
|
12
|
+
#include <inttypes.h>
|
|
13
|
+
#include <signal.h>
|
|
14
|
+
#include <stdio.h>
|
|
15
|
+
#include <stdlib.h>
|
|
16
|
+
#include <string.h>
|
|
17
|
+
#include <time.h>
|
|
18
|
+
#include <unistd.h>
|
|
19
|
+
|
|
20
|
+
struct coalition_info { uint64_t ids[2], reserved[3]; };
|
|
21
|
+
struct unique_info { uint8_t uuid[16]; uint64_t unique, parent; int32_t version, parent_version; uint64_t reserved[2]; };
|
|
22
|
+
struct process_info { struct proc_bsdinfo bsd; struct unique_info unique; };
|
|
23
|
+
_Static_assert(sizeof(struct coalition_info) == 40, "coalition ABI");
|
|
24
|
+
_Static_assert(sizeof(struct unique_info) == 56, "process ABI");
|
|
25
|
+
static int (*resource_usage)(uint64_t, void *, size_t);
|
|
26
|
+
static int (*signal_token)(audit_token_t *, int);
|
|
27
|
+
static char boot[64];
|
|
28
|
+
|
|
29
|
+
static void fail(const char *message) { fprintf(stderr, "%s (OS error %d).\n", message, errno); exit(1); }
|
|
30
|
+
static void changed(void) { fputs("Mac execution membership changed during inspection.\n",stderr); exit(75); }
|
|
31
|
+
static uint64_t number(const char *text) {
|
|
32
|
+
char *end; errno=0; uint64_t value=strtoull(text,&end,10);
|
|
33
|
+
if(errno || !*text || *end || !value) fail("Invalid Mac execution identity");
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
static int coalition(pid_t pid, uint64_t *id) {
|
|
37
|
+
struct coalition_info info={0}; int n=proc_pidinfo(pid,20,0,&info,sizeof(info));
|
|
38
|
+
if(n==sizeof(info)) { *id=info.ids[0]; return 1; }
|
|
39
|
+
if(n==0 && (errno==ESRCH || errno==ENOENT)) return 0;
|
|
40
|
+
fail("Mac execution ownership cannot be inspected"); return 0;
|
|
41
|
+
}
|
|
42
|
+
static int process_info(pid_t pid, struct process_info *info) {
|
|
43
|
+
int n=proc_pidinfo(pid,18,0,info,sizeof(*info));
|
|
44
|
+
if(n==sizeof(*info)) return 1;
|
|
45
|
+
if(n==0 && (errno==ESRCH || errno==ENOENT)) return 0;
|
|
46
|
+
fail("Mac process lifetime cannot be inspected"); return 0;
|
|
47
|
+
}
|
|
48
|
+
static int active(uint64_t id, uint64_t *count) {
|
|
49
|
+
uint64_t usage[2]={0};
|
|
50
|
+
if(resource_usage(id,usage,sizeof(usage))!=0) {
|
|
51
|
+
if(errno==ESRCH) { *count=0; return 0; }
|
|
52
|
+
fail("Mac execution membership cannot be inspected");
|
|
53
|
+
}
|
|
54
|
+
if(usage[1]>usage[0]) fail("Invalid Mac execution membership");
|
|
55
|
+
*count=usage[0]-usage[1]; return 1;
|
|
56
|
+
}
|
|
57
|
+
static void capability(void) {
|
|
58
|
+
resource_usage=dlsym(RTLD_DEFAULT,"coalition_info_resource_usage");
|
|
59
|
+
signal_token=dlsym(RTLD_DEFAULT,"proc_signal_with_audittoken");
|
|
60
|
+
size_t size=sizeof(boot);
|
|
61
|
+
if(!resource_usage || !signal_token || sysctlbyname("kern.bootsessionuuid",boot,&size,NULL,0)!=0 || size<36 || size>sizeof(boot))
|
|
62
|
+
fail("This Mac cannot verify safe agent execution; no agent was started");
|
|
63
|
+
uint64_t id, count; struct process_info info={0};
|
|
64
|
+
if(!coalition(getpid(),&id) || !process_info(getpid(),&info) || !active(id,&count))
|
|
65
|
+
fail("This Mac cannot verify safe agent execution; no agent was started");
|
|
66
|
+
}
|
|
67
|
+
static void same_boot(const char *expected) {
|
|
68
|
+
if(strcmp(expected,boot)) fail("Mac execution belongs to a different boot");
|
|
69
|
+
}
|
|
70
|
+
static void birth(struct process_info *info, char *output, size_t capacity) {
|
|
71
|
+
time_t seconds=(time_t)info->bsd.pbi_start_tvsec; struct tm tm; char date[32];
|
|
72
|
+
gmtime_r(&seconds,&tm); strftime(date,sizeof(date),"%Y-%m-%dT%H:%M:%S",&tm);
|
|
73
|
+
snprintf(output,capacity,"%s.%06"PRIu64"Z",date,info->bsd.pbi_start_tvusec);
|
|
74
|
+
}
|
|
75
|
+
int main(int argc,char **argv) {
|
|
76
|
+
capability();
|
|
77
|
+
if(argc==5 && !strcmp(argv[1],"signal-process")) {
|
|
78
|
+
same_boot(argv[2]); pid_t pid=(pid_t)number(argv[3]); struct process_info info={0}; char started[64];
|
|
79
|
+
if(!process_info(pid,&info)) return 0;
|
|
80
|
+
birth(&info,started,sizeof(started));
|
|
81
|
+
if(strcmp(started,argv[4])) return 0;
|
|
82
|
+
if(info.bsd.pbi_uid!=getuid()) fail("Mac execution belongs to another user");
|
|
83
|
+
audit_token_t token={{0}}; token.val[5]=(uint32_t)pid; token.val[7]=(uint32_t)info.unique.version;
|
|
84
|
+
if(signal_token(&token,SIGKILL)!=0 && errno!=ESRCH) fail("Owned Mac process could not be stopped");
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
if(argc==3 && !strcmp(argv[1],"process")) {
|
|
88
|
+
pid_t pid=(pid_t)number(argv[2]); uint64_t id; struct process_info info={0};
|
|
89
|
+
if(!process_info(pid,&info) || !coalition(pid,&id)) { puts("null"); return 0; }
|
|
90
|
+
if(info.bsd.pbi_uid!=getuid()) fail("Mac execution belongs to another user");
|
|
91
|
+
time_t seconds=(time_t)info.bsd.pbi_start_tvsec; struct tm tm; char date[32];
|
|
92
|
+
gmtime_r(&seconds,&tm); strftime(date,sizeof(date),"%Y-%m-%dT%H:%M:%S",&tm);
|
|
93
|
+
printf("{\"id\":\"%"PRIu64"\",\"boot\":\"%s\",\"pid\":%d,\"ppid\":%u,\"pgid\":%u,\"owner\":%u,\"birth\":\"%s.%06"PRIu64"Z\",\"version\":%u}\n",id,boot,pid,info.bsd.pbi_ppid,info.bsd.pbi_pgid,getuid(),date,info.bsd.pbi_start_tvusec,(uint32_t)info.unique.version); return 0;
|
|
94
|
+
}
|
|
95
|
+
int all=argc==2 && !strcmp(argv[1],"all");
|
|
96
|
+
if(!all && argc<4) fail("Invalid Mac execution helper command");
|
|
97
|
+
uint64_t id=all?0:number(argv[2]);
|
|
98
|
+
// These one-shot jobs have no future-login plist. A changed kernel boot also
|
|
99
|
+
// proves every member and queued launch from the recorded boot has ended.
|
|
100
|
+
if(argc==4 && !strcmp(argv[1],"inspect") && strcmp(argv[3],boot)) { puts("{\"exists\":false,\"active\":0,\"members\":[]}"); return 0; }
|
|
101
|
+
if(!all) same_boot(argv[3]);
|
|
102
|
+
if(argc==7 && !strcmp(argv[1],"signal")) {
|
|
103
|
+
pid_t pid=(pid_t)number(argv[4]); uint64_t version=number(argv[5]), actual;
|
|
104
|
+
int signal=!strcmp(argv[6],"TERM")?SIGTERM:!strcmp(argv[6],"KILL")?SIGKILL:0;
|
|
105
|
+
if(!signal) fail("Invalid Mac execution signal");
|
|
106
|
+
struct process_info info={0};
|
|
107
|
+
if(!coalition(pid,&actual) || actual!=id || !process_info(pid,&info) || (uint32_t)info.unique.version!=version) return 0;
|
|
108
|
+
if(info.bsd.pbi_uid!=getuid()) fail("Mac execution belongs to another user");
|
|
109
|
+
audit_token_t token={{0}}; token.val[5]=(uint32_t)pid; token.val[7]=(uint32_t)version;
|
|
110
|
+
// The kernel compares the PID version while holding the process reference;
|
|
111
|
+
// a reused PID cannot receive this signal.
|
|
112
|
+
if(signal_token(&token,signal)!=0 && errno!=ESRCH) fail("Owned Mac execution could not be stopped");
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
if(!all && (argc!=4 || strcmp(argv[1],"inspect"))) fail("Invalid Mac execution helper command");
|
|
116
|
+
uint64_t count=1; int exists=all?1:active(id,&count);
|
|
117
|
+
if(!exists || !count) { printf("{\"exists\":%s,\"active\":0,\"members\":[]}\n",exists?"true":"false"); return 0; }
|
|
118
|
+
int capacity=proc_listpids(PROC_UID_ONLY,getuid(),NULL,0)/(int)sizeof(pid_t)+1024;
|
|
119
|
+
if(capacity<=1024 || capacity>1000000) fail("Mac process enumeration is unavailable");
|
|
120
|
+
pid_t *pids=calloc((size_t)capacity,sizeof(pid_t));
|
|
121
|
+
struct process_info *rows=calloc((size_t)capacity,sizeof(struct process_info));
|
|
122
|
+
if(!pids || !rows) fail("Mac process enumeration could not allocate memory");
|
|
123
|
+
int bytes=proc_listpids(PROC_UID_ONLY,getuid(),pids,capacity*(int)sizeof(pid_t));
|
|
124
|
+
if(bytes<0 || bytes%(int)sizeof(pid_t)) fail("Mac process enumeration is unavailable");
|
|
125
|
+
int total=bytes/(int)sizeof(pid_t), found=0;
|
|
126
|
+
if(total<0 || total>=capacity) changed();
|
|
127
|
+
for(int i=0;i<total;i++) {
|
|
128
|
+
uint64_t candidate; if(pids[i]<=0 || (!all && (!coalition(pids[i],&candidate) || candidate!=id))) continue;
|
|
129
|
+
struct process_info row={0}, after={0};
|
|
130
|
+
if(!process_info(pids[i],&row) || (!all && (!coalition(pids[i],&candidate) || candidate!=id)) || !process_info(pids[i],&after)) continue;
|
|
131
|
+
if(row.unique.version!=after.unique.version) continue;
|
|
132
|
+
if(row.bsd.pbi_uid!=getuid()) fail("Mac execution membership includes a different user");
|
|
133
|
+
rows[found++]=row;
|
|
134
|
+
}
|
|
135
|
+
if(all) count=(uint64_t)found; else exists=active(id,&count);
|
|
136
|
+
// A new task missing from the snapshot is uncertainty, never empty success.
|
|
137
|
+
if(count>(uint64_t)found) changed();
|
|
138
|
+
printf("{\"exists\":%s,\"active\":%"PRIu64",\"members\":[",exists?"true":"false",count);
|
|
139
|
+
for(int i=0;count && i<found;i++) {
|
|
140
|
+
struct process_info *row=&rows[i]; time_t seconds=(time_t)row->bsd.pbi_start_tvsec; struct tm tm; char date[32];
|
|
141
|
+
gmtime_r(&seconds,&tm); strftime(date,sizeof(date),"%Y-%m-%dT%H:%M:%S",&tm);
|
|
142
|
+
printf("%s{\"pid\":%u,\"ppid\":%u,\"pgid\":%u,\"owner\":\"%u\",\"birth\":\"%s.%06"PRIu64"Z\",\"version\":%u}",i?",":"",row->bsd.pbi_pid,row->bsd.pbi_ppid,row->bsd.pbi_pgid,row->bsd.pbi_uid,date,row->bsd.pbi_start_tvusec,(uint32_t)row->unique.version);
|
|
143
|
+
}
|
|
144
|
+
puts("]}"); free(rows); free(pids); return 0;
|
|
145
|
+
}
|