@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 +2 -2
- package/src/agents/agent-adapter.ts +16 -3
- package/src/agents/build-agent-adapters.ts +1 -1
- package/src/agents/build-codex-auth-file.ts +62 -0
- package/src/agents/build-codex-config.ts +10 -0
- package/src/agents/build-codex-guest-launch.ts +53 -0
- package/src/agents/build-codex-hook-file.ts +23 -0
- package/src/agents/build-codex-trust-seed.ts +13 -0
- package/src/agents/codex-adapter.ts +167 -1
- package/src/agents/codex-trust-seed-file.ts +4 -0
- package/src/agents/print-codex-hook-file.ts +3 -18
- package/src/daemon/broker-authority-error.ts +1 -1
- package/src/daemon/imp-client-port.ts +28 -11
- package/src/daemon/imp-port.ts +21 -2
- package/src/daemon/load-oauth-states.ts +67 -0
- package/src/daemon/runtime-auth-binder.ts +9 -1
- package/src/daemon/sessions.ts +13 -8
- package/src/daemon/verify-broker-authority.ts +15 -3
- package/src/protocol/protocol.ts +1 -0
- package/src/shared/collect-agents.ts +46 -7
- package/src/shared/collect-auth-profiles.ts +9 -7
- package/src/shared/collect-codex-auth.ts +70 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgeoff/atc",
|
|
3
|
-
"version": "
|
|
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.
|
|
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,
|
|
47
|
-
* placeholder variables the harness holds in place of a credential,
|
|
48
|
-
*
|
|
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
|
-
|
|
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.
|
|
@@ -1,26 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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;
|
package/src/daemon/imp-port.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
2103
|
-
binding: Pick<AuthBinding, 'placeholderEnv' | 'profileEnv'>,
|
|
2104
|
+
auth: HarnessAuthSetup,
|
|
2104
2105
|
): Promise<NonNullable<GuestPaths['auth']>> {
|
|
2105
|
-
const held =
|
|
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,
|
|
8
|
-
*
|
|
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,
|
|
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);
|
package/src/protocol/protocol.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
172
|
-
problems.push(
|
|
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) && !
|
|
177
|
-
problems.push(
|
|
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.
|
|
10
|
-
* rule
|
|
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
|
|
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';
|