@bridge4dev/runner 0.65.1 → 0.67.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,8 +1,37 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
- import { type AgentAdapter, type AgentQuestion, type AgentSession, type SessionSpec } from './types.js';
2
+ import { type AgentAdapter, type AgentMode, type AgentQuestion, type AgentSession, type SessionSpec } from './types.js';
3
+ import { type SessionAccount } from '../claude-homes.js';
3
4
  export declare function truncate(text: string, limit?: number): string;
4
5
  /** Exported for the one-shot commit-message run (session 14), same rules. */
5
6
  export declare function scrubbedEnv(): Record<string, string>;
7
+ /**
8
+ * The one env var the allowlist above refuses, added back for exactly one mode
9
+ * (ticket #156).
10
+ *
11
+ * The CLI's own guard, read out of the binary verbatim:
12
+ *
13
+ * ```js
14
+ * if (t === "bypassPermissions" || r) {
15
+ * if (typeof process.getuid === "function" && process.getuid() === 0
16
+ * && process.env.IS_SANDBOX !== "1" && !Z.CLAUDE_CODE_BUBBLEWRAP)
17
+ * console.error("--dangerously-skip-permissions cannot be used with root/sudo privileges …"),
18
+ * process.exit(1)
19
+ * }
20
+ * ```
21
+ *
22
+ * The runner's default install is root (`install.sh`), so on an ordinary
23
+ * machine «Unrestricted» did not degrade — it killed the session at launch with
24
+ * exit code 1. On the machine this was found on it happened to start, because
25
+ * `~/.claude/settings.json` carries `env: { IS_SANDBOX: "1" }` and this adapter
26
+ * loads user settings; that is a hole in the scrub covering a bug, not a fix.
27
+ *
28
+ * The condition here is the CLI's own, no wider: `full` AND uid 0. It is safe
29
+ * precisely where it is applied — `bypassPermissions` is the mode in which the
30
+ * SDK never calls `canUseTool` (it says so itself), so layer 1 is already inert
31
+ * and there is nothing left for `IS_SANDBOX` to weaken. In every other mode the
32
+ * scrub stands, which is what the comment on `ENV_ALLOWLIST` has always meant.
33
+ */
34
+ export declare function agentEnv(mode: AgentMode, sessionId: string, account?: Pick<SessionAccount, 'kind' | 'home'>): Record<string, string>;
6
35
  /**
7
36
  * The whole `AskUserQuestion` payload, not just its first line.
8
37
  *
@@ -13,6 +13,7 @@ import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './ra
13
13
  import { AgentTaskTray } from './agent-tasks.js';
14
14
  import { applyUsagePercentages, lastUsageRows, readUsageRows } from './claude-usage.js';
15
15
  import { assertClaudeInstalled, claudeExecutableOption, sessionClaudePath, } from '../agent-binary.js';
16
+ import { noteSessionAccount, refreshAccountIdentity, releaseSessionAccount, resolveSessionAccount, currentHomeOrgId, usageSignature, withAccountHome, } from '../claude-homes.js';
16
17
  /** Same 2KB the SDK keeps: enough for the CLI's last words, not a log sink. */
17
18
  const STDERR_TAIL_LIMIT = 2048;
18
19
  import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
