@zgeoff/atc 2.30.0 → 2.31.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.30.0",
3
+ "version": "2.31.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",
@@ -76,7 +76,10 @@
76
76
  "hono": ">=4.13.5",
77
77
  "ip-address": "10.7.2",
78
78
  "js-yaml": ">=4.3.2 <5",
79
- "qs": "6.16.0"
79
+ "proxy-addr": "2.0.8",
80
+ "qs": "6.16.0",
81
+ "smol-toml": "1.9.0",
82
+ "tinypool": "2.1.2"
80
83
  },
81
84
  "packageManager": "bun@1.3.10"
82
85
  }
@@ -2,11 +2,14 @@ import { isRecord } from './report';
2
2
 
3
3
  /**
4
4
  * A named reference to a credential impd holds: the secret's name, never
5
- * its value, and the rule impd applies when a request reaches the host,
6
- * plus the profiles a session selecting this one needs beside it. Only
7
- * the `custom` kind and the `bearer` scheme are bound.
5
+ * its value, and the profiles a session selecting this one needs beside
6
+ * it. A `custom` profile holds the one rule impd applies when a request
7
+ * reaches its host, always a bearer header. A `github` profile holds no
8
+ * rule: impd's `github` kind fixes its hosts and headers.
8
9
  */
9
- export interface AuthProfile {
10
+ export type AuthProfile = CustomAuthProfile | GitHubAuthProfile;
11
+
12
+ interface CustomAuthProfile {
10
13
  readonly name: string;
11
14
  readonly secret: string;
12
15
  readonly kind: 'custom';
@@ -16,6 +19,13 @@ export interface AuthProfile {
16
19
  readonly dependencies: readonly string[];
17
20
  }
18
21
 
22
+ interface GitHubAuthProfile {
23
+ readonly name: string;
24
+ readonly secret: string;
25
+ readonly kind: 'github';
26
+ readonly dependencies: readonly string[];
27
+ }
28
+
19
29
  interface AuthProfiles {
20
30
  readonly profiles: ReadonlyMap<string, AuthProfile>;
21
31
  readonly errors: readonly string[];
@@ -78,15 +88,33 @@ function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
78
88
  const header = entry['header'];
79
89
  const scheme = entry['scheme'];
80
90
  const dependencies = entry['dependencies'];
91
+ const kind = entry['kind'] ?? 'custom';
81
92
 
82
- if (entry['kind'] !== undefined && entry['kind'] !== 'custom') {
83
- return 'kind must be custom, the one kind atc binds';
93
+ if (kind !== 'custom' && kind !== 'github') {
94
+ return 'kind must be custom or github, the kinds atc binds';
84
95
  }
85
96
 
86
97
  if (typeof secret !== 'string' || !SECRET_NAME.test(secret)) {
87
98
  return 'secret must be a lowercase letter followed by up to 30 lowercase letters, digits or hyphens';
88
99
  }
89
100
 
101
+ if (
102
+ dependencies !== undefined &&
103
+ (!Array.isArray(dependencies) || !dependencies.every((dep) => typeof dep === 'string'))
104
+ ) {
105
+ return 'dependencies must be an array of profile names';
106
+ }
107
+
108
+ if (kind === 'github') {
109
+ const extra = ['host', 'header', 'scheme', 'user'].find((key) => entry[key] !== undefined);
110
+
111
+ if (extra !== undefined) {
112
+ return `${extra} cannot be set on a github profile, whose hosts and headers impd's github kind fixes`;
113
+ }
114
+
115
+ return { name, secret, kind, dependencies: dependencies ?? [] };
116
+ }
117
+
90
118
  if (typeof host !== 'string' || host.length > BROKER_HOST_MAX || !BROKER_HOST.test(host)) {
91
119
  return 'host must be a lowercase hostname such as api.example.com';
92
120
  }
@@ -103,13 +131,6 @@ function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
103
131
  return 'user pairs only with the basic scheme, which atc does not bind';
104
132
  }
105
133
 
106
- if (
107
- dependencies !== undefined &&
108
- (!Array.isArray(dependencies) || !dependencies.every((dep) => typeof dep === 'string'))
109
- ) {
110
- return 'dependencies must be an array of profile names';
111
- }
112
-
113
134
  return {
114
135
  name,
115
136
  secret,
@@ -1,12 +1,14 @@
1
1
  import type { AuthProfile } from './collect-auth-profiles';
2
2
 
3
3
  /**
4
- * How impd applies one secret to requests for one host.
4
+ * How impd applies one secret to requests for one host. Only a basic rule
5
+ * holds a user.
5
6
  */
6
7
  interface AuthRule {
7
8
  readonly host: string;
8
9
  readonly header: string;
9
- readonly scheme: 'bearer';
10
+ readonly scheme: 'basic' | 'bearer';
11
+ readonly user?: string;
10
12
  }
11
13
 
12
14
  /**
@@ -15,7 +17,7 @@ interface AuthRule {
15
17
  */
16
18
  export interface ResolvedAuthSecret {
17
19
  readonly secret: string;
18
- readonly kind: 'custom';
20
+ readonly kind: AuthProfile['kind'];
19
21
  readonly rules: readonly AuthRule[];
20
22
  }
21
23
 
@@ -49,11 +51,18 @@ export type AuthProfileResolution =
49
51
  | { readonly resolved: ResolvedAuthProfiles }
50
52
  | { readonly problem: AuthProfileProblem };
51
53
 
54
+ // One rule of a profile, with the profile it came from.
55
+ interface ProfileRule {
56
+ readonly profile: AuthProfile;
57
+ readonly rule: AuthRule;
58
+ }
59
+
52
60
  /**
53
61
  * Expands the selected profiles through their dependencies, then checks
54
62
  * 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.
63
+ * credential is never picked by order, and each secret one kind. Two
64
+ * profiles holding the identical secret and rule for a host merge into one
65
+ * rule.
57
66
  */
58
67
  export function resolveAuthProfiles(
59
68
  profiles: ReadonlyMap<string, AuthProfile>,
@@ -73,21 +82,37 @@ export function resolveAuthProfiles(
73
82
 
74
83
  const ordered = [...reached.values()].toSorted((a, b) => (a.name < b.name ? -1 : 1));
75
84
 
76
- const byHost = new Map<string, AuthProfile>();
85
+ const byHost = new Map<string, ProfileRule>();
86
+ const kinds = new Map<string, AuthProfile>();
77
87
 
78
88
  for (const profile of ordered) {
79
- const other = byHost.get(profile.host);
89
+ const kindOwner = kinds.get(profile.secret);
80
90
 
81
- if (other === undefined) {
82
- byHost.set(profile.host, profile);
83
- } else if (!hasSameRule(other, profile)) {
91
+ if (kindOwner === undefined) {
92
+ kinds.set(profile.secret, profile);
93
+ } else if (kindOwner.kind !== profile.kind) {
84
94
  return {
85
95
  problem: {
86
96
  code: 'auth_collision',
87
- message: `profiles ${other.name} and ${profile.name} both send a credential to ${profile.host}`,
97
+ message: `profiles ${kindOwner.name} and ${profile.name} bind secret ${profile.secret} as different kinds`,
88
98
  },
89
99
  };
90
100
  }
101
+
102
+ for (const rule of getProfileRules(profile)) {
103
+ const other = byHost.get(rule.host);
104
+
105
+ if (other === undefined) {
106
+ byHost.set(rule.host, { profile, rule });
107
+ } else if (!hasSameRule(other, { profile, rule })) {
108
+ return {
109
+ problem: {
110
+ code: 'auth_collision',
111
+ message: `profiles ${other.profile.name} and ${profile.name} both send a credential to ${rule.host}`,
112
+ },
113
+ };
114
+ }
115
+ }
91
116
  }
92
117
 
93
118
  return {
@@ -156,31 +181,50 @@ function isAuthProfileProblem(
156
181
  return 'code' in value;
157
182
  }
158
183
 
159
- function hasSameRule(left: AuthProfile, right: AuthProfile): boolean {
184
+ // The rules impd's github kind applies: git over HTTPS on github.com takes
185
+ // Basic auth for x-access-token, and the REST and upload APIs take a bearer
186
+ // token.
187
+ const GITHUB_RULES: readonly AuthRule[] = [
188
+ { host: 'api.github.com', header: 'authorization', scheme: 'bearer' },
189
+ { host: 'github.com', header: 'authorization', scheme: 'basic', user: 'x-access-token' },
190
+ { host: 'uploads.github.com', header: 'authorization', scheme: 'bearer' },
191
+ ];
192
+
193
+ // The rules impd holds for a profile's secret on the hosts it covers.
194
+ function getProfileRules(profile: AuthProfile): readonly AuthRule[] {
195
+ if (profile.kind === 'github') {
196
+ return GITHUB_RULES;
197
+ }
198
+
199
+ return [{ host: profile.host, header: profile.header, scheme: profile.scheme }];
200
+ }
201
+
202
+ function hasSameRule(left: ProfileRule, right: ProfileRule): boolean {
160
203
  return (
161
- left.secret === right.secret &&
162
- left.header === right.header &&
163
- left.scheme === right.scheme &&
164
- left.kind === right.kind
204
+ left.profile.secret === right.profile.secret &&
205
+ left.profile.kind === right.profile.kind &&
206
+ left.rule.header === right.rule.header &&
207
+ left.rule.scheme === right.rule.scheme &&
208
+ left.rule.user === right.rule.user
165
209
  );
166
210
  }
167
211
 
168
212
  // 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[]>();
213
+ function buildSecrets(entries: readonly ProfileRule[]): ResolvedAuthSecret[] {
214
+ const bySecret = new Map<string, { kind: AuthProfile['kind']; rules: AuthRule[] }>();
171
215
 
172
- for (const profile of profiles) {
173
- const list = rules.get(profile.secret) ?? [];
216
+ for (const item of entries) {
217
+ const entry = bySecret.get(item.profile.secret) ?? { kind: item.profile.kind, rules: [] };
174
218
 
175
- list.push({ host: profile.host, header: profile.header, scheme: profile.scheme });
176
- rules.set(profile.secret, list);
219
+ entry.rules.push(item.rule);
220
+ bySecret.set(item.profile.secret, entry);
177
221
  }
178
222
 
179
- return [...rules.entries()]
223
+ return [...bySecret.entries()]
180
224
  .toSorted(([a], [b]) => (a < b ? -1 : 1))
181
- .map(([secret, list]) => ({
225
+ .map(([secret, entry]) => ({
182
226
  secret,
183
- kind: 'custom',
184
- rules: list.toSorted((a, b) => (a.host < b.host ? -1 : 1)),
227
+ kind: entry.kind,
228
+ rules: entry.rules.toSorted((a, b) => (a.host < b.host ? -1 : 1)),
185
229
  }));
186
230
  }