@zgeoff/atc 2.24.0 → 2.26.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 +1 -1
- package/src/agents/agent-adapter.ts +27 -2
- package/src/agents/gateway-adapter.ts +272 -25
- package/src/client/collect-agent-picks.ts +2 -5
- package/src/client/collect-target-picks.ts +5 -0
- package/src/client/spawn-picker.ts +15 -1
- package/src/daemon/broker-auth-host.ts +25 -0
- package/src/daemon/broker-authority-error.ts +2 -1
- package/src/daemon/build-agent-list.ts +16 -3
- package/src/daemon/build-scoped-context.ts +2 -0
- package/src/daemon/build-target-list.ts +5 -0
- package/src/daemon/daemon-connection.ts +66 -7
- package/src/daemon/daemon-context.ts +12 -0
- package/src/daemon/daemon.ts +84 -1
- package/src/daemon/execution-provider.ts +50 -2
- package/src/daemon/format-log-field.ts +38 -0
- package/src/daemon/imp-client-port.ts +71 -23
- package/src/daemon/imp-harness.ts +299 -18
- package/src/daemon/imp-port.ts +23 -1
- package/src/daemon/imp-provider.ts +161 -40
- package/src/daemon/local-pty-provider.ts +11 -0
- package/src/daemon/make-non-blocking-log.ts +91 -0
- package/src/daemon/refusal-log.ts +163 -0
- package/src/daemon/restore-fleet.ts +16 -1
- package/src/daemon/runtime-auth-binder.ts +1086 -0
- package/src/daemon/sessions.ts +565 -25
- package/src/daemon/start-tcp-listener.ts +47 -1
- package/src/daemon/verify-broker-authority.ts +10 -5
- package/src/mcp/mcp-tools.ts +2 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/protocol/daemon-features.ts +4 -0
- package/src/protocol/protocol.ts +17 -0
- package/src/protocol/request-param-schemas.ts +5 -0
- package/src/shared/collect-gateways.ts +1 -22
- package/src/shared/is-broker-variable.ts +24 -0
- package/src/store/state-store.ts +11 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { DaemonConnection, TCPPeer } from './daemon-connection';
|
|
2
2
|
import { findTokenFingerprint } from './find-token-fingerprint';
|
|
3
|
+
import { formatLogField } from './format-log-field';
|
|
3
4
|
import { HandshakeThrottle } from './handshake-throttle';
|
|
5
|
+
import { RefusalLog } from './refusal-log';
|
|
4
6
|
|
|
5
7
|
// The slice of an accepted socket a protocol connection writes to and ends.
|
|
6
8
|
interface ListenerSocket {
|
|
@@ -26,6 +28,16 @@ interface TCPListenerOptions {
|
|
|
26
28
|
|
|
27
29
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- a connection is a live object the daemon releases
|
|
28
30
|
readonly closeConnection: (connection: DaemonConnection) => void;
|
|
31
|
+
|
|
32
|
+
// Where the listener start and each refusal are logged, one line at a
|
|
33
|
+
// time, with the clock and the window that summarize repeated refusals
|
|
34
|
+
// from one peer.
|
|
35
|
+
readonly log: (line: string) => void;
|
|
36
|
+
readonly now: () => number;
|
|
37
|
+
readonly refusalLogIntervalMs: number;
|
|
38
|
+
|
|
39
|
+
// How many peers and kinds of refusal the refusal log tracks at once.
|
|
40
|
+
readonly maxRefusalWindows: number;
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
export interface TCPListener {
|
|
@@ -55,6 +67,13 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
55
67
|
const throttle = new HandshakeThrottle(opts.failureDelayMs);
|
|
56
68
|
const connections = new Set<DaemonConnection>();
|
|
57
69
|
|
|
70
|
+
const refusals = new RefusalLog({
|
|
71
|
+
log: opts.log,
|
|
72
|
+
now: opts.now,
|
|
73
|
+
intervalMs: opts.refusalLogIntervalMs,
|
|
74
|
+
maxWindows: opts.maxRefusalWindows,
|
|
75
|
+
});
|
|
76
|
+
|
|
58
77
|
let delayed = 0;
|
|
59
78
|
|
|
60
79
|
// Ends the delayed handshake of each open socket once it closes.
|
|
@@ -77,6 +96,12 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
77
96
|
if (delay > 0 && delayed >= opts.maxDelayedHandshakes) {
|
|
78
97
|
throttle.recordFailure(address, Date.now());
|
|
79
98
|
|
|
99
|
+
refusals.record({
|
|
100
|
+
event: 'handshake_refused',
|
|
101
|
+
peer: address,
|
|
102
|
+
reason: 'delay_cap_full',
|
|
103
|
+
});
|
|
104
|
+
|
|
80
105
|
return null;
|
|
81
106
|
}
|
|
82
107
|
|
|
@@ -96,6 +121,12 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
96
121
|
delayed--;
|
|
97
122
|
|
|
98
123
|
if (waited === 'closed') {
|
|
124
|
+
refusals.record({
|
|
125
|
+
event: 'handshake_refused',
|
|
126
|
+
peer: address,
|
|
127
|
+
reason: 'closed_during_delay',
|
|
128
|
+
});
|
|
129
|
+
|
|
99
130
|
return null;
|
|
100
131
|
}
|
|
101
132
|
}
|
|
@@ -107,12 +138,22 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
107
138
|
|
|
108
139
|
if (fingerprint === null) {
|
|
109
140
|
throttle.recordFailure(address, Date.now());
|
|
141
|
+
|
|
142
|
+
refusals.record({
|
|
143
|
+
event: 'handshake_refused',
|
|
144
|
+
peer: address,
|
|
145
|
+
reason: presented === null ? 'missing_token' : 'unauthorized',
|
|
146
|
+
});
|
|
110
147
|
}
|
|
111
148
|
|
|
112
149
|
return fingerprint;
|
|
113
150
|
},
|
|
114
|
-
recordFailure: () => {
|
|
151
|
+
recordFailure: (reason) => {
|
|
115
152
|
throttle.recordFailure(address, Date.now());
|
|
153
|
+
refusals.record({ event: 'handshake_refused', peer: address, reason });
|
|
154
|
+
},
|
|
155
|
+
recordRefusedPrincipal: () => {
|
|
156
|
+
refusals.record({ event: 'principal_refused', peer: address });
|
|
116
157
|
},
|
|
117
158
|
};
|
|
118
159
|
|
|
@@ -136,6 +177,10 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
136
177
|
},
|
|
137
178
|
});
|
|
138
179
|
|
|
180
|
+
opts.log(
|
|
181
|
+
`atc tcp event=listening host=${formatLogField(server.hostname)} port=${String(server.port)}`,
|
|
182
|
+
);
|
|
183
|
+
|
|
139
184
|
return {
|
|
140
185
|
port: server.port,
|
|
141
186
|
setTokens(next) {
|
|
@@ -160,6 +205,7 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
160
205
|
}
|
|
161
206
|
|
|
162
207
|
server.stop(true);
|
|
208
|
+
refusals.drain();
|
|
163
209
|
},
|
|
164
210
|
};
|
|
165
211
|
}
|
|
@@ -15,8 +15,9 @@ export interface BrokerActivation {
|
|
|
15
15
|
/**
|
|
16
16
|
* The gate before atc grants or relies on a brokered credential. It reads
|
|
17
17
|
* impd's features, then the token's identity, and writes nothing, so a
|
|
18
|
-
* refusal leaves impd as it was. It rejects unless impd has
|
|
19
|
-
* tokens
|
|
18
|
+
* refusal leaves impd as it was. It rejects unless impd has grantable
|
|
19
|
+
* tokens, secret rebinds and exec requirements, so a start can refuse to
|
|
20
|
+
* run without a ready broker, the token may manage each imp and reaches no
|
|
20
21
|
* imp outside the namespace whose imp names start with the prefix, and the
|
|
21
22
|
* token may grant every bound secret.
|
|
22
23
|
* Whether each secret's rules match the binding is a separate comparison.
|
|
@@ -28,11 +29,15 @@ export async function verifyBrokerAuthority(
|
|
|
28
29
|
): Promise<void> {
|
|
29
30
|
const features = await port.readFeatures();
|
|
30
31
|
|
|
31
|
-
if (!features.grantableTokens || !features.secretRebind) {
|
|
32
|
+
if (!features.grantableTokens || !features.secretRebind || !features.execRequire) {
|
|
32
33
|
throw new BrokerAuthorityError(
|
|
33
34
|
'auth_impd_too_old',
|
|
34
|
-
'impd lacks grantable tokens or
|
|
35
|
-
{
|
|
35
|
+
'impd lacks grantable tokens, secret rebinds or exec requirements; it must be 0.30.0 or later',
|
|
36
|
+
{
|
|
37
|
+
grantableTokens: features.grantableTokens,
|
|
38
|
+
secretRebind: features.secretRebind,
|
|
39
|
+
execRequire: features.execRequire,
|
|
40
|
+
},
|
|
36
41
|
);
|
|
37
42
|
}
|
|
38
43
|
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -213,6 +213,7 @@ const AGENTS_OUTPUT: Readonly<Record<string, unknown>> = {
|
|
|
213
213
|
label: { type: 'string' },
|
|
214
214
|
kind: { type: 'string' },
|
|
215
215
|
installed: { type: 'boolean' },
|
|
216
|
+
brokerAuth: { type: 'boolean' },
|
|
216
217
|
capabilities: {
|
|
217
218
|
type: 'object',
|
|
218
219
|
properties: {
|
|
@@ -249,6 +250,7 @@ const AGENTS_OUTPUT: Readonly<Record<string, unknown>> = {
|
|
|
249
250
|
type: 'object',
|
|
250
251
|
additionalProperties: { type: 'boolean' },
|
|
251
252
|
},
|
|
253
|
+
brokerAuth: { type: 'boolean' },
|
|
252
254
|
},
|
|
253
255
|
required: ['id', 'provider', 'identity', 'available', 'default', 'capabilities'],
|
|
254
256
|
},
|
|
@@ -23,6 +23,7 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
|
23
23
|
'git.probe': 'git.probe',
|
|
24
24
|
'transport.tcp': 'a TCP connection to the daemon',
|
|
25
25
|
'idempotency.replayOnly': 'a resend that only replays a held idempotency key',
|
|
26
|
+
'session.auth': 'session.auth.revoke and session.auth.rebind',
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -73,6 +73,10 @@ export const DAEMON_FEATURES = [
|
|
|
73
73
|
// replays a key the daemon holds and refuses one it does not hold with
|
|
74
74
|
// `idempotency_key_unknown`, running nothing.
|
|
75
75
|
'idempotency.replayOnly',
|
|
76
|
+
|
|
77
|
+
// `session.auth.revoke` and `session.auth.rebind` exist, open to the
|
|
78
|
+
// daemon's owner only.
|
|
79
|
+
'session.auth',
|
|
76
80
|
] as const;
|
|
77
81
|
|
|
78
82
|
export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
|
package/src/protocol/protocol.ts
CHANGED
|
@@ -49,6 +49,23 @@ const ERROR_CODES = [
|
|
|
49
49
|
'host_unavailable',
|
|
50
50
|
'auth_not_configured',
|
|
51
51
|
'auth_target_unsupported',
|
|
52
|
+
'auth_impd_too_old',
|
|
53
|
+
'auth_token_scope',
|
|
54
|
+
'auth_token_too_broad',
|
|
55
|
+
'auth_imp_out_of_scope',
|
|
56
|
+
'auth_secret_not_grantable',
|
|
57
|
+
'auth_secret_mismatch',
|
|
58
|
+
'auth_runtime_mismatch',
|
|
59
|
+
'auth_runtime_exists',
|
|
60
|
+
'auth_grant_missing',
|
|
61
|
+
'auth_grants_mismatch',
|
|
62
|
+
'auth_rebind_required',
|
|
63
|
+
'auth_blocked',
|
|
64
|
+
'auth_binding_mismatch',
|
|
65
|
+
'auth_binding_invalid',
|
|
66
|
+
'auth_revocation_pending',
|
|
67
|
+
'broker_not_ready',
|
|
68
|
+
'auth_placeholder_unsupported',
|
|
52
69
|
'host_leased',
|
|
53
70
|
'confirmation_required',
|
|
54
71
|
'confirm_token_invalid',
|
|
@@ -204,6 +204,11 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
204
204
|
.min(1, 'session.forget confirmToken must not be empty')
|
|
205
205
|
.optional(),
|
|
206
206
|
}),
|
|
207
|
+
|
|
208
|
+
// Owner-only: withdraw the grants of the runtime auth binding on the
|
|
209
|
+
// session's host, or bind that host to its agent's current selection.
|
|
210
|
+
'session.auth.revoke': SESSION_DEFAULTED,
|
|
211
|
+
'session.auth.rebind': SESSION_DEFAULTED,
|
|
207
212
|
'session.update': SESSION_DEFAULTED.extend({
|
|
208
213
|
name: buildOptionalString(),
|
|
209
214
|
pinned: buildOptionalBoolean(),
|
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import type { AgentID } from './agent-id';
|
|
3
3
|
import { buildOptionalStringArray } from './build-optional-string-array';
|
|
4
4
|
import type { AuthProfile } from './collect-auth-profiles';
|
|
5
|
+
import { isBrokerVariable } from './is-broker-variable';
|
|
5
6
|
import { isRecord } from './report';
|
|
6
7
|
import { resolveAuthProfiles } from './resolve-auth-profiles';
|
|
7
8
|
|
|
@@ -251,28 +252,6 @@ function collectEnvProblems(source: string, keys: readonly string[]): string[] {
|
|
|
251
252
|
.map((key) => `${source} must not set ${key}`);
|
|
252
253
|
}
|
|
253
254
|
|
|
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
255
|
function collectPlaceholderProblems(env: Readonly<Record<string, string>>): string[] {
|
|
277
256
|
return Object.keys(env)
|
|
278
257
|
.filter((key) => isBrokerVariable(key) || key === BASE_URL_VARIABLE)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a variable is one impd sets in a brokered exec to route requests
|
|
3
|
+
* through the broker and trust its CA. impd lets a variable the caller
|
|
4
|
+
* sets win, so a caller's own value would route around the broker or mask
|
|
5
|
+
* a failed CA install. Some clients read proxy and CA variables in either
|
|
6
|
+
* case, so the check ignores case.
|
|
7
|
+
*/
|
|
8
|
+
export function isBrokerVariable(key: string): boolean {
|
|
9
|
+
return BROKER_VARIABLES.has(key.toUpperCase());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const BROKER_VARIABLES: ReadonlySet<string> = new Set([
|
|
13
|
+
'HTTPS_PROXY',
|
|
14
|
+
'HTTP_PROXY',
|
|
15
|
+
'ALL_PROXY',
|
|
16
|
+
'NO_PROXY',
|
|
17
|
+
'NODE_USE_ENV_PROXY',
|
|
18
|
+
'SSL_CERT_FILE',
|
|
19
|
+
'SSL_CERT_DIR',
|
|
20
|
+
'NODE_EXTRA_CA_CERTS',
|
|
21
|
+
'GIT_SSL_CAINFO',
|
|
22
|
+
'REQUESTS_CA_BUNDLE',
|
|
23
|
+
'CURL_CA_BUNDLE',
|
|
24
|
+
]);
|
package/src/store/state-store.ts
CHANGED
|
@@ -1054,6 +1054,17 @@ export class StateStore {
|
|
|
1054
1054
|
return row === undefined ? null : toRuntimeAuthBinding(row);
|
|
1055
1055
|
}
|
|
1056
1056
|
|
|
1057
|
+
// Every host's binding, by host key.
|
|
1058
|
+
async collectAuthBindings(): Promise<RuntimeAuthBinding[]> {
|
|
1059
|
+
const rows = await this.db
|
|
1060
|
+
.selectFrom('runtime_auth_binding')
|
|
1061
|
+
.selectAll()
|
|
1062
|
+
.orderBy('host_key')
|
|
1063
|
+
.execute();
|
|
1064
|
+
|
|
1065
|
+
return rows.map((row) => toRuntimeAuthBinding(row));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1057
1068
|
// Writes one secret's grant row for a host, or moves the existing one to
|
|
1058
1069
|
// the given phase, revision and attempt.
|
|
1059
1070
|
async upsertAuthGrant(grant: Omit<RuntimeAuthGrant, 'updatedAt'>, at: number): Promise<void> {
|