@zgeoff/atc 2.19.0 → 2.20.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,13 +1,26 @@
1
1
  import { z } from 'zod';
2
2
  import type { AgentID } from './agent-id';
3
3
  import { buildOptionalStringArray } from './build-optional-string-array';
4
+ import type { AuthProfile } from './collect-auth-profiles';
4
5
  import { isRecord } from './report';
6
+ import { resolveAuthProfiles } from './resolve-auth-profiles';
7
+
8
+ /**
9
+ * The auth profiles a gateway's sessions are bound to through impd's
10
+ * broker, and the variables each session gets in place of a credential,
11
+ * every value the fixed placeholder.
12
+ */
13
+ export interface GatewayAuth {
14
+ readonly profiles: readonly string[];
15
+ readonly placeholderEnv: Readonly<Record<string, string>>;
16
+ }
5
17
 
6
18
  /**
7
19
  * A Claude-compatible backend the Claude CLI is pointed at: its own agent id,
8
20
  * its own row in the spawn menu, and its own generated settings file. The
9
- * credential is never held here — a helper command supplies it at run time,
10
- * so it stays out of the file atc writes.
21
+ * credential is never held here: a helper command supplies it at run time,
22
+ * or, with `auth`, impd's credential broker adds it to each request on the
23
+ * host's side, so it stays out of the file atc writes.
11
24
  */
