@bridge4dev/runner 0.66.0 → 0.68.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,9 +1,9 @@
1
- import type { AccountLoginCodeResult, AccountLoginTarget, LoginStartResult } from './auth-relay.js';
1
+ import type { AccountLoginCodeResult, AccountLoginTarget, LoginStartResult, RelayAgent } from './auth-relay.js';
2
2
  /**
3
3
  * Several logins of one agent, on the wire (#422 S2, plan §8).
4
4
  *
5
- * Five commands, each a thin door over what `claude-homes.ts` already does on
6
- * the machine. New NAMES rather than new arguments of `login_start`: an older
5
+ * Five commands, each a thin door over what `claude-homes.ts` (Claude) and
6
+ * `codex-accounts.ts` (Codex, S4) already do on the machine. New NAMES rather than new arguments of `login_start`: an older
7
7
  * runner silently ignores an argument it does not know and would sign in over
8
8
  * the machine login while the API believed a saved account was being added; an
9
9
  * unknown name it answers «Unknown command» at once.
@@ -21,10 +21,9 @@ export declare const ACCOUNT_COMMANDS: readonly ["agent_accounts", "agent_accoun
21
21
  export type AccountCommand = (typeof ACCOUNT_COMMANDS)[number];
22
22
  /**
23
23
  * The agents this runner keeps several logins for (`capabilities.accounts`,
24
- * R11). Codex joins in S4; until then its account commands are refused with a
25
- * sentence rather than half-served.
24
+ * R11). Codex joined in S4 – the same five commands and the same card.
26
25
  */
27
- export declare const ACCOUNT_AGENTS: readonly ["claude"];
26
+ export declare const ACCOUNT_AGENTS: readonly ["claude", "codex"];
28
27
  /** What `supervisor.ts` sends back as `command_result`, minus the envelope. */
