@bridge4dev/runner 0.65.0 → 0.66.0

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.
@@ -1,6 +1,7 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
2
  import { assertClaudeInstalled, claudeExecutableOption } from './agent-binary.js';
3
3
  import { scrubbedEnv } from './adapters/claude.js';
4
+ import { activeAccountHome, withAccountHome } from './claude-homes.js';
4
5
  import { gitBranchDiff } from './gitops.js';
5
6
  import { isSecretPath, maskString } from './policy.js';
6
7
  /**
@@ -160,7 +161,9 @@ export async function proposeCommitMessage(input, queryFn = query) {
160
161
  prompt: buildPrompt(input, diff),
161
162
  options: {
162
163
  cwd: input.worktreePath,
163
- env: scrubbedEnv(),
164
+ // The machine's active account (#422), with the same rule as a session:
165
+ // its home, and no operator token above it.
166
+ env: withAccountHome(scrubbedEnv(), activeAccountHome()),
164
167
  // The second place Claude `Options` are built. It must follow the same
165
168
  // constant, or after C3 commit messages would still be written by the
166
169
  // bundled binary while sessions had moved to the system one.
package/dist/config.d.ts CHANGED
@@ -15,12 +15,12 @@ declare const ConfigSchema: z.ZodObject<{
15
15
  name: z.ZodString;
16
16
  token: z.ZodString;
17
17
  }, "strip", z.ZodTypeAny, {
18
- name: string;
19
18
  id: string;
19
+ name: string;
20
20
  token: string;
21
21
  }, {
22
- name: string;
23
22
  id: string;
23
+ name: string;
24
24
  token: string;
25
25
  }>;
26
26
  mcp: z.ZodOptional<z.ZodObject<{
@@ -179,10 +179,13 @@ declare const ConfigSchema: z.ZodObject<{
179
179
  ws_url: string;
180
180
  };
181
181
  server: {
182
- name: string;
183
182
  id: string;
183
+ name: string;
184
184
  token: string;
185
185
  };
186
+ codex?: {
187
+ auth: "link" | "own";
188
+ } | undefined;
186
189
  checkpoints?: {
187
190
  enabled: boolean;
188
191
  } | undefined;
@@ -190,8 +193,8 @@ declare const ConfigSchema: z.ZodObject<{
190
193
  url: string;
191
194
  token: string;
192
195
  } | undefined;
193
- codex?: {
194
- auth: "link" | "own";
196
+ agents?: {
197
+ install_enabled: boolean;
195
198
  } | undefined;
196
199
  limits?: {
197
200
  max_sessions?: number | undefined;
@@ -204,19 +207,19 @@ declare const ConfigSchema: z.ZodObject<{
204
207
  verify?: {
205
208
  enabled: boolean;
206
209
  } | undefined;
207
- agents?: {
208
- install_enabled: boolean;
209
- } | undefined;
210
210
  }, {
211
211
  api: {
212
212
  url: string;
213
213
  ws_url: string;
214
214
  };
215
215
  server: {
216
- name: string;
217
216
  id: string;
217
+ name: string;
218
218
  token: string;
219
219
  };
220
+ codex?: {
221
+ auth?: "link" | "own" | undefined;
222
+ } | undefined;
220
223
  checkpoints?: {
221
224
  enabled?: boolean | undefined;
222
225
  } | undefined;
@@ -224,8 +227,8 @@ declare const ConfigSchema: z.ZodObject<{
224
227
  url: string;
225
228
  token: string;
226
229
  } | undefined;
227
- codex?: {
228
- auth?: "link" | "own" | undefined;
230
+ agents?: {
231
+ install_enabled?: boolean | undefined;
229
232
  } | undefined;
230
233
  limits?: {
231
234
  max_sessions?: number | undefined;
@@ -238,9 +241,6 @@ declare const ConfigSchema: z.ZodObject<{
238
241
  verify?: {
239
242
  enabled?: boolean | undefined;
240
243
  } | undefined;
241
- agents?: {
242
- install_enabled?: boolean | undefined;
243
- } | undefined;
244
244
  }>;
245
245
  export type RunnerConfig = z.infer<typeof ConfigSchema>;
246
246
  export declare function loadConfig(): RunnerConfig | null;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The one way out of the daemon process (#431).
3
+ *
4
+ * Its own module, and not four lines inside `cmdDaemon`, for one reason: the
5
+ * defect this ticket is about WAS those four lines. `supervisor.shutdown();
6
+ * ws.stop(); process.exit(0);` in a single tick killed one process per session
7
+ * and left everything that process had started running inside a cgroup systemd
8
+ * would then refuse to reuse by name. Nothing in the suite could see that,
9
+ * because no test in this package imports `index.ts` — found by the independent
10
+ * review of #431, which also pointed out that the next edit to that file would
11
+ * be just as invisible.
12
+ *
13
+ * So the order is code with a test on it rather than a comment:
14
+ *
15
+ * 1. **`shutdown()`** — its synchronous half runs before it returns: the cards
16
+ * a person was still being asked are withdrawn, the line saying what this
17
+ * restart costs goes into the journal, the agents are killed.
18
+ * 2. **the socket is closed** — after the events above needed it open, and
19
+ * before the wait below, which must not be a window in which an `event_ack`
20
+ * or a `session_start` reaches a supervisor that has let go of everything.
21
+ * 3. **the promise is awaited** — the cages, and nothing else. What does not
22
+ * finish inside the supervisor's own budget continues on systemd's side:
23
+ * the stop has been accepted by then.
24
+ * 4. **`exit`**, always — including when the wait ends in a rejection. A
25
+ * daemon that would not die because a bus call failed is worse than one
26
+ * that leaves a scope behind.
27
+ */
28
+ export interface DaemonExitDeps {
29
+ /** The supervisor's shutdown. The synchronous half must run before it returns. */
30
+ shutdown: () => Promise<void>;
31
+ /** Close the socket, both directions. */
32
+ stopWs: () => void;
33
+ /** Write the status file, so `doctor` sees a daemon that is going down. */
34
+ noteStatus?: () => void;
35
+ /** End the process. Injected so the order above can be proved. */
36
+ exit: (code: number) => void;
37
+ }
38
+ /** Build the exit door. The returned function is safe to call more than once. */
39
+ export declare function daemonExit(deps: DaemonExitDeps): (code: number) => void;
40
+ //# sourceMappingURL=daemon-exit.d.ts.map
@@ -0,0 +1,29 @@
1
+ import { log } from './log.js';
2
+ /** Build the exit door. The returned function is safe to call more than once. */
3
+ export function daemonExit(deps) {
4
+ let leaving = false;
5
+ return (code) => {
6
+ /**
7
+ * A second signal does not shorten the first departure.
8
+ *
9
+ * Two `SIGTERM`s, or «Update runner» racing a restart command, used to run
10
+ * the whole thing twice: a second `verify.shutdown()` signalling a group
11
+ * that is already dying, and an `exit` from the second call cutting the
12
+ * first call's wait for its cages — the very wait this door exists for.
13
+ */
14
+ if (leaving) {
15
+ log.warn('daemon: already leaving, this second request changes nothing', { code });
16
+ return;
17
+ }
18
+ leaving = true;
19
+ const cages = deps.shutdown();
20
+ deps.stopWs();
21
+ deps.noteStatus?.();
22
+ void cages
23
+ .catch((error) => {
24
+ log.warn('daemon: the cages did not all stop cleanly', { error: String(error) });
25
+ })
26
+ .finally(() => deps.exit(code));
27
+ };
28
+ }
29
+ //# sourceMappingURL=daemon-exit.js.map
package/dist/index.js CHANGED
@@ -15,6 +15,9 @@ import { loadConfig, mergeIntoPairedConfig, requireConfig, saveConfig, } from '.
15
15
  import { log } from './log.js';
