@runuai/host 0.9.150 → 0.9.151
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/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1876 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1830 -224
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +623 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1061 -139
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- package/src/ui/server.ts +48 -10
|
@@ -0,0 +1,2399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 — the shared contract for cloud-only credentials.
|
|
3
|
+
*
|
|
4
|
+
* Every repository env value, AI account, MCP connection, GitHub sign-in and
|
|
5
|
+
* SSH key has one copy, in Parameter Store. A runner (owned or task machine)
|
|
6
|
+
* reads what one live task needs under that task's lease and keeps it in
|
|
7
|
+
* memory only. This module is what the cloud and the runner agree on:
|
|
8
|
+
*
|
|
9
|
+
* - the parameter layout: lease-readable copies under `/uai/lease`, the
|
|
10
|
+
* broker-only OAuth material under the disjoint root `/uai-broker`, each
|
|
11
|
+
* credential's material in immutable revisions;
|
|
12
|
+
* - the v1 grant: the task's credential manifest (names and versions, never
|
|
13
|
+
* values) and the STS sessions that read it;
|
|
14
|
+
* - the frames that renew, narrow, refresh and activate it.
|
|
15
|
+
*
|
|
16
|
+
* It holds no I/O and imports nothing, so the cloud, the runner and the
|
|
17
|
+
* sign-in box can all load it. Every parser is exact: unknown keys, wrong
|
|
18
|
+
* types and out-of-bound sizes are refused, never dropped or repaired.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Advertised by a runner that reads credentials only through v1 leases. */
|
|
22
|
+
export const HOST_CLOUD_CREDENTIALS_FEATURE = "host-cloud-credentials-v1";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Parameter layout
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/** What a task may receive: a static key, or an OAuth login's brokered copy. */
|
|
29
|
+
export const LEASE_PARAMETER_ROOT = "/uai/lease";
|
|
30
|
+
/**
|
|
31
|
+
* The full OAuth material only the broker reads. Outside `/uai` altogether:
|
|
32
|
+
* `GetParametersByPath` returns every descendant of a path its caller may
|
|
33
|
+
* read, even one a Deny names, so containment comes from the root alone.
|
|
34
|
+
*/
|
|
35
|
+
export const BROKER_PARAMETER_ROOT = "/uai-broker";
|
|
36
|
+
|
|
37
|
+
export type CredentialClass = "engine" | "mcp" | "github" | "ssh";
|
|
38
|
+
export type CredentialScope = "org" | "user";
|
|
39
|
+
|
|
40
|
+
/** Where one revision of one credential lives. */
|
|
41
|
+
export interface CredentialLocation {
|
|
42
|
+
class: CredentialClass;
|
|
43
|
+
/** `github` and `ssh` are always `user`: one per person. */
|
|
44
|
+
scope: CredentialScope;
|
|
45
|
+
/** The org id for `org` scope, the user id for `user` scope. */
|
|
46
|
+
ownerId: string;
|
|
47
|
+
/** `engine` only: the engine kind (`codex`, `claude`, …). */
|
|
48
|
+
engineKind?: string;
|
|
49
|
+
/** `engine` and `mcp` only: the credential's stable id. */
|
|
50
|
+
credentialId?: string;
|
|
51
|
+
/** One immutable revision of the material. */
|
|
52
|
+
revision: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** SSM allows `[A-Za-z0-9_.-]` in a path segment. An id that already fits,
|
|
56
|
+
* starts with a letter or digit and has no dot is used as it is; any other
|
|
57
|
+
* id is `-` followed by the hex of its UTF-8 bytes. No plain id starts with
|
|
58
|
+
* `-`, so the mapping is injective. Legacy workspace ids
|
|
59
|
+
* (`personal:<user>`) take the second form. */
|
|
60
|
+
const PLAIN_ID_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
61
|
+
const ENCODED_ID_SEGMENT = /^-(?:[0-9a-f]{2})+$/;
|
|
62
|
+
const ENGINE_KIND = /^[a-z][a-z0-9-]{0,31}$/;
|
|
63
|
+
export const MAX_CREDENTIAL_ID_CHARS = 128;
|
|
64
|
+
/** SSM's limit for a parameter name, its ARN included. */
|
|
65
|
+
export const MAX_PARAMETER_NAME_CHARS = 1011;
|
|
66
|
+
|
|
67
|
+
function isIdValue(value: unknown): value is string {
|
|
68
|
+
return (
|
|
69
|
+
typeof value === "string" &&
|
|
70
|
+
value.length > 0 &&
|
|
71
|
+
value.length <= MAX_CREDENTIAL_ID_CHARS &&
|
|
72
|
+
!/[\u0000-\u001f\u007f]/.test(value)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function utf8Hex(value: string): string {
|
|
77
|
+
let out = "";
|
|
78
|
+
for (const byte of new TextEncoder().encode(value)) {
|
|
79
|
+
out += byte.toString(16).padStart(2, "0");
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function fromUtf8Hex(hex: string): string | null {
|
|
85
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
86
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
87
|
+
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The path segment for an id (see `PLAIN_ID_SEGMENT`). Throws on an id no
|
|
97
|
+
* segment may carry: empty, too long, or containing control characters. */
|
|
98
|
+
export function idPathSegment(id: string): string {
|
|
99
|
+
if (!isIdValue(id)) throw new Error("invalid credential path id");
|
|
100
|
+
return PLAIN_ID_SEGMENT.test(id) ? id : `-${utf8Hex(id)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Inverse of `idPathSegment`; null for a segment it never produces. */
|
|
104
|
+
export function parseIdPathSegment(segment: string): string | null {
|
|
105
|
+
if (PLAIN_ID_SEGMENT.test(segment)) {
|
|
106
|
+
return segment.length <= MAX_CREDENTIAL_ID_CHARS ? segment : null;
|
|
107
|
+
}
|
|
108
|
+
if (!ENCODED_ID_SEGMENT.test(segment)) return null;
|
|
109
|
+
const id = fromUtf8Hex(segment.slice(1));
|
|
110
|
+
if (id === null || !isIdValue(id) || PLAIN_ID_SEGMENT.test(id)) return null;
|
|
111
|
+
return id;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function isEngineKind(value: unknown): value is string {
|
|
115
|
+
return typeof value === "string" && ENGINE_KIND.test(value);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function locationSegments(location: CredentialLocation): string[] {
|
|
119
|
+
const owner = location.scope === "org" ? "orgs" : "users";
|
|
120
|
+
const segments = [owner, idPathSegment(location.ownerId)];
|
|
121
|
+
switch (location.class) {
|
|
122
|
+
case "engine": {
|
|
123
|
+
if (!isEngineKind(location.engineKind)) {
|
|
124
|
+
throw new Error("invalid engine kind");
|
|
125
|
+
}
|
|
126
|
+
if (location.credentialId === undefined) {
|
|
127
|
+
throw new Error("an engine credential needs its id");
|
|
128
|
+
}
|
|
129
|
+
segments.push(
|
|
130
|
+
"engines",
|
|
131
|
+
location.engineKind,
|
|
132
|
+
idPathSegment(location.credentialId),
|
|
133
|
+
);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case "mcp": {
|
|
137
|
+
if (location.engineKind !== undefined) {
|
|
138
|
+
throw new Error("an MCP credential has no engine kind");
|
|
139
|
+
}
|
|
140
|
+
if (location.credentialId === undefined) {
|
|
141
|
+
throw new Error("an MCP credential needs its id");
|
|
142
|
+
}
|
|
143
|
+
segments.push("mcp", idPathSegment(location.credentialId));
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case "github":
|
|
147
|
+
case "ssh": {
|
|
148
|
+
if (location.scope !== "user") {
|
|
149
|
+
throw new Error(`${location.class} credentials belong to a user`);
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
location.engineKind !== undefined ||
|
|
153
|
+
location.credentialId !== undefined
|
|
154
|
+
) {
|
|
155
|
+
throw new Error(`${location.class} has one credential per person`);
|
|
156
|
+
}
|
|
157
|
+
segments.push(location.class);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
default:
|
|
161
|
+
throw new Error("invalid credential class");
|
|
162
|
+
}
|
|
163
|
+
segments.push(idPathSegment(location.revision));
|
|
164
|
+
return segments;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function nameUnder(root: string, location: CredentialLocation): string {
|
|
168
|
+
const name = `${root}/${locationSegments(location).join("/")}`;
|
|
169
|
+
if (name.length > MAX_PARAMETER_NAME_CHARS) {
|
|
170
|
+
throw new Error("credential parameter name too long");
|
|
171
|
+
}
|
|
172
|
+
return name;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The lease-readable parameter of one revision. */
|
|
176
|
+
export function leaseParameterName(location: CredentialLocation): string {
|
|
177
|
+
return nameUnder(LEASE_PARAMETER_ROOT, location);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The broker-only parameter of one revision of an OAuth credential. GitHub
|
|
181
|
+
* tokens and SSH keys are static and have none. */
|
|
182
|
+
export function brokerParameterName(location: CredentialLocation): string {
|
|
183
|
+
if (location.class === "github" || location.class === "ssh") {
|
|
184
|
+
throw new Error(`${location.class} credentials have no broker part`);
|
|
185
|
+
}
|
|
186
|
+
return nameUnder(BROKER_PARAMETER_ROOT, location);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parseUnder(root: string, name: string): CredentialLocation | null {
|
|
190
|
+
if (
|
|
191
|
+
typeof name !== "string" ||
|
|
192
|
+
name.length > MAX_PARAMETER_NAME_CHARS ||
|
|
193
|
+
!name.startsWith(`${root}/`)
|
|
194
|
+
) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const parts = name.slice(root.length + 1).split("/");
|
|
198
|
+
const [ownerKind, ownerSegment] = parts;
|
|
199
|
+
const scope: CredentialScope | null =
|
|
200
|
+
ownerKind === "orgs" ? "org" : ownerKind === "users" ? "user" : null;
|
|
201
|
+
if (scope === null || ownerSegment === undefined) return null;
|
|
202
|
+
const ownerId = parseIdPathSegment(ownerSegment);
|
|
203
|
+
if (ownerId === null) return null;
|
|
204
|
+
const revisionOf = (segment: string | undefined): string | null =>
|
|
205
|
+
segment === undefined ? null : parseIdPathSegment(segment);
|
|
206
|
+
|
|
207
|
+
if (parts[2] === "engines" && parts.length === 6) {
|
|
208
|
+
const credentialId = parseIdPathSegment(parts[4]!);
|
|
209
|
+
const revision = revisionOf(parts[5]);
|
|
210
|
+
if (!isEngineKind(parts[3]) || credentialId === null || revision === null) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
class: "engine",
|
|
215
|
+
scope,
|
|
216
|
+
ownerId,
|
|
217
|
+
engineKind: parts[3],
|
|
218
|
+
credentialId,
|
|
219
|
+
revision,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (parts[2] === "mcp" && parts.length === 5) {
|
|
223
|
+
const credentialId = parseIdPathSegment(parts[3]!);
|
|
224
|
+
const revision = revisionOf(parts[4]);
|
|
225
|
+
if (credentialId === null || revision === null) return null;
|
|
226
|
+
return { class: "mcp", scope, ownerId, credentialId, revision };
|
|
227
|
+
}
|
|
228
|
+
if (
|
|
229
|
+
(parts[2] === "github" || parts[2] === "ssh") &&
|
|
230
|
+
parts.length === 4 &&
|
|
231
|
+
scope === "user"
|
|
232
|
+
) {
|
|
233
|
+
const revision = revisionOf(parts[3]);
|
|
234
|
+
if (revision === null) return null;
|
|
235
|
+
return { class: parts[2], scope, ownerId, revision };
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function parseLeaseParameterName(
|
|
241
|
+
name: string,
|
|
242
|
+
): CredentialLocation | null {
|
|
243
|
+
return parseUnder(LEASE_PARAMETER_ROOT, name);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function parseBrokerParameterName(
|
|
247
|
+
name: string,
|
|
248
|
+
): CredentialLocation | null {
|
|
249
|
+
const location = parseUnder(BROKER_PARAMETER_ROOT, name);
|
|
250
|
+
return location && (location.class === "engine" || location.class === "mcp")
|
|
251
|
+
? location
|
|
252
|
+
: null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Lease-readable values
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/** An Advanced-tier parameter holds 8 KiB; every envelope must fit. */
|
|
260
|
+
export const MAX_CREDENTIAL_VALUE_BYTES = 8 * 1024;
|
|
261
|
+
|
|
262
|
+
/** The env vars an env-token engine's account may set, per engine kind. A
|
|
263
|
+
* value naming any other variable is refused, so a stored account can never
|
|
264
|
+
* set `LD_PRELOAD` or `PATH` on an agent's spawn. */
|
|
265
|
+
export const ENGINE_ENV_CREDENTIAL_KEYS: Readonly<Record<string, readonly string[]>> = {
|
|
266
|
+
claude: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
|
267
|
+
cursor: ["CURSOR_API_KEY"],
|
|
268
|
+
grok: ["XAI_API_KEY"],
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** The files a config-directory engine's account may carry, per engine kind:
|
|
272
|
+
* only what the lane owns, written into the engine's home, nothing else. */
|
|
273
|
+
export const ENGINE_CREDENTIAL_FILES: Readonly<Record<string, readonly string[]>> = {
|
|
274
|
+
codex: ["auth.json"],
|
|
275
|
+
grok: ["auth.json"],
|
|
276
|
+
opencode: ["auth.json"],
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
interface LeasedValueBase {
|
|
280
|
+
v: 1;
|
|
281
|
+
part: "lease";
|
|
282
|
+
/** The parameter this value belongs to, which the reader checks: a value
|
|
283
|
+
* copied to another credential's or revision's parameter is refused. */
|
|
284
|
+
location: CredentialLocation;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export type LeasedValue =
|
|
288
|
+
| (LeasedValueBase & { class: "engine"; authKind: "env"; env: Record<string, string> })
|
|
289
|
+
| (LeasedValueBase & {
|
|
290
|
+
class: "engine";
|
|
291
|
+
authKind: "config-dir";
|
|
292
|
+
/** The brokered copy: access token in, refresh token replaced by the
|
|
293
|
+
* broker's placeholder. */
|
|
294
|
+
files: Record<string, string>;
|
|
295
|
+
expiresAt: number | null;
|
|
296
|
+
})
|
|
297
|
+
| (LeasedValueBase & { class: "mcp"; authKind: "token"; header: { name: string; value: string } })
|
|
298
|
+
| (LeasedValueBase & {
|
|
299
|
+
class: "mcp";
|
|
300
|
+
authKind: "oauth";
|
|
301
|
+
accessToken: string;
|
|
302
|
+
tokenType: "Bearer";
|
|
303
|
+
expiresAt: number | null;
|
|
304
|
+
})
|
|
305
|
+
| (LeasedValueBase & { class: "github"; token: string })
|
|
306
|
+
| (LeasedValueBase & { class: "ssh"; privateKey: string; publicKey: string });
|
|
307
|
+
|
|
308
|
+
const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
|
|
309
|
+
/** Hop-by-hop and routing headers a stored connection may not set. */
|
|
310
|
+
const FORBIDDEN_HEADER_NAMES = new Set([
|
|
311
|
+
"connection",
|
|
312
|
+
"content-length",
|
|
313
|
+
"host",
|
|
314
|
+
"keep-alive",
|
|
315
|
+
"proxy-authorization",
|
|
316
|
+
"te",
|
|
317
|
+
"trailer",
|
|
318
|
+
"transfer-encoding",
|
|
319
|
+
"upgrade",
|
|
320
|
+
]);
|
|
321
|
+
|
|
322
|
+
function sameLocation(a: CredentialLocation, b: CredentialLocation): boolean {
|
|
323
|
+
return (
|
|
324
|
+
a.class === b.class &&
|
|
325
|
+
a.scope === b.scope &&
|
|
326
|
+
a.ownerId === b.ownerId &&
|
|
327
|
+
a.engineKind === b.engineKind &&
|
|
328
|
+
a.credentialId === b.credentialId &&
|
|
329
|
+
a.revision === b.revision
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function parseLocationField(value: unknown): CredentialLocation | null {
|
|
334
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
335
|
+
const o = value as WireObject;
|
|
336
|
+
const keys = Object.keys(o);
|
|
337
|
+
const allowed = new Set(["class", "scope", "ownerId", "engineKind", "credentialId", "revision"]);
|
|
338
|
+
if (keys.some((key) => !allowed.has(key))) return null;
|
|
339
|
+
if (
|
|
340
|
+
(o.class !== "engine" && o.class !== "mcp" && o.class !== "github" && o.class !== "ssh") ||
|
|
341
|
+
!isScope(o.scope) ||
|
|
342
|
+
!isIdValue(o.ownerId) ||
|
|
343
|
+
!isIdValue(o.revision) ||
|
|
344
|
+
!(o.engineKind === undefined || isEngineKind(o.engineKind)) ||
|
|
345
|
+
!(o.credentialId === undefined || isIdValue(o.credentialId))
|
|
346
|
+
) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
const location: CredentialLocation = {
|
|
350
|
+
class: o.class,
|
|
351
|
+
scope: o.scope,
|
|
352
|
+
ownerId: o.ownerId,
|
|
353
|
+
...(o.engineKind !== undefined ? { engineKind: o.engineKind as string } : {}),
|
|
354
|
+
...(o.credentialId !== undefined ? { credentialId: o.credentialId as string } : {}),
|
|
355
|
+
revision: o.revision,
|
|
356
|
+
};
|
|
357
|
+
try {
|
|
358
|
+
leaseParameterName(location);
|
|
359
|
+
} catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
return location;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function parseStringMap(
|
|
366
|
+
value: unknown,
|
|
367
|
+
allowedKeys: readonly string[],
|
|
368
|
+
maxChars: number,
|
|
369
|
+
): Record<string, string> | null {
|
|
370
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
371
|
+
const entries = Object.entries(value as WireObject);
|
|
372
|
+
if (entries.length === 0) return null;
|
|
373
|
+
const out: Record<string, string> = {};
|
|
374
|
+
for (const [key, item] of entries) {
|
|
375
|
+
if (!allowedKeys.includes(key) || !isBoundedString(item, maxChars)) return null;
|
|
376
|
+
out[key] = item;
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The JSON a writer stores in a lease-readable parameter. */
|
|
382
|
+
export function serializeLeasedValue(value: LeasedValue): string {
|
|
383
|
+
const json = canonicalJson(value);
|
|
384
|
+
if (new TextEncoder().encode(json).length > MAX_CREDENTIAL_VALUE_BYTES) {
|
|
385
|
+
throw new Error("credential value too large for one parameter");
|
|
386
|
+
}
|
|
387
|
+
return json;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* A lease-readable value, read from the parameter at `expected`, or null.
|
|
392
|
+
* Exact like every parser here, and bound to its parameter: the envelope's
|
|
393
|
+
* location must be the one the reader asked for, so a value copied into
|
|
394
|
+
* another credential's or revision's parameter is refused, not used.
|
|
395
|
+
*/
|
|
396
|
+
export function parseLeasedValue(
|
|
397
|
+
raw: string,
|
|
398
|
+
expected: CredentialLocation,
|
|
399
|
+
): LeasedValue | null {
|
|
400
|
+
if (
|
|
401
|
+
typeof raw !== "string" ||
|
|
402
|
+
new TextEncoder().encode(raw).length > MAX_CREDENTIAL_VALUE_BYTES
|
|
403
|
+
) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
let parsed: unknown;
|
|
407
|
+
try {
|
|
408
|
+
parsed = JSON.parse(raw);
|
|
409
|
+
} catch {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
413
|
+
const o = parsed as WireObject;
|
|
414
|
+
if (o.v !== 1 || o.part !== "lease") return null;
|
|
415
|
+
const location = parseLocationField(o.location);
|
|
416
|
+
if (!location || !sameLocation(location, expected) || o.class !== location.class) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
const base = { v: 1 as const, part: "lease" as const, location };
|
|
420
|
+
switch (location.class) {
|
|
421
|
+
case "engine": {
|
|
422
|
+
const kind = location.engineKind!;
|
|
423
|
+
if (o.authKind === "env") {
|
|
424
|
+
const env = exactObject(o, ["v", "part", "location", "class", "authKind", "env"])
|
|
425
|
+
? parseStringMap(o.env, ENGINE_ENV_CREDENTIAL_KEYS[kind] ?? [], 4096)
|
|
426
|
+
: null;
|
|
427
|
+
return env ? { ...base, class: "engine", authKind: "env", env } : null;
|
|
428
|
+
}
|
|
429
|
+
if (o.authKind === "config-dir") {
|
|
430
|
+
if (
|
|
431
|
+
!exactObject(o, ["v", "part", "location", "class", "authKind", "files", "expiresAt"]) ||
|
|
432
|
+
!(o.expiresAt === null || isEpochMs(o.expiresAt))
|
|
433
|
+
) {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
const files = parseStringMap(o.files, ENGINE_CREDENTIAL_FILES[kind] ?? [], MAX_CREDENTIAL_VALUE_BYTES);
|
|
437
|
+
return files
|
|
438
|
+
? { ...base, class: "engine", authKind: "config-dir", files, expiresAt: o.expiresAt }
|
|
439
|
+
: null;
|
|
440
|
+
}
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
case "mcp": {
|
|
444
|
+
if (o.authKind === "token") {
|
|
445
|
+
const header = exactObject(o.header, ["name", "value"]);
|
|
446
|
+
if (
|
|
447
|
+
!exactObject(o, ["v", "part", "location", "class", "authKind", "header"]) ||
|
|
448
|
+
!header ||
|
|
449
|
+
typeof header.name !== "string" ||
|
|
450
|
+
!HEADER_NAME.test(header.name) ||
|
|
451
|
+
FORBIDDEN_HEADER_NAMES.has(header.name.toLowerCase()) ||
|
|
452
|
+
!isBoundedString(header.value, 4096) ||
|
|
453
|
+
/[\r\n]/.test(header.value)
|
|
454
|
+
) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
...base,
|
|
459
|
+
class: "mcp",
|
|
460
|
+
authKind: "token",
|
|
461
|
+
header: { name: header.name, value: header.value },
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
if (o.authKind === "oauth") {
|
|
465
|
+
if (
|
|
466
|
+
!exactObject(o, ["v", "part", "location", "class", "authKind", "accessToken", "tokenType", "expiresAt"]) ||
|
|
467
|
+
!isBoundedString(o.accessToken, 4096) ||
|
|
468
|
+
/[\r\n]/.test(o.accessToken) ||
|
|
469
|
+
o.tokenType !== "Bearer" ||
|
|
470
|
+
!(o.expiresAt === null || isEpochMs(o.expiresAt))
|
|
471
|
+
) {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
...base,
|
|
476
|
+
class: "mcp",
|
|
477
|
+
authKind: "oauth",
|
|
478
|
+
accessToken: o.accessToken,
|
|
479
|
+
tokenType: "Bearer",
|
|
480
|
+
expiresAt: o.expiresAt,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
case "github":
|
|
486
|
+
return exactObject(o, ["v", "part", "location", "class", "token"]) &&
|
|
487
|
+
isBoundedString(o.token, 512) &&
|
|
488
|
+
!/\s/.test(o.token)
|
|
489
|
+
? { ...base, class: "github", token: o.token }
|
|
490
|
+
: null;
|
|
491
|
+
case "ssh":
|
|
492
|
+
return exactObject(o, ["v", "part", "location", "class", "privateKey", "publicKey"]) &&
|
|
493
|
+
isBoundedString(o.privateKey, 4096) &&
|
|
494
|
+
isBoundedString(o.publicKey, 1024)
|
|
495
|
+
? { ...base, class: "ssh", privateKey: o.privateKey, publicKey: o.publicKey }
|
|
496
|
+
: null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
// Capture flows (cloud ↔ sign-in box)
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
/** Advertised by a sign-in box that writes captures as ADR-154 revisions. */
|
|
505
|
+
export const SIGNIN_BOX_CAPTURE_FLOWS_FEATURE = "signin-box-capture-flows-v1";
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* What the cloud tells the sign-in box to write for one capture: the
|
|
509
|
+
* revision's parameters (the box never chooses a name) and the ids it tags
|
|
510
|
+
* them with. The box checks every field against the others before it writes.
|
|
511
|
+
*/
|
|
512
|
+
export interface CaptureFlowWire {
|
|
513
|
+
credentialId: string;
|
|
514
|
+
revision: string;
|
|
515
|
+
leaseParameter: string;
|
|
516
|
+
/** Null for a static credential. */
|
|
517
|
+
brokerParameter: string | null;
|
|
518
|
+
/** The workspace tag: required except for GitHub and SSH. */
|
|
519
|
+
orgId: string | null;
|
|
520
|
+
/** The person, for a personal credential. */
|
|
521
|
+
userId: string | null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** What the box answers once both parts are written: never material. */
|
|
525
|
+
export interface CaptureResultWire {
|
|
526
|
+
credentialId: string;
|
|
527
|
+
revision: string;
|
|
528
|
+
/** The lease-readable parameter's Parameter Store version. */
|
|
529
|
+
leaseVersion: number;
|
|
530
|
+
/** The access token's expiry when the box could read it. */
|
|
531
|
+
expiresAt: number | null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* A capture flow, rebuilt and checked, with the location its parameters name,
|
|
536
|
+
* or null. The two parameters must name the same credential and revision under
|
|
537
|
+
* their own roots, and the tags must be the parameter's owner: a workspace
|
|
538
|
+
* credential's `orgId` is its owner and it has no `userId`; a personal AI
|
|
539
|
+
* account or MCP connection names both its person and its workspace; GitHub
|
|
540
|
+
* and SSH name their person alone.
|
|
541
|
+
*/
|
|
542
|
+
export function parseCaptureFlowWire(
|
|
543
|
+
value: unknown,
|
|
544
|
+
): (CaptureFlowWire & { location: CredentialLocation }) | null {
|
|
545
|
+
const o = exactObject(value, [
|
|
546
|
+
"credentialId",
|
|
547
|
+
"revision",
|
|
548
|
+
"leaseParameter",
|
|
549
|
+
"brokerParameter",
|
|
550
|
+
"orgId",
|
|
551
|
+
"userId",
|
|
552
|
+
]);
|
|
553
|
+
if (
|
|
554
|
+
!o ||
|
|
555
|
+
!isIdValue(o.credentialId) ||
|
|
556
|
+
!isIdValue(o.revision) ||
|
|
557
|
+
typeof o.leaseParameter !== "string" ||
|
|
558
|
+
!(o.brokerParameter === null || typeof o.brokerParameter === "string") ||
|
|
559
|
+
!(o.orgId === null || (isIdValue(o.orgId) && isTagValue(o.orgId))) ||
|
|
560
|
+
!(o.userId === null || (isIdValue(o.userId) && isTagValue(o.userId)))
|
|
561
|
+
) {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
const location = parseLeaseParameterName(o.leaseParameter);
|
|
565
|
+
if (!location || location.revision !== o.revision) return null;
|
|
566
|
+
const isPerson = location.class === "github" || location.class === "ssh";
|
|
567
|
+
if (!isPerson && location.credentialId !== o.credentialId) return null;
|
|
568
|
+
if (o.brokerParameter !== null) {
|
|
569
|
+
const broker = parseBrokerParameterName(o.brokerParameter);
|
|
570
|
+
if (!broker || !sameLocation(broker, location)) return null;
|
|
571
|
+
}
|
|
572
|
+
if (isPerson && o.brokerParameter !== null) return null;
|
|
573
|
+
const ownerOk =
|
|
574
|
+
location.scope === "org"
|
|
575
|
+
? o.orgId === location.ownerId && o.userId === null
|
|
576
|
+
: o.userId === location.ownerId && (isPerson ? o.orgId === null : o.orgId !== null);
|
|
577
|
+
if (!ownerOk) return null;
|
|
578
|
+
return {
|
|
579
|
+
credentialId: o.credentialId,
|
|
580
|
+
revision: o.revision,
|
|
581
|
+
leaseParameter: o.leaseParameter,
|
|
582
|
+
brokerParameter: o.brokerParameter,
|
|
583
|
+
orgId: o.orgId,
|
|
584
|
+
userId: o.userId,
|
|
585
|
+
location,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function parseCaptureResultWire(value: unknown): CaptureResultWire | null {
|
|
590
|
+
const o = exactObject(value, ["credentialId", "revision", "leaseVersion", "expiresAt"]);
|
|
591
|
+
return o &&
|
|
592
|
+
isIdValue(o.credentialId) &&
|
|
593
|
+
isIdValue(o.revision) &&
|
|
594
|
+
isPositiveInteger(o.leaseVersion) &&
|
|
595
|
+
(o.expiresAt === null || isEpochMs(o.expiresAt))
|
|
596
|
+
? {
|
|
597
|
+
credentialId: o.credentialId,
|
|
598
|
+
revision: o.revision,
|
|
599
|
+
leaseVersion: o.leaseVersion,
|
|
600
|
+
expiresAt: o.expiresAt,
|
|
601
|
+
}
|
|
602
|
+
: null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
// Session tags
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Every v1 `credentials` session carries `lease=v1`, and every role statement
|
|
611
|
+
* that grants `/uai/lease` requires it, so the statements v1 adds never widen
|
|
612
|
+
* a v0 session (which carries no such tag).
|
|
613
|
+
*/
|
|
614
|
+
export const LEASE_V1_SESSION_TAG = { key: "lease", value: "v1" } as const;
|
|
615
|
+
/** The session tag the role policy puts into `/uai/lease/orgs/${…}` paths:
|
|
616
|
+
* the workspace id as `idPathSegment` writes it. `org` keeps the raw id,
|
|
617
|
+
* which resource-tag conditions compare. */
|
|
618
|
+
export const ORG_PATH_SESSION_TAG_KEY = "orgseg";
|
|
619
|
+
|
|
620
|
+
const TAG_VALUE = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]{1,256}$/u;
|
|
621
|
+
|
|
622
|
+
/** STS session tag and SSM resource tag values: the same grammar, refused
|
|
623
|
+
* rather than rewritten, so a principal tag and a resource tag written for
|
|
624
|
+
* one id always compare equal. */
|
|
625
|
+
export function isTagValue(value: unknown): value is string {
|
|
626
|
+
return typeof value === "string" && TAG_VALUE.test(value);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
// The v1 grant and its manifest
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
export const MAX_LEASE_SESSIONS = 5;
|
|
634
|
+
export const MAX_LEASE_CREDENTIAL_SESSIONS = 4;
|
|
635
|
+
export const MAX_MANIFEST_PROJECTS = 32;
|
|
636
|
+
export const MAX_MANIFEST_ENGINES = 64;
|
|
637
|
+
export const MAX_MANIFEST_MCP = 64;
|
|
638
|
+
export const MAX_MANIFEST_LABEL_CHARS = 64;
|
|
639
|
+
export const MAX_MANIFEST_URL_CHARS = 2048;
|
|
640
|
+
export const MAX_MANIFEST_SLUG_CHARS = 64;
|
|
641
|
+
export const MAX_LEASE_ERROR_CHARS = 512;
|
|
642
|
+
const MAX_LEASE_OP_ID_CHARS = 128;
|
|
643
|
+
const MAX_SESSION_SECRET_CHARS = 4096;
|
|
644
|
+
const MAX_REGION_CHARS = 32;
|
|
645
|
+
const MAX_BUCKET_CHARS = 63;
|
|
646
|
+
const MAX_PREFIX_CHARS = 512;
|
|
647
|
+
const DIGEST = /^[0-9a-f]{64}$/;
|
|
648
|
+
const OP_ID = /^[A-Za-z0-9_-]+$/;
|
|
649
|
+
|
|
650
|
+
export type LeaseSessionPurpose = "files" | "credentials";
|
|
651
|
+
|
|
652
|
+
/** One STS session. A task machine's grant has exactly one `files` session,
|
|
653
|
+
* which only Mountpoint uses; an owned runner's has none. */
|
|
654
|
+
export interface LeaseSession {
|
|
655
|
+
id: string;
|
|
656
|
+
purpose: LeaseSessionPurpose;
|
|
657
|
+
accessKeyId: string;
|
|
658
|
+
secretAccessKey: string;
|
|
659
|
+
sessionToken: string;
|
|
660
|
+
/** Epoch ms. */
|
|
661
|
+
expiresAt: number;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export interface LeaseManifestProject {
|
|
665
|
+
projectId: string;
|
|
666
|
+
/** The session whose policy names this project's env values. */
|
|
667
|
+
sessionId: string;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export interface LeaseManifestEngine {
|
|
671
|
+
credentialId: string;
|
|
672
|
+
revision: string;
|
|
673
|
+
/** The revision's lease-readable parameter. */
|
|
674
|
+
parameter: string;
|
|
675
|
+
/** Its Parameter Store version as the cloud last recorded it. */
|
|
676
|
+
version: number;
|
|
677
|
+
expiresAt: number | null;
|
|
678
|
+
sessionId: string;
|
|
679
|
+
engineKind: string;
|
|
680
|
+
scope: CredentialScope;
|
|
681
|
+
label: string;
|
|
682
|
+
authKind: "env" | "config-dir";
|
|
683
|
+
/** Position within its scope; the list itself is already in try order. */
|
|
684
|
+
position: number;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export interface LeaseManifestMcp {
|
|
688
|
+
credentialId: string;
|
|
689
|
+
slug: string;
|
|
690
|
+
url: string;
|
|
691
|
+
scope: CredentialScope;
|
|
692
|
+
authKind: "oauth" | "token" | "none";
|
|
693
|
+
/** Null exactly when `authKind` is `none`: nothing secret to read. */
|
|
694
|
+
revision: string | null;
|
|
695
|
+
parameter: string | null;
|
|
696
|
+
version: number | null;
|
|
697
|
+
expiresAt: number | null;
|
|
698
|
+
sessionId: string | null;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export interface LeaseManifestStatic {
|
|
702
|
+
credentialId: string;
|
|
703
|
+
revision: string;
|
|
704
|
+
parameter: string;
|
|
705
|
+
version: number;
|
|
706
|
+
sessionId: string;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** What a task may read, by name and version — never a value. */
|
|
710
|
+
export interface CredentialManifest {
|
|
711
|
+
/** The task's projects, lead first. */
|
|
712
|
+
projects: LeaseManifestProject[];
|
|
713
|
+
/** Per engine kind: the owner's personal accounts, then the workspace's. */
|
|
714
|
+
engines: LeaseManifestEngine[];
|
|
715
|
+
mcp: LeaseManifestMcp[];
|
|
716
|
+
github: LeaseManifestStatic | null;
|
|
717
|
+
ssh: LeaseManifestStatic | null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export interface LeaseFiles {
|
|
721
|
+
bucket: string;
|
|
722
|
+
orgPrefix: string | null;
|
|
723
|
+
userPrefix: string;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** ADR-154 §4: the grant a v1 runner installs for one live task. */
|
|
727
|
+
export interface TaskLeaseV1 {
|
|
728
|
+
v: 1;
|
|
729
|
+
leaseId: string;
|
|
730
|
+
taskId: string;
|
|
731
|
+
orgId: string;
|
|
732
|
+
userId: string;
|
|
733
|
+
/** The task's run epoch: advanced by the cloud at every start, resume and
|
|
734
|
+
* wake. With the runner the task is assigned to, it is the assignment a
|
|
735
|
+
* grant, a replica frame and the cleanup inventory are fenced to. */
|
|
736
|
+
runEpoch: number;
|
|
737
|
+
/** Only grows per task. */
|
|
738
|
+
manifestRevision: number;
|
|
739
|
+
/** sha256 (lowercase hex) of `canonicalManifestJson(manifest)`. */
|
|
740
|
+
manifestDigest: string;
|
|
741
|
+
region: string;
|
|
742
|
+
sessions: LeaseSession[];
|
|
743
|
+
/** Present exactly when a `files` session is. */
|
|
744
|
+
files: LeaseFiles | null;
|
|
745
|
+
manifest: CredentialManifest;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* A task command's run epoch (`TaskCommandTask.runEpoch`): undefined when
|
|
750
|
+
* absent, since a cloud that predates ADR-154 sends none. A present value that
|
|
751
|
+
* is not a positive safe integer throws; it is never read as absent.
|
|
752
|
+
*/
|
|
753
|
+
export function runEpochFromWire(
|
|
754
|
+
value: unknown,
|
|
755
|
+
field = "runEpoch",
|
|
756
|
+
): number | undefined {
|
|
757
|
+
if (value === undefined) return undefined;
|
|
758
|
+
if (!isPositiveInteger(value)) throw new Error(`invalid ${field}`);
|
|
759
|
+
return value;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** A grant expires with its earliest session. */
|
|
763
|
+
export function taskLeaseV1ExpiresAt(lease: TaskLeaseV1): number {
|
|
764
|
+
return Math.min(...lease.sessions.map((session) => session.expiresAt));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* The bytes `manifestDigest` covers: JSON with every object's keys sorted,
|
|
769
|
+
* arrays in order, no whitespace. Callers hash it with their own crypto, so
|
|
770
|
+
* this module stays loadable everywhere.
|
|
771
|
+
*/
|
|
772
|
+
export function canonicalManifestJson(manifest: CredentialManifest): string {
|
|
773
|
+
return canonicalJson(manifest);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function canonicalJson(value: unknown): string {
|
|
777
|
+
if (value === null || typeof value !== "object") {
|
|
778
|
+
return JSON.stringify(value);
|
|
779
|
+
}
|
|
780
|
+
if (Array.isArray(value)) {
|
|
781
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
782
|
+
}
|
|
783
|
+
const record = value as Record<string, unknown>;
|
|
784
|
+
const keys = Object.keys(record)
|
|
785
|
+
.filter((key) => record[key] !== undefined)
|
|
786
|
+
.sort();
|
|
787
|
+
return `{${keys
|
|
788
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
|
789
|
+
.join(",")}}`;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
// Frames
|
|
794
|
+
// ---------------------------------------------------------------------------
|
|
795
|
+
|
|
796
|
+
/** Runner → cloud: a v1 lease request for one exact task run. */
|
|
797
|
+
export interface LeaseRequestV1Frame {
|
|
798
|
+
kind: "lease.request";
|
|
799
|
+
opId: string;
|
|
800
|
+
taskId: string;
|
|
801
|
+
runEpoch: number;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Cloud → runner: a v1 grant answering `lease.request`. A separate kind from
|
|
805
|
+
* ADR-133's `lease.grant`, so a runner that predates v1 drops it unread. */
|
|
806
|
+
export interface LeaseGrantV1Frame {
|
|
807
|
+
kind: "lease.grant.v1";
|
|
808
|
+
opId: string;
|
|
809
|
+
lease: TaskLeaseV1;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Cloud → runner: STS or the network failed; ask again later. Not a denial:
|
|
813
|
+
* the runner keeps an unexpired grant that is not below `minRevision`. */
|
|
814
|
+
export interface LeaseUnavailableFrame {
|
|
815
|
+
kind: "lease.unavailable";
|
|
816
|
+
opId: string;
|
|
817
|
+
error: string;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Cloud → runner: the task's credentials changed. Renew until a grant at or
|
|
822
|
+
* above `wantedRevision` is installed. With `minRevision`, grants below it are
|
|
823
|
+
* unusable at once and `removed` names the credentials that left, whose
|
|
824
|
+
* cleanup runs before the next grant is installed.
|
|
825
|
+
*/
|
|
826
|
+
export interface LeaseStaleFrame {
|
|
827
|
+
kind: "lease.stale";
|
|
828
|
+
taskId: string;
|
|
829
|
+
runEpoch: number;
|
|
830
|
+
wantedRevision: number;
|
|
831
|
+
minRevision: number | null;
|
|
832
|
+
removed: string[];
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Cloud → runner: a credential the task's manifest names has a new version
|
|
836
|
+
* of its lease-readable parameter (a broker refresh). */
|
|
837
|
+
export interface CredentialChangedFrame {
|
|
838
|
+
kind: "credential.changed";
|
|
839
|
+
taskId: string;
|
|
840
|
+
runEpoch: number;
|
|
841
|
+
credentialId: string;
|
|
842
|
+
revision: string;
|
|
843
|
+
version: number;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Runner → cloud: a credential-invalid signal or an MCP 401. */
|
|
847
|
+
export interface CredentialRefreshFrame {
|
|
848
|
+
kind: "credential.refresh";
|
|
849
|
+
opId: string;
|
|
850
|
+
taskId: string;
|
|
851
|
+
runEpoch: number;
|
|
852
|
+
credentialId: string;
|
|
853
|
+
revision: string;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
export type CredentialRefreshOutcome =
|
|
857
|
+
| "refreshed"
|
|
858
|
+
| "fresh"
|
|
859
|
+
| "needs_sign_in"
|
|
860
|
+
| "busy"
|
|
861
|
+
| "unavailable";
|
|
862
|
+
|
|
863
|
+
/** Cloud → runner: the broker's typed answer, never material. `version` and
|
|
864
|
+
* `expiresAt` accompany `refreshed` and `fresh` only. */
|
|
865
|
+
export interface CredentialRefreshResultFrame {
|
|
866
|
+
kind: "credential.refresh.result";
|
|
867
|
+
opId: string;
|
|
868
|
+
outcome: CredentialRefreshOutcome;
|
|
869
|
+
version: number | null;
|
|
870
|
+
expiresAt: number | null;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
export type CredentialsActivationPhase = "prepare" | "commit";
|
|
874
|
+
|
|
875
|
+
/** Runner → cloud: ADR-154 §13's prepare and commit steps. */
|
|
876
|
+
export interface CredentialsActivateFrame {
|
|
877
|
+
kind: "credentials.activate";
|
|
878
|
+
opId: string;
|
|
879
|
+
phase: CredentialsActivationPhase;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
export interface CredentialsActivateResultFrame {
|
|
883
|
+
kind: "credentials.activate.result";
|
|
884
|
+
opId: string;
|
|
885
|
+
phase: CredentialsActivationPhase;
|
|
886
|
+
ok: boolean;
|
|
887
|
+
error: string | null;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Runner → cloud: the purge is complete; the runner now advertises v1. */
|
|
891
|
+
export interface CredentialsActivatedFrame {
|
|
892
|
+
kind: "credentials.activated";
|
|
893
|
+
opId: string;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ---------------------------------------------------------------------------
|
|
897
|
+
// Parsers
|
|
898
|
+
// ---------------------------------------------------------------------------
|
|
899
|
+
|
|
900
|
+
type WireObject = Record<string, unknown>;
|
|
901
|
+
|
|
902
|
+
function exactObject(
|
|
903
|
+
value: unknown,
|
|
904
|
+
required: readonly string[],
|
|
905
|
+
): WireObject | null {
|
|
906
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
907
|
+
const object = value as WireObject;
|
|
908
|
+
const keys = Object.keys(object);
|
|
909
|
+
if (
|
|
910
|
+
keys.length !== required.length ||
|
|
911
|
+
required.some((key) => !Object.hasOwn(object, key))
|
|
912
|
+
) {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
return object;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function isBoundedString(value: unknown, max: number): value is string {
|
|
919
|
+
return typeof value === "string" && value.length > 0 && value.length <= max;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function isOpId(value: unknown): value is string {
|
|
923
|
+
return isBoundedString(value, MAX_LEASE_OP_ID_CHARS) && OP_ID.test(value);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function isPositiveInteger(value: unknown): value is number {
|
|
927
|
+
return Number.isSafeInteger(value) && (value as number) > 0;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function isNonNegativeInteger(value: unknown): value is number {
|
|
931
|
+
return Number.isSafeInteger(value) && (value as number) >= 0;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function isEpochMs(value: unknown): value is number {
|
|
935
|
+
return isPositiveInteger(value);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function isScope(value: unknown): value is CredentialScope {
|
|
939
|
+
return value === "org" || value === "user";
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function uniqueBy<T>(items: readonly T[], key: (item: T) => string): boolean {
|
|
943
|
+
return new Set(items.map(key)).size === items.length;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function parseSession(value: unknown): LeaseSession | null {
|
|
947
|
+
const o = exactObject(value, [
|
|
948
|
+
"id",
|
|
949
|
+
"purpose",
|
|
950
|
+
"accessKeyId",
|
|
951
|
+
"secretAccessKey",
|
|
952
|
+
"sessionToken",
|
|
953
|
+
"expiresAt",
|
|
954
|
+
]);
|
|
955
|
+
if (
|
|
956
|
+
!o ||
|
|
957
|
+
!isOpId(o.id) ||
|
|
958
|
+
(o.purpose !== "files" && o.purpose !== "credentials") ||
|
|
959
|
+
!isBoundedString(o.accessKeyId, 128) ||
|
|
960
|
+
!isBoundedString(o.secretAccessKey, MAX_SESSION_SECRET_CHARS) ||
|
|
961
|
+
!isBoundedString(o.sessionToken, MAX_SESSION_SECRET_CHARS) ||
|
|
962
|
+
!isEpochMs(o.expiresAt)
|
|
963
|
+
) {
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
return {
|
|
967
|
+
id: o.id,
|
|
968
|
+
purpose: o.purpose,
|
|
969
|
+
accessKeyId: o.accessKeyId,
|
|
970
|
+
secretAccessKey: o.secretAccessKey,
|
|
971
|
+
sessionToken: o.sessionToken,
|
|
972
|
+
expiresAt: o.expiresAt,
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** The owner a lease parameter must belong to, for a grant's org and user. */
|
|
977
|
+
function belongsTo(
|
|
978
|
+
location: CredentialLocation,
|
|
979
|
+
grant: { orgId: string; userId: string },
|
|
980
|
+
): boolean {
|
|
981
|
+
return location.scope === "org"
|
|
982
|
+
? location.ownerId === grant.orgId
|
|
983
|
+
: location.ownerId === grant.userId;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function parseEngine(
|
|
987
|
+
value: unknown,
|
|
988
|
+
grant: { orgId: string; userId: string },
|
|
989
|
+
credentialSessions: ReadonlySet<string>,
|
|
990
|
+
): LeaseManifestEngine | null {
|
|
991
|
+
const o = exactObject(value, [
|
|
992
|
+
"credentialId",
|
|
993
|
+
"revision",
|
|
994
|
+
"parameter",
|
|
995
|
+
"version",
|
|
996
|
+
"expiresAt",
|
|
997
|
+
"sessionId",
|
|
998
|
+
"engineKind",
|
|
999
|
+
"scope",
|
|
1000
|
+
"label",
|
|
1001
|
+
"authKind",
|
|
1002
|
+
"position",
|
|
1003
|
+
]);
|
|
1004
|
+
if (
|
|
1005
|
+
!o ||
|
|
1006
|
+
!isIdValue(o.credentialId) ||
|
|
1007
|
+
!isIdValue(o.revision) ||
|
|
1008
|
+
typeof o.parameter !== "string" ||
|
|
1009
|
+
!isPositiveInteger(o.version) ||
|
|
1010
|
+
!(o.expiresAt === null || isEpochMs(o.expiresAt)) ||
|
|
1011
|
+
typeof o.sessionId !== "string" ||
|
|
1012
|
+
!credentialSessions.has(o.sessionId) ||
|
|
1013
|
+
!isEngineKind(o.engineKind) ||
|
|
1014
|
+
!isScope(o.scope) ||
|
|
1015
|
+
!isBoundedString(o.label, MAX_MANIFEST_LABEL_CHARS) ||
|
|
1016
|
+
(o.authKind !== "env" && o.authKind !== "config-dir") ||
|
|
1017
|
+
!isNonNegativeInteger(o.position)
|
|
1018
|
+
) {
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
const location = parseLeaseParameterName(o.parameter);
|
|
1022
|
+
if (
|
|
1023
|
+
!location ||
|
|
1024
|
+
location.class !== "engine" ||
|
|
1025
|
+
location.scope !== o.scope ||
|
|
1026
|
+
location.engineKind !== o.engineKind ||
|
|
1027
|
+
location.credentialId !== o.credentialId ||
|
|
1028
|
+
location.revision !== o.revision ||
|
|
1029
|
+
!belongsTo(location, grant)
|
|
1030
|
+
) {
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
return {
|
|
1034
|
+
credentialId: o.credentialId,
|
|
1035
|
+
revision: o.revision,
|
|
1036
|
+
parameter: o.parameter,
|
|
1037
|
+
version: o.version,
|
|
1038
|
+
expiresAt: o.expiresAt,
|
|
1039
|
+
sessionId: o.sessionId,
|
|
1040
|
+
engineKind: o.engineKind,
|
|
1041
|
+
scope: o.scope,
|
|
1042
|
+
label: o.label,
|
|
1043
|
+
authKind: o.authKind,
|
|
1044
|
+
position: o.position,
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function isMcpUrl(value: unknown): value is string {
|
|
1049
|
+
if (!isBoundedString(value, MAX_MANIFEST_URL_CHARS)) return false;
|
|
1050
|
+
try {
|
|
1051
|
+
const url = new URL(value);
|
|
1052
|
+
return url.protocol === "https:" && url.username === "" && url.password === "";
|
|
1053
|
+
} catch {
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function parseMcp(
|
|
1059
|
+
value: unknown,
|
|
1060
|
+
grant: { orgId: string; userId: string },
|
|
1061
|
+
credentialSessions: ReadonlySet<string>,
|
|
1062
|
+
): LeaseManifestMcp | null {
|
|
1063
|
+
const o = exactObject(value, [
|
|
1064
|
+
"credentialId",
|
|
1065
|
+
"slug",
|
|
1066
|
+
"url",
|
|
1067
|
+
"scope",
|
|
1068
|
+
"authKind",
|
|
1069
|
+
"revision",
|
|
1070
|
+
"parameter",
|
|
1071
|
+
"version",
|
|
1072
|
+
"expiresAt",
|
|
1073
|
+
"sessionId",
|
|
1074
|
+
]);
|
|
1075
|
+
if (
|
|
1076
|
+
!o ||
|
|
1077
|
+
!isIdValue(o.credentialId) ||
|
|
1078
|
+
!isBoundedString(o.slug, MAX_MANIFEST_SLUG_CHARS) ||
|
|
1079
|
+
!/^[a-z0-9][a-z0-9-]*$/.test(o.slug) ||
|
|
1080
|
+
!isMcpUrl(o.url) ||
|
|
1081
|
+
!isScope(o.scope) ||
|
|
1082
|
+
(o.authKind !== "oauth" && o.authKind !== "token" && o.authKind !== "none")
|
|
1083
|
+
) {
|
|
1084
|
+
return null;
|
|
1085
|
+
}
|
|
1086
|
+
const authKind: LeaseManifestMcp["authKind"] = o.authKind;
|
|
1087
|
+
const base = {
|
|
1088
|
+
credentialId: o.credentialId,
|
|
1089
|
+
slug: o.slug,
|
|
1090
|
+
url: o.url,
|
|
1091
|
+
scope: o.scope,
|
|
1092
|
+
authKind,
|
|
1093
|
+
};
|
|
1094
|
+
if (authKind === "none") {
|
|
1095
|
+
return o.revision === null &&
|
|
1096
|
+
o.parameter === null &&
|
|
1097
|
+
o.version === null &&
|
|
1098
|
+
o.expiresAt === null &&
|
|
1099
|
+
o.sessionId === null
|
|
1100
|
+
? {
|
|
1101
|
+
...base,
|
|
1102
|
+
revision: null,
|
|
1103
|
+
parameter: null,
|
|
1104
|
+
version: null,
|
|
1105
|
+
expiresAt: null,
|
|
1106
|
+
sessionId: null,
|
|
1107
|
+
}
|
|
1108
|
+
: null;
|
|
1109
|
+
}
|
|
1110
|
+
if (
|
|
1111
|
+
!isIdValue(o.revision) ||
|
|
1112
|
+
typeof o.parameter !== "string" ||
|
|
1113
|
+
!isPositiveInteger(o.version) ||
|
|
1114
|
+
!(o.expiresAt === null || isEpochMs(o.expiresAt)) ||
|
|
1115
|
+
typeof o.sessionId !== "string" ||
|
|
1116
|
+
!credentialSessions.has(o.sessionId)
|
|
1117
|
+
) {
|
|
1118
|
+
return null;
|
|
1119
|
+
}
|
|
1120
|
+
const location = parseLeaseParameterName(o.parameter);
|
|
1121
|
+
if (
|
|
1122
|
+
!location ||
|
|
1123
|
+
location.class !== "mcp" ||
|
|
1124
|
+
location.scope !== o.scope ||
|
|
1125
|
+
location.credentialId !== o.credentialId ||
|
|
1126
|
+
location.revision !== o.revision ||
|
|
1127
|
+
!belongsTo(location, grant)
|
|
1128
|
+
) {
|
|
1129
|
+
return null;
|
|
1130
|
+
}
|
|
1131
|
+
return {
|
|
1132
|
+
...base,
|
|
1133
|
+
revision: o.revision,
|
|
1134
|
+
parameter: o.parameter,
|
|
1135
|
+
version: o.version,
|
|
1136
|
+
expiresAt: o.expiresAt,
|
|
1137
|
+
sessionId: o.sessionId,
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function parseStatic(
|
|
1142
|
+
value: unknown,
|
|
1143
|
+
credentialClass: "github" | "ssh",
|
|
1144
|
+
grant: { orgId: string; userId: string },
|
|
1145
|
+
credentialSessions: ReadonlySet<string>,
|
|
1146
|
+
): LeaseManifestStatic | null {
|
|
1147
|
+
const o = exactObject(value, [
|
|
1148
|
+
"credentialId",
|
|
1149
|
+
"revision",
|
|
1150
|
+
"parameter",
|
|
1151
|
+
"version",
|
|
1152
|
+
"sessionId",
|
|
1153
|
+
]);
|
|
1154
|
+
if (
|
|
1155
|
+
!o ||
|
|
1156
|
+
!isIdValue(o.credentialId) ||
|
|
1157
|
+
!isIdValue(o.revision) ||
|
|
1158
|
+
typeof o.parameter !== "string" ||
|
|
1159
|
+
!isPositiveInteger(o.version) ||
|
|
1160
|
+
typeof o.sessionId !== "string" ||
|
|
1161
|
+
!credentialSessions.has(o.sessionId)
|
|
1162
|
+
) {
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
const location = parseLeaseParameterName(o.parameter);
|
|
1166
|
+
if (
|
|
1167
|
+
!location ||
|
|
1168
|
+
location.class !== credentialClass ||
|
|
1169
|
+
location.revision !== o.revision ||
|
|
1170
|
+
location.ownerId !== grant.userId
|
|
1171
|
+
) {
|
|
1172
|
+
return null;
|
|
1173
|
+
}
|
|
1174
|
+
return {
|
|
1175
|
+
credentialId: o.credentialId,
|
|
1176
|
+
revision: o.revision,
|
|
1177
|
+
parameter: o.parameter,
|
|
1178
|
+
version: o.version,
|
|
1179
|
+
sessionId: o.sessionId,
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function parseManifest(
|
|
1184
|
+
value: unknown,
|
|
1185
|
+
grant: { orgId: string; userId: string },
|
|
1186
|
+
credentialSessions: ReadonlySet<string>,
|
|
1187
|
+
): CredentialManifest | null {
|
|
1188
|
+
const o = exactObject(value, ["projects", "engines", "mcp", "github", "ssh"]);
|
|
1189
|
+
if (
|
|
1190
|
+
!o ||
|
|
1191
|
+
!Array.isArray(o.projects) ||
|
|
1192
|
+
o.projects.length > MAX_MANIFEST_PROJECTS ||
|
|
1193
|
+
!Array.isArray(o.engines) ||
|
|
1194
|
+
o.engines.length > MAX_MANIFEST_ENGINES ||
|
|
1195
|
+
!Array.isArray(o.mcp) ||
|
|
1196
|
+
o.mcp.length > MAX_MANIFEST_MCP
|
|
1197
|
+
) {
|
|
1198
|
+
return null;
|
|
1199
|
+
}
|
|
1200
|
+
const projects: LeaseManifestProject[] = [];
|
|
1201
|
+
for (const item of o.projects) {
|
|
1202
|
+
const p = exactObject(item, ["projectId", "sessionId"]);
|
|
1203
|
+
if (
|
|
1204
|
+
!p ||
|
|
1205
|
+
!isIdValue(p.projectId) ||
|
|
1206
|
+
typeof p.sessionId !== "string" ||
|
|
1207
|
+
!credentialSessions.has(p.sessionId)
|
|
1208
|
+
) {
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
projects.push({ projectId: p.projectId, sessionId: p.sessionId });
|
|
1212
|
+
}
|
|
1213
|
+
const engines: LeaseManifestEngine[] = [];
|
|
1214
|
+
for (const item of o.engines) {
|
|
1215
|
+
const engine = parseEngine(item, grant, credentialSessions);
|
|
1216
|
+
if (!engine) return null;
|
|
1217
|
+
engines.push(engine);
|
|
1218
|
+
}
|
|
1219
|
+
const mcp: LeaseManifestMcp[] = [];
|
|
1220
|
+
for (const item of o.mcp) {
|
|
1221
|
+
const connection = parseMcp(item, grant, credentialSessions);
|
|
1222
|
+
if (!connection) return null;
|
|
1223
|
+
mcp.push(connection);
|
|
1224
|
+
}
|
|
1225
|
+
const github =
|
|
1226
|
+
o.github === null
|
|
1227
|
+
? null
|
|
1228
|
+
: parseStatic(o.github, "github", grant, credentialSessions);
|
|
1229
|
+
const ssh =
|
|
1230
|
+
o.ssh === null ? null : parseStatic(o.ssh, "ssh", grant, credentialSessions);
|
|
1231
|
+
if ((o.github !== null && !github) || (o.ssh !== null && !ssh)) return null;
|
|
1232
|
+
if (
|
|
1233
|
+
!uniqueBy(projects, (p) => p.projectId) ||
|
|
1234
|
+
!uniqueBy(engines, (e) => e.credentialId) ||
|
|
1235
|
+
!uniqueBy(mcp, (m) => m.credentialId) ||
|
|
1236
|
+
!uniqueBy(mcp, (m) => m.slug)
|
|
1237
|
+
) {
|
|
1238
|
+
return null;
|
|
1239
|
+
}
|
|
1240
|
+
return { projects, engines, mcp, github, ssh };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function parseFiles(value: unknown): LeaseFiles | null {
|
|
1244
|
+
const o = exactObject(value, ["bucket", "orgPrefix", "userPrefix"]);
|
|
1245
|
+
if (
|
|
1246
|
+
!o ||
|
|
1247
|
+
!isBoundedString(o.bucket, MAX_BUCKET_CHARS) ||
|
|
1248
|
+
!(o.orgPrefix === null || isBoundedString(o.orgPrefix, MAX_PREFIX_CHARS)) ||
|
|
1249
|
+
!isBoundedString(o.userPrefix, MAX_PREFIX_CHARS)
|
|
1250
|
+
) {
|
|
1251
|
+
return null;
|
|
1252
|
+
}
|
|
1253
|
+
return { bucket: o.bucket, orgPrefix: o.orgPrefix, userPrefix: o.userPrefix };
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* The exact v1 grant, rebuilt field by field, or null. Beyond shapes it
|
|
1258
|
+
* checks the grant is self-consistent: one to four `credentials` sessions
|
|
1259
|
+
* and at most one `files` session, `files` present exactly with that
|
|
1260
|
+
* session, unique session ids, every manifest entry read by a `credentials`
|
|
1261
|
+
* session, and every parameter a lease-readable name owned by the grant's
|
|
1262
|
+
* workspace or user.
|
|
1263
|
+
*/
|
|
1264
|
+
export function parseTaskLeaseV1(value: unknown): TaskLeaseV1 | null {
|
|
1265
|
+
const o = exactObject(value, [
|
|
1266
|
+
"v",
|
|
1267
|
+
"leaseId",
|
|
1268
|
+
"taskId",
|
|
1269
|
+
"orgId",
|
|
1270
|
+
"userId",
|
|
1271
|
+
"runEpoch",
|
|
1272
|
+
"manifestRevision",
|
|
1273
|
+
"manifestDigest",
|
|
1274
|
+
"region",
|
|
1275
|
+
"sessions",
|
|
1276
|
+
"files",
|
|
1277
|
+
"manifest",
|
|
1278
|
+
]);
|
|
1279
|
+
if (
|
|
1280
|
+
!o ||
|
|
1281
|
+
o.v !== 1 ||
|
|
1282
|
+
!isOpId(o.leaseId) ||
|
|
1283
|
+
!isIdValue(o.taskId) ||
|
|
1284
|
+
!isIdValue(o.orgId) ||
|
|
1285
|
+
!isIdValue(o.userId) ||
|
|
1286
|
+
!isPositiveInteger(o.runEpoch) ||
|
|
1287
|
+
!isPositiveInteger(o.manifestRevision) ||
|
|
1288
|
+
typeof o.manifestDigest !== "string" ||
|
|
1289
|
+
!DIGEST.test(o.manifestDigest) ||
|
|
1290
|
+
!isBoundedString(o.region, MAX_REGION_CHARS) ||
|
|
1291
|
+
!Array.isArray(o.sessions) ||
|
|
1292
|
+
o.sessions.length === 0 ||
|
|
1293
|
+
o.sessions.length > MAX_LEASE_SESSIONS
|
|
1294
|
+
) {
|
|
1295
|
+
return null;
|
|
1296
|
+
}
|
|
1297
|
+
const sessions: LeaseSession[] = [];
|
|
1298
|
+
for (const item of o.sessions) {
|
|
1299
|
+
const session = parseSession(item);
|
|
1300
|
+
if (!session) return null;
|
|
1301
|
+
sessions.push(session);
|
|
1302
|
+
}
|
|
1303
|
+
const credentialSessions = new Set(
|
|
1304
|
+
sessions.filter((s) => s.purpose === "credentials").map((s) => s.id),
|
|
1305
|
+
);
|
|
1306
|
+
const filesSessions = sessions.filter((s) => s.purpose === "files").length;
|
|
1307
|
+
if (
|
|
1308
|
+
!uniqueBy(sessions, (s) => s.id) ||
|
|
1309
|
+
credentialSessions.size === 0 ||
|
|
1310
|
+
credentialSessions.size > MAX_LEASE_CREDENTIAL_SESSIONS ||
|
|
1311
|
+
filesSessions > 1 ||
|
|
1312
|
+
(filesSessions === 1) !== (o.files !== null)
|
|
1313
|
+
) {
|
|
1314
|
+
return null;
|
|
1315
|
+
}
|
|
1316
|
+
const files = o.files === null ? null : parseFiles(o.files);
|
|
1317
|
+
if (o.files !== null && !files) return null;
|
|
1318
|
+
const grant = { orgId: o.orgId, userId: o.userId };
|
|
1319
|
+
const manifest = parseManifest(o.manifest, grant, credentialSessions);
|
|
1320
|
+
if (!manifest) return null;
|
|
1321
|
+
return {
|
|
1322
|
+
v: 1,
|
|
1323
|
+
leaseId: o.leaseId,
|
|
1324
|
+
taskId: o.taskId,
|
|
1325
|
+
orgId: o.orgId,
|
|
1326
|
+
userId: o.userId,
|
|
1327
|
+
runEpoch: o.runEpoch,
|
|
1328
|
+
manifestRevision: o.manifestRevision,
|
|
1329
|
+
manifestDigest: o.manifestDigest,
|
|
1330
|
+
region: o.region,
|
|
1331
|
+
sessions,
|
|
1332
|
+
files,
|
|
1333
|
+
manifest,
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
export function parseLeaseGrantV1Frame(value: unknown): LeaseGrantV1Frame | null {
|
|
1338
|
+
const o = exactObject(value, ["kind", "opId", "lease"]);
|
|
1339
|
+
if (!o || o.kind !== "lease.grant.v1" || !isOpId(o.opId)) return null;
|
|
1340
|
+
const lease = parseTaskLeaseV1(o.lease);
|
|
1341
|
+
return lease ? { kind: "lease.grant.v1", opId: o.opId, lease } : null;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
export function parseLeaseRequestV1Frame(
|
|
1345
|
+
value: unknown,
|
|
1346
|
+
): LeaseRequestV1Frame | null {
|
|
1347
|
+
const o = exactObject(value, ["kind", "opId", "taskId", "runEpoch"]);
|
|
1348
|
+
return o &&
|
|
1349
|
+
o.kind === "lease.request" &&
|
|
1350
|
+
isOpId(o.opId) &&
|
|
1351
|
+
isIdValue(o.taskId) &&
|
|
1352
|
+
isPositiveInteger(o.runEpoch)
|
|
1353
|
+
? {
|
|
1354
|
+
kind: "lease.request",
|
|
1355
|
+
opId: o.opId,
|
|
1356
|
+
taskId: o.taskId,
|
|
1357
|
+
runEpoch: o.runEpoch,
|
|
1358
|
+
}
|
|
1359
|
+
: null;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
export function parseLeaseUnavailableFrame(
|
|
1363
|
+
value: unknown,
|
|
1364
|
+
): LeaseUnavailableFrame | null {
|
|
1365
|
+
const o = exactObject(value, ["kind", "opId", "error"]);
|
|
1366
|
+
return o &&
|
|
1367
|
+
o.kind === "lease.unavailable" &&
|
|
1368
|
+
isOpId(o.opId) &&
|
|
1369
|
+
isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
|
|
1370
|
+
? { kind: "lease.unavailable", opId: o.opId, error: o.error }
|
|
1371
|
+
: null;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
export function parseLeaseStaleFrame(value: unknown): LeaseStaleFrame | null {
|
|
1375
|
+
const o = exactObject(value, [
|
|
1376
|
+
"kind",
|
|
1377
|
+
"taskId",
|
|
1378
|
+
"runEpoch",
|
|
1379
|
+
"wantedRevision",
|
|
1380
|
+
"minRevision",
|
|
1381
|
+
"removed",
|
|
1382
|
+
]);
|
|
1383
|
+
if (
|
|
1384
|
+
!o ||
|
|
1385
|
+
o.kind !== "lease.stale" ||
|
|
1386
|
+
!isIdValue(o.taskId) ||
|
|
1387
|
+
!isPositiveInteger(o.runEpoch) ||
|
|
1388
|
+
!isPositiveInteger(o.wantedRevision) ||
|
|
1389
|
+
!(o.minRevision === null || isPositiveInteger(o.minRevision)) ||
|
|
1390
|
+
!Array.isArray(o.removed) ||
|
|
1391
|
+
o.removed.length > MAX_MANIFEST_ENGINES + MAX_MANIFEST_MCP + 2 ||
|
|
1392
|
+
!o.removed.every(isIdValue) ||
|
|
1393
|
+
new Set(o.removed).size !== o.removed.length
|
|
1394
|
+
) {
|
|
1395
|
+
return null;
|
|
1396
|
+
}
|
|
1397
|
+
// Removing a credential is what makes older grants unusable, so the two
|
|
1398
|
+
// travel together: `removed` without `minRevision` (or a floor above the
|
|
1399
|
+
// revision the runner is told to want) is refused.
|
|
1400
|
+
if (o.minRevision === null && o.removed.length > 0) return null;
|
|
1401
|
+
if (o.minRevision !== null && o.minRevision > o.wantedRevision) return null;
|
|
1402
|
+
return {
|
|
1403
|
+
kind: "lease.stale",
|
|
1404
|
+
taskId: o.taskId,
|
|
1405
|
+
runEpoch: o.runEpoch,
|
|
1406
|
+
wantedRevision: o.wantedRevision,
|
|
1407
|
+
minRevision: o.minRevision,
|
|
1408
|
+
removed: [...(o.removed as string[])],
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
export function parseCredentialChangedFrame(
|
|
1413
|
+
value: unknown,
|
|
1414
|
+
): CredentialChangedFrame | null {
|
|
1415
|
+
const o = exactObject(value, [
|
|
1416
|
+
"kind",
|
|
1417
|
+
"taskId",
|
|
1418
|
+
"runEpoch",
|
|
1419
|
+
"credentialId",
|
|
1420
|
+
"revision",
|
|
1421
|
+
"version",
|
|
1422
|
+
]);
|
|
1423
|
+
return o &&
|
|
1424
|
+
o.kind === "credential.changed" &&
|
|
1425
|
+
isIdValue(o.taskId) &&
|
|
1426
|
+
isPositiveInteger(o.runEpoch) &&
|
|
1427
|
+
isIdValue(o.credentialId) &&
|
|
1428
|
+
isIdValue(o.revision) &&
|
|
1429
|
+
isPositiveInteger(o.version)
|
|
1430
|
+
? {
|
|
1431
|
+
kind: "credential.changed",
|
|
1432
|
+
taskId: o.taskId,
|
|
1433
|
+
runEpoch: o.runEpoch,
|
|
1434
|
+
credentialId: o.credentialId,
|
|
1435
|
+
revision: o.revision,
|
|
1436
|
+
version: o.version,
|
|
1437
|
+
}
|
|
1438
|
+
: null;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
export function parseCredentialRefreshFrame(
|
|
1442
|
+
value: unknown,
|
|
1443
|
+
): CredentialRefreshFrame | null {
|
|
1444
|
+
const o = exactObject(value, [
|
|
1445
|
+
"kind",
|
|
1446
|
+
"opId",
|
|
1447
|
+
"taskId",
|
|
1448
|
+
"runEpoch",
|
|
1449
|
+
"credentialId",
|
|
1450
|
+
"revision",
|
|
1451
|
+
]);
|
|
1452
|
+
return o &&
|
|
1453
|
+
o.kind === "credential.refresh" &&
|
|
1454
|
+
isOpId(o.opId) &&
|
|
1455
|
+
isIdValue(o.taskId) &&
|
|
1456
|
+
isPositiveInteger(o.runEpoch) &&
|
|
1457
|
+
isIdValue(o.credentialId) &&
|
|
1458
|
+
isIdValue(o.revision)
|
|
1459
|
+
? {
|
|
1460
|
+
kind: "credential.refresh",
|
|
1461
|
+
opId: o.opId,
|
|
1462
|
+
taskId: o.taskId,
|
|
1463
|
+
runEpoch: o.runEpoch,
|
|
1464
|
+
credentialId: o.credentialId,
|
|
1465
|
+
revision: o.revision,
|
|
1466
|
+
}
|
|
1467
|
+
: null;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
const REFRESH_OUTCOMES: readonly CredentialRefreshOutcome[] = [
|
|
1471
|
+
"refreshed",
|
|
1472
|
+
"fresh",
|
|
1473
|
+
"needs_sign_in",
|
|
1474
|
+
"busy",
|
|
1475
|
+
"unavailable",
|
|
1476
|
+
];
|
|
1477
|
+
|
|
1478
|
+
export function parseCredentialRefreshResultFrame(
|
|
1479
|
+
value: unknown,
|
|
1480
|
+
): CredentialRefreshResultFrame | null {
|
|
1481
|
+
const o = exactObject(value, [
|
|
1482
|
+
"kind",
|
|
1483
|
+
"opId",
|
|
1484
|
+
"outcome",
|
|
1485
|
+
"version",
|
|
1486
|
+
"expiresAt",
|
|
1487
|
+
]);
|
|
1488
|
+
if (
|
|
1489
|
+
!o ||
|
|
1490
|
+
o.kind !== "credential.refresh.result" ||
|
|
1491
|
+
!isOpId(o.opId) ||
|
|
1492
|
+
!(REFRESH_OUTCOMES as readonly unknown[]).includes(o.outcome)
|
|
1493
|
+
) {
|
|
1494
|
+
return null;
|
|
1495
|
+
}
|
|
1496
|
+
const outcome = o.outcome as CredentialRefreshOutcome;
|
|
1497
|
+
const carriesVersion = outcome === "refreshed" || outcome === "fresh";
|
|
1498
|
+
if (carriesVersion) {
|
|
1499
|
+
if (
|
|
1500
|
+
!isPositiveInteger(o.version) ||
|
|
1501
|
+
!(o.expiresAt === null || isEpochMs(o.expiresAt))
|
|
1502
|
+
) {
|
|
1503
|
+
return null;
|
|
1504
|
+
}
|
|
1505
|
+
} else if (o.version !== null || o.expiresAt !== null) {
|
|
1506
|
+
return null;
|
|
1507
|
+
}
|
|
1508
|
+
return {
|
|
1509
|
+
kind: "credential.refresh.result",
|
|
1510
|
+
opId: o.opId,
|
|
1511
|
+
outcome,
|
|
1512
|
+
version: o.version as number | null,
|
|
1513
|
+
expiresAt: o.expiresAt as number | null,
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function isPhase(value: unknown): value is CredentialsActivationPhase {
|
|
1518
|
+
return value === "prepare" || value === "commit";
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
export function parseCredentialsActivateFrame(
|
|
1522
|
+
value: unknown,
|
|
1523
|
+
): CredentialsActivateFrame | null {
|
|
1524
|
+
const o = exactObject(value, ["kind", "opId", "phase"]);
|
|
1525
|
+
return o &&
|
|
1526
|
+
o.kind === "credentials.activate" &&
|
|
1527
|
+
isOpId(o.opId) &&
|
|
1528
|
+
isPhase(o.phase)
|
|
1529
|
+
? { kind: "credentials.activate", opId: o.opId, phase: o.phase }
|
|
1530
|
+
: null;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
export function parseCredentialsActivateResultFrame(
|
|
1534
|
+
value: unknown,
|
|
1535
|
+
): CredentialsActivateResultFrame | null {
|
|
1536
|
+
const o = exactObject(value, ["kind", "opId", "phase", "ok", "error"]);
|
|
1537
|
+
if (
|
|
1538
|
+
!o ||
|
|
1539
|
+
o.kind !== "credentials.activate.result" ||
|
|
1540
|
+
!isOpId(o.opId) ||
|
|
1541
|
+
!isPhase(o.phase) ||
|
|
1542
|
+
typeof o.ok !== "boolean"
|
|
1543
|
+
) {
|
|
1544
|
+
return null;
|
|
1545
|
+
}
|
|
1546
|
+
if (o.ok ? o.error !== null : !isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)) {
|
|
1547
|
+
return null;
|
|
1548
|
+
}
|
|
1549
|
+
return {
|
|
1550
|
+
kind: "credentials.activate.result",
|
|
1551
|
+
opId: o.opId,
|
|
1552
|
+
phase: o.phase,
|
|
1553
|
+
ok: o.ok,
|
|
1554
|
+
error: o.error as string | null,
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
export function parseCredentialsActivatedFrame(
|
|
1559
|
+
value: unknown,
|
|
1560
|
+
): CredentialsActivatedFrame | null {
|
|
1561
|
+
const o = exactObject(value, ["kind", "opId"]);
|
|
1562
|
+
return o && o.kind === "credentials.activated" && isOpId(o.opId)
|
|
1563
|
+
? { kind: "credentials.activated", opId: o.opId }
|
|
1564
|
+
: null;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// ---------------------------------------------------------------------------
|
|
1568
|
+
// ADR-154 §7: the broker's exact-name entry point
|
|
1569
|
+
// ---------------------------------------------------------------------------
|
|
1570
|
+
|
|
1571
|
+
const HOUR_MS = 60 * 60_000;
|
|
1572
|
+
|
|
1573
|
+
/**
|
|
1574
|
+
* When the cloud's scheduler names a lane's revision to the broker: this
|
|
1575
|
+
* long before its access token's known expiry, or on this cadence when the
|
|
1576
|
+
* expiry cannot be read. The broker's lanes keep the same numbers; the broker
|
|
1577
|
+
* decides from the material itself and answers `fresh` when not due.
|
|
1578
|
+
*/
|
|
1579
|
+
export const CREDENTIAL_LANE_SCHEDULES: Readonly<
|
|
1580
|
+
Record<string, { leadMs: number; everyMs?: number }>
|
|
1581
|
+
> = {
|
|
1582
|
+
"openai-oauth": { leadMs: 48 * HOUR_MS },
|
|
1583
|
+
"xai-oauth": { leadMs: 3 * HOUR_MS, everyMs: 4 * HOUR_MS },
|
|
1584
|
+
// ADR-154 §11: MCP access tokens are often an hour long; one that names no
|
|
1585
|
+
// expiry is refreshed on a cadence below that.
|
|
1586
|
+
"mcp-oauth": { leadMs: 10 * 60_000, everyMs: 45 * 60_000 },
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1589
|
+
/** Cloud → broker: refresh exactly this revision of this credential. */
|
|
1590
|
+
export interface BrokerRevisionRequest {
|
|
1591
|
+
v: 1;
|
|
1592
|
+
credentialId: string;
|
|
1593
|
+
revision: string;
|
|
1594
|
+
brokerParameter: string;
|
|
1595
|
+
leaseParameter: string;
|
|
1596
|
+
/** A runner reported the credential invalid: refresh even if not due. */
|
|
1597
|
+
force: boolean;
|
|
1598
|
+
/**
|
|
1599
|
+
* An adopted revision only (owner answer 3): today's parameter it was
|
|
1600
|
+
* adopted from, which the broker keeps agreeing with the revision (the
|
|
1601
|
+
* brokered copy, never the refresh token) for the runners still reading
|
|
1602
|
+
* it, until the fleet audit. Never created when it is gone.
|
|
1603
|
+
*/
|
|
1604
|
+
legacyParameter?: string;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/** Broker → cloud: the answer for that one credential, never material.
|
|
1608
|
+
* For an adopted revision (a request with `legacyParameter`), `legacy`
|
|
1609
|
+
* says whether today's parameter now agrees with it: `behind` when its
|
|
1610
|
+
* write did not land, which the cloud asks the broker to repair soon. */
|
|
1611
|
+
export type BrokerRevisionAnswer =
|
|
1612
|
+
| { outcome: "refreshed" | "fresh"; version: number; expiresAt: number | null; legacy?: "synced" | "behind" }
|
|
1613
|
+
| { outcome: "needs_sign_in" }
|
|
1614
|
+
| { outcome: "busy" }
|
|
1615
|
+
| { outcome: "unavailable"; error: string };
|
|
1616
|
+
|
|
1617
|
+
/** The request, exactly, or null: both parameters must name the same
|
|
1618
|
+
* revision of the same credential, of a class the broker refreshes. */
|
|
1619
|
+
export function parseBrokerRevisionRequest(
|
|
1620
|
+
value: unknown,
|
|
1621
|
+
legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1622
|
+
): BrokerRevisionRequest | null {
|
|
1623
|
+
const fields = ["v", "credentialId", "revision", "brokerParameter", "leaseParameter", "force"];
|
|
1624
|
+
const o = exactObject(value, fields) ?? exactObject(value, [...fields, "legacyParameter"]);
|
|
1625
|
+
if (
|
|
1626
|
+
!o ||
|
|
1627
|
+
o.v !== 1 ||
|
|
1628
|
+
!isIdValue(o.credentialId) ||
|
|
1629
|
+
!isIdValue(o.revision) ||
|
|
1630
|
+
typeof o.brokerParameter !== "string" ||
|
|
1631
|
+
typeof o.leaseParameter !== "string" ||
|
|
1632
|
+
typeof o.force !== "boolean"
|
|
1633
|
+
) {
|
|
1634
|
+
return null;
|
|
1635
|
+
}
|
|
1636
|
+
const broker = parseBrokerParameterName(o.brokerParameter);
|
|
1637
|
+
const lease = parseLeaseParameterName(o.leaseParameter);
|
|
1638
|
+
if (
|
|
1639
|
+
!broker ||
|
|
1640
|
+
!lease ||
|
|
1641
|
+
!sameLocation(broker, lease) ||
|
|
1642
|
+
(broker.class !== "engine" && broker.class !== "mcp") ||
|
|
1643
|
+
broker.credentialId !== o.credentialId ||
|
|
1644
|
+
broker.revision !== o.revision
|
|
1645
|
+
) {
|
|
1646
|
+
return null;
|
|
1647
|
+
}
|
|
1648
|
+
if (o.legacyParameter !== undefined && !adoptedFrom(o.legacyParameter, broker, legacyPrefix)) return null;
|
|
1649
|
+
return {
|
|
1650
|
+
v: 1,
|
|
1651
|
+
credentialId: o.credentialId,
|
|
1652
|
+
revision: o.revision,
|
|
1653
|
+
brokerParameter: o.brokerParameter,
|
|
1654
|
+
leaseParameter: o.leaseParameter,
|
|
1655
|
+
force: o.force,
|
|
1656
|
+
...(o.legacyParameter !== undefined ? { legacyParameter: o.legacyParameter as string } : {}),
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/** The broker's answer, exactly, or null. */
|
|
1661
|
+
export function parseBrokerRevisionAnswer(value: unknown): BrokerRevisionAnswer | null {
|
|
1662
|
+
const outcome = (value as { outcome?: unknown } | null)?.outcome;
|
|
1663
|
+
if (outcome === "refreshed" || outcome === "fresh") {
|
|
1664
|
+
const fields = ["outcome", "version", "expiresAt"];
|
|
1665
|
+
const o = exactObject(value, fields) ?? exactObject(value, [...fields, "legacy"]);
|
|
1666
|
+
if (
|
|
1667
|
+
!o ||
|
|
1668
|
+
!isPositiveInteger(o.version) ||
|
|
1669
|
+
!(o.expiresAt === null || isEpochMs(o.expiresAt)) ||
|
|
1670
|
+
!(o.legacy === undefined || o.legacy === "synced" || o.legacy === "behind")
|
|
1671
|
+
) {
|
|
1672
|
+
return null;
|
|
1673
|
+
}
|
|
1674
|
+
return {
|
|
1675
|
+
outcome,
|
|
1676
|
+
version: o.version,
|
|
1677
|
+
expiresAt: o.expiresAt,
|
|
1678
|
+
...(o.legacy !== undefined ? { legacy: o.legacy as "synced" | "behind" } : {}),
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
if (outcome === "needs_sign_in" || outcome === "busy") {
|
|
1682
|
+
return exactObject(value, ["outcome"]) ? { outcome } : null;
|
|
1683
|
+
}
|
|
1684
|
+
if (outcome === "unavailable") {
|
|
1685
|
+
const o = exactObject(value, ["outcome", "error"]);
|
|
1686
|
+
return o && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
|
|
1687
|
+
? { outcome, error: o.error }
|
|
1688
|
+
: null;
|
|
1689
|
+
}
|
|
1690
|
+
return null;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* What a broker answer may say about a failure: the error's kind (an AWS or
|
|
1695
|
+
* Node error's name), never its message, which can quote a value a request
|
|
1696
|
+
* carried.
|
|
1697
|
+
*/
|
|
1698
|
+
export function brokerErrorKind(error: unknown): string {
|
|
1699
|
+
const name = error instanceof Error ? error.name : "";
|
|
1700
|
+
return /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(name) ? name : "Error";
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// ---------------------------------------------------------------------------
|
|
1704
|
+
// ADR-154, owner answer 3: Uai Cloud's workspace AI accounts adopted in place
|
|
1705
|
+
// ---------------------------------------------------------------------------
|
|
1706
|
+
|
|
1707
|
+
/** ADR-134's default root. Installed Uai-operated tiers may configure a
|
|
1708
|
+
* different root; the cloud and broker must agree on the exact value. */
|
|
1709
|
+
export const DEFAULT_CREDENTIAL_STORE_PREFIX = "/uai";
|
|
1710
|
+
const CREDENTIAL_STORE_PREFIX = /^\/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/;
|
|
1711
|
+
|
|
1712
|
+
/** A credential-store root in the same form accepted by the host setting. */
|
|
1713
|
+
export function parseCredentialStorePrefix(value: unknown): string | null {
|
|
1714
|
+
return typeof value === "string" && CREDENTIAL_STORE_PREFIX.test(value) ? value : null;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function credentialStoreRoot(prefix: string, suffix: string): string {
|
|
1718
|
+
const parsed = parseCredentialStorePrefix(prefix);
|
|
1719
|
+
if (!parsed) throw new Error("invalid credential-store prefix");
|
|
1720
|
+
return `${parsed}/${suffix}`;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/** Today's layout (ADR-134) for workspace AI accounts. */
|
|
1724
|
+
export function legacyEngineParameterRoot(prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX): string {
|
|
1725
|
+
return credentialStoreRoot(prefix, "orgs");
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/** Where the broker records adoption, with the legacy path below it. */
|
|
1729
|
+
export function legacyAdoptedRoot(prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX): string {
|
|
1730
|
+
return credentialStoreRoot(prefix, "adopted");
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/** Default-root aliases retained for callers describing the standard layout. */
|
|
1734
|
+
export const LEGACY_ENGINE_PARAMETER_ROOT = legacyEngineParameterRoot();
|
|
1735
|
+
export const LEGACY_ADOPTED_ROOT = legacyAdoptedRoot();
|
|
1736
|
+
/** At most this many accounts in one listing page. */
|
|
1737
|
+
export const MAX_LEGACY_ACCOUNTS = 500;
|
|
1738
|
+
const MAX_LEGACY_LABEL_CHARS = 256;
|
|
1739
|
+
/** ADR-134's segment rule, as its ids are actually minted (no dots). */
|
|
1740
|
+
const LEGACY_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
1741
|
+
|
|
1742
|
+
/** A parameter under today's layout the cloud adopts: a workspace AI
|
|
1743
|
+
* account. People's MCP connections wait for a fence against the v0 task
|
|
1744
|
+
* machines that refresh them themselves. */
|
|
1745
|
+
export type LegacyParameter = { kind: "engine"; orgId: string; engineKind: string; accountId: string };
|
|
1746
|
+
|
|
1747
|
+
export type LegacyEngineParameter = Extract<LegacyParameter, { kind: "engine" }>;
|
|
1748
|
+
|
|
1749
|
+
/** A parameter under today's layout the cloud adopts, or null. */
|
|
1750
|
+
export function parseLegacyParameterName(
|
|
1751
|
+
name: unknown,
|
|
1752
|
+
prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1753
|
+
): LegacyParameter | null {
|
|
1754
|
+
const parsedPrefix = parseCredentialStorePrefix(prefix);
|
|
1755
|
+
if (typeof name !== "string" || !parsedPrefix) return null;
|
|
1756
|
+
const engineRoot = `${parsedPrefix}/orgs`;
|
|
1757
|
+
if (name.startsWith(`${engineRoot}/`)) {
|
|
1758
|
+
const parts = name.slice(engineRoot.length + 1).split("/");
|
|
1759
|
+
if (parts.length !== 4 || parts[1] !== "engines") return null;
|
|
1760
|
+
const [orgId, , engineKind, accountId] = parts as [string, string, string, string];
|
|
1761
|
+
if (!LEGACY_SEGMENT.test(orgId) || !isEngineKind(engineKind) || !LEGACY_SEGMENT.test(accountId)) return null;
|
|
1762
|
+
return { kind: "engine", orgId, engineKind, accountId };
|
|
1763
|
+
}
|
|
1764
|
+
return null;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/** A workspace AI account's parameter under today's layout, or null. */
|
|
1768
|
+
export function parseLegacyEngineParameterName(
|
|
1769
|
+
name: unknown,
|
|
1770
|
+
prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1771
|
+
): LegacyEngineParameter | null {
|
|
1772
|
+
const legacy = parseLegacyParameterName(name, prefix);
|
|
1773
|
+
return legacy?.kind === "engine" ? legacy : null;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/** The adoption marker for a legacy parameter: the same path under that
|
|
1777
|
+
* store's adopted root (`<prefix>/adopted/orgs/…`). */
|
|
1778
|
+
export function legacyAdoptionMarkerName(
|
|
1779
|
+
legacyParameter: string,
|
|
1780
|
+
prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1781
|
+
): string {
|
|
1782
|
+
if (!parseLegacyParameterName(legacyParameter, prefix)) {
|
|
1783
|
+
throw new Error("not a legacy parameter the cloud adopts");
|
|
1784
|
+
}
|
|
1785
|
+
return `${legacyAdoptedRoot(prefix)}${legacyParameter.slice(prefix.length)}`;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/** A parameter under today's layout may be adopted into (and be kept
|
|
1789
|
+
* agreeing with) exactly this credential's location: a workspace AI account
|
|
1790
|
+
* into that workspace's account of the same kind. */
|
|
1791
|
+
function adoptedFrom(
|
|
1792
|
+
legacyParameter: unknown,
|
|
1793
|
+
location: CredentialLocation,
|
|
1794
|
+
prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1795
|
+
): boolean {
|
|
1796
|
+
const legacy = parseLegacyParameterName(legacyParameter, prefix);
|
|
1797
|
+
if (legacy?.kind === "engine") {
|
|
1798
|
+
return (
|
|
1799
|
+
location.class === "engine" &&
|
|
1800
|
+
location.scope === "org" &&
|
|
1801
|
+
location.ownerId === legacy.orgId &&
|
|
1802
|
+
location.engineKind === legacy.engineKind
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
return false;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/** Cloud → broker: one page of today's workspace AI accounts, metadata
|
|
1809
|
+
* only, by parameter name, after `after` (null: from the first). */
|
|
1810
|
+
export interface BrokerLegacyListRequest {
|
|
1811
|
+
v: 1;
|
|
1812
|
+
op: "legacy.list";
|
|
1813
|
+
after: string | null;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
export interface BrokerLegacyAccount {
|
|
1817
|
+
parameter: string;
|
|
1818
|
+
orgId: string;
|
|
1819
|
+
engineKind: string;
|
|
1820
|
+
accountId: string;
|
|
1821
|
+
label: string;
|
|
1822
|
+
authKind: "env" | "config-dir";
|
|
1823
|
+
position: number;
|
|
1824
|
+
/** The lane that refreshes it; null for a key that does not rotate. */
|
|
1825
|
+
lane: string | null;
|
|
1826
|
+
/** The revision it was adopted into, once it was. */
|
|
1827
|
+
adopted: { credentialId: string; revision: string } | null;
|
|
1828
|
+
/** Why it cannot be adopted as it is (a fixed reason code), or null. */
|
|
1829
|
+
unsupported: string | null;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
export type BrokerLegacyListAnswer =
|
|
1833
|
+
/** `next`: the name to list after for the following page, or null. */
|
|
1834
|
+
| { outcome: "listed"; accounts: BrokerLegacyAccount[]; next: string | null }
|
|
1835
|
+
| { outcome: "unavailable"; error: string };
|
|
1836
|
+
|
|
1837
|
+
/** Cloud → broker: adopt one parameter under today's layout as this
|
|
1838
|
+
* revision, which the cloud has opened for it in `orgId` (and for `userId`,
|
|
1839
|
+
* a person's). */
|
|
1840
|
+
export interface BrokerAdoptRequest {
|
|
1841
|
+
v: 1;
|
|
1842
|
+
op: "legacy.adopt";
|
|
1843
|
+
legacyParameter: string;
|
|
1844
|
+
credentialId: string;
|
|
1845
|
+
revision: string;
|
|
1846
|
+
leaseParameter: string;
|
|
1847
|
+
/** The broker-only part, exactly when the material rotates. */
|
|
1848
|
+
brokerParameter: string | null;
|
|
1849
|
+
orgId: string;
|
|
1850
|
+
userId: string | null;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
export type BrokerAdoptAnswer =
|
|
1854
|
+
/** Both parts exist; `version` is the lease-readable part's. */
|
|
1855
|
+
| { outcome: "adopted"; version: number; expiresAt: number | null }
|
|
1856
|
+
/** Today's parameter is gone: there is nothing to adopt. */
|
|
1857
|
+
| { outcome: "absent" }
|
|
1858
|
+
/** It cannot be adopted as it is, ever (a fixed reason code). */
|
|
1859
|
+
| { outcome: "unsupported"; reason: string }
|
|
1860
|
+
| { outcome: "busy" }
|
|
1861
|
+
| { outcome: "unavailable"; error: string };
|
|
1862
|
+
|
|
1863
|
+
const isReasonCode = (value: unknown): value is string =>
|
|
1864
|
+
typeof value === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(value);
|
|
1865
|
+
|
|
1866
|
+
export function parseBrokerLegacyListRequest(
|
|
1867
|
+
value: unknown,
|
|
1868
|
+
legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1869
|
+
): BrokerLegacyListRequest | null {
|
|
1870
|
+
const o = exactObject(value, ["v", "op", "after"]);
|
|
1871
|
+
if (!o || o.v !== 1 || o.op !== "legacy.list") return null;
|
|
1872
|
+
if (o.after !== null && !parseLegacyEngineParameterName(o.after, legacyPrefix)) return null;
|
|
1873
|
+
return { v: 1, op: "legacy.list", after: o.after as string | null };
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
export function parseBrokerAdoptRequest(
|
|
1877
|
+
value: unknown,
|
|
1878
|
+
legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1879
|
+
): BrokerAdoptRequest | null {
|
|
1880
|
+
const o = exactObject(value, [
|
|
1881
|
+
"v",
|
|
1882
|
+
"op",
|
|
1883
|
+
"legacyParameter",
|
|
1884
|
+
"credentialId",
|
|
1885
|
+
"revision",
|
|
1886
|
+
"leaseParameter",
|
|
1887
|
+
"brokerParameter",
|
|
1888
|
+
"orgId",
|
|
1889
|
+
"userId",
|
|
1890
|
+
]);
|
|
1891
|
+
if (
|
|
1892
|
+
!o ||
|
|
1893
|
+
o.v !== 1 ||
|
|
1894
|
+
o.op !== "legacy.adopt" ||
|
|
1895
|
+
typeof o.legacyParameter !== "string" ||
|
|
1896
|
+
!isIdValue(o.credentialId) ||
|
|
1897
|
+
!isIdValue(o.revision) ||
|
|
1898
|
+
typeof o.leaseParameter !== "string" ||
|
|
1899
|
+
(o.brokerParameter !== null && typeof o.brokerParameter !== "string") ||
|
|
1900
|
+
!isIdValue(o.orgId) ||
|
|
1901
|
+
(o.userId !== null && !isIdValue(o.userId))
|
|
1902
|
+
) {
|
|
1903
|
+
return null;
|
|
1904
|
+
}
|
|
1905
|
+
const lease = parseLeaseParameterName(o.leaseParameter);
|
|
1906
|
+
if (
|
|
1907
|
+
!lease ||
|
|
1908
|
+
!adoptedFrom(o.legacyParameter, lease, legacyPrefix) ||
|
|
1909
|
+
lease.credentialId !== o.credentialId ||
|
|
1910
|
+
lease.revision !== o.revision ||
|
|
1911
|
+
// The flow's owners: a workspace's account is the workspace's alone; a
|
|
1912
|
+
// person's connection is theirs, in a workspace.
|
|
1913
|
+
(lease.scope === "org" ? o.userId !== null || o.orgId !== lease.ownerId : o.userId !== lease.ownerId)
|
|
1914
|
+
) {
|
|
1915
|
+
return null;
|
|
1916
|
+
}
|
|
1917
|
+
if (o.brokerParameter !== null) {
|
|
1918
|
+
const broker = parseBrokerParameterName(o.brokerParameter);
|
|
1919
|
+
if (!broker || !sameLocation(broker, lease)) return null;
|
|
1920
|
+
}
|
|
1921
|
+
return {
|
|
1922
|
+
v: 1,
|
|
1923
|
+
op: "legacy.adopt",
|
|
1924
|
+
legacyParameter: o.legacyParameter,
|
|
1925
|
+
credentialId: o.credentialId,
|
|
1926
|
+
revision: o.revision,
|
|
1927
|
+
leaseParameter: o.leaseParameter,
|
|
1928
|
+
brokerParameter: o.brokerParameter as string | null,
|
|
1929
|
+
orgId: o.orgId,
|
|
1930
|
+
userId: o.userId as string | null,
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
function parseLegacyAccount(
|
|
1935
|
+
value: unknown,
|
|
1936
|
+
legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1937
|
+
): BrokerLegacyAccount | null {
|
|
1938
|
+
const o = exactObject(value, [
|
|
1939
|
+
"parameter",
|
|
1940
|
+
"orgId",
|
|
1941
|
+
"engineKind",
|
|
1942
|
+
"accountId",
|
|
1943
|
+
"label",
|
|
1944
|
+
"authKind",
|
|
1945
|
+
"position",
|
|
1946
|
+
"lane",
|
|
1947
|
+
"adopted",
|
|
1948
|
+
"unsupported",
|
|
1949
|
+
]);
|
|
1950
|
+
const legacy = o ? parseLegacyEngineParameterName(o.parameter, legacyPrefix) : null;
|
|
1951
|
+
if (
|
|
1952
|
+
!o ||
|
|
1953
|
+
!legacy ||
|
|
1954
|
+
legacy.orgId !== o.orgId ||
|
|
1955
|
+
legacy.engineKind !== o.engineKind ||
|
|
1956
|
+
legacy.accountId !== o.accountId ||
|
|
1957
|
+
!isBoundedString(o.label, MAX_LEGACY_LABEL_CHARS) ||
|
|
1958
|
+
(o.authKind !== "env" && o.authKind !== "config-dir") ||
|
|
1959
|
+
typeof o.position !== "number" ||
|
|
1960
|
+
!Number.isSafeInteger(o.position) ||
|
|
1961
|
+
(o.lane !== null && !isBoundedString(o.lane, 64)) ||
|
|
1962
|
+
(o.unsupported !== null && !isReasonCode(o.unsupported))
|
|
1963
|
+
) {
|
|
1964
|
+
return null;
|
|
1965
|
+
}
|
|
1966
|
+
let adopted: BrokerLegacyAccount["adopted"] = null;
|
|
1967
|
+
if (o.adopted !== null) {
|
|
1968
|
+
const a = exactObject(o.adopted, ["credentialId", "revision"]);
|
|
1969
|
+
if (!a || !isIdValue(a.credentialId) || !isIdValue(a.revision)) return null;
|
|
1970
|
+
adopted = { credentialId: a.credentialId, revision: a.revision };
|
|
1971
|
+
}
|
|
1972
|
+
return {
|
|
1973
|
+
parameter: o.parameter as string,
|
|
1974
|
+
orgId: legacy.orgId,
|
|
1975
|
+
engineKind: legacy.engineKind,
|
|
1976
|
+
accountId: legacy.accountId,
|
|
1977
|
+
label: o.label,
|
|
1978
|
+
authKind: o.authKind,
|
|
1979
|
+
position: o.position,
|
|
1980
|
+
lane: o.lane as string | null,
|
|
1981
|
+
adopted,
|
|
1982
|
+
unsupported: o.unsupported as string | null,
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
/** The broker's listing page, exactly, or null. */
|
|
1987
|
+
export function parseBrokerLegacyListAnswer(
|
|
1988
|
+
value: unknown,
|
|
1989
|
+
legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1990
|
+
): BrokerLegacyListAnswer | null {
|
|
1991
|
+
const outcome = (value as { outcome?: unknown } | null)?.outcome;
|
|
1992
|
+
if (outcome === "listed") {
|
|
1993
|
+
const o = exactObject(value, ["outcome", "accounts", "next"]);
|
|
1994
|
+
if (!o || !Array.isArray(o.accounts) || o.accounts.length > MAX_LEGACY_ACCOUNTS) return null;
|
|
1995
|
+
if (o.next !== null && !parseLegacyEngineParameterName(o.next, legacyPrefix)) return null;
|
|
1996
|
+
const accounts: BrokerLegacyAccount[] = [];
|
|
1997
|
+
for (const entry of o.accounts) {
|
|
1998
|
+
const account = parseLegacyAccount(entry, legacyPrefix);
|
|
1999
|
+
if (!account) return null;
|
|
2000
|
+
accounts.push(account);
|
|
2001
|
+
}
|
|
2002
|
+
return { outcome, accounts, next: o.next as string | null };
|
|
2003
|
+
}
|
|
2004
|
+
if (outcome === "unavailable") {
|
|
2005
|
+
const o = exactObject(value, ["outcome", "error"]);
|
|
2006
|
+
return o && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS) ? { outcome, error: o.error } : null;
|
|
2007
|
+
}
|
|
2008
|
+
return null;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/** The broker's answer to an adoption, exactly, or null. */
|
|
2012
|
+
export function parseBrokerAdoptAnswer(value: unknown): BrokerAdoptAnswer | null {
|
|
2013
|
+
const outcome = (value as { outcome?: unknown } | null)?.outcome;
|
|
2014
|
+
if (outcome === "adopted") {
|
|
2015
|
+
const o = exactObject(value, ["outcome", "version", "expiresAt"]);
|
|
2016
|
+
return o && isPositiveInteger(o.version) && (o.expiresAt === null || isEpochMs(o.expiresAt))
|
|
2017
|
+
? { outcome, version: o.version, expiresAt: o.expiresAt }
|
|
2018
|
+
: null;
|
|
2019
|
+
}
|
|
2020
|
+
if (outcome === "absent" || outcome === "busy") {
|
|
2021
|
+
return exactObject(value, ["outcome"]) ? { outcome } : null;
|
|
2022
|
+
}
|
|
2023
|
+
if (outcome === "unsupported") {
|
|
2024
|
+
const o = exactObject(value, ["outcome", "reason"]);
|
|
2025
|
+
return o && isReasonCode(o.reason) ? { outcome, reason: o.reason } : null;
|
|
2026
|
+
}
|
|
2027
|
+
if (outcome === "unavailable") {
|
|
2028
|
+
const o = exactObject(value, ["outcome", "error"]);
|
|
2029
|
+
return o && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS) ? { outcome, error: o.error } : null;
|
|
2030
|
+
}
|
|
2031
|
+
return null;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// ---------------------------------------------------------------------------
|
|
2035
|
+
// ADR-154 §11: an MCP connection's handshake on the sign-in box
|
|
2036
|
+
// ---------------------------------------------------------------------------
|
|
2037
|
+
|
|
2038
|
+
/** Advertised by a sign-in box that runs MCP handshakes as capture flows. */
|
|
2039
|
+
export const SIGNIN_BOX_MCP_CAPTURE_FEATURE = "signin-box-mcp-capture-v1";
|
|
2040
|
+
|
|
2041
|
+
const MAX_MCP_URL_CHARS = 2048;
|
|
2042
|
+
const MAX_MCP_STATE_CHARS = 512;
|
|
2043
|
+
const MAX_MCP_CODE_CHARS = 4096;
|
|
2044
|
+
const MAX_MCP_CLIENT_CHARS = 1024;
|
|
2045
|
+
const MAX_MCP_SCOPES = 64;
|
|
2046
|
+
|
|
2047
|
+
/** Cloud → box: probe an MCP server for a connection being added. */
|
|
2048
|
+
export interface McpCaptureProbeFrame {
|
|
2049
|
+
kind: "mcp.capture.probe";
|
|
2050
|
+
opId: string;
|
|
2051
|
+
flow: CaptureFlowWire;
|
|
2052
|
+
url: string;
|
|
2053
|
+
/** A static header the server takes, when the person gave one. */
|
|
2054
|
+
header: { name: string; value: string } | null;
|
|
2055
|
+
/** A client the person (or the catalog) brings, when the authorization
|
|
2056
|
+
* server cannot register one. */
|
|
2057
|
+
clientId: string | null;
|
|
2058
|
+
clientSecret: string | null;
|
|
2059
|
+
redirectUri: string;
|
|
2060
|
+
state: string;
|
|
2061
|
+
/** The scopes to ask for (a catalog entry's); null asks for none and
|
|
2062
|
+
* takes the authorization server's default. Never its whole
|
|
2063
|
+
* `scopes_supported`, which is a capability list, not a need. */
|
|
2064
|
+
scopes: string[] | null;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
/** Cloud → box: the authorization server redirected back. */
|
|
2068
|
+
export interface McpCaptureCompleteFrame {
|
|
2069
|
+
kind: "mcp.capture.complete";
|
|
2070
|
+
opId: string;
|
|
2071
|
+
flow: CaptureFlowWire;
|
|
2072
|
+
code: string;
|
|
2073
|
+
state: string;
|
|
2074
|
+
/** RFC 9207: the issuer the redirect named, when it named one. */
|
|
2075
|
+
iss: string | null;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
/** Cloud → box: the cloud ended this sign-in (expired, cancelled): forget its
|
|
2079
|
+
* verifier and client secret now. No answer. */
|
|
2080
|
+
export interface McpCaptureCancelFrame {
|
|
2081
|
+
kind: "mcp.capture.cancel";
|
|
2082
|
+
opId: string;
|
|
2083
|
+
flow: CaptureFlowWire;
|
|
2084
|
+
state: string;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
export type McpCaptureOutcome = "none" | "token" | "auth_required" | "connected" | "error";
|
|
2088
|
+
|
|
2089
|
+
/** Box → cloud: what the handshake came to. `capture` is the revision it
|
|
2090
|
+
* wrote (`token`, `connected`); `authorizeUrl` where the person signs in
|
|
2091
|
+
* (`auth_required`); `error` why it failed. Never material. */
|
|
2092
|
+
export interface McpCaptureAckFrame {
|
|
2093
|
+
kind: "mcp.capture.ack";
|
|
2094
|
+
opId: string;
|
|
2095
|
+
outcome: McpCaptureOutcome;
|
|
2096
|
+
capture: CaptureResultWire | null;
|
|
2097
|
+
authorizeUrl: string | null;
|
|
2098
|
+
scopes: string[] | null;
|
|
2099
|
+
error: string | null;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
function isHttpsUrl(value: unknown): value is string {
|
|
2103
|
+
if (!isBoundedString(value, MAX_MCP_URL_CHARS)) return false;
|
|
2104
|
+
try {
|
|
2105
|
+
const url = new URL(value);
|
|
2106
|
+
return url.protocol === "https:" && url.username === "" && url.password === "" && url.hostname !== "";
|
|
2107
|
+
} catch {
|
|
2108
|
+
return false;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
function isMcpFlow(value: unknown): value is CaptureFlowWire {
|
|
2113
|
+
const flow = parseCaptureFlowWire(value);
|
|
2114
|
+
return flow !== null && flow.location.class === "mcp";
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
function isOptionalClient(value: unknown): value is string | null {
|
|
2118
|
+
return value === null || isBoundedString(value, MAX_MCP_CLIENT_CHARS);
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/** RFC 6749 scope tokens: printable ASCII but space, `"` and `\\`. */
|
|
2122
|
+
const SCOPE_TOKEN = /^[\x21\x23-\x5B\x5D-\x7E]{1,128}$/;
|
|
2123
|
+
|
|
2124
|
+
export function isScopeList(value: unknown): value is string[] {
|
|
2125
|
+
return (
|
|
2126
|
+
Array.isArray(value) &&
|
|
2127
|
+
value.length <= MAX_MCP_SCOPES &&
|
|
2128
|
+
value.every((scope) => typeof scope === "string" && SCOPE_TOKEN.test(scope))
|
|
2129
|
+
);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
export function parseMcpCaptureProbeFrame(value: unknown): McpCaptureProbeFrame | null {
|
|
2133
|
+
const o = exactObject(value, [
|
|
2134
|
+
"kind",
|
|
2135
|
+
"opId",
|
|
2136
|
+
"flow",
|
|
2137
|
+
"url",
|
|
2138
|
+
"header",
|
|
2139
|
+
"clientId",
|
|
2140
|
+
"clientSecret",
|
|
2141
|
+
"redirectUri",
|
|
2142
|
+
"state",
|
|
2143
|
+
"scopes",
|
|
2144
|
+
]);
|
|
2145
|
+
if (
|
|
2146
|
+
!o ||
|
|
2147
|
+
o.kind !== "mcp.capture.probe" ||
|
|
2148
|
+
!isOpId(o.opId) ||
|
|
2149
|
+
!isMcpFlow(o.flow) ||
|
|
2150
|
+
!isHttpsUrl(o.url) ||
|
|
2151
|
+
!isOptionalClient(o.clientId) ||
|
|
2152
|
+
!isOptionalClient(o.clientSecret) ||
|
|
2153
|
+
!isHttpsUrl(o.redirectUri) ||
|
|
2154
|
+
!isBoundedString(o.state, MAX_MCP_STATE_CHARS) ||
|
|
2155
|
+
!(o.scopes === null || isScopeList(o.scopes))
|
|
2156
|
+
) {
|
|
2157
|
+
return null;
|
|
2158
|
+
}
|
|
2159
|
+
let header: { name: string; value: string } | null = null;
|
|
2160
|
+
if (o.header !== null) {
|
|
2161
|
+
const h = exactObject(o.header, ["name", "value"]);
|
|
2162
|
+
if (
|
|
2163
|
+
!h ||
|
|
2164
|
+
typeof h.name !== "string" ||
|
|
2165
|
+
!HEADER_NAME.test(h.name) ||
|
|
2166
|
+
FORBIDDEN_HEADER_NAMES.has(h.name.toLowerCase()) ||
|
|
2167
|
+
!isBoundedString(h.value, 4096) ||
|
|
2168
|
+
/[\r\n]/.test(h.value)
|
|
2169
|
+
) {
|
|
2170
|
+
return null;
|
|
2171
|
+
}
|
|
2172
|
+
header = { name: h.name, value: h.value };
|
|
2173
|
+
}
|
|
2174
|
+
return {
|
|
2175
|
+
kind: "mcp.capture.probe",
|
|
2176
|
+
opId: o.opId,
|
|
2177
|
+
flow: o.flow as CaptureFlowWire,
|
|
2178
|
+
url: o.url,
|
|
2179
|
+
header,
|
|
2180
|
+
clientId: o.clientId,
|
|
2181
|
+
clientSecret: o.clientSecret,
|
|
2182
|
+
redirectUri: o.redirectUri,
|
|
2183
|
+
state: o.state,
|
|
2184
|
+
scopes: o.scopes === null ? null : [...(o.scopes as string[])],
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
export function parseMcpCaptureCompleteFrame(value: unknown): McpCaptureCompleteFrame | null {
|
|
2189
|
+
const o = exactObject(value, ["kind", "opId", "flow", "code", "state", "iss"]);
|
|
2190
|
+
return o &&
|
|
2191
|
+
o.kind === "mcp.capture.complete" &&
|
|
2192
|
+
isOpId(o.opId) &&
|
|
2193
|
+
isMcpFlow(o.flow) &&
|
|
2194
|
+
isBoundedString(o.code, MAX_MCP_CODE_CHARS) &&
|
|
2195
|
+
isBoundedString(o.state, MAX_MCP_STATE_CHARS) &&
|
|
2196
|
+
(o.iss === null || isBoundedString(o.iss, MAX_MCP_URL_CHARS))
|
|
2197
|
+
? {
|
|
2198
|
+
kind: "mcp.capture.complete",
|
|
2199
|
+
opId: o.opId,
|
|
2200
|
+
flow: o.flow as CaptureFlowWire,
|
|
2201
|
+
code: o.code,
|
|
2202
|
+
state: o.state,
|
|
2203
|
+
iss: o.iss,
|
|
2204
|
+
}
|
|
2205
|
+
: null;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
export function parseMcpCaptureCancelFrame(value: unknown): McpCaptureCancelFrame | null {
|
|
2209
|
+
const o = exactObject(value, ["kind", "opId", "flow", "state"]);
|
|
2210
|
+
return o &&
|
|
2211
|
+
o.kind === "mcp.capture.cancel" &&
|
|
2212
|
+
isOpId(o.opId) &&
|
|
2213
|
+
isMcpFlow(o.flow) &&
|
|
2214
|
+
isBoundedString(o.state, MAX_MCP_STATE_CHARS)
|
|
2215
|
+
? { kind: "mcp.capture.cancel", opId: o.opId, flow: o.flow as CaptureFlowWire, state: o.state }
|
|
2216
|
+
: null;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
export function parseMcpCaptureAckFrame(value: unknown): McpCaptureAckFrame | null {
|
|
2220
|
+
const o = exactObject(value, ["kind", "opId", "outcome", "capture", "authorizeUrl", "scopes", "error"]);
|
|
2221
|
+
if (!o || o.kind !== "mcp.capture.ack" || !isOpId(o.opId)) return null;
|
|
2222
|
+
const outcome = o.outcome;
|
|
2223
|
+
const capture = o.capture === null ? null : parseCaptureResultWire(o.capture);
|
|
2224
|
+
if (o.capture !== null && !capture) return null;
|
|
2225
|
+
const scopes = o.scopes === null ? null : isScopeList(o.scopes) ? [...o.scopes] : undefined;
|
|
2226
|
+
if (scopes === undefined) return null;
|
|
2227
|
+
const base = { kind: "mcp.capture.ack" as const, opId: o.opId, scopes };
|
|
2228
|
+
switch (outcome) {
|
|
2229
|
+
case "none":
|
|
2230
|
+
return capture === null && o.authorizeUrl === null && o.error === null
|
|
2231
|
+
? { ...base, outcome, capture: null, authorizeUrl: null, error: null }
|
|
2232
|
+
: null;
|
|
2233
|
+
case "token":
|
|
2234
|
+
case "connected":
|
|
2235
|
+
return capture !== null && o.authorizeUrl === null && o.error === null
|
|
2236
|
+
? { ...base, outcome, capture, authorizeUrl: null, error: null }
|
|
2237
|
+
: null;
|
|
2238
|
+
case "auth_required":
|
|
2239
|
+
return capture === null && isHttpsUrl(o.authorizeUrl) && o.error === null
|
|
2240
|
+
? { ...base, outcome, capture: null, authorizeUrl: o.authorizeUrl, error: null }
|
|
2241
|
+
: null;
|
|
2242
|
+
case "error":
|
|
2243
|
+
return capture === null && o.authorizeUrl === null && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
|
|
2244
|
+
? { ...base, outcome, capture: null, authorizeUrl: null, error: o.error }
|
|
2245
|
+
: null;
|
|
2246
|
+
default:
|
|
2247
|
+
return null;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// ---------------------------------------------------------------------------
|
|
2252
|
+
// GitHub sign-ins and SSH keys on the sign-in box (ADR-154 §12)
|
|
2253
|
+
// ---------------------------------------------------------------------------
|
|
2254
|
+
|
|
2255
|
+
/** Advertised by a sign-in box that writes GitHub and SSH revisions, lists a
|
|
2256
|
+
* person's GitHub installations from the revision the cloud names, and
|
|
2257
|
+
* revokes a retired GitHub token. */
|
|
2258
|
+
export const SIGNIN_BOX_PERSON_CREDENTIALS_FEATURE = "signin-box-person-credentials-v1";
|
|
2259
|
+
|
|
2260
|
+
const MAX_GITHUB_TOKEN_CHARS = 512;
|
|
2261
|
+
const MAX_GITHUB_CLIENT_ID_CHARS = 128;
|
|
2262
|
+
const MAX_GITHUB_CLIENT_SECRET_CHARS = 256;
|
|
2263
|
+
export const MAX_SSH_PUBLIC_KEY_CHARS = 1024;
|
|
2264
|
+
|
|
2265
|
+
/** Cloud → box: write the GitHub user token the cloud's callback obtained as
|
|
2266
|
+
* this revision. This frame is the token's only trip past the cloud. */
|
|
2267
|
+
export interface GithubCaptureFrame {
|
|
2268
|
+
kind: "github.capture";
|
|
2269
|
+
opId: string;
|
|
2270
|
+
flow: CaptureFlowWire;
|
|
2271
|
+
token: string;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
/** Cloud → box: mint an SSH key as this revision. The private half goes to
|
|
2275
|
+
* the revision's parameter and nowhere else. */
|
|
2276
|
+
export interface SshMintFrame {
|
|
2277
|
+
kind: "ssh.mint";
|
|
2278
|
+
opId: string;
|
|
2279
|
+
flow: CaptureFlowWire;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
/** Box → cloud: the revision written (with an SSH key's public half), or why
|
|
2283
|
+
* not. Never material. */
|
|
2284
|
+
export interface PersonCaptureAckFrame {
|
|
2285
|
+
kind: "person.capture.ack";
|
|
2286
|
+
opId: string;
|
|
2287
|
+
capture: CaptureResultWire | null;
|
|
2288
|
+
publicKey: string | null;
|
|
2289
|
+
error: string | null;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
/** Cloud → box: revoke a retired GitHub revision's token at GitHub. The App's
|
|
2293
|
+
* client id and secret ride the frame; the box keeps neither. */
|
|
2294
|
+
export interface GithubRevokeFrame {
|
|
2295
|
+
kind: "github.revoke";
|
|
2296
|
+
opId: string;
|
|
2297
|
+
/** The retired revision's lease-readable parameter. */
|
|
2298
|
+
parameter: string;
|
|
2299
|
+
clientId: string;
|
|
2300
|
+
clientSecret: string;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
/** `revoked`: GitHub confirmed; `absent`: the token, or its parameter, is
|
|
2304
|
+
* gone already; `retry`: ask again later. */
|
|
2305
|
+
export type GithubRevokeOutcome = "revoked" | "absent" | "retry";
|
|
2306
|
+
|
|
2307
|
+
export interface GithubRevokeAckFrame {
|
|
2308
|
+
kind: "github.revoke.ack";
|
|
2309
|
+
opId: string;
|
|
2310
|
+
outcome: GithubRevokeOutcome;
|
|
2311
|
+
error: string | null;
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
function isPersonFlow(value: unknown, credentialClass: "github" | "ssh"): value is CaptureFlowWire {
|
|
2315
|
+
const flow = parseCaptureFlowWire(value);
|
|
2316
|
+
return flow !== null && flow.location.class === credentialClass;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/** A GitHub token as the leased value holds it: bounded, one word. */
|
|
2320
|
+
export function isGithubToken(value: unknown): value is string {
|
|
2321
|
+
return isBoundedString(value, MAX_GITHUB_TOKEN_CHARS) && !/\s/.test(value);
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
export function parseGithubCaptureFrame(value: unknown): GithubCaptureFrame | null {
|
|
2325
|
+
const o = exactObject(value, ["kind", "opId", "flow", "token"]);
|
|
2326
|
+
return o &&
|
|
2327
|
+
o.kind === "github.capture" &&
|
|
2328
|
+
isOpId(o.opId) &&
|
|
2329
|
+
isPersonFlow(o.flow, "github") &&
|
|
2330
|
+
isGithubToken(o.token)
|
|
2331
|
+
? { kind: "github.capture", opId: o.opId, flow: o.flow as CaptureFlowWire, token: o.token }
|
|
2332
|
+
: null;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
export function parseSshMintFrame(value: unknown): SshMintFrame | null {
|
|
2336
|
+
const o = exactObject(value, ["kind", "opId", "flow"]);
|
|
2337
|
+
return o && o.kind === "ssh.mint" && isOpId(o.opId) && isPersonFlow(o.flow, "ssh")
|
|
2338
|
+
? { kind: "ssh.mint", opId: o.opId, flow: o.flow as CaptureFlowWire }
|
|
2339
|
+
: null;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
export function parsePersonCaptureAckFrame(value: unknown): PersonCaptureAckFrame | null {
|
|
2343
|
+
const o = exactObject(value, ["kind", "opId", "capture", "publicKey", "error"]);
|
|
2344
|
+
if (!o || o.kind !== "person.capture.ack" || !isOpId(o.opId)) return null;
|
|
2345
|
+
const capture = o.capture === null ? null : parseCaptureResultWire(o.capture);
|
|
2346
|
+
if (o.capture !== null && !capture) return null;
|
|
2347
|
+
if (!(o.publicKey === null || (isBoundedString(o.publicKey, MAX_SSH_PUBLIC_KEY_CHARS) && !/[\r\n]/.test(o.publicKey)))) {
|
|
2348
|
+
return null;
|
|
2349
|
+
}
|
|
2350
|
+
if (capture !== null) {
|
|
2351
|
+
return o.error === null
|
|
2352
|
+
? { kind: "person.capture.ack", opId: o.opId, capture, publicKey: o.publicKey, error: null }
|
|
2353
|
+
: null;
|
|
2354
|
+
}
|
|
2355
|
+
return o.publicKey === null && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
|
|
2356
|
+
? { kind: "person.capture.ack", opId: o.opId, capture: null, publicKey: null, error: o.error }
|
|
2357
|
+
: null;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
export function parseGithubRevokeFrame(value: unknown): GithubRevokeFrame | null {
|
|
2361
|
+
const o = exactObject(value, ["kind", "opId", "parameter", "clientId", "clientSecret"]);
|
|
2362
|
+
if (
|
|
2363
|
+
!o ||
|
|
2364
|
+
o.kind !== "github.revoke" ||
|
|
2365
|
+
!isOpId(o.opId) ||
|
|
2366
|
+
typeof o.parameter !== "string" ||
|
|
2367
|
+
parseLeaseParameterName(o.parameter)?.class !== "github" ||
|
|
2368
|
+
!isBoundedString(o.clientId, MAX_GITHUB_CLIENT_ID_CHARS) ||
|
|
2369
|
+
!isBoundedString(o.clientSecret, MAX_GITHUB_CLIENT_SECRET_CHARS)
|
|
2370
|
+
) {
|
|
2371
|
+
return null;
|
|
2372
|
+
}
|
|
2373
|
+
return {
|
|
2374
|
+
kind: "github.revoke",
|
|
2375
|
+
opId: o.opId,
|
|
2376
|
+
parameter: o.parameter,
|
|
2377
|
+
clientId: o.clientId,
|
|
2378
|
+
clientSecret: o.clientSecret,
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
export function parseGithubRevokeAckFrame(value: unknown): GithubRevokeAckFrame | null {
|
|
2383
|
+
const o = exactObject(value, ["kind", "opId", "outcome", "error"]);
|
|
2384
|
+
if (!o || o.kind !== "github.revoke.ack" || !isOpId(o.opId)) return null;
|
|
2385
|
+
if (o.outcome === "revoked" || o.outcome === "absent") {
|
|
2386
|
+
return o.error === null ? { kind: "github.revoke.ack", opId: o.opId, outcome: o.outcome, error: null } : null;
|
|
2387
|
+
}
|
|
2388
|
+
return o.outcome === "retry" && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
|
|
2389
|
+
? { kind: "github.revoke.ack", opId: o.opId, outcome: "retry", error: o.error }
|
|
2390
|
+
: null;
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
/** A GitHub revision the cloud names for the repository picker: it must be
|
|
2394
|
+
* that person's own. */
|
|
2395
|
+
export function isPersonGithubParameter(parameter: unknown, userId: string): parameter is string {
|
|
2396
|
+
if (typeof parameter !== "string") return false;
|
|
2397
|
+
const location = parseLeaseParameterName(parameter);
|
|
2398
|
+
return location !== null && location.class === "github" && location.ownerId === userId;
|
|
2399
|
+
}
|