@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/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 { getClient } from './supabase.js';
9
- import { foreignToolsServerAlive, claudeRegisteredOnDisk, codexRegisteredOnDisk } from './mcp.js';
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 status Show the whole setup state and the one next step to take
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 id = getMachineIdentity();
39
+ const machine = getMachineIdentity();
42
40
  const installed = detectAgents();
43
- /* Signed-out is a FILE question, not a network one. getClient() throws
44
- NotLoggedIn whenever setSession fails FOR ANY REASON, including a network
45
- failure (supabase.ts), so trusting it would tell a signed-in user on bad
46
- wifi to go and sign in again the confident-wrong instruction this feature
47
- exists to kill. readSession() returning null is the only true "signed out". */
48
- const stored = readSession();
49
- let signedIn = false;
50
- let email = null;
51
- let sessionProblem = null;
52
- if (stored) {
53
- try {
54
- const client = await getClient();
55
- const { data } = await client.auth.getUser();
56
- email = data.user?.email ?? null;
57
- signedIn = true;
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
- catch (err) {
60
- /* The REASON only. getClient()'s NotLoggedIn message ends with its own
61
- "Run `cs login` again.", which would print twice in the step below. */
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
- else if (unregistered.length) {
98
- console.log(`The tools are being served but are not registered into ${unregistered.join(' or ')}. Restart the`);
99
- console.log('background daemon, then run `cs status` again.');
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
- console.log('Tools are registered. If this agent cannot see them, restart it. An agent picks');
103
- console.log('up MCP servers only when it starts.');
104
- return;
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': return runDaemon();
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
- console.log(clearSession() ? 'Signed out.' : 'Was not signed in.');
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 { writeSession, getMachineIdentity } from './config.js';
6
- import { getClient } from './supabase.js';
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 client = await getClient();
86
- const { data, error } = await client.auth.getUser();
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: data.user.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, readSession } from './config.js';
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
- /** Mark ONE connection's open session ended (connection closed). Best-effort. */
11370
- export async function endConnectionSession(connectionId) {
11371
- const s = openSessions.get(connectionId);
11372
- /* 18c Slice 6: the remembered session is cleared HERE and only here, because
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
- try {
11382
- const { error } = await toolsClient
11383
- .from('cliv2_agent_sessions')
11384
- .update({ status: 'ended' })
11385
- .eq('id', s.sessionId);
11386
- if (error)
11387
- throw error;
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
- const sessionIds = [...openSessions.values()].map((s) => s.sessionId);
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 (!toolsClient)
11408
+ if (!client)
11410
11409
  return;
11411
- try {
11412
- const { error } = await toolsClient
11413
- .from('cliv2_agent_sessions')
11414
- .update({ status: 'ended' })
11415
- .in('id', sessionIds);
11416
- if (error)
11417
- throw error;
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 (!readSession()) {
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
- await endAllOpenSessions();
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
+ }