16
16
  import { installIsWritable, installPrefixFor, isSupervisedProcess, restartCapability, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
17
17
  import { applyStoredClaudeToken } from './agent-auth.js';
18
+ import { discardAbandonedStagingHomes, recoverInterruptedSwaps, refreshAccountIdentity, } from './claude-homes.js';
19
+ import { ACCOUNT_AGENTS, ACCOUNT_COMMANDS } from './account-commands.js';
20
+ import { MACHINE_ACCOUNT_ID } from './login-marks.js';
18
21
  import { Supervisor } from './supervisor.js';
19
22
  import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
20
23
  import { RunnerWsClient } from './ws-client.js';
@@ -25,6 +28,7 @@ import { buildUnit, cpuQuotaPercent, devbridgeSliceOverridePath, limitsOverrideI
25
28
  import { defaultCageProbe, initSessionCage, readSliceLimits, listSessionScopes, sessionCage, sweepOrphanSessionScopes, SESSION_TASKS_MAX, } from './session-cage.js';
26
29
  import { SEARCH_GUARD_ENABLED, claudeSettingsPath, installSearchGuard, removeSearchGuard, searchGuardCommand, searchGuardHome, searchGuardHookPath, searchGuardStatus, } from './claude-settings.js';
27
30
  import { readOomKills, recordCrash, takeLastExit } from './crash-note.js';
31
+ import { daemonExit } from './daemon-exit.js';
28
32
  import { agentAuthStatuses } from './auth-relay.js';
29
33
  import { addSafeDirectory, agentConfigContour, dockerCheck, ensureAgentPath, firstUnreachableAncestor, hasSafeDirectory, inspectPath, knownWorkspacePaths, lingerEnabled, nodeCheck, otherHomeWithAgents, runnerIdentity, safeDirectoryCommand, systemctlHint, systemdUserBusReachable, } from './environment.js';
30
34
  import { mcpConfigDir } from './paths.js';
@@ -514,6 +518,13 @@ function runnerCapabilities(apiUrlOverride) {
514
518
  * `kind: 'oom'` is inferred from the cgroup counter — see `crash-note.ts`.
515
519
  */
516
520
  ...(LAST_EXIT ? { lastExit: LAST_EXIT } : {}),
521
+ /**
522
+ * #422 (R11): the agents this runner keeps several logins for. Static per
523
+ * version – the LIST of accounts is always asked with `agent_accounts`,
524
+ * never read from here (a snapshot taken at start, #395). The dashboard
525
+ * shows the account window only to an agent named here.
526
+ */
527
+ accounts: [...ACCOUNT_AGENTS],
517
528
  /** Commands beyond the stage-A/B set. */
518
529
  commands: [
519
530
  'purge_session',
@@ -577,6 +588,10 @@ function runnerCapabilities(apiUrlOverride) {
577
588
  // ANSWERS the command, with a sentence saying why — which is a better
578
589
  // outcome than a frame nobody replies to.
579
590
  'runner_restart',
591
+ // #422: list, sign in to, switch and forget agent logins (plan §8).
592
+ // Unconditional: they read and write only the runner's own state, and an
593
+ // agent they do not serve yet is answered with a sentence.
594
+ ...ACCOUNT_COMMANDS,
580
595
  ],
581
596
  };
582
597
  }
@@ -954,6 +969,31 @@ async function cmdDaemon() {
954
969
  if (applyStoredClaudeToken()) {
955
970
  log.info('daemon: using the Claude token stored on this server');
956
971
  }
972
+ /**
973
+ * #422: a swap of Claude homes the previous daemon died inside is finished or
974
+ * undone before anything can run under an account; and who the machine login
975
+ * is gets asked once, in the background (R14 c) – the verdict and the list read
976
+ * the answer, the minute poll never asks.
977
+ */
978
+ try {
979
+ recoverInterruptedSwaps();
980
+ }
981
+ catch (error) {
982
+ log.warn('daemon: could not finish an interrupted swap of Claude homes', {
983
+ error: String(error),
984
+ });
985
+ }
986
+ try {
987
+ // A sign-in the previous daemon accepted but never adopted (S2): nothing can
988
+ // be adopting before the supervisor exists.
989
+ discardAbandonedStagingHomes();
990
+ }
991
+ catch (error) {
992
+ log.warn('daemon: could not remove sign-in homes a previous daemon left', {
993
+ error: String(error),
994
+ });
995
+ }
996
+ void refreshAccountIdentity(MACHINE_ACCOUNT_ID).catch(() => undefined);
957
997
  const agents = installedAgents();
958
998
  const ws = new RunnerWsClient(config.api.ws_url, config.server.token, runnerCapabilities());
959
999
  const codex = agents.includes('codex') ? bootstrapCodex(config) : null;
@@ -1019,11 +1059,7 @@ async function cmdDaemon() {
1019
1059
  from: outcome.fromVersion,
1020
1060
  to: outcome.toVersion ?? 'unknown',
1021
1061
  });
1022
- const timer = setTimeout(() => {
1023
- supervisor.shutdown();
1024
- ws.stop();
1025
- process.exit(0);
1026
- }, RESTART_DELAY_MS);
1062
+ const timer = setTimeout(() => leave(0), RESTART_DELAY_MS);
1027
1063
  timer.unref();
1028
1064
  },
1029
1065
  // #396: the same exit, asked for directly rather than as the tail of an
@@ -1035,11 +1071,7 @@ async function cmdDaemon() {
1035
1071
  sessions: supervisor.activeSessionIds.length,
1036
1072
  told: Boolean(note),
1037
1073
  });