29
28
  export type AccountCommandReply = {
30
29
  ok: true;
@@ -34,11 +33,31 @@ export type AccountCommandReply = {
34
33
  error: string;
35
34
  result?: unknown;
36
35
  };
36
+ /**
37
+ * What became of the sessions running when an account was switched (#422 S4.1).
38
+ *
39
+ * `switched` – parked right now, so their next message starts them on the new
40
+ * account with their conversation kept; `pending` – in the middle of a turn,
41
+ * marked and parked the moment that turn ends. A turn is never killed for a
42
+ * switch.
43
+ */
44
+ export interface AccountApplyReport {
45
+ switched: number;
46
+ pending: number;
47
+ }
48
+ /**
49
+ * The supervisor's side of «apply to the open sessions» – it is the one that
50
+ * holds them. Absent in the tests that drive the commands alone; then the
51
+ * answer carries no `applied` and nothing was moved.
52
+ */
53
+ export type AccountApply = (agent: RelayAgent, accountId: string, label?: string) => AccountApplyReport;
37
54
  /** The two sign-in steps the commands drive – `AuthRelay`, or a test double. */
38
55
  export interface AccountSignIn {
39
- startAccountLogin(target: AccountLoginTarget): Promise<LoginStartResult>;
56
+ startAccountLogin(target: AccountLoginTarget, agent?: RelayAgent, options?: {
57
+ activate?: boolean;
58
+ }): Promise<LoginStartResult>;
40
59
  submitAccountCode(code: string): Promise<AccountLoginCodeResult>;
41
60
  }
42
61
  export declare function isAccountCommand(name: string): name is AccountCommand;
43
- export declare function runAccountCommand(name: AccountCommand, args: Record<string, unknown>, signIn: AccountSignIn): Promise<AccountCommandReply>;
62
+ export declare function runAccountCommand(name: AccountCommand, args: Record<string, unknown>, signIn: AccountSignIn, apply?: AccountApply): Promise<AccountCommandReply>;
44
63
  //# sourceMappingURL=account-commands.d.ts.map
@@ -1,11 +1,12 @@
1
- import { AccountError, forgetAccount, isAccountId, listAccounts, setActiveAccount, } from './claude-homes.js';
2
- import { MACHINE_ACCOUNT_ID } from './login-marks.js';
1
+ import { forgetAccount, listAccounts, readActiveAccountSummary, setActiveAccount, } from './claude-homes.js';
2
+ import { activateCodexAccount, codexActiveAccountSummary, forgetCodexAccount, listCodexAccounts, } from './codex-accounts.js';
3
+ import { AccountError, MACHINE_ACCOUNT_ID, isAccountId } from './login-marks.js';
3
4
  import { maskString } from './policy.js';
4
5
  /**
5
6
  * Several logins of one agent, on the wire (#422 S2, plan §8).
6
7
  *
7
- * Five commands, each a thin door over what `claude-homes.ts` already does on
8
- * the machine. New NAMES rather than new arguments of `login_start`: an older
8
+ * Five commands, each a thin door over what `claude-homes.ts` (Claude) and
9
+ * `codex-accounts.ts` (Codex, S4) already do on the machine. New NAMES rather than new arguments of `login_start`: an older
9
10
  * runner silently ignores an argument it does not know and would sign in over
10
11
  * the machine login while the API believed a saved account was being added; an
11
12
  * unknown name it answers «Unknown command» at once.
@@ -28,10 +29,18 @@ export const ACCOUNT_COMMANDS = [
28
29
  ];
29
30
  /**
30
31
  * The agents this runner keeps several logins for (`capabilities.accounts`,
31
- * R11). Codex joins in S4; until then its account commands are refused with a
32
- * sentence rather than half-served.
32
+ * R11). Codex joined in S4 – the same five commands and the same card.
33
33
  */
34
- export const ACCOUNT_AGENTS = ['claude'];
34
+ export const ACCOUNT_AGENTS = ['claude', 'codex'];
35
+ /**
36
+ * «And make it the one new sessions start on», the window's tick (S4.1).
37
+ *
38
+ * Absent – true: that is what every sign-in did before the tick existed, and an
39
+ * older API sends no such argument.
40
+ */
41
+ function activateArg(args) {
42
+ return args['activate'] === false ? { activate: false } : {};
43
+ }
35
44
  export function isAccountCommand(name) {
36
45
  return ACCOUNT_COMMANDS.includes(name);
37
46
  }
@@ -46,11 +55,40 @@ function refusal(error) {
46
55
  function accountIdArg(value) {
47
56
  return value === MACHINE_ACCOUNT_ID || isAccountId(value) ? value : null;
48
57
  }
49
- export async function runAccountCommand(name, args, signIn) {
50
- const agent = args['agent'];
51
- if (agent === 'codex') {
52
- return { ok: false, error: 'several Codex logins are not supported by this runner yet' };
58
+ /**
59
+ * Who the account now active is, for the note the parked sessions are given.
60
+ *
61
+ * Read locally and never allowed to fail the switch itself: a label is a
62
+ * courtesy, the switch is the work.
63
+ */
64
+ function labelOf(agent, id) {
65
+ try {
66
+ const summary = agent === 'codex' ? codexActiveAccountSummary(id) : readActiveAccountSummary(id);
67
+ return summary.email;
53
68
  }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
73
+ /**
74
+ * «Apply to the sessions already running», when the caller asked for it (S4.1).
75
+ *
76
+ * An ARGUMENT of an existing command rather than a name of its own, which §8
77
+ * otherwise forbids – and safe here for one reason: a runner that does not know
78
+ * it still switches the account, it simply leaves the open sessions where they
79
+ * are. The API tells the two apart by this very field: an answer without
80
+ * `applied` to a request that asked for it IS «that machine is older», and the
81
+ * window says so instead of claiming sessions were moved.
82
+ */
83
+ function appliedTo(agent, active, args, apply) {
84
+ if (args['applySessions'] !== true || !apply)
85
+ return {};
86
+ return { applied: apply(agent, active, labelOf(agent, active)) };
87
+ }
88
+ export async function runAccountCommand(name, args, signIn, apply) {
89
+ const agent = args['agent'];
90
+ if (agent === 'codex')
91
+ return runCodexAccountCommand(name, args, signIn, apply);
54
92
  if (agent !== 'claude')
55
93
  return { ok: false, error: 'agent must be claude or codex' };
56
94
  try {
@@ -64,7 +102,10 @@ export async function runAccountCommand(name, args, signIn) {
64
102
  if (target !== 'machine' && target !== 'saved') {
65
103
  return { ok: false, error: 'target must be machine or saved' };
66
104
  }
67
- return { ok: true, result: await signIn.startAccountLogin(target) };
105
+ return {
106
+ ok: true,
107
+ result: await signIn.startAccountLogin(target, 'claude', activateArg(args)),
108
+ };
68
109
  }
69
110
  case 'agent_account_login_code': {
70
111
  const code = args['code'];
@@ -80,7 +121,8 @@ export async function runAccountCommand(name, args, signIn) {
80
121
  return { ok: false, error: 'no such account on this server' };
81
122
  // The usage cache is keyed by subscription, so a switch leaves it alone:
82
123
  // the new row's figures are already its own (R16, §8).
83
- return { ok: true, result: { active: setActiveAccount(id) } };
124
+ const active = setActiveAccount(id);
125
+ return { ok: true, result: { active, ...appliedTo('claude', active, args, apply) } };
84
126
  }
85
127
  case 'agent_account_forget': {
86
128
  const id = accountIdArg(args['id']);
@@ -96,4 +138,52 @@ export async function runAccountCommand(name, args, signIn) {
96
138
  return refusal(error);
97
139
  }
98
140
  }
141
+ /**
142
+ * The same five doors for Codex (#422 S4). What differs is said where it differs:
143
+ * the machine login is the host user's own file (no sign-in into it, R13), and a
144
+ * device sign-in finishes on the provider's page – there is no code to send; the
145
+ * window learns the result from the list (§8).
146
+ */
147
+ async function runCodexAccountCommand(name, args, signIn, apply) {
148
+ try {
149
+ switch (name) {
150
+ case 'agent_accounts':
151
+ return { ok: true, result: listCodexAccounts() };
152
+ case 'agent_account_login_start': {
153
+ const target = args['target'];
154
+ if (target !== 'machine' && target !== 'saved') {
155
+ return { ok: false, error: 'target must be machine or saved' };
156
+ }
157
+ return {
158
+ ok: true,
159
+ result: await signIn.startAccountLogin(target, 'codex', activateArg(args)),
160
+ };
161
+ }
162
+ case 'agent_account_login_code': {
163
+ const result = {
164
+ ok: false,
165
+ detail: 'a Codex sign-in finishes on the provider page – there is no code to send; the account list shows it once it is done',
166
+ };
167
+ return { ok: false, error: result.detail, result };
168
+ }
169
+ case 'agent_account_activate': {
170
+ const id = accountIdArg(args['id']);
171
+ if (!id)
172
+ return { ok: false, error: 'no such account on this server' };
173
+ const active = activateCodexAccount(id);
174
+ return { ok: true, result: { active, ...appliedTo('codex', active, args, apply) } };
175
+ }
176
+ case 'agent_account_forget': {
177
+ const id = accountIdArg(args['id']);
178
+ if (!id)
179
+ return { ok: false, error: 'no such account on this server' };
180
+ const { active } = forgetCodexAccount(id);
181
+ return { ok: true, result: { ok: true, active } };
182
+ }
183
+ }
184
+ }
185
+ catch (error) {
186
+ return refusal(error);
187
+ }
188
+ }
99
189
  //# sourceMappingURL=account-commands.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,74 @@ 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
+ activate?: boolean;
183
+ }): StoredCodexLogin;
61
184
  //# sourceMappingURL=codex-home.d.ts.map