@zgeoff/atc 2.42.0 → 3.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.42.0",
3
+ "version": "3.1.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -45,7 +45,7 @@
45
45
  "@modelcontextprotocol/sdk": "1.31.0",
46
46
  "@xterm/addon-serialize": "0.14.0",
47
47
  "@xterm/headless": "6.0.0",
48
- "@zgeoff/imp-client": "0.27.0",
48
+ "@zgeoff/imp-client": "0.39.0",
49
49
  "better-auth": "1.7.6",
50
50
  "bun-pty": "0.4.10",
51
51
  "citty": "0.2.2",
@@ -43,9 +43,11 @@ export interface SpawnPlan {
43
43
  * host, null when the host has none, and the folder the session's own
44
44
  * files unpack into. `auth` is given when the harness takes its credential
45
45
  * from impd's broker: the revision of the host's runtime auth binding it
46
- * launches under, which keys any settings the agent writes for it, and the
47
- * placeholder variables the harness holds in place of a credential, and
48
- * the variables its auth profiles set.
46
+ * launches under, which keys any settings the agent writes for it, the
47
+ * placeholder variables the harness holds in place of a credential, the
48
+ * variables its auth profiles set, and, when the binding holds a secret of
49
+ * kind `oauth`, the sign-in state impd lists for each such secret, keyed by
50
+ * the secret's name.
49
51
  */
50
52
  export interface GuestPaths {
51
53
  readonly atc: string | null;
@@ -54,9 +56,20 @@ export interface GuestPaths {
54
56
  readonly revision: number;
55
57
  readonly env: Readonly<Record<string, string>>;
56
58
  readonly profileEnv: Readonly<Record<string, string>>;
59
+ readonly oauth?: Readonly<Record<string, GuestOAuthState>>;
57
60
  };
58
61
  }
59
62
 
63
+ /**
64
+ * Where the sign-in of an `oauth` secret stands in impd: `ready` while impd
65
+ * holds an access token for it, and the payload of its last ID token,
66
+ * identifiers and never a credential, or null before impd has one.
67
+ */
68
+ export interface GuestOAuthState {
69
+ readonly status: 'pending' | 'ready' | 'needs_login';
70
+ readonly idClaims: Readonly<Record<string, unknown>> | null;
71
+ }
72
+
60
73
  /**
61
74
  * The credential an agent takes from impd's broker instead of holding it:
62
75
  * its gateway's endpoint and auth selection, and the auth profiles that
@@ -16,7 +16,7 @@ export function buildAgentAdapters(
16
16
  ): AgentAdapter[] {
17
17
  return config.agents.map((entry): AgentAdapter => {
18
18
  if (entry.kind === 'codex') {
19
- return new CodexAdapter(entry);
19
+ return new CodexAdapter(entry, config);
20
20
  }
21
21
 
22
22
  if (entry.kind === 'grok') {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The `auth.json` a Codex session on a remote host signs in with, which
3
+ * holds no credential: impd's broker sets the real access token on each
4
+ * request to chatgpt.com. Codex reads a ChatGPT sign-in from it with no
5
+ * sign-in step when the access token is opaque, so it never refreshes the
6
+ * token at start, and the last refresh lies far ahead, so it never
7
+ * refreshes on age. The ID token carries the email and the OpenAI auth
8
+ * claims of impd's ID token and nothing else, under an unsigned header,
9
+ * since Codex reads its claims and never checks a signature. The account
10
+ * id must be the real one: Codex sends it with every request and checks
11
+ * it against the account's workspaces before each session.
12
+ */
13
+ export function buildCodexAuthFile(
14
+ idClaims: Readonly<Record<string, unknown>>,
15
+ accountID: string,
16
+ ): string {
17
+ const auth = {
18
+ auth_mode: 'chatgpt',
19
+ OPENAI_API_KEY: null,
20
+ tokens: {
21
+ id_token: buildUnsignedIDToken(idClaims),
22
+ access_token: PLACEHOLDER,
23
+ refresh_token: PLACEHOLDER,
24
+ account_id: accountID,
25
+ },
26
+ last_refresh: LAST_REFRESH,
27
+ };
28
+
29
+ return `${JSON.stringify(auth, null, 2)}\n`;
30
+ }
31
+
32
+ // The value impd's broker replaces with the access token on the host's side.
33
+ const PLACEHOLDER = 'imp-broker-placeholder';
34
+
35
+ // A last refresh Codex never counts as stale.
36
+ const LAST_REFRESH = '2099-01-01T00:00:00Z';
37
+
38
+ // The claims of impd's ID token that Codex reads: the account's email and
39
+ // the object that holds its plan and account ids.
40
+ const KEPT_CLAIMS = ['email', 'https://api.openai.com/auth'] as const;
41
+
42
+ // A JWT of three base64url segments whose header says it is unsigned and
43
+ // whose signature segment holds the placeholder, since Codex needs three
44
+ // non-empty segments.
45
+ function buildUnsignedIDToken(idClaims: Readonly<Record<string, unknown>>): string {
46
+ const payload = Object.fromEntries(
47
+ KEPT_CLAIMS.filter((claim) => idClaims[claim] !== undefined).map((claim) => [
48
+ claim,
49
+ idClaims[claim],
50
+ ]),
51
+ );
52
+
53
+ return [
54
+ toBase64URL(JSON.stringify({ alg: 'none', typ: 'JWT' })),
55
+ toBase64URL(JSON.stringify(payload)),
56
+ toBase64URL(PLACEHOLDER),
57
+ ].join('.');
58
+ }
59
+
60
+ function toBase64URL(text: string): string {
61
+ return Buffer.from(text, 'utf8').toString('base64url');
62
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The `config.toml` of a Codex session on a remote host: credentials stay
3
+ * in the `auth.json` atc writes, never in a keyring, and the TUI checks
4
+ * for no update, since the host's image fixes the version it runs.
5
+ */
6
+ export function buildCodexConfig(): string {
7
+ return ['cli_auth_credentials_store = "file"', 'check_for_update_on_startup = false', ''].join(
8
+ '\n',
9
+ );
10
+ }
@@ -0,0 +1,53 @@
1
+ import type { SpawnPlan } from './agent-adapter';
2
+ import { CODEX_TRUST_SEED_FILE } from './codex-trust-seed-file';
3
+
4
+ /**
5
+ * The launch of a Codex CLI that runs in a remote host with a Codex home of
6
+ * the session's own inside its guest folder, so no sign-in or setting of
7
+ * the host's image reaches it. The folder persists for the session, so a
8
+ * resume finds the sessions Codex recorded there. Before each run a shell
9
+ * copies in the sign-in file, the config, and the hook file staged under
10
+ * `authDir`, a folder of the guest folder, appends the clone trust seed to
11
+ * that config when the guest folder holds one, and unsets every variable
12
+ * that would sign Codex in another way. `argv` is the CLI's own command
13
+ * line.
14
+ */
15
+ export function buildCodexGuestLaunch(
16
+ guestDir: string,
17
+ authDir: string,
18
+ argv: readonly string[],
19
+ ): SpawnPlan & { readonly env: { readonly CODEX_HOME: string } } {
20
+ const codexHome = `${guestDir}/${CODEX_HOME_FOLDER}`;
21
+
22
+ return {
23
+ bin: 'sh',
24
+ args: [
25
+ '-c',
26
+ SEED_HOME_SCRIPT,
27
+ 'sh',
28
+ codexHome,
29
+ `${guestDir}/${authDir}`,
30
+ `${guestDir}/${CODEX_TRUST_SEED_FILE}`,
31
+ ...argv,
32
+ ],
33
+ env: { CODEX_HOME: codexHome },
34
+ };
35
+ }
36
+
37
+ const CODEX_HOME_FOLDER = 'codex-home';
38
+
39
+ // Variables Codex takes a credential from ahead of its sign-in file.
40
+ const CREDENTIAL_VARIABLES = ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_ACCESS_TOKEN'];
41
+
42
+ // The arguments are the Codex home, the staged folder, the trust seed, and
43
+ // the CLI's own command line. The home and its sign-in file are its
44
+ // owner's alone; the CLI itself starts under the host's own umask.
45
+ const SEED_HOME_SCRIPT = [
46
+ '( umask 077 && mkdir -p "$1" && cp "$2/auth.json" "$1/auth.json" )',
47
+ 'cp "$2/config.toml" "$1/config.toml"',
48
+ '{ [ ! -f "$3" ] || cat "$3" >> "$1/config.toml"; }',
49
+ 'cp "$2/hooks.json" "$1/hooks.json"',
50
+ 'shift 3',
51
+ `unset ${CREDENTIAL_VARIABLES.join(' ')}`,
52
+ 'exec "$@"',
53
+ ].join(' && ');
@@ -0,0 +1,23 @@
1
+ import { buildCLIArgv } from './build-cli-argv';
2
+ import { buildCLICommand } from './build-cli-command';
3
+
4
+ const CODEX_HOOK_EVENTS = [
5
+ 'SessionStart',
6
+ 'UserPromptSubmit',
7
+ 'PermissionRequest',
8
+ 'Stop',
9
+ ] as const;
10
+
11
+ /**
12
+ * The Codex hook file whose entries report each hook event to atc under
13
+ * the codex agent, run through `argv`: the atc of the daemon's machine by
14
+ * default, or the atc inside a remote host.
15
+ */
16
+ export function buildCodexHookFile(argv: readonly string[] = buildCLIArgv()): string {
17
+ const cmd = buildCLICommand('hook-report --agent codex', argv);
18
+ const buildEntry = (timeout: number) => [{ hooks: [{ type: 'command', command: cmd, timeout }] }];
19
+ const hooks = Object.fromEntries(CODEX_HOOK_EVENTS.map((event) => [event, buildEntry(5)]));
20
+
21
+ // Codex caps SessionEnd hooks at three seconds.
22
+ return `${JSON.stringify({ hooks: { ...hooks, SessionEnd: buildEntry(3) } }, null, 2)}\n`;
23
+ }
@@ -0,0 +1,13 @@
1
+ import { CODEX_TRUST_SEED_FILE } from './codex-trust-seed-file';
2
+
3
+ /**
4
+ * The guest file that trusts the exact root of a verified clone in a Codex
5
+ * session's own config: a `projects` table the launch appends to the
6
+ * session's `config.toml`. A JSON string is a valid TOML basic string, so
7
+ * the root is quoted as one.
8
+ */
9
+ export function buildCodexTrustSeed(root: string): Readonly<Record<string, string>> {
10
+ return {
11
+ [CODEX_TRUST_SEED_FILE]: `\n[projects.${JSON.stringify(root)}]\ntrust_level = "trusted"\n`,
12
+ };
13
+ }
@@ -2,18 +2,25 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import type { AdapterEvent } from '../protocol/adapter-event';
5
+ import { DaemonError } from '../protocol/daemon-error';
5
6
  import type { HookEvent } from '../protocol/hook-event';
6
7
  import type { AgentID } from '../shared/agent-id';
7
8
  import type { AgentSessionID } from '../shared/agent-session-id';
8
9
  import { buildOptionalString } from '../shared/build-optional-string';
9
10
  import type { AgentEntry } from '../shared/collect-agents';
11
+ import type { Config } from '../shared/config';
10
12
  import { isRecord } from '../shared/report';
13
+ import { resolveAuthProfiles } from '../shared/resolve-auth-profiles';
11
14
  import { toAgentSessionID } from '../shared/to-agent-session-id';
12
15
  import { toShellArg } from '../shared/to-shell-arg';
13
16
  import { truncateDetail } from '../shared/truncate-detail';
14
17
  import type {
15
18
  AgentAdapter,
16
19
  AgentProfile,
20
+ AuthSelection,
21
+ GuestOAuthState,
22
+ GuestPaths,
23
+ GuestSpawnPlan,
17
24
  NameUpdate,
18
25
  ResumeCheck,
19
26
  SpawnOptionSpecs,
@@ -21,6 +28,11 @@ import type {
21
28
  SpawnPlan,
22
29
  } from './agent-adapter';
23
30
  import { buildArgsWithoutFlags } from './build-args-without-flags';
31
+ import { buildCodexAuthFile } from './build-codex-auth-file';
32
+ import { buildCodexConfig } from './build-codex-config';
33
+ import { buildCodexGuestLaunch } from './build-codex-guest-launch';
34
+ import { buildCodexHookFile } from './build-codex-hook-file';
35
+ import { buildCodexTrustSeed } from './build-codex-trust-seed';
24
36
  import { findFlagValue } from './find-flag-value';
25
37
  import { planPastedLineInput } from './plan-pasted-line-input';
26
38
  import { resolveAgentHome } from './resolve-agent-home';
@@ -46,6 +58,8 @@ const CODEX_MODEL_FLAGS = ['-m', '--model'];
46
58
  * semantics, and session_index.jsonl name-pulling. Hooks are a user-installed
47
59
  * entry in the Codex hook config (`atc codex-hooks` prints it) that the user
48
60
  * trusts once in the Codex TUI; atc never writes into the user's Codex config.
61
+ * An entry with `auth` signs in on an imp through the ChatGPT sign-in impd's
62
+ * broker holds, with a Codex home of the session's own that atc writes.
49
63
  */
50
64
  export class CodexAdapter implements AgentAdapter {
51
65
  readonly id: AgentID;
@@ -68,12 +82,24 @@ export class CodexAdapter implements AgentAdapter {
68
82
  // line is pasted and then submitted.
69
83
  readonly planLineInput = planPastedLineInput;
70
84
 
85
+ // Present only for an entry with `auth`: any other entry runs on a
86
+ // remote host as a local spawn plans it, under the sign-in of the host's
87
+ // image, and needs no atc there.
88
+ readonly planGuestSpawn?: (opts: SpawnOptions, guest: GuestPaths) => GuestSpawnPlan | null;
89
+
71
90
  private readonly entry: AgentEntry;
72
91
 
73
- constructor(entry: AgentEntry) {
92
+ private readonly authProfiles: Config['authProfiles'];
93
+
94
+ constructor(entry: AgentEntry, config?: Pick<Config, 'authProfiles'>) {
74
95
  this.entry = entry;
96
+ this.authProfiles = config?.authProfiles ?? new Map();
75
97
  this.id = entry.id;
76
98
 
99
+ if (entry.auth !== undefined) {
100
+ this.planGuestSpawn = (opts, guest) => this.planSignedInGuestSpawn(opts, guest);
101
+ }
102
+
77
103
  this.profile = {
78
104
  label: entry.label,
79
105
  kind: 'codex',
@@ -102,6 +128,131 @@ export class CodexAdapter implements AgentAdapter {
102
128
  };
103
129
  }
104
130
 
131
+ // With `auth` configured, Codex signs in with the ChatGPT sign-in impd's
132
+ // broker holds on a target that reaches the broker, and with the sign-in
133
+ // of the host it runs on anywhere else.
134
+ findAuthSelection(): AuthSelection | null {
135
+ const auth = this.entry.auth;
136
+
137
+ if (auth === undefined) {
138
+ return null;
139
+ }
140
+
141
+ return {
142
+ gateway: { id: this.id, baseURL: CHATGPT_CODEX_URL, auth },
143
+ profiles: this.authProfiles,
144
+ brokerRequired: false,
145
+ };
146
+ }
147
+
148
+ // A remote session without the broker runs as a local spawn plans it,
149
+ // under the sign-in of the host's image. One behind the broker reports
150
+ // through the atc inside its host, so without one it cannot run there,
151
+ // and it runs with a Codex home of its own: a sign-in file that holds
152
+ // no credential, a config, and the hook file, staged per binding
153
+ // revision. Its hooks are trusted at launch, since atc wrote them.
154
+ private planSignedInGuestSpawn(opts: SpawnOptions, guest: GuestPaths): GuestSpawnPlan | null {
155
+ if (guest.auth === undefined) {
156
+ return { ...this.planSpawn(opts), files: {} };
157
+ }
158
+
159
+ if (guest.atc === null) {
160
+ return null;
161
+ }
162
+
163
+ const authDir = `auth-r${guest.auth.revision}`;
164
+ const signIn = this.requireSignIn(guest.auth.oauth ?? {});
165
+ const plan = this.planSpawn(opts);
166
+
167
+ // the flag outranks a trusted clone's own config, which could move the
168
+ // sign-in out of the file atc writes
169
+ const launch = buildCodexGuestLaunch(guest.dir, authDir, [
170
+ plan.bin,
171
+ HOOK_TRUST_FLAG,
172
+ '-c',
173
+ FILE_CREDENTIALS_OVERRIDE,
174
+ ...plan.args,
175
+ ]);
176
+
177
+ return {
178
+ bin: launch.bin,
179
+ args: launch.args,
180
+ files: {
181
+ [`${authDir}/auth.json`]: buildCodexAuthFile(signIn.idClaims, signIn.accountID),
182
+ [`${authDir}/config.toml`]: buildCodexConfig(),
183
+ [`${authDir}/hooks.json`]: buildCodexHookFile([guest.atc]),
184
+ },
185
+ env: { ...guest.auth.profileEnv, ...guest.auth.env, ...launch.env },
186
+ };
187
+ }
188
+
189
+ // Seeds the config of a session that signs in through impd's broker with
190
+ // trust for the clone; any other remote session reads the config of the
191
+ // host's image, which atc never writes.
192
+ planGuestWorkspaceTrust(root: string): Readonly<Record<string, string>> | null {
193
+ if (this.entry.auth === undefined) {
194
+ return null;
195
+ }
196
+
197
+ return buildCodexTrustSeed(root);
198
+ }
199
+
200
+ // The ID token claims and account id of the oauth secret this entry's
201
+ // profiles send to chatgpt.com, or the refusal for a sign-in that is not
202
+ // ready or that holds no account id.
203
+ private requireSignIn(states: Readonly<Record<string, GuestOAuthState>>): {
204
+ readonly idClaims: Readonly<Record<string, unknown>>;
205
+ readonly accountID: string;
206
+ } {
207
+ const secret = this.findChatGPTSecret();
208
+ const state = secret === null ? undefined : states[secret];
209
+ const name = secret ?? 'the oauth secret';
210
+ const renew = `sign Codex in again and run imp secret add ${name} --kind oauth ... --replace with the new refresh token`;
211
+
212
+ if (state?.status !== 'ready') {
213
+ throw new DaemonError(
214
+ 'auth_signin_needed',
215
+ `agent '${this.id}' signs in through ${name}, whose sign-in in impd is ${state?.status ?? 'not listed'}; ${renew}`,
216
+ { agent: this.id, secret, status: state?.status ?? null },
217
+ );
218
+ }
219
+
220
+ const auth = state.idClaims?.[OPENAI_AUTH_CLAIM];
221
+ const accountID = isRecord(auth) ? auth['chatgpt_account_id'] : undefined;
222
+
223
+ if (state.idClaims === null || typeof accountID !== 'string' || accountID === '') {
224
+ throw new DaemonError(
225
+ 'auth_signin_needed',
226
+ `agent '${this.id}' signs in through ${name}, whose ID token in impd holds no ChatGPT account id; ${renew}`,
227
+ { agent: this.id, secret, status: state.status },
228
+ );
229
+ }
230
+
231
+ return { idClaims: state.idClaims, accountID };
232
+ }
233
+
234
+ // The secret this entry's profiles send to chatgpt.com, or null when
235
+ // they no longer resolve to one.
236
+ private findChatGPTSecret(): string | null {
237
+ const auth = this.entry.auth;
238
+
239
+ if (auth === undefined) {
240
+ return null;
241
+ }
242
+
243
+ const resolution = resolveAuthProfiles(this.authProfiles, auth.profiles);
244
+
245
+ if ('problem' in resolution) {
246
+ return null;
247
+ }
248
+
249
+ const found = resolution.resolved.secrets.find((secret) =>
250
+ secret.rules.some((rule) => rule.host === CHATGPT_HOST),
251
+ );
252
+
253
+ return found?.secret ?? null;
254
+ }
255
+
105
256
  normalizeHook(e: HookEvent): AdapterEvent {
106
257
  const parsed = CODEX_HOOK_PAYLOAD_SCHEMA.safeParse(e.payload);
107
258
  const payload: CodexHookPayload = parsed.success ? parsed.data : {};
@@ -222,6 +373,21 @@ export class CodexAdapter implements AgentAdapter {
222
373
  }
223
374
  }
224
375
 
376
+ // Where Codex sends its ChatGPT sign-in: the endpoint its model requests go
377
+ // to, and the host impd's broker sets the access token on.
378
+ const CHATGPT_CODEX_URL = 'https://chatgpt.com/backend-api/codex';
379
+ const CHATGPT_HOST = 'chatgpt.com';
380
+
381
+ // The ID token claim that holds the ChatGPT account and plan.
382
+ const OPENAI_AUTH_CLAIM = 'https://api.openai.com/auth';
383
+
384
+ // Codex's documented flag that runs hooks without the trust a person
385
+ // gives them in the TUI, meant for automation that vets its hook sources.
386
+ const HOOK_TRUST_FLAG = '--dangerously-bypass-hook-trust';
387
+
388
+ // A config override on the command line, which outranks every config file.
389
+ const FILE_CREDENTIALS_OVERRIDE = 'cli_auth_credentials_store="file"';
390
+
225
391
  // What a Codex spawn can override. Codex documents its reasoning effort as
226
392
  // whatever the selected model advertises, with no closed list of levels, so
227
393
  // atc takes no effort for it.
@@ -0,0 +1,4 @@
1
+ /**
2
+ * The guest-relative file that holds a Codex session's clone trust.
3
+ */
4
+ export const CODEX_TRUST_SEED_FILE = 'codex-trust.toml';
@@ -1,26 +1,11 @@
1
- import { buildCLICommand } from './build-cli-command';
2
-
3
- const CODEX_HOOK_EVENTS = [
4
- 'SessionStart',
5
- 'UserPromptSubmit',
6
- 'PermissionRequest',
7
- 'Stop',
8
- ] as const;
1
+ import { buildCodexHookFile } from './build-codex-hook-file';
9
2
 
10
3
  /**
11
4
  * Print the Codex hook entries to stdout. The operator merges them into
12
5
  * `$CODEX_HOME/hooks.json` and trusts them once in the Codex TUI — Codex
13
- * parses untrusted hooks but never runs them. atc never writes that path.
6
+ * parses untrusted hooks but never runs them. atc never writes the user's
7
+ * own Codex config.
14
8
  */
15
9
  export function printCodexHookFile(): void {
16
10
  process.stdout.write(buildCodexHookFile());
17
11
  }
18
-
19
- function buildCodexHookFile(): string {
20
- const cmd = buildCLICommand('hook-report --agent codex');
21
- const buildEntry = (timeout: number) => [{ hooks: [{ type: 'command', command: cmd, timeout }] }];
22
- const hooks = Object.fromEntries(CODEX_HOOK_EVENTS.map((event) => [event, buildEntry(5)]));
23
-
24
- // Codex caps SessionEnd hooks at three seconds.
25
- return `${JSON.stringify({ hooks: { ...hooks, SessionEnd: buildEntry(3) } }, null, 2)}\n`;
26
- }
@@ -3,7 +3,7 @@
3
3
  * may not clean up after one:
4
4
  *
5
5
  * - `auth_impd_too_old`: impd lacks grantable tokens, secret rebinds or
6
- * exec requirements.
6
+ * exec requirements, or oauth secrets for a binding that holds one.
7
7
  * - `auth_token_scope`: the token's scope is below `manage`.
8
8
  * - `auth_token_too_broad`: the token can reach imps outside atc's
9
9
  * namespace, through no imp patterns or a pattern whose literal text
@@ -65,6 +65,7 @@ export class ImpClientPort implements ImpPort {
65
65
  grantableTokens: features?.['grantableTokens'] === true,
66
66
  secretRebind: features?.['secretRebind'] === true,
67
67
  execRequire: features?.['execRequire'] === true,
68
+ oauthSecrets: features?.['oauthSecrets'] === true,
68
69
  };
69
70
  };
70
71
 
@@ -84,17 +85,7 @@ export class ImpClientPort implements ImpPort {
84
85
  readonly readSecrets = async (): Promise<readonly ImpSecret[]> => {
85
86
  const secrets = await this.tryCall((client) => client.secrets.list());
86
87
 
87
- return secrets.map((secret) => ({
88
- name: secret.name,
89
- kind: secret.kind,
90
- rules: secret.rules.map((rule) => ({
91
- host: rule.host,
92
- header: rule.header,
93
- scheme: rule.scheme,
94
- ...(rule.user === undefined ? {} : { user: rule.user }),
95
- })),
96
- imps: [...secret.imps],
97
- }));
88
+ return secrets.map((secret) => toImpSecret(secret));
98
89
  };
99
90
 
100
91
  readonly readGrants = async (name: string): Promise<readonly string[]> => {
@@ -327,6 +318,32 @@ export class ImpClientPort implements ImpPort {
327
318
  }
328
319
  }
329
320
 
321
+ // A secret as the client lists it.
322
+ type ClientSecret = Awaited<ReturnType<ImpClient['secrets']['list']>>[number];
323
+
324
+ // A secret in the port's terms, with an oauth secret's sign-in status and
325
+ // ID token claims and none of its other state.
326
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a secret's dates have no readonly form
327
+ function toImpSecret(secret: ClientSecret): ImpSecret {
328
+ const listed: ImpSecret = {
329
+ name: secret.name,
330
+ kind: secret.kind,
331
+ rules: secret.rules.map((rule) => ({
332
+ host: rule.host,
333
+ header: rule.header,
334
+ scheme: rule.scheme,
335
+ ...(rule.user === undefined ? {} : { user: rule.user }),
336
+ })),
337
+ imps: [...secret.imps],
338
+ };
339
+
340
+ const oauth = secret.oauth;
341
+
342
+ return oauth === undefined
343
+ ? listed
344
+ : { ...listed, oauth: { status: oauth.status, idClaims: oauth.idClaims } };
345
+ }
346
+
330
347
  // A lease as the client returns it.
331
348
  interface ClientLease {
332
349
  readonly name: string;
@@ -84,6 +84,10 @@ export interface ImpFeatures {
84
84
  // A start may list what must be ready before the command runs, and impd
85
85
  // refuses the start without running it when one is not.
86
86
  readonly execRequire: boolean;
87
+
88
+ // impd holds secrets of kind `oauth`, whose access token it renews from a
89
+ // refresh token.
90
+ readonly oauthSecrets: boolean;
87
91
  }
88
92
 
89
93
  /**
@@ -96,7 +100,7 @@ export type ImpExecRequirement = 'broker';
96
100
  type ImpScope = 'read' | 'exec' | 'manage';
97
101
 
98
102
  export interface ImpIdentity {
99
- readonly kind: 'token' | 'ssh' | 'tailnet' | 'dashboard';
103
+ readonly kind: 'token' | 'ssh' | 'tailnet' | 'dashboard' | 'oauth';
100
104
  readonly name: string;
101
105
  readonly scope: ImpScope;
102
106
 
@@ -111,11 +115,26 @@ export interface ImpIdentity {
111
115
 
112
116
  export interface ImpSecret {
113
117
  readonly name: string;
114
- readonly kind: 'anthropic' | 'custom' | 'github' | 'npm';
118
+ readonly kind: 'anthropic' | 'custom' | 'github' | 'npm' | 'oauth';
115
119
  readonly rules: readonly ImpSecretRule[];
116
120
 
117
121
  // The imps holding a grant of the secret.
118
122
  readonly imps: readonly string[];
123
+
124
+ // The sign-in state of an `oauth` secret; absent for any other kind.
125
+ readonly oauth?: ImpOAuthState;
126
+ }
127
+
128
+ /**
129
+ * Where an `oauth` secret's sign-in stands: `ready` while impd holds an
130
+ * access token, `pending` until a refresh first works, and `needs_login`
131
+ * once the token endpoint refused the refresh token for good. `idClaims`
132
+ * holds the payload of the last ID token, identifiers and never a
133
+ * credential, or null before impd has one.
134
+ */
135
+ interface ImpOAuthState {
136
+ readonly status: 'pending' | 'ready' | 'needs_login';
137
+ readonly idClaims: Readonly<Record<string, unknown>> | null;
119
138
  }
120
139
 
121
140
  // How impd adds a secret to requests for one host.
@@ -0,0 +1,67 @@
1
+ import type { GuestOAuthState } from '../agents/agent-adapter';
2
+ import { DaemonError } from '../protocol/daemon-error';
3
+ import type { BrokerAuthHost } from './broker-auth-host';
4
+ import type { AuthBinding } from './build-auth-binding';
5
+ import { collectSecretRuleMismatches } from './collect-secret-rule-mismatches';
6
+ import { ImpPortError } from './imp-port-error';
7
+
8
+ /**
9
+ * The sign-in state impd lists for each secret a binding holds as kind
10
+ * `oauth`, keyed by the secret's name, or undefined for a binding that
11
+ * holds none, which reads nothing from impd. It reads impd's features and
12
+ * then its secrets once, and refuses an impd without oauth secrets, and a
13
+ * bound oauth secret that impd lacks or holds with another kind or rules,
14
+ * with the codes the binding's own gate would give.
15
+ */
16
+ export async function loadOAuthStates(
17
+ host: BrokerAuthHost,
18
+ binding: AuthBinding,
19
+ ): Promise<Readonly<Record<string, GuestOAuthState>> | undefined> {
20
+ const expected = binding.secrets.filter((secret) => secret.kind === 'oauth');
21
+
22
+ if (expected.length === 0) {
23
+ return undefined;
24
+ }
25
+
26
+ try {
27
+ const features = await host.port.readFeatures();
28
+
29
+ if (!features.oauthSecrets) {
30
+ throw new DaemonError(
31
+ 'auth_impd_too_old',
32
+ `impd lacks oauth secret support, which ${expected.map((secret) => secret.secret).join(', ')} needs`,
33
+ { oauthSecrets: false, secrets: expected.map((secret) => secret.secret) },
34
+ );
35
+ }
36
+
37
+ const held = await host.port.readSecrets();
38
+
39
+ const mismatches = collectSecretRuleMismatches(expected, held);
40
+
41
+ if (mismatches.length > 0) {
42
+ throw new DaemonError(
43
+ 'auth_secret_mismatch',
44
+ `impd holds ${mismatches.map((mismatch) => `${mismatch.secret} (${mismatch.reason})`).join(', ')} unlike the binding`,
45
+ { mismatches },
46
+ );
47
+ }
48
+
49
+ return Object.fromEntries(
50
+ held.flatMap((secret) =>
51
+ secret.oauth !== undefined && expected.some((want) => want.secret === secret.name)
52
+ ? [[secret.name, { status: secret.oauth.status, idClaims: secret.oauth.idClaims }]]
53
+ : [],
54
+ ),
55
+ );
56
+ } catch (error) {
57
+ if (error instanceof ImpPortError) {
58
+ throw new DaemonError(
59
+ 'host_unavailable',
60
+ `impd refused a runtime auth call: ${error.message}`,
61
+ { provider: 'imp', problem: error.code.toLowerCase() },
62
+ );
63
+ }
64
+
65
+ throw error;
66
+ }
67
+ }
@@ -955,7 +955,15 @@ async function verifyGate(
955
955
  ): Promise<void> {
956
956
  const secrets = binding.secrets.map((secret) => secret.secret);
957
957
 
958
- await verifyBrokerAuthority(host.port, { impNames: [impName], secrets }, host.impPrefix);
958
+ const oauthSecrets = binding.secrets
959
+ .filter((secret) => secret.kind === 'oauth')
960
+ .map((secret) => secret.secret);
961
+
962
+ await verifyBrokerAuthority(
963
+ host.port,
964
+ { impNames: [impName], secrets, oauthSecrets },
965
+ host.impPrefix,
966
+ );
959
967
 
960
968
  const held = await host.port.readSecrets();
961
969
 
@@ -46,6 +46,7 @@ import type {
46
46
  } from './execution-provider';
47
47
  import { findExecutionRefusal } from './find-execution-refusal';
48
48
  import type { BridgeBinding } from './is-binding-current';
49
+ import { loadOAuthStates } from './load-oauth-states';
49
50
  import { LocalPTYProvider } from './local-pty-provider';
50
51
  import { mintSessionID } from './mint-session-id';
51
52
  import { pickSessionState } from './pick-session-state';
@@ -982,7 +983,7 @@ export class SessionManager {
982
983
  if (!isLocalTrust && !isGuestTrust) {
983
984
  throw new DaemonError(
984
985
  'unsupported',
985
- 'trustClonedWorkspace requires stock Claude on the local target, or stock Claude or a Claude gateway signed in through the broker on an imp target',
986
+ 'trustClonedWorkspace requires stock Claude on the local target, or stock Claude, a Claude gateway, or Codex signed in through the broker on an imp target',
986
987
  );
987
988
  }
988
989
  }
@@ -2014,7 +2015,7 @@ export class SessionManager {
2014
2015
  : {
2015
2016
  atc: guest.atc,
2016
2017
  dir,
2017
- auth: await this.planGuestAuth(hostKey, auth.mode, auth.binding),
2018
+ auth: await this.planGuestAuth(hostKey, auth),
2018
2019
  };
2019
2020
 
2020
2021
  const plan =
@@ -2096,18 +2097,22 @@ export class SessionManager {
2096
2097
 
2097
2098
  // The binding revision and placeholders a guest plan behind the broker
2098
2099
  // launches under: the next host's first revision, or the revision the
2099
- // shared host holds.
2100
+ // shared host holds, with the sign-in state impd lists for each oauth
2101
+ // secret the binding holds.
2100
2102
  private async planGuestAuth(
2101
2103
  hostKey: SessionID,
2102
- mode: HarnessAuthSetup['mode'],
2103
- binding: Pick<AuthBinding, 'placeholderEnv' | 'profileEnv'>,
2104
+ auth: HarnessAuthSetup,
2104
2105
  ): Promise<NonNullable<GuestPaths['auth']>> {
2105
- const held = mode === 'create' ? null : await this.requireAuthBinder().findBinding(hostKey);
2106
+ const held =
2107
+ auth.mode === 'create' ? null : await this.requireAuthBinder().findBinding(hostKey);
2108
+
2109
+ const oauth = await loadOAuthStates(auth.host, auth.binding);
2106
2110
 
2107
2111
  return {
2108
2112
  revision: held?.revision ?? 1,
2109
- env: binding.placeholderEnv,
2110
- profileEnv: binding.profileEnv,
2113
+ env: auth.binding.placeholderEnv,
2114
+ profileEnv: auth.binding.profileEnv,
2115
+ ...(oauth === undefined ? {} : { oauth }),
2111
2116
  };
2112
2117
  }
2113
2118
 
@@ -4,12 +4,13 @@ import { verifyTokenImpAuthority } from './verify-token-imp-authority';
4
4
 
5
5
  /**
6
6
  * What a provisioning call is about to touch: the imps under their actual
7
- * names, as the target's runtime namespace builds them, and every secret
8
- * the binding grants them.
7
+ * names, as the target's runtime namespace builds them, every secret the
8
+ * binding grants them, and those of the secrets it binds as kind `oauth`.
9
9
  */
10
10
  export interface BrokerActivation {
11
11
  readonly impNames: readonly string[];
12
12
  readonly secrets: readonly string[];
13
+ readonly oauthSecrets?: readonly string[];
13
14
  }
14
15
 
15
16
  /**
@@ -17,7 +18,8 @@ export interface BrokerActivation {
17
18
  * impd's features, then the token's identity, and writes nothing, so a
18
19
  * refusal leaves impd as it was. It rejects unless impd has grantable
19
20
  * tokens, secret rebinds and exec requirements, so a start can refuse to
20
- * run without a ready broker, the token may manage each imp and reaches no
21
+ * run without a ready broker, and oauth secrets when the binding holds one,
22
+ * the token may manage each imp and reaches no
21
23
  * imp outside the namespace whose imp names start with the prefix, and the
22
24
  * token may grant every bound secret.
23
25
  * Whether each secret's rules match the binding is a separate comparison.
@@ -41,6 +43,16 @@ export async function verifyBrokerAuthority(
41
43
  );
42
44
  }
43
45
 
46
+ const oauthSecrets = activation.oauthSecrets ?? [];
47
+
48
+ if (oauthSecrets.length > 0 && !features.oauthSecrets) {
49
+ throw new BrokerAuthorityError(
50
+ 'auth_impd_too_old',
51
+ `impd lacks oauth secret support, which ${oauthSecrets.join(', ')} needs`,
52
+ { oauthSecrets: features.oauthSecrets, secrets: oauthSecrets },
53
+ );
54
+ }
55
+
44
56
  const identity = await port.readIdentity();
45
57
 
46
58
  verifyTokenImpAuthority(identity, activation.impNames, impPrefix);
@@ -51,6 +51,7 @@ const ERROR_CODES = [
51
51
  'auth_not_configured',
52
52
  'auth_target_unsupported',
53
53
  'auth_impd_too_old',
54
+ 'auth_signin_needed',
54
55
  'auth_token_scope',
55
56
  'auth_token_too_broad',
56
57
  'auth_imp_out_of_scope',
@@ -4,6 +4,7 @@ import type { GatewayAuth } from './check-gateway-auth';
4
4
  import type { AuthProfile } from './collect-auth-profiles';
5
5
  import { collectClaudeAuth } from './collect-claude-auth';
6
6
  import type { ClaudeMCPServer } from './collect-claude-auth';
7
+ import { collectCodexAuth } from './collect-codex-auth';
7
8
  import { collectProfileEnvProblems } from './collect-profile-env-problems';
8
9
  import { isSubscriptionOverrideVariable } from './is-subscription-override-variable';
9
10
  import { isRecord } from './report';
@@ -20,7 +21,8 @@ type AgentKind = 'claude' | 'codex' | 'grok';
20
21
  * environment, and the backend and credential it runs against. A Claude entry
21
22
  * with a `baseURL` is a gateway; without one it is stock Claude, whose
22
23
  * `auth` holds profiles alone, for the subscription sign-in, and whose
23
- * `mcpServers` reach their hosts through those profiles.
24
+ * `mcpServers` reach their hosts through those profiles. A Codex entry's
25
+ * `auth` holds profiles alone too, for its ChatGPT sign-in.
24
26
  */
25
27
  export interface AgentEntry {
26
28
  readonly id: AgentID;
@@ -83,9 +85,21 @@ function isAgentKind(value: unknown): value is AgentKind {
83
85
  return KINDS.some((kind) => kind === value);
84
86
  }
85
87
 
86
- // The fields every kind takes, and the ones only a Claude entry takes.
88
+ // The fields every kind takes, and the ones each kind takes beside them.
87
89
  const COMMON_FIELDS = new Set(['kind', 'label', 'mark', 'bin', 'args']);
88
- const CLAUDE_FIELDS = new Set(['settings', 'env', 'baseURL', 'apiKeyHelper', 'auth']);
90
+
91
+ const KIND_FIELDS: Readonly<Record<AgentKind, ReadonlySet<string>>> = {
92
+ claude: new Set(['settings', 'env', 'baseURL', 'apiKeyHelper', 'auth']),
93
+ codex: new Set(['auth']),
94
+ grok: new Set(),
95
+ };
96
+
97
+ const KNOWN_FIELDS: ReadonlySet<string> = new Set([
98
+ ...COMMON_FIELDS,
99
+ ...KIND_FIELDS.claude,
100
+ ...KIND_FIELDS.codex,
101
+ ...KIND_FIELDS.grok,
102
+ ]);
89
103
 
90
104
  const DEFAULT_LABELS: Readonly<Record<AgentKind, string>> = {
91
105
  claude: 'Claude',
@@ -124,6 +138,10 @@ function parseAgentEntry(
124
138
 
125
139
  const entry = buildEntry(id, found.kind, raw);
126
140
 
141
+ if (found.kind === 'codex') {
142
+ return readCodexAuth(entry, raw['auth'], authProfiles);
143
+ }
144
+
127
145
  if (found.kind !== 'claude') {
128
146
  return { entry };
129
147
  }
@@ -168,13 +186,13 @@ function collectFieldProblems(raw: Readonly<Record<string, unknown>>, kind: Agen
168
186
  const problems: string[] = [];
169
187
 
170
188
  for (const [name, value] of Object.entries(raw)) {
171
- if (CLAUDE_FIELDS.has(name) && kind !== 'claude') {
172
- problems.push(`${name} is not valid for kind ${kind}`);
189
+ if (!KNOWN_FIELDS.has(name)) {
190
+ problems.push(`unknown field ${name}`);
173
191
  continue;
174
192
  }
175
193
 
176
- if (!COMMON_FIELDS.has(name) && !CLAUDE_FIELDS.has(name)) {
177
- problems.push(`unknown field ${name}`);
194
+ if (!COMMON_FIELDS.has(name) && !KIND_FIELDS[kind].has(name)) {
195
+ problems.push(`${name} is not valid for kind ${kind}`);
178
196
  continue;
179
197
  }
180
198
 
@@ -262,6 +280,27 @@ function buildDefaultLabel(id: string, kind: AgentKind): string {
262
280
  return id === kind ? DEFAULT_LABELS[kind] : id;
263
281
  }
264
282
 
283
+ // A Codex entry with the `auth` it holds, or every problem with that auth.
284
+ // Codex takes no placeholder variable: atc writes the sign-in file the CLI
285
+ // reads in place of a credential.
286
+ function readCodexAuth(
287
+ entry: AgentEntry,
288
+ raw: unknown,
289
+ authProfiles: ReadonlyMap<string, AuthProfile>,
290
+ ): ParsedEntry {
291
+ if (raw === undefined) {
292
+ return { entry };
293
+ }
294
+
295
+ const collected = collectCodexAuth(raw, authProfiles);
296
+
297
+ if (collected.profiles === null) {
298
+ return { problems: [...collected.errors] };
299
+ }
300
+
301
+ return { entry: { ...entry, auth: { profiles: collected.profiles, placeholderEnv: {} } } };
302
+ }
303
+
265
304
  interface ReadClaudeAuth {
266
305
  readonly auth?: GatewayAuth;
267
306
  readonly mcpServers?: readonly ClaudeMCPServer[];
@@ -6,15 +6,17 @@ import { isRecord } from './report';
6
6
  * A named reference to a credential impd holds: the secret's name, never
7
7
  * its value, and the profiles a session selecting this one needs beside
8
8
  * it. A `custom` profile holds the one rule impd applies when a request
9
- * reaches its host, always a bearer header. A `github` profile holds no
10
- * rule: impd's `github` kind fixes its hosts and headers.
9
+ * reaches its host, always a bearer header. An `oauth` profile holds the
10
+ * same rule for a secret whose access token impd renews from a refresh
11
+ * token. A `github` profile holds no rule: impd's `github` kind fixes its
12
+ * hosts and headers.
11
13
  */
12
14
  export type AuthProfile = CustomAuthProfile | GitHubAuthProfile;
13
15
 
14
16
  interface CustomAuthProfile {
15
17
  readonly name: string;
16
18
  readonly secret: string;
17
- readonly kind: 'custom';
19
+ readonly kind: 'custom' | 'oauth';
18
20
  readonly host: string;
19
21
  readonly header: string;
20
22
  readonly scheme: 'bearer';
@@ -94,8 +96,8 @@ function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
94
96
  const dependencies = entry['dependencies'];
95
97
  const kind = entry['kind'] ?? 'custom';
96
98
 
97
- if (kind !== 'custom' && kind !== 'github') {
98
- return 'kind must be custom or github, the kinds atc binds';
99
+ if (kind !== 'custom' && kind !== 'oauth' && kind !== 'github') {
100
+ return 'kind must be custom, oauth, or github, the kinds atc binds';
99
101
  }
100
102
 
101
103
  if (typeof secret !== 'string' || !SECRET_NAME.test(secret)) {
@@ -148,7 +150,7 @@ function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
148
150
  return {
149
151
  name,
150
152
  secret,
151
- kind: 'custom',
153
+ kind,
152
154
  host,
153
155
  header,
154
156
  scheme,
@@ -162,7 +164,7 @@ function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
162
164
  // other value, so a credential is never written into a profile.
163
165
  const ENV_PLACEHOLDER = 'imp-broker-placeholder';
164
166
  const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
165
- const RESERVED_ENV_PREFIXES = ['ANTHROPIC_', 'CLAUDE_', 'ATC_'];
167
+ const RESERVED_ENV_PREFIXES = ['ANTHROPIC_', 'CLAUDE_', 'ATC_', 'CODEX_', 'OPENAI_'];
166
168
 
167
169
  const RESERVED_ENV_NAMES: ReadonlySet<string> = new Set(['PATH', 'HOME']);
168
170
 
@@ -0,0 +1,70 @@
1
+ import type { AuthProfile } from './collect-auth-profiles';
2
+ import { isRecord } from './report';
3
+ import { resolveAuthProfiles } from './resolve-auth-profiles';
4
+
5
+ interface CollectedCodexAuth {
6
+ readonly profiles: readonly string[] | null;
7
+ readonly errors: readonly string[];
8
+ }
9
+
10
+ /**
11
+ * Reads the `auth` of a Codex entry against the auth profiles: the
12
+ * profiles a Codex session on a target that reaches impd's broker signs in
13
+ * through. One of them must send an `oauth` secret to `chatgpt.com` as a
14
+ * bearer authorization header, where Codex sends its ChatGPT sign-in, and
15
+ * the rest bind beside it, such as a GitHub token. An entry that does not
16
+ * resolve or holds no such profile gets null and every problem with it.
17
+ */
18
+ export function collectCodexAuth(
19
+ raw: unknown,
20
+ authProfiles: ReadonlyMap<string, AuthProfile>,
21
+ ): CollectedCodexAuth {
22
+ const profiles = isRecord(raw) ? raw['profiles'] : undefined;
23
+
24
+ if (
25
+ !isRecord(raw) ||
26
+ !Array.isArray(profiles) ||
27
+ profiles.length === 0 ||
28
+ !profiles.every((name) => typeof name === 'string')
29
+ ) {
30
+ return { profiles: null, errors: ['auth must be an object with a non-empty profiles array'] };
31
+ }
32
+
33
+ const extra = Object.keys(raw).find((key) => key !== 'profiles');
34
+
35
+ if (extra !== undefined) {
36
+ return {
37
+ profiles: null,
38
+ errors: [
39
+ `auth.${extra} cannot be set: atc fixes the endpoint and the sign-in of a Codex session`,
40
+ ],
41
+ };
42
+ }
43
+
44
+ const selected = profiles.map(String);
45
+ const resolution = resolveAuthProfiles(authProfiles, selected);
46
+
47
+ if ('problem' in resolution) {
48
+ return { profiles: null, errors: [`auth: ${resolution.problem.message}`] };
49
+ }
50
+
51
+ const secret = resolution.resolved.secrets.find((s) =>
52
+ s.rules.some((rule) => rule.host === CHATGPT_HOST),
53
+ );
54
+
55
+ const rule = secret?.rules.find((r) => r.host === CHATGPT_HOST);
56
+
57
+ if (secret?.kind !== 'oauth' || rule?.header !== 'authorization' || rule.scheme !== 'bearer') {
58
+ return {
59
+ profiles: null,
60
+ errors: [
61
+ `auth needs an oauth profile that sets a bearer authorization header for ${CHATGPT_HOST}, where Codex sends its ChatGPT sign-in`,
62
+ ],
63
+ };
64
+ }
65
+
66
+ return { profiles: selected, errors: [] };
67
+ }
68
+
69
+ // The one host Codex sends its ChatGPT sign-in to.
70
+ const CHATGPT_HOST = 'chatgpt.com';