1038
- const timer = setTimeout(() => {
1039
- supervisor.shutdown();
1040
- ws.stop();
1041
- process.exit(0);
1042
- }, RESTART_DELAY_MS);
1074
+ const timer = setTimeout(() => leave(0), RESTART_DELAY_MS);
1043
1075
  timer.unref();
1044
1076
  },
1045
1077
  });
@@ -1052,13 +1084,21 @@ async function cmdDaemon() {
1052
1084
  activeSessionIds: supervisor.activeSessionIds,
1053
1085
  updatedAt: new Date().toISOString(),
1054
1086
  });
1087
+ /**
1088
+ * The one way out of this process — the order lives in `daemon-exit.ts`,
1089
+ * where a test can hold it (#431).
1090
+ */
1091
+ const leave = daemonExit({
1092
+ shutdown: () => supervisor.shutdown(),
1093
+ stopWs: () => ws.stop(),
1094
+ noteStatus: updateStatus,
1095
+ exit: (code) => process.exit(code),
1096
+ });
1055
1097
  ws.on('open', updateStatus);
1056
1098
  ws.on('close', updateStatus);
1057
1099
  ws.on('revoked', (reason) => {
1058
1100
  log.error(`daemon: access revoked (${reason}) — exiting`);
1059
- supervisor.shutdown();
1060
- updateStatus();
1061
- process.exit(3);
1101
+ leave(3);
1062
1102
  });
