@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.
|
|
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
|
-
"
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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 (
|
|
83
|
-
return 'kind must be custom, the
|
|
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: '
|
|
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
|
|
56
|
-
* secret and rule for a host merge into one
|
|
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,
|
|
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
|
|
89
|
+
const kindOwner = kinds.get(profile.secret);
|
|
80
90
|
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
} else if (
|
|
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 ${
|
|
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
|
-
|
|
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.
|
|
163
|
-
left.
|
|
164
|
-
left.
|
|
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(
|
|
170
|
-
const
|
|
213
|
+
function buildSecrets(entries: readonly ProfileRule[]): ResolvedAuthSecret[] {
|
|
214
|
+
const bySecret = new Map<string, { kind: AuthProfile['kind']; rules: AuthRule[] }>();
|
|
171
215
|
|
|
172
|
-
for (const
|
|
173
|
-
const
|
|
216
|
+
for (const item of entries) {
|
|
217
|
+
const entry = bySecret.get(item.profile.secret) ?? { kind: item.profile.kind, rules: [] };
|
|
174
218
|
|
|
175
|
-
|
|
176
|
-
|
|
219
|
+
entry.rules.push(item.rule);
|
|
220
|
+
bySecret.set(item.profile.secret, entry);
|
|
177
221
|
}
|
|
178
222
|
|
|
179
|
-
return [...
|
|
223
|
+
return [...bySecret.entries()]
|
|
180
224
|
.toSorted(([a], [b]) => (a < b ? -1 : 1))
|
|
181
|
-
.map(([secret,
|
|
225
|
+
.map(([secret, entry]) => ({
|
|
182
226
|
secret,
|
|
183
|
-
kind:
|
|
184
|
-
rules:
|
|
227
|
+
kind: entry.kind,
|
|
228
|
+
rules: entry.rules.toSorted((a, b) => (a.host < b.host ? -1 : 1)),
|
|
185
229
|
}));
|
|
186
230
|
}
|