@@ -101,8 +102,18 @@ export function scrubbedEnv() {
101
102
  * and there is nothing left for `IS_SANDBOX` to weaken. In every other mode the
102
103
  * scrub stands, which is what the comment on `ENV_ALLOWLIST` has always meant.
103
104
  */
104
- function agentEnv(mode, sessionId) {
105
- const env = scrubbedEnv();
105
+ export function agentEnv(mode, sessionId, account = { kind: 'machine', home: null }) {
106
+ /**
107
+ * The account's home (#422 S1 item 4) – HERE, in the session's environment, and
108
+ * never in the daemon's (К1): the daemon's environment is also what the verdict
109
+ * and `doctor` would read, and a variable there turns every verdict green.
110
+ *
111
+ * For a saved account the operator's `CLAUDE_CODE_OAUTH_TOKEN` comes OUT: the
112
+ * CLI ranks it above every login file, so with it left in, switching accounts
113
+ * would change nothing under a green verdict (К14). For the machine login the
114
+ * environment is exactly what it was before #422.
115
+ */
116
+ const env = withAccountHome(scrubbedEnv(), account);
106
117
  if (mode === 'full' && process.getuid?.() === 0)
107
118
  env['IS_SANDBOX'] = '1';
108
119
  /**
@@ -480,8 +491,30 @@ class ClaudeSession {
480
491
  this.beginTurnFacts();
481
492
  }
482
493
  events = this.output;
494
+ /**
495
+ * The account this process runs under, captured at its start and kept to its
496
+ * end (#422 R14, R16): the limits it measures, the refusal it may meet and the
497
+ * account its card names all belong to THIS account, whatever the machine is
498
+ * switched to meanwhile.
499
+ */
500
+ account;
501
+ accountId;
502
+ /** Started on the operator's token: its limits go out unsigned (see the constructor). */
503
+ onOperatorToken;
483
504
  constructor(spec, queryFn) {
484
505
  this.spec = spec;
506
+ const account = resolveSessionAccount();
507
+ // A machine-login session that runs on a TOKEN is not the account of the
508
+ // credentials file: `claude auth status` is asked without the token, so the
509
+ // identity on file names the file's login, and this session's card and its
510
+ // limits would carry somebody else's name (found by the independent check of
511
+ // S1). Such a session keeps only the row id – unsigned, as before #422.
512
+ this.onOperatorToken =
513
+ account.kind === 'machine' && Boolean(process.env['CLAUDE_CODE_OAUTH_TOKEN']);
514
+ this.account = this.onOperatorToken
515
+ ? { id: account.id, kind: account.kind, home: null }
516
+ : account;
517
+ this.accountId = this.account.id;
485
518
  // Refused rather than honoured, and BEFORE anything is built from it: on a
486
519
  // STRICT workspace `full` would launch the CLI in `bypassPermissions`, and
487
520
  // there layer 1 is never consulted — the manager's shield would be spent by
@@ -522,7 +555,7 @@ class ClaudeSession {
522
555
  this.mcpConfigFile = mcpConfigFile;
523
556
  const options = {
524
557
  cwd: spec.cwd,
525
- env: agentEnv(this.mode, spec.sessionId),
558
+ env: agentEnv(this.mode, spec.sessionId, this.account),
526
559
  // Empty while `USE_BUNDLED_CLAUDE` — the SDK keeps resolving its own
527
560
  // bundled binary, exactly as before. After C3 this pins the system
528
561
  // `claude`, which is the file the card measures and the button installs.
@@ -615,7 +648,17 @@ class ClaudeSession {
615
648
  ? { mcpServers }
616
649
  : {}),
617
650
  };
618
- this.q = queryFn({ prompt: this.input, options });
651
+ // Registered before the process exists, released when it is gone
652
+ // (`consume`'s finally): `forget` refuses an account a live process writes
653
+ // into, and a replaced login's old home waits for exactly these to end.
654
+ noteSessionAccount(spec.sessionId, this.account.id);
655
+ try {
656
+ this.q = queryFn({ prompt: this.input, options });
657
+ }
658
+ catch (error) {
659
+ releaseSessionAccount(spec.sessionId);
660
+ throw error;
661
+ }
619
662
  void this.consume();
620
663
  // Ticket #119 / QA-114 MAJOR-3: if the protected file could not be written,
621
664
  // say so where a person will see it, not only in journald.
@@ -897,12 +940,25 @@ class ClaudeSession {
897
940
  refreshUsage() {
898
941
  if (this.stopped)
899
942
  return;
900
- if (this.absorbUsageReading(lastUsageRows()))
943
+ // THIS session's account (#422 R16): the reading is keyed by its
944
+ // subscription, measured under its home, and signed by what that home says
945
+ // right after – never whatever the machine is switched to now.
946
+ const account = {
947
+ id: this.account.id,
948
+ home: this.account.home,
949
+ ...(this.account.orgId ? { orgId: this.account.orgId } : {}),
950
+ };
951
+ if (this.absorbUsageReading(lastUsageRows(account)))
901
952
  this.emitRateLimits();
902
953
  const binary = sessionClaudePath();
903
954
  if (!binary)
904
955
  return;
905
- void readUsageRows({ binary })
956
+ void readUsageRows({
957
+ binary,
958
+ account,
959
+ env: withAccountHome({ ...process.env }, this.account),
960
+ sign: () => usageSignature(this.account),
961
+ })
906
962
  .then((reading) => {
907
963
  if (this.stopped)
908
964
  return;
@@ -913,6 +969,48 @@ class ClaudeSession {
913
969
  }
914
970
  /** The measurement already folded in, so an older one cannot undo a newer event. */
915
971
  usageAppliedAtMs = 0;
972
+ /**
973
+ * The CLI's own signature on the last reading folded in (#422 §8). Only ever
974
+ * this session's account: a reading signed by another subscription never
975
+ * reaches `absorbUsageReading`'s fold.
976
+ */
977
+ usageAccountUuid;
978
+ /**
979
+ * The subscription of a session that could not name one at start: the first
980
+ * signed reading measured under its home, and never overwritten. Taken from
981
+ * every reading instead, it moved with the home – a terminal login into
982
+ * another subscription re-based a running session onto it, and the latch below
983
+ * never saw a change (found by the second independent check of S2).
984
+ */
985
+ firstReadingOrgId;
986
+ /** Latched: this home was signed in to another subscription while the session ran. */
987
+ signedInElsewhere = false;
988
+ /**
989
+ * Has somebody signed this session's home in to a different subscription?
990
+ *
991
+ * «Log in again» on the machine row, or `claude auth login` in a terminal,
992
+ * rewrites the home the session reads by PATH, and the CLI process moves onto
993
+ * the new login at its next token refresh (gotcha 531). From then on its
994
+ * figures are the new subscription's, and signing them with the one captured at
995
+ * start would put B's percentages under A's name – exactly what the signature
996
+ * exists to prevent (found by the independent check of S2). The frame then goes
997
+ * unsigned, as before #422, until the process ends.
998
+ */
999
+ homeSignedInElsewhere() {
1000
+ if (this.signedInElsewhere)
1001
+ return true;
1002
+ const captured = this.signatureOrgId();
1003
+ if (!captured)
1004
+ return false;
1005
+ const now = currentHomeOrgId(this.account.home);
1006
+ if (now !== undefined && now !== captured) {
1007
+ this.signedInElsewhere = true;
1008
+ log.info('claude: the session home was signed in to another subscription – limits unsigned', {
1009
+ sessionId: this.spec.sessionId,
1010
+ });
1011
+ }
1012
+ return this.signedInElsewhere;
1013
+ }
916
1014
  /**
917
1015
  * Fold a `/usage` reading into this session's window map.
918
1016
  *
@@ -936,6 +1034,23 @@ class ClaudeSession {
936
1034
  absorbUsageReading(reading) {
937
1035
  if (!reading || reading.rows.length === 0)
938
1036
  return false;
1037
+ // Somebody else's figures (#422 S1 item 5): a reading signed by a different
1038
+ // subscription than this session's never lands in its windows. Unsigned on
1039
+ // either side is taken as before – that is every pre-#422 reading.
1040
+ const own = this.signatureOrgId();
1041
+ if (reading.orgId && own && reading.orgId !== own)
1042
+ return false;
1043
+ // A session that could not name its subscription takes the first signed
1044
+ // reading as its own – but only one of the subscription its home names NOW.
1045
+ // The machine's cache can still hold a reading from the home's previous
1046
+ // login (a terminal sign-in is not seen by `forgetClaudeUsage`), and fixing
1047
+ // that as the signature refused every fresh reading for the rest of the
1048
+ // session (found by the check of the second fix round of S2).
1049
+ if (reading.orgId && !own) {
1050
+ const homeNow = currentHomeOrgId(this.account.home);
1051
+ if (homeNow !== undefined && homeNow !== reading.orgId)
1052
+ return false;
1053
+ }
939
1054
  const fresh = reading.measuredAtMs > this.usageAppliedAtMs;
940
1055
  const before = this.rateLimitsFingerprint();
941
1056
  // A percentage proves a plan as surely as the event does.
@@ -949,8 +1064,16 @@ class ClaudeSession {
949
1064
  }
950
1065
  if (fresh)
951
1066
  this.usageAppliedAtMs = reading.measuredAtMs;
1067
+ if (reading.accountUuid)
1068
+ this.usageAccountUuid = reading.accountUuid;
1069
+ if (reading.orgId && !own)
1070
+ this.firstReadingOrgId = reading.orgId;
952
1071
  return this.rateLimitsFingerprint() !== before;
953
1072
  }
1073
+ /** The subscription this session signs with: captured at start, else its first signed reading. */
1074
+ signatureOrgId() {
1075
+ return this.account.orgId ?? this.firstReadingOrgId;
1076
+ }
954
1077
  rateLimitsFingerprint() {
955
1078
  return JSON.stringify([this.rateLimitsAvailable, [...this.rateLimitWindows.values()]]);
956
1079
  }
@@ -961,9 +1084,20 @@ class ClaudeSession {
961
1084
  return blocked;
962
1085
  }
963
1086
  emitRateLimits(blocked = null) {
1087
+ // Signed by THIS session's account (#422 §8, S2 item 4): the windows come
1088
+ // from its own stream and from readings measured under its own home. The
1089
+ // subscription is the one captured at start; a session that could not name
1090
+ // it borrows the reading's, which was measured in the very same home. A
1091
+ // session on the operator's token signs nothing: the home's file names the
1092
+ // file's login, not the token's (the constructor's note).
1093
+ const unsigned = this.onOperatorToken || this.homeSignedInElsewhere();
1094
+ const orgId = unsigned ? undefined : this.signatureOrgId();
1095
+ const accountUuid = unsigned ? undefined : this.usageAccountUuid;
964
1096
  this.emit({
965
1097
  type: 'rate_limits',
966
1098
  limits: {
1099
+ ...(accountUuid ? { accountUuid } : {}),
1100
+ ...(orgId ? { orgId } : {}),
967
1101
  blocked,
968
1102
  available: this.rateLimitsAvailable,
969
1103
  planType: this.ratePlanType,
@@ -1204,12 +1338,24 @@ class ClaudeSession {
1204
1338
  currentMode: this.mode,
1205
1339
  ...(this.model ? { currentModel: this.model } : {}),
1206
1340
  ...(this.effort ? { currentEffort: this.effort } : {}),
1207
- ...(account
1341
+ // What the live CLI says first, what the runner last read about the home
1342
+ // second; plus WHICH of the machine's accounts this is (#422 §8), which
1343
+ // only the runner knows. No block at all when neither side knows anything
1344
+ // – exactly as before.
1345
+ ...(account || this.account.email
1208
1346
  ? {
1209
1347
  account: {
1210
- ...(account.email ? { email: account.email } : {}),
1211
- ...(account.organization ? { organization: account.organization } : {}),
1212
- ...(account.subscriptionType ? { plan: account.subscriptionType } : {}),
1348
+ id: this.account.id,
1349
+ ...((account?.email ?? this.account.email)
1350
+ ? { email: account?.email ?? this.account.email }
1351
+ : {}),
1352
+ ...((account?.organization ?? this.account.orgName)
1353
+ ? { organization: account?.organization ?? this.account.orgName }
1354
+ : {}),
1355
+ ...((account?.subscriptionType ?? this.account.plan)
1356
+ ? { plan: account?.subscriptionType ?? this.account.plan }
1357
+ : {}),
1358
+ ...(this.account.orgId ? { orgId: this.account.orgId } : {}),
1213
1359
  },
1214
1360
  }
1215
1361
  : {}),
@@ -2502,6 +2648,7 @@ class ClaudeSession {
2502
2648
  // the path that catches a session which died before the CLI ever
2503
2649
  // answered.
2504
2650
  this.removeMcpConfig();
2651
+ releaseSessionAccount(this.spec.sessionId);
2505
2652
  this.stopped = true;
2506
2653
  this.input.end();
2507
2654
  this.output.end();
@@ -2735,7 +2882,20 @@ export class ClaudeAdapter {
2735
2882
  */
2736
2883
  if (this.queryFn === query)
2737
2884
  assertClaudeInstalled();
2738
- return new ClaudeSession(spec, this.queryFn);
2885
+ const session = new ClaudeSession(spec, this.queryFn);
2886
+ /**
2887
+ * Who the home is, asked once when nobody knows or the home says it changed
2888
+ * (#422 R14 c) – in the background, after the session is on its way. The
2889
+ * next session and the account list get the answer; this one keeps what it
2890
+ * started with.
2891
+ *
2892
+ * Only for the real SDK, for the reason above: an injected `query` is a test,
2893
+ * and a test must not run `claude auth status` against this machine's login.
2894
+ */
2895
+ if (this.queryFn === query) {
2896
+ void refreshAccountIdentity(session.accountId).catch(() => undefined);
2897
+ }
2898
+ return session;
2739
2899
  }
2740
2900
  }
2741
2901
  //# sourceMappingURL=claude.js.map
@@ -1,24 +1,90 @@
1
1
  export type CodexAuthMode = 'link' | 'own';
2
2
  export interface CodexHome {
3
3
  path: string;
4
- /** How the home is authenticated — reported in notices, not a secret. */
5
- auth: 'linked' | 'own' | 'missing';
4
+ /**
5
+ * How the home is authenticated — reported in notices, not a secret.
6
+ * `account` – a saved login is linked (`accountId`); `own` – a real file the
7
+ * runner could not move into the store and keeps using where it is.
8
+ */
9
+ auth: 'linked' | 'account' | 'own' | 'missing';
10
+ /** The saved login the home is set to – present with `account`, and with `missing` under it. */
11
+ accountId?: string;
6
12
  }
7
13
  export declare function codexHomePath(): string;
14
+ /** Where the saved Codex logins live – inside the one home, beside what they share. */
15
+ export declare function codexAccountsDir(home?: string): string;
16
+ /** The directory of one saved login. Throws on anything that is not an account id. */
17
+ export declare function codexAccountDir(id: string, home?: string): string;
18
+ /** The login file of one saved account. */
19
+ export declare function codexAccountAuthFile(id: string, home?: string): string;
20
+ /** The machine login of Codex – the host user's own file, never written by DevBridge (R13). */
21
+ export declare function hostCodexAuthFile(homedir?: string): string;
8
22
  /**
9
- * Throwaway home for a device-code login. The flow runs here and is promoted
10
- * into the real home only on success, so an abandoned or timed-out sign-in
11
- * cannot destroy a credential that was working.
23
+ * Throwaway homes for device-code logins, one per attempt. The flow runs there
24
+ * and its login is moved into the store only on success, so an abandoned or
25
+ * timed-out sign-in cannot destroy a credential that was working.
12
26
  */
13
27
  export declare function stagingCodexHomePath(): string;
28
+ /**
29
+ * `[codex] auth` as the machine's owner WROTE it – `undefined` when the key is
30
+ * not in `config.toml`. Set once at daemon start.
31
+ *
32
+ * Only a mode that is really written may be forced, and even then not over a
33
+ * saved login (S4 item 2): the runner used to pass `link` by default from three
34
+ * places – the daemon start, every session start and the minute probe – and each
35
+ * of them would have undone a switch of account within a minute (К4).
36
+ */
37
+ export declare function configureCodexAuth(mode: CodexAuthMode | undefined): void;
38
+ export declare function configuredCodexAuth(): CodexAuthMode | undefined;
39
+ /** Which saved login the home is set to, if any – the mark, read only. */
40
+ export declare function markedCodexAccount(home?: string): string | null;
41
+ /** Who a Codex login is, read locally from its own file – never a token. */
42
+ export interface CodexIdentity {
43
+ email?: string;
44
+ /** `tokens.account_id` – the key of a Codex subscription (plan §8, S4 item 5). */
45
+ orgId?: string;
46
+ /** `chatgpt_plan_type` – `plus`, `pro`, `team`, … */
47
+ plan?: string;
48
+ }
49
+ export interface CodexCredential {
50
+ /**
51
+ * `ok` – a login the CLI can use; `expired` – a login past its date with no
52
+ * refresh token; `missing` – no file; `unreadable` – a file that is not a login
53
+ * (torn, hand-edited); `unknown` – we could not look (EACCES).
54
+ */
55
+ status: 'ok' | 'expired' | 'missing' | 'unreadable' | 'unknown';
56
+ /** A ChatGPT login (tokens) or an API key – only for a file that is a login. */
57
+ kind?: 'chatgpt' | 'apikey';
58
+ identity: CodexIdentity;
59
+ /** Only when the access token's own date is the login's date (no refresh token). */
60
+ expiresAt?: string;
61
+ /** Last write of the file, whole ms – a refusal mark older than this no longer holds. */
62
+ writtenMs?: number;
63
+ }
64
+ /**
65
+ * Read a Codex login file without letting a token out of this function.
66
+ *
67
+ * The identity comes from the claims of `id_token`, decoded locally (S4 item 8):
68
+ * `email`, and under `https://api.openai.com/auth` the plan and the ChatGPT
69
+ * account; `tokens.account_id` first for the key, the claim when it is absent. An
70
+ * internal file of the CLI (§11): whatever cannot be read is simply not there –
71
+ * no identity is invented, and a row without a key merges with nothing.
72
+ *
73
+ * The verdict is the pre-S4 one (`readCodexCredential`): a refresh token means
74
+ * the CLI renews the access token on its own, so only a lone access token past
75
+ * its `exp` is `expired`.
76
+ */
77
+ export declare function readCodexCredentialFile(file: string): CodexCredential;
14
78
  /**
15
79
  * Create (or refresh) the runner's CODEX_HOME and return it.
16
80
  *
17
- * `auth: 'link'` (default) symlinks the host user's `~/.codex/auth.json` so the
18
- * runner uses their ChatGPT subscription and — importantly — shares one
19
- * credential store with their own CLI, so a token refresh on either side keeps
20
- * both working. `auth: 'own'` leaves the home unauthenticated until a
21
- * device-code login writes into it, which is the choice for full isolation.
81
+ * `link` (the default when nothing else is decided) symlinks the host user's
82
+ * `~/.codex/auth.json` so the runner uses their ChatGPT subscription and —
83
+ * importantly — shares one credential store with their own CLI, so a token
84
+ * refresh on either side keeps both working. `own` leaves the home
85
+ * unauthenticated until a device-code login is stored for it, which is the
86
+ * choice for full isolation. A saved login (`account:<id>`) is kept whatever
87
+ * the configured mode says (S4 item 2).
22
88
  *
23
89
  * A home under the OS temp dir still works, but codex then refuses to install
24
90
  * its helper binaries ("Refusing to create helper binaries under temporary
@@ -35,6 +101,9 @@ export declare function ensureCodexHome(options?: {
35
101
  * it only answers "is the credential still where we left it, and if not, can we
36
102
  * put it back". This is what makes a credential disappearing under a running
37
103
  * daemon self-healing instead of permanent.
104
+ *
105
+ * Forces nothing it was not told to by the machine's owner (S4 item 2): with no
106
+ * `[codex] auth` in `config.toml` the mark decides, and a saved login stays.
38
107
  */
39
108
  export declare function repairCodexAuth(options?: {
40
109
  auth?: CodexAuthMode;
@@ -42,20 +111,73 @@ export declare function repairCodexAuth(options?: {
42
111
  }): CodexHome;
43
112
  /**
44
113
  * Drop a linked auth.json so a device-code login writes our own file instead.
45
- *
46
- * Only ever called AFTER a login has actually succeeded in the staging home —
47
- * never as a pre-step. Detaching first meant an abandoned or timed-out sign-in
48
- * left the server permanently "not signed in", recoverable only by restarting
49
- * the daemon.
114
+ * Never a real credential: only a symlink is removed.
50
115
  */
51
116
  export declare function detachLinkedAuth(dir?: string): void;
117
+ export interface StoredCodexLogin {
118
+ id: string;
119
+ /** The saved row whose login this one replaced – the same subscription (D19). */
120
+ replaced?: string;
121
+ }
122
+ /**
123
+ * Move a login file into the store (§8 `agent_account_login_code` for Codex, S4
124
+ * items 4 and 5) – by rename, and one subscription, one row.
125
+ *
126
+ * A known `account_id` that a saved row already has REPLACES that row's login:
127
+ * the row keeps its id (refusal marks and a live session are keyed by it, R15)
128
+ * and its `addedAt` becomes now – the date is that of the login the row holds,
129
+ * and a second sign-in of the same subscription must change something the
130
+ * window can see (S3 hands this over: `deviceSignInResult`). An unknown key
131
+ * merges with nothing. The machine row takes no part: it is the host's own file.
132
+ *
133
+ * The record is written BEFORE the file moves, so a failure never leaves a login
134
+ * nobody can list or forget; a move that fails takes its new record back.
135
+ */
136
+ export declare function storeCodexLogin(source: string, options: {
137
+ activate: boolean;
138
+ home?: string;
139
+ replaceActive?: boolean;
140
+ }): StoredCodexLogin;
52
141
  /**
53
- * Promote a credential produced by a staging login into the real home, and
54
- * record that this home now owns its own login.
142
+ * Make a row the one the home uses: the mark first, then the link.
143
+ *
144
+ * In that order on purpose – a daemon that dies between the two leaves a mark
145
+ * the next repair finishes (it points the link where the mark says), never a
146
+ * link that the next repair would undo.
147
+ *
148
+ * A real file in place of the link is dealt with FIRST (§8 `activate`, S4 item
149
+ * 7): the CLI may have refreshed the login in use a moment ago, and repointing
150
+ * over it would throw that token away.
151
+ */
152
+ export declare function markActiveCodexLogin(id: string, dir?: string): void;
153
+ export declare function setCodexActiveLogin(id: string, dir?: string): CodexHome;
154
+ /**
155
+ * A fresh throwaway home for one device-code sign-in (config only, no credential).
156
+ *
157
+ * One directory per attempt, and every other one is removed first: only one
158
+ * sign-in runs on a machine at a time (the relay cancels the previous one before
159
+ * it gets here), and the pre-S4 runner kept a single fixed staging home that was
160
+ * never cleaned up – on this machine it had lain there since 01.08.2026.
55
161
  */
56
- export declare function adoptLoginResult(stagingDir: string, dir?: string): boolean;
57
- /** Seed a throwaway home for the device-code flow (config only, no credential). */
58
162
  export declare function prepareStagingHome(): string;
59
- /** Remove the staging home whatever the outcome — it may hold a credential. */
60
- export declare function discardStagingHome(): void;
163
+ /** Remove one sign-in's home whatever the outcome — it may hold a credential. */
164
+ export declare function discardStagingHome(dir: string): void;
165
+ /**
166
+ * Remove every sign-in home – at daemon start (nothing can be signing in yet)
167
+ * and before a new sign-in. Includes the fixed-path staging home of runners
168
+ * before S4.
169
+ */
170
+ export declare function discardAbandonedCodexStagingHomes(): number;
171
+ /**
172
+ * A device-code sign-in in `stagingDir` finished: its login becomes a saved
173
+ * account – by rename, deduplicated by subscription – and the one in use (R15).
174
+ * The staging home is removed either way.
175
+ *
176
+ * `replaceActive` – the sign-in came through the one-login window, where it has
177
+ * always meant «this login from now on»: it takes the place of the login in use
178
+ * rather than adding a row (D27, for an organization without several logins).
179
+ */
180
+ export declare function adoptLoginResult(stagingDir: string, options?: {
181
+ replaceActive?: boolean;
182
+ }): StoredCodexLogin;
61
183
  //# sourceMappingURL=codex-home.d.ts.map