1063
1103
  const statusTimer = setInterval(updateStatus, 30_000);
1064
1104
  statusTimer.unref();
@@ -1072,10 +1112,7 @@ async function cmdDaemon() {
1072
1112
  limitsTimer.unref();
1073
1113
  const shutdown = (signal) => {
1074
1114
  log.info(`daemon: ${signal} received, shutting down`);
1075
- supervisor.shutdown();
1076
- ws.stop();
1077
- updateStatus();
1078
- process.exit(0);
1115
+ leave(0);
1079
1116
  };
1080
1117
  process.on('SIGINT', () => shutdown('SIGINT'));
1081
1118
  process.on('SIGTERM', () => shutdown('SIGTERM'));
@@ -1381,6 +1418,15 @@ async function runnerChecks() {
1381
1418
  });
1382
1419
  return checks;
1383
1420
  }
1421
+ /** « · account <email> (saved)» – nothing for the machine login nobody named. */
1422
+ function accountLine(account) {
1423
+ if (!account)
1424
+ return '';
1425
+ if (account.id === MACHINE_ACCOUNT_ID) {
1426
+ return account.email ? ` · machine login ${account.email}` : '';
1427
+ }
1428
+ return ` · saved account ${account.email ?? account.id}`;
1429
+ }
1384
1430
  async function agentChecks() {
1385
1431
  const me = runnerIdentity();
1386
1432
  // The probes log a verdict line each, which belongs in the journal, not in
@@ -1442,8 +1488,12 @@ async function agentChecks() {
1442
1488
  checks.push({
1443
1489
  ok: signedIn,
1444
1490
  name: `${agent} login`,
1491
+ // Which account the verdict is about (#422): the same rule as the panel,
1492
+ // read from the same file, so doctor and the dashboard cannot answer about
1493
+ // two different logins on one machine.
1445
1494
  detail: (info.detail ?? info.status) +
1446
- (info.expiresAt ? ` · until ${formatLocalDay(info.expiresAt)}` : ''),
1495
+ (info.expiresAt ? ` · until ${formatLocalDay(info.expiresAt)}` : '') +
1496
+ accountLine(info.activeAccount),
1447
1497
  ...(signedIn
1448
1498
  ? {}
1449
1499
  : {
@@ -0,0 +1,31 @@
1
+ import type { RelayAgentName } from './agent-registry.js';
2
+ /**
3
+ * What the agent actually experienced with a sign-in — per agent AND per account.
4
+ *
5
+ * The credentials file cannot tell us the provider revoked a login: the refresh
6
+ * token still sits there, dated. The one authority on that is a real refusal
7
+ * from a real run — so a session that failed to authenticate demotes the file's
8
+ * verdict for a while, and any successful turn clears it again.
9
+ *
10
+ * Moved out of `auth-relay.ts` for #422 and keyed by account as well as agent.
11
+ * With several Claude logins on one machine «Claude was refused» stopped being
12
+ * a statement about anything: the session that was refused ran under ONE
13
+ * account, and the machine may have been switched to another since. The mark
14
+ * belongs to the account the session started under (R14) – which is why both
15
+ * the account list (`claude-homes.ts`) and the panel verdict (`auth-relay.ts`)
16
+ * read it from here rather than each keeping a copy.
17
+ *
18
+ * Process memory on purpose: no protocol, no disk. The consequence is worth
19
+ * naming — restarting the daemon forgets the refusal and the panel goes back
20
+ * to trusting the file until the next session tries. (A saved account also
21
+ * carries a mark on disk, D2 – see `markLoginExpired` in `claude-homes.ts`.)
22
+ */
23
+ /** The machine's own login – the one row every agent has. */
24
+ export declare const MACHINE_ACCOUNT_ID = "machine";
25
+ /** A session under this agent and account was just refused. */
26
+ export declare function noteRefusal(agent: RelayAgentName, accountId?: string): void;
27
+ /** The sign-in worked again. Answers whether a refusal was being held. */
28
+ export declare function clearRefusal(agent: RelayAgentName, accountId?: string): boolean;
29
+ /** Is a refusal still being held against this agent and account? */
30
+ export declare function refusalActive(agent: RelayAgentName, accountId?: string): boolean;
31
+ //# sourceMappingURL=login-marks.d.ts.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * What the agent actually experienced with a sign-in — per agent AND per account.
3
+ *
4
+ * The credentials file cannot tell us the provider revoked a login: the refresh
5
+ * token still sits there, dated. The one authority on that is a real refusal
6
+ * from a real run — so a session that failed to authenticate demotes the file's
7
+ * verdict for a while, and any successful turn clears it again.
8
+ *
9
+ * Moved out of `auth-relay.ts` for #422 and keyed by account as well as agent.
10
+ * With several Claude logins on one machine «Claude was refused» stopped being
11
+ * a statement about anything: the session that was refused ran under ONE
12
+ * account, and the machine may have been switched to another since. The mark
13
+ * belongs to the account the session started under (R14) – which is why both
14
+ * the account list (`claude-homes.ts`) and the panel verdict (`auth-relay.ts`)
15
+ * read it from here rather than each keeping a copy.
16
+ *
17
+ * Process memory on purpose: no protocol, no disk. The consequence is worth
18
+ * naming — restarting the daemon forgets the refusal and the panel goes back
19
+ * to trusting the file until the next session tries. (A saved account also
20
+ * carries a mark on disk, D2 – see `markLoginExpired` in `claude-homes.ts`.)
21
+ */
22
+ /** The machine's own login – the one row every agent has. */
23
+ export const MACHINE_ACCOUNT_ID = 'machine';
24
+ const AUTH_FAILURE_TTL_MS = 15 * 60_000;
25
+ const refusals = new Map();
26
+ const keyOf = (agent, accountId) => `${agent}:${accountId}`;
27
+ /** A session under this agent and account was just refused. */
28
+ export function noteRefusal(agent, accountId = MACHINE_ACCOUNT_ID) {
29
+ refusals.set(keyOf(agent, accountId), Date.now());
30
+ }
31
+ /** The sign-in worked again. Answers whether a refusal was being held. */
32
+ export function clearRefusal(agent, accountId = MACHINE_ACCOUNT_ID) {
33
+ return refusals.delete(keyOf(agent, accountId));
34
+ }
35
+ /** Is a refusal still being held against this agent and account? */
36
+ export function refusalActive(agent, accountId = MACHINE_ACCOUNT_ID) {
37
+ const key = keyOf(agent, accountId);
38
+ const at = refusals.get(key);
39
+ if (at === undefined)
40
+ return false;
41
+ if (Date.now() - at > AUTH_FAILURE_TTL_MS) {
42
+ refusals.delete(key);
43
+ return false;
44
+ }
45
+ return true;
46
+ }
47
+ //# sourceMappingURL=login-marks.js.map