12
25
  export interface GatewayConfig {
13
26
  readonly id: AgentID;
@@ -19,6 +32,14 @@ export interface GatewayConfig {
19
32
  readonly apiKeyHelper?: string;
20
33
  readonly env: Readonly<Record<string, string>>;
21
34
  readonly settings?: Readonly<Record<string, unknown>>;
35
+ readonly auth?: GatewayAuth;
36
+ }
37
+
38
+ interface Gateways {
39
+ readonly gateways: GatewayConfig[];
40
+
41
+ // The problems that kept a gateway with `auth` out, one per problem.
42
+ readonly errors: string[];
22
43
  }
23
44
 
24
45
  // Ids the built-in adapters answer to; a gateway may not take one.
@@ -41,18 +62,22 @@ const GATEWAY_ENTRY_SCHEMA = z.object({
41
62
  /**
42
63
  * Reads the gateway map into menu order. An entry without a base URL, or
43
64
  * under an id another adapter already answers to, is left out rather than
44
- * registered half-formed: every gateway in the result can be spawned.
65
+ * registered half-formed: every gateway in the result can be spawned. A
66
+ * gateway with `auth` is checked against the auth profiles, and one that
67
+ * fails a check is left out with an error for each problem.
45
68
  */
46
69
  export function collectGateways(
47
70
  raw: unknown,
48
71
  claudeBin: string,
49
72
  claudeArgs: readonly string[],
50
- ): GatewayConfig[] {
73
+ authProfiles: ReadonlyMap<string, AuthProfile> = new Map(),
74
+ ): Gateways {
51
75
  if (!isRecord(raw)) {
52
- return [];
76
+ return { gateways: [], errors: [] };
53
77
  }
54
78
 
55
79
  const gateways: GatewayConfig[] = [];
80
+ const errors: string[] = [];
56
81
 
57
82
  for (const [id, entry] of Object.entries(raw)) {
58
83
  if (id === '' || BUILT_IN_IDS.has(id)) {
@@ -65,6 +90,19 @@ export function collectGateways(
65
90
  continue;
66
91
  }
67
92
 
93
+ let auth: GatewayAuth | undefined;
94
+
95
+ if (isRecord(entry) && entry['auth'] !== undefined) {
96
+ const checked = checkGatewayAuth(entry['auth'], parsed.data, authProfiles);
97
+
98
+ if (Array.isArray(checked)) {
99
+ errors.push(...checked.map((problem) => `gateways.${id}: ${problem}`));
100
+ continue;
101
+ }
102
+
103
+ auth = checked;
104
+ }
105
+
68
106
  const firstOfMark = (parsed.data.mark ?? id).codePointAt(0);
69
107
 
70
108
  gateways.push({
@@ -79,10 +117,189 @@ export function collectGateways(
79
117
  ...(parsed.data.apiKeyHelper === undefined ? {} : { apiKeyHelper: parsed.data.apiKeyHelper }),
80
118
  env: parsed.data.env,
81
119
  ...(parsed.data.settings === undefined ? {} : { settings: parsed.data.settings }),
120
+ ...(auth === undefined ? {} : { auth }),
82
121
  });
83
122
  }
84
123
 
85
- return gateways;
124
+ return { gateways, errors };
125
+ }
126
+
127
+ interface GatewayEntry {
128
+ readonly baseURL?: string | undefined;
129
+ readonly apiKeyHelper?: string | undefined;
130
+ readonly env: Readonly<Record<string, string>>;
131
+ readonly settings?: Readonly<Record<string, unknown>> | undefined;
132
+ }
133
+
134
+ // The gateway's auth, or every problem that refuses it. The checks cover
135
+ // each place a session's environment comes from: the gateway env, the
136
+ // settings env, and the placeholders, and which of them would win.
137
+ function checkGatewayAuth(
138
+ raw: unknown,
139
+ entry: GatewayEntry,
140
+ authProfiles: ReadonlyMap<string, AuthProfile>,
141
+ ): GatewayAuth | string[] {
142
+ const auth = parseGatewayAuth(raw);
143
+
144
+ if (typeof auth === 'string') {
145
+ return [auth];
146
+ }
147
+
148
+ const problems: string[] = [];
149
+
150
+ if (entry.apiKeyHelper !== undefined) {
151
+ problems.push(
152
+ 'apiKeyHelper cannot be set together with auth, which supplies the credential through the broker',
153
+ );
154
+ }
155
+
156
+ if (entry.settings?.['apiKeyHelper'] !== undefined) {
157
+ problems.push(
158
+ 'settings.apiKeyHelper cannot be set together with auth, which supplies the credential through the broker',
159
+ );
160
+ }
161
+
162
+ const settingsEnv = toEnvKeys(entry.settings?.['env']);
163
+
164
+ problems.push(
165
+ ...collectEnvProblems('env', Object.keys(entry.env)),
166
+ ...collectEnvProblems('settings.env', settingsEnv),
167
+ ...collectPlaceholderProblems(auth.placeholderEnv),
168
+ );
169
+
170
+ for (const key of Object.keys(auth.placeholderEnv)) {
171
+ for (const [source, keys] of [
172
+ ['env', Object.keys(entry.env)],
173
+ ['settings.env', settingsEnv],
174
+ ] as const) {
175
+ if (keys.includes(key)) {
176
+ problems.push(`placeholderEnv.${key} is also set in ${source}, which would override it`);
177
+ }
178
+ }
179
+ }
180
+
181
+ const resolution = resolveAuthProfiles(authProfiles, auth.profiles);
182
+
183
+ if ('problem' in resolution) {
184
+ problems.push(resolution.problem.message);
185
+ } else {
186
+ const hostProblem = findBaseURLProblem(entry.baseURL ?? '', resolution.resolved.hosts);
187
+
188
+ if (hostProblem !== null) {
189
+ problems.push(hostProblem);
190
+ }
191
+ }
192
+
193
+ return problems.length > 0 ? problems : auth;
194
+ }
195
+
196
+ // The value every placeholder variable holds; impd's broker swaps the real
197
+ // credential in on the host's side.
198
+ const PLACEHOLDER = 'imp-broker-placeholder';
199
+
200
+ function parseGatewayAuth(raw: unknown): GatewayAuth | string {
201
+ const profiles = isRecord(raw) ? raw['profiles'] : undefined;
202
+
203
+ if (
204
+ !isRecord(raw) ||
205
+ !Array.isArray(profiles) ||
206
+ profiles.length === 0 ||
207
+ !profiles.every((name) => typeof name === 'string')
208
+ ) {
209
+ return 'auth must be an object with a non-empty profiles array';
210
+ }
211
+
212
+ const placeholderEnv = raw['placeholderEnv'] ?? {};
213
+
214
+ if (!isRecord(placeholderEnv) || Array.isArray(placeholderEnv)) {
215
+ return 'placeholderEnv must be an object of variable names';
216
+ }
217
+
218
+ const env: Record<string, string> = {};
219
+
220
+ for (const [key, value] of Object.entries(placeholderEnv)) {
221
+ if (value !== PLACEHOLDER) {
222
+ return `placeholderEnv.${key} must be ${PLACEHOLDER}`;
223
+ }
224
+
225
+ env[key] = value;
226
+ }
227
+
228
+ return { profiles: profiles.map(String), placeholderEnv: env };
229
+ }
230
+
231
+ // A settings env's variable names; anything but an object sets none.
232
+ function toEnvKeys(value: unknown): string[] {
233
+ return isRecord(value) && !Array.isArray(value) ? Object.keys(value) : [];
234
+ }
235
+
236
+ // Variables Claude reads its endpoint and credential from. Only the
237
+ // placeholders may set a credential variable, and the base URL comes from
238
+ // `baseURL` alone, whose host is checked against the profiles.
239
+ const CREDENTIAL_VARIABLES: ReadonlySet<string> = new Set([
240
+ 'ANTHROPIC_AUTH_TOKEN',
241
+ 'ANTHROPIC_API_KEY',
242
+ ]);
243
+
244
+ const BASE_URL_VARIABLE = 'ANTHROPIC_BASE_URL';
245
+
246
+ function collectEnvProblems(source: string, keys: readonly string[]): string[] {
247
+ return keys
248
+ .filter(
249
+ (key) => isBrokerVariable(key) || CREDENTIAL_VARIABLES.has(key) || key === BASE_URL_VARIABLE,
250
+ )
251
+ .map((key) => `${source} must not set ${key}`);
252
+ }
253
+
254
+ // Variables impd sets in a brokered exec to route requests through the
255
+ // broker and trust its CA. impd lets a variable the caller sets win, so a
256
+ // configured one would route around the broker or mask a failed CA install.
257
+ const BROKER_VARIABLES: ReadonlySet<string> = new Set([
258
+ 'HTTPS_PROXY',
259
+ 'HTTP_PROXY',
260
+ 'ALL_PROXY',
261
+ 'NO_PROXY',
262
+ 'NODE_USE_ENV_PROXY',
263
+ 'SSL_CERT_FILE',
264
+ 'SSL_CERT_DIR',
265
+ 'NODE_EXTRA_CA_CERTS',
266
+ 'GIT_SSL_CAINFO',
267
+ 'REQUESTS_CA_BUNDLE',
268
+ 'CURL_CA_BUNDLE',
269
+ ]);
270
+
271
+ // Proxy and CA variables are read in either case by some clients.
272
+ function isBrokerVariable(key: string): boolean {
273
+ return BROKER_VARIABLES.has(key.toUpperCase());
274
+ }
275
+
276
+ function collectPlaceholderProblems(env: Readonly<Record<string, string>>): string[] {
277
+ return Object.keys(env)
278
+ .filter((key) => isBrokerVariable(key) || key === BASE_URL_VARIABLE)
279
+ .map((key) => `placeholderEnv must not set ${key}`);
280
+ }
281
+
282
+ // The broker matches a request by the exact host it connects to on the
283
+ // default https port, so the base URL must be https on that port, with a
284
+ // host one of the selected profiles covers.
285
+ function findBaseURLProblem(baseURL: string, hosts: readonly string[]): string | null {
286
+ let url: URL;
287
+
288
+ try {
289
+ url = new URL(baseURL);
290
+ } catch {
291
+ return 'baseURL must be an https URL with no port or user info';
292
+ }
293
+
294
+ if (url.protocol !== 'https:' || url.port !== '' || url.username !== '' || url.password !== '') {
295
+ return 'baseURL must be an https URL with no port or user info';
296
+ }
297
+
298
+ if (!hosts.includes(url.hostname)) {
299
+ return `baseURL host ${url.hostname} is not a host of the selected profiles (${hosts.join(', ')})`;
300
+ }
301
+
302
+ return null;
86
303
  }
87
304
 
88
305
  // Claude Code settings the sessions of this gateway are started with, on top of
@@ -3,6 +3,8 @@ import { dirname, join } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { buildOptionalString } from './build-optional-string';
5
5
  import { buildOptionalStringArray } from './build-optional-string-array';
6
+ import { collectAuthProfiles } from './collect-auth-profiles';
7
+ import type { AuthProfile } from './collect-auth-profiles';
6
8
  import { collectDirRoots } from './collect-dir-roots';
7
9
  import { collectGateways } from './collect-gateways';
8
10
  import type { GatewayConfig } from './collect-gateways';
@@ -28,6 +30,14 @@ export interface Config {
28
30
  dirs: DirsConfig;
29
31
  workspaces: WorkspacesConfig;
30
32
  gateways: GatewayConfig[];
33
+
34
+ // The problems that kept a gateway with auth out of the gateways.
35
+ gatewayErrors: readonly string[];
36
+
37
+ // The credential references a gateway's auth selects from, by profile
38
+ // name, and the problems that kept a profile out.
39
+ authProfiles: ReadonlyMap<string, AuthProfile>;
40
+ authProfileErrors: readonly string[];
31
41
  hooks: HooksConfig;
32
42
  leader: LeaderKey;
33
43
 
@@ -78,6 +88,9 @@ const DEFAULTS: Config = {
78
88
  targetRoots: new Map(),
79
89
  },
80
90
  gateways: [],
91
+ gatewayErrors: [],
92
+ authProfiles: new Map(),
93
+ authProfileErrors: [],
81
94
  hooks: {},
82
95
  leader: { code: 0, label: '^Space' },
83
96
  targets: [{ id: 'local', provider: 'local-pty', options: {} }],
@@ -118,6 +131,7 @@ const CONFIG_SCHEMA = z.object({
118
131
  dirs: z.unknown().optional(),
119
132
  workspaces: z.unknown().optional(),
120
133
  gateways: z.unknown().optional(),
134
+ authProfiles: z.unknown().optional(),
121
135
  hooks: z.unknown().optional(),
122
136
  leader: buildOptionalString(),
123
137
  targets: z.unknown().optional(),
@@ -206,11 +220,14 @@ function tryWriteDefaultConfig(file: string): void {
206
220
  /**
207
221
  * The config.json text a first run writes. It leaves out the targets and
208
222
  * principals, so the file holds the one implicit `local` target and no
209
- * principals until the user sets their own, and the errors a parse
210
- * reports, which belong to no file.
223
+ * principals until the user sets their own, no auth profiles, and the
224
+ * errors a parse reports, which belong to no file.
211
225
  */
212
226
  export function renderDefaultConfig(): string {
213
227
  const {
228
+ gatewayErrors: _gatewayErrors,
229
+ authProfiles: _authProfiles,
230
+ authProfileErrors: _authProfileErrors,
214
231
  targets: _targets,
215
232
  defaultTarget: _default,
216
233
  targetErrors: _errors,
@@ -266,7 +283,15 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
266
283
  const codexArgs = parsed.data.codexArgs ?? DEFAULTS.codexArgs;
267
284
  const dirs = { roots: collectDirRoots(parsed.data.dirs) };
268
285
  const workspaces = collectWorkspacesConfig(parsed.data.workspaces);
269
- const gateways = collectGateways(parsed.data.gateways, claudeBin, claudeArgs);
286
+ const authProfiles = collectAuthProfiles(parsed.data.authProfiles);
287
+
288
+ const gateways = collectGateways(
289
+ parsed.data.gateways,
290
+ claudeBin,
291
+ claudeArgs,
292
+ authProfiles.profiles,
293
+ );
294
+
270
295
  const hooks = collectHooks(parsed.data.hooks);
271
296
  const targets = collectTargets(parsed.data.targets, parsed.data.defaultTarget);
272
297
  const principals = collectPrincipals(parsed.data.principals);
@@ -283,7 +308,10 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
283
308
  codexArgs,
284
309
  dirs,
285
310
  workspaces: workspaces.workspaces,
286
- gateways,
311
+ gateways: gateways.gateways,
312
+ gatewayErrors: gateways.errors,
313
+ authProfiles: authProfiles.profiles,
314
+ authProfileErrors: authProfiles.errors,
287
315
  hooks,
288
316
  leader,
289
317
  targets: targets.targets,
@@ -0,0 +1,186 @@
1
+ import type { AuthProfile } from './collect-auth-profiles';
2
+
3
+ /**
4
+ * How impd applies one secret to requests for one host.
5
+ */
6
+ interface AuthRule {
7
+ readonly host: string;
8
+ readonly header: string;
9
+ readonly scheme: 'bearer';
10
+ }
11
+
12
+ /**
13
+ * One secret and the complete set of rules a selection needs impd to hold
14
+ * for it, one per host, sorted by host.
15
+ */
16
+ export interface ResolvedAuthSecret {
17
+ readonly secret: string;
18
+ readonly kind: 'custom';
19
+ readonly rules: readonly AuthRule[];
20
+ }
21
+
22
+ /**
23
+ * A selection with its dependencies expanded: every profile it reaches,
24
+ * every host those profiles send a credential to, and the rules grouped by
25
+ * secret, each list sorted so two selections of the same profiles resolve
26
+ * the same.
27
+ */
28
+ interface ResolvedAuthProfiles {
29
+ readonly profiles: readonly string[];
30
+ readonly hosts: readonly string[];
31
+ readonly secrets: readonly ResolvedAuthSecret[];
32
+ }
33
+
34
+ /**
35
+ * Why a selection cannot be bound:
36
+ *
37
+ * - `auth_profile_unknown`: it reaches a profile that is missing or that
38
+ * the config refused.
39
+ * - `auth_dependency_cycle`: the dependencies loop back on themselves.
40
+ * - `auth_collision`: two profiles send different credentials or rules to
41
+ * one host, which impd could not tell apart.
42
+ */
43
+ export interface AuthProfileProblem {
44
+ readonly code: 'auth_profile_unknown' | 'auth_dependency_cycle' | 'auth_collision';
45
+ readonly message: string;
46
+ }
47
+
48
+ export type AuthProfileResolution =
49
+ | { readonly resolved: ResolvedAuthProfiles }
50
+ | { readonly problem: AuthProfileProblem };
51
+
52
+ /**
53
+ * Expands the selected profiles through their dependencies, then checks
54
+ * the whole expanded set: each host must get exactly one rule, so a
55
+ * credential is never picked by order. Two profiles holding the identical
56
+ * secret and rule for a host merge into one rule.
57
+ */
58
+ export function resolveAuthProfiles(
59
+ profiles: ReadonlyMap<string, AuthProfile>,
60
+ selected: readonly string[],
61
+ ): AuthProfileResolution {
62
+ let reached: ReadonlyMap<string, AuthProfile> = new Map();
63
+
64
+ for (const name of selected) {
65
+ const next = collectReached(profiles, name, null, [], reached);
66
+
67
+ if (isAuthProfileProblem(next)) {
68
+ return { problem: next };
69
+ }
70
+
71
+ reached = next;
72
+ }
73
+
74
+ const ordered = [...reached.values()].toSorted((a, b) => (a.name < b.name ? -1 : 1));
75
+
76
+ const byHost = new Map<string, AuthProfile>();
77
+
78
+ for (const profile of ordered) {
79
+ const other = byHost.get(profile.host);
80
+
81
+ if (other === undefined) {
82
+ byHost.set(profile.host, profile);
83
+ } else if (!hasSameRule(other, profile)) {
84
+ return {
85
+ problem: {
86
+ code: 'auth_collision',
87
+ message: `profiles ${other.name} and ${profile.name} both send a credential to ${profile.host}`,
88
+ },
89
+ };
90
+ }
91
+ }
92
+
93
+ return {
94
+ resolved: {
95
+ profiles: ordered.map((profile) => profile.name),
96
+ hosts: [...byHost.keys()].toSorted(),
97
+ secrets: buildSecrets([...byHost.values()]),
98
+ },
99
+ };
100
+ }
101
+
102
+ // Depth-first over the dependencies from one name: returns `reached` plus
103
+ // every profile this name reaches, each added once its own dependencies are
104
+ // done. A profile already reached is not walked again, so a shared
105
+ // dependency costs one visit.
106
+ // `path` holds the names on the way here, so a name already on it closes a
107
+ // cycle.
108
+ function collectReached(
109
+ profiles: ReadonlyMap<string, AuthProfile>,
110
+ name: string,
111
+ from: string | null,
112
+ path: readonly string[],
113
+ reached: ReadonlyMap<string, AuthProfile>,
114
+ ): ReadonlyMap<string, AuthProfile> | AuthProfileProblem {
115
+ if (path.includes(name)) {
116
+ return {
117
+ code: 'auth_dependency_cycle',
118
+ message: `profile dependencies form a cycle: ${[...path.slice(path.indexOf(name)), name].join(' -> ')}`,
119
+ };
120
+ }
121
+
122
+ if (reached.has(name)) {
123
+ return reached;
124
+ }
125
+
126
+ const profile = profiles.get(name);
127
+
128
+ if (profile === undefined) {
129
+ return {
130
+ code: 'auth_profile_unknown',
131
+ message:
132
+ from === null
133
+ ? `profile ${name} is selected, but authProfiles has no usable profile by that name`
134
+ : `profile ${from} depends on ${name}, but authProfiles has no usable profile by that name`,
135
+ };
136
+ }
137
+
138
+ let next = reached;
139
+
140
+ for (const dependency of profile.dependencies) {
141
+ const after = collectReached(profiles, dependency, name, [...path, name], next);
142
+
143
+ if (isAuthProfileProblem(after)) {
144
+ return after;
145
+ }
146
+
147
+ next = after;
148
+ }
149
+
150
+ return new Map([...next, [name, profile]]);
151
+ }
152
+
153
+ function isAuthProfileProblem(
154
+ value: ReadonlyMap<string, AuthProfile> | AuthProfileProblem,
155
+ ): value is AuthProfileProblem {
156
+ return 'code' in value;
157
+ }
158
+
159
+ function hasSameRule(left: AuthProfile, right: AuthProfile): boolean {
160
+ return (
161
+ left.secret === right.secret &&
162
+ left.header === right.header &&
163
+ left.scheme === right.scheme &&
164
+ left.kind === right.kind
165
+ );
166
+ }
167
+
168
+ // One entry per secret, sorted by secret, each with its rules sorted by host.
169
+ function buildSecrets(profiles: readonly AuthProfile[]): ResolvedAuthSecret[] {
170
+ const rules = new Map<string, AuthRule[]>();
171
+
172
+ for (const profile of profiles) {
173
+ const list = rules.get(profile.secret) ?? [];
174
+
175
+ list.push({ host: profile.host, header: profile.header, scheme: profile.scheme });
176
+ rules.set(profile.secret, list);
177
+ }
178
+
179
+ return [...rules.entries()]
180
+ .toSorted(([a], [b]) => (a < b ? -1 : 1))
181
+ .map(([secret, list]) => ({
182
+ secret,
183
+ kind: 'custom',
184
+ rules: list.toSorted((a, b) => (a.host < b.host ? -1 : 1)),
185
+ }));
186
+ }
@@ -5,6 +5,7 @@ import { DEFAULT_MIGRATION_TABLE, Migrator } from 'kysely/migration';
5
5
  import type { Migration, MigrationProvider, MigrationResultSet } from 'kysely/migration';
6
6
  import type { IdempotencyState } from './idempotency-record';
7
7
  import type { MessageStatus } from './message-record';
8
+ import type { RuntimeAuthBindingState, RuntimeAuthGrantPhase } from './runtime-auth-binding';
8
9
  import type { MaterializationPhase } from './workspace-materialization';
9
10
 
10
11
  interface FleetTable {
@@ -118,6 +119,41 @@ interface WorkspaceMaterializationTable {
118
119
  withheld_env: string | null;
119
120
  }
120
121
 
122
+ // One host's runtime auth binding, keyed by the session that owns the imp.
123
+ // The rebind columns hold a rebind in flight or the one that failed, all
124
+ // null otherwise.
125
+ interface RuntimeAuthBindingTable {
126
+ host_key: string;
127
+ target: string;
128
+ target_identity: string;
129
+ imp_name: string;
130
+ imp_id: string | null;
131
+ revision: number;
132
+ binding_hash: string;
133
+ binding_json: string;
134
+ state: RuntimeAuthBindingState;
135
+ attempt_id: string;
136
+ imp_created_by_attempt: number;
137
+ rebind_revision: number | null;
138
+ rebind_binding_hash: string | null;
139
+ rebind_binding_json: string | null;
140
+ rebind_attempt_id: string | null;
141
+ created_at: number;
142
+ updated_at: number;
143
+ revoked_at: number | null;
144
+ }
145
+
146
+ // One secret's grant to a host's imp, keyed by the host and the secret.
147
+ interface RuntimeAuthGrantTable {
148
+ host_key: string;
149
+ secret: string;
150
+ revision: number;
151
+ attempt_id: string;
152
+ preexisting: number;
153
+ phase: RuntimeAuthGrantPhase;
154
+ updated_at: number;
155
+ }
156
+
121
157
  interface MessagesTable {
122
158
  id: string;
123
159
  atc_id: string;
@@ -148,6 +184,8 @@ export interface StateStoreSchema {
148
184
  session_owner: SessionOwnerTable;
149
185
  idempotency: IdempotencyTable;
150
186
  workspace_materialization: WorkspaceMaterializationTable;
187
+ runtime_auth_binding: RuntimeAuthBindingTable;
188
+ runtime_auth_grant: RuntimeAuthGrantTable;
151
189
  }
152
190
 
153
191
  // Every shape the fleet table has shipped with: the oldest carries only
@@ -472,6 +510,45 @@ const MIGRATIONS: Record<string, Migration> = {
472
510
  }
473
511
  },
474
512
  },
513
+ '025_create_runtime_auth': {
514
+ async up(db: Kysely<StateStoreSchema>) {
515
+ await db.schema
516
+ .createTable('runtime_auth_binding')
517
+ .ifNotExists()
518
+ .addColumn('host_key', 'text', (c) => c.primaryKey())
519
+ .addColumn('target', 'text', (c) => c.notNull())
520
+ .addColumn('target_identity', 'text', (c) => c.notNull())
521
+ .addColumn('imp_name', 'text', (c) => c.notNull())
522
+ .addColumn('imp_id', 'text')
523
+ .addColumn('revision', 'integer', (c) => c.notNull())
524
+ .addColumn('binding_hash', 'text', (c) => c.notNull())
525
+ .addColumn('binding_json', 'text', (c) => c.notNull())
526
+ .addColumn('state', 'text', (c) => c.notNull())
527
+ .addColumn('attempt_id', 'text', (c) => c.notNull())
528
+ .addColumn('imp_created_by_attempt', 'integer', (c) => c.notNull().defaultTo(0))
529
+ .addColumn('rebind_revision', 'integer')
530
+ .addColumn('rebind_binding_hash', 'text')
531
+ .addColumn('rebind_binding_json', 'text')
532
+ .addColumn('rebind_attempt_id', 'text')
533
+ .addColumn('created_at', 'integer', (c) => c.notNull())
534
+ .addColumn('updated_at', 'integer', (c) => c.notNull())
535
+ .addColumn('revoked_at', 'integer')
536
+ .execute();
537
+
538
+ await db.schema
539
+ .createTable('runtime_auth_grant')
540
+ .ifNotExists()
541
+ .addColumn('host_key', 'text', (c) => c.notNull())
542
+ .addColumn('secret', 'text', (c) => c.notNull())
543
+ .addColumn('revision', 'integer', (c) => c.notNull())
544
+ .addColumn('attempt_id', 'text', (c) => c.notNull())
545
+ .addColumn('preexisting', 'integer', (c) => c.notNull())
546
+ .addColumn('phase', 'text', (c) => c.notNull())
547
+ .addColumn('updated_at', 'integer', (c) => c.notNull())
548
+ .addPrimaryKeyConstraint('runtime_auth_grant_pk', ['host_key', 'secret'])
549
+ .execute();
550
+ },
551
+ },
475
552
  };
476
553
 
477
554
  const PROVIDER: MigrationProvider = {