@bridge4dev/runner 0.65.1 → 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.
@@ -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
@@ -87,9 +87,17 @@ export interface CommandOption {
87
87
  argumentHint?: string;
88
88
  }
89
89
  export interface AgentAccountInfo {
90
+ /**
91
+ * Which of the machine's accounts THIS session started under (#422 §8) –
92
+ * `machine` or a saved account's id. Kept to the session's end: switching the
93
+ * machine does not move a running session.
94
+ */
95
+ id?: string;
90
96
  email?: string;
91
97
  organization?: string;
92
98
  plan?: string;
99
+ /** The subscription key (D19) – what the limits panel matches figures against. */
100
+ orgId?: string;
93
101
  }
94
102
  export interface McpServerStatusInfo {
95
103
  name: string;
@@ -197,6 +205,15 @@ export interface AgentRateLimits {
197
205
  key: AgentRateLimitWindow['key'];
198
206
  resetsAt: string | null;
199
207
  } | null;
208
+ /**
209
+ * Whose figures these are (#422 §8) – the account the session runs under.
210
+ * `orgId` is the subscription key (`claude auth status`), `accountUuid` the
211
+ * signature the CLI itself writes next to a `/usage` reading. Both optional:
212
+ * a session nobody could identify sends none, and so does every runner from
213
+ * before #422 – the panel then draws exactly as it did (R12).
214
+ */
215
+ accountUuid?: string;
216
+ orgId?: string;
200
217
  }
201
218
  /** Everything the dashboard needs to render agent controls, live from the agent. */
202
219
  export interface AgentCapabilities {
@@ -727,6 +744,11 @@ export type InterruptOutcome =
727
744
  export interface AgentSession {
728
745
  /** Ends when the underlying agent process is gone. */
729
746
  events: AsyncIterable<AgentEvent>;
747
+ /**
748
+ * The account this session's process runs under (#422) – what a refusal or a
749
+ * successful turn is attributed to (R14). Absent: the machine login.
750
+ */
751
+ readonly accountId?: string;
730
752
  answerPermission(requestId: string, allow: boolean, note?: string): void;
731
753
  /**
732
754
  * Answer (or discuss) an open question. Returns false when the ask is no
@@ -1,7 +1,83 @@
1
1
  export declare function agentAuthPath(): string;
2
+ /**
3
+ * Who a Claude home last said it was – the answer of `claude auth status` in it.
4
+ *
5
+ * Kept on disk (#422 R14) so a restarted runner does not forget whose numbers a
6
+ * usage reading is and whom `auth_status` names as active. Not a verdict on the
7
+ * login: `auth status` says `loggedIn: true` over a dead credential, which is why
8
+ * the verdict stays with the credentials file.
9
+ */
10
+ export interface ClaudeIdentity {
11
+ email?: string;
12
+ /** The subscription key (D19, R1): one subscription – one row. */
13
+ orgId?: string;
14
+ orgName?: string;
15
+ /** `subscriptionType` – `max`, `pro`, … */
16
+ plan?: string;
17
+ /** When this identity was read, ISO. */
18
+ at: string;
19
+ }
20
+ /** One saved Claude login of this machine (#422). Files live in its home, not here. */
21
+ export interface ClaudeAccountRecord {
22
+ id: string;
23
+ addedAt: string;
24
+ lastSeenIdentity?: ClaudeIdentity;
25
+ /**
26
+ * A session under this account was refused (D2): the row is marked, never
27
+ * removed. Read against the credentials file – a file written after the mark
28
+ * (the CLI refreshed, somebody signed in again) outranks it.
29
+ */
30
+ loginExpiredAt?: string;
31
+ }
32
+ /** What the runner remembers about the machine row itself – it has no record of its own. */
33
+ export interface ClaudeMachineRecord {
34
+ lastSeenIdentity?: ClaudeIdentity;
35
+ /**
36
+ * A refusal held against the token this runner captured (`claudeOauthToken`)
37
+ * – set only when the refused session actually ran with that token. It
38
+ * replaces the old «discard the token after a refusal», which erased the whole
39
+ * file and with it every saved account (#422 S1 item 3).
40
+ */
41
+ loginExpiredAt?: string;
42
+ }
43
+ export interface AgentAuthFile {
44
+ claudeOauthToken?: string;
45
+ /**
46
+ * When `claudeOauthToken` was stored – the token's age, and ONLY that. Every
47
+ * other write leaves it alone, or saving an account would make a
48
+ * months-old token look freshly captured.
49
+ */
50
+ updatedAt?: string;
51
+ claudeAccounts?: ClaudeAccountRecord[];
52
+ /** Absent means the machine login – the pre-#422 file reads exactly as before. */
53
+ claudeActiveAccount?: string;
54
+ claudeMachine?: ClaudeMachineRecord;
55
+ }
56
+ /** Everything this runner keeps in `agent-auth.json`, read forgivingly. */
57
+ export declare function readAgentAuth(): AgentAuthFile;
58
+ /**
59
+ * Change some fields of the file and keep every other one.
60
+ *
61
+ * Read → change → write, never «write what I know». The two writers this file
62
+ * had before #422 wrote the WHOLE file (`storeClaudeToken`) or deleted it
63
+ * (`clearStoredClaudeToken`), and with a list of accounts beside the token either
64
+ * would have erased every saved login's record on its way past (S1 item 3).
65
+ *
66
+ * A file that exists but cannot be parsed is moved aside, not overwritten: it may
67
+ * be the only record of which home belongs to which account, and «unreadable»
68
+ * must not quietly become «empty». A file that cannot be READ (EACCES) throws –
69
+ * writing over something we could not look at is the same mistake.
70
+ *
71
+ * Synchronous from read to rename, so two changes inside this process cannot
72
+ * interleave. Same write-then-rename as the config, 0600.
73
+ */
74
+ export declare function updateAgentAuth(change: (file: AgentAuthFile) => void): AgentAuthFile;
2
75
  /** The token we hold for Claude, or null once it is too old to trust. */
3
76
  export declare function storedClaudeToken(): string | null;
77
+ /** When the stored token was captured, in ms – null without one. */
78
+ export declare function storedClaudeTokenAtMs(): number | null;
4
79
  export declare function storeClaudeToken(token: string): void;
80
+ /** Drop the stored token – and nothing else: the accounts beside it stay (#422). */
5
81
  export declare function clearStoredClaudeToken(): void;
6
82
  /**
7
83
  * Put a stored token into this process's environment, unless the operator
@@ -15,6 +91,17 @@ export declare function clearStoredClaudeToken(): void;
15
91
  * Returns true when it applied one.
16
92
  */
17
93
  export declare function applyStoredClaudeToken(): boolean;
94
+ /**
95
+ * Was a session refused with the token this runner captured – after it was
96
+ * captured? A token stored after the refusal is newer evidence and outranks it.
97
+ * A token with no readable capture date is judged by the mark alone.
98
+ */
99
+ export declare function storedClaudeTokenRefused(): boolean;
100
+ /**
101
+ * The daemon's environment carries exactly the token this runner captured – so
102
+ * a machine-login session refused now was refused WITH it.
103
+ */
104
+ export declare function environmentCarriesStoredToken(): boolean;
18
105
  /**
19
106
  * The OAuth token `claude setup-token` printed, reassembled out of pty output.
20
107
  *