@runuai/host 0.9.149 → 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 +1847 -236
- 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 +1062 -140
- 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,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §12 on the sign-in box: a person's GitHub sign-in and SSH key as
|
|
3
|
+
* revisions, the repository picker reading the exact revision the cloud
|
|
4
|
+
* names, and the revocation of a retired GitHub token.
|
|
5
|
+
*
|
|
6
|
+
* - GitHub: the cloud's callback obtained the token; the box writes it as the
|
|
7
|
+
* revision the cloud named and answers with the name and version.
|
|
8
|
+
* - SSH: the box mints the key in memory, writes the private half to the
|
|
9
|
+
* revision's parameter (its public half as a tag) and answers with the
|
|
10
|
+
* public half. Nothing touches the box's disk. A mint retried for a
|
|
11
|
+
* revision that already holds a key answers that key's public half, never
|
|
12
|
+
* the one it just made, so the cloud always shows the key tasks use.
|
|
13
|
+
* - Each write ends at a deadline inside the cloud's answer deadline: the
|
|
14
|
+
* AWS CLI is killed then, so nothing the cloud gave up on is sent later.
|
|
15
|
+
* - The box reads a GitHub revision back only to list the person's
|
|
16
|
+
* installations and repositories for the picker, and to revoke a retired
|
|
17
|
+
* token at GitHub with the App's client id and secret, which ride the
|
|
18
|
+
* revoke frame and are kept nowhere.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { generateKeyPairSync, randomBytes } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
isGithubToken,
|
|
25
|
+
isPersonGithubParameter,
|
|
26
|
+
parseLeaseParameterName,
|
|
27
|
+
parseLeasedValue,
|
|
28
|
+
serializeLeasedValue,
|
|
29
|
+
type CaptureResultWire,
|
|
30
|
+
type GithubCaptureFrame,
|
|
31
|
+
type GithubRevokeAckFrame,
|
|
32
|
+
type GithubRevokeFrame,
|
|
33
|
+
type PersonCaptureAckFrame,
|
|
34
|
+
type SshMintFrame,
|
|
35
|
+
} from "../src/cloud-credentials";
|
|
36
|
+
import { captureTags, parseCaptureFlow, writeCapture, type ParsedCaptureFlow } from "./capture-flow";
|
|
37
|
+
import { ImmutableParameterConflictError, storeFailureKind, type CredentialStore } from "./credential-store";
|
|
38
|
+
import { githubApiOrigin } from "./github-origin";
|
|
39
|
+
|
|
40
|
+
export type PersonCaptureAnswer = Omit<PersonCaptureAckFrame, "kind" | "opId">;
|
|
41
|
+
export type GithubRevokeAnswer = Omit<GithubRevokeAckFrame, "kind" | "opId">;
|
|
42
|
+
|
|
43
|
+
/** One step's whole budget, inside the cloud's 30-second answer deadline. */
|
|
44
|
+
export const PERSON_STEP_DEADLINE_MS = 25_000;
|
|
45
|
+
/** The tag an SSH revision's public half rides on: the key blob, base64. */
|
|
46
|
+
export const SSH_PUBLIC_KEY_TAG = "uai_ssh_public_key";
|
|
47
|
+
|
|
48
|
+
function refused(error: string): PersonCaptureAnswer {
|
|
49
|
+
return { capture: null, publicKey: null, error };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tookTooLong(): PersonCaptureAnswer {
|
|
53
|
+
return refused("The sign-in box took too long; nothing usable was kept. Try again.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function writePerson(
|
|
57
|
+
store: CredentialStore,
|
|
58
|
+
flow: ParsedCaptureFlow,
|
|
59
|
+
lease: string,
|
|
60
|
+
signal: AbortSignal,
|
|
61
|
+
leaseTags?: Record<string, string>,
|
|
62
|
+
): Promise<CaptureResultWire> {
|
|
63
|
+
return writeCapture(store, flow, { lease, broker: null, expiresAt: null, leaseTags }, { signal });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Write the GitHub token the cloud's callback obtained as this revision. */
|
|
67
|
+
export async function captureGithub(
|
|
68
|
+
frame: GithubCaptureFrame,
|
|
69
|
+
deps: { store: CredentialStore | null; deadlineMs?: number },
|
|
70
|
+
): Promise<PersonCaptureAnswer> {
|
|
71
|
+
const flow = parseCaptureFlow(frame.flow);
|
|
72
|
+
if (!flow || flow.location.class !== "github" || flow.brokerParameter !== null) {
|
|
73
|
+
return refused("The sign-in's destination is invalid; nothing was kept.");
|
|
74
|
+
}
|
|
75
|
+
if (!deps.store) return refused("The sign-in box has no credential store.");
|
|
76
|
+
const signal = AbortSignal.timeout(deps.deadlineMs ?? PERSON_STEP_DEADLINE_MS);
|
|
77
|
+
try {
|
|
78
|
+
// A retry for this revision carries the same token: the cloud's callback
|
|
79
|
+
// spends its state once, so one revision never meets two sign-ins.
|
|
80
|
+
const capture = await writePerson(
|
|
81
|
+
deps.store,
|
|
82
|
+
flow,
|
|
83
|
+
serializeLeasedValue({ v: 1, part: "lease", location: flow.location, class: "github", token: frame.token }),
|
|
84
|
+
signal,
|
|
85
|
+
);
|
|
86
|
+
return { capture, publicKey: null, error: null };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (signal.aborted) return tookTooLong();
|
|
89
|
+
console.warn(`[person-capture] github ${flow.credentialId}/${flow.revision}: write failed (${storeFailureKind(err)})`);
|
|
90
|
+
return refused("The credential store refused the write; nothing was kept.");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Mint an SSH key as this revision. */
|
|
95
|
+
export async function mintSsh(
|
|
96
|
+
frame: SshMintFrame,
|
|
97
|
+
deps: { store: CredentialStore | null; keygen?: (comment: string) => OpenSshKeyPair; deadlineMs?: number },
|
|
98
|
+
): Promise<PersonCaptureAnswer> {
|
|
99
|
+
const flow = parseCaptureFlow(frame.flow);
|
|
100
|
+
if (!flow || flow.location.class !== "ssh" || flow.brokerParameter !== null) {
|
|
101
|
+
return refused("The key's destination is invalid; nothing was kept.");
|
|
102
|
+
}
|
|
103
|
+
if (!deps.store) return refused("The sign-in box has no credential store.");
|
|
104
|
+
const signal = AbortSignal.timeout(deps.deadlineMs ?? PERSON_STEP_DEADLINE_MS);
|
|
105
|
+
const comment = sshComment(flow);
|
|
106
|
+
let key: OpenSshKeyPair;
|
|
107
|
+
try {
|
|
108
|
+
key = (deps.keygen ?? openSshEd25519)(comment);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.warn(`[person-capture] ssh ${flow.credentialId}/${flow.revision}: no key was made (${storeFailureKind(err)})`);
|
|
111
|
+
return refused("The sign-in box could not make a key; nothing was kept.");
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const capture = await writePerson(
|
|
115
|
+
deps.store,
|
|
116
|
+
flow,
|
|
117
|
+
serializeLeasedValue({
|
|
118
|
+
v: 1,
|
|
119
|
+
part: "lease",
|
|
120
|
+
location: flow.location,
|
|
121
|
+
class: "ssh",
|
|
122
|
+
privateKey: key.privateKey,
|
|
123
|
+
publicKey: key.publicKey,
|
|
124
|
+
}),
|
|
125
|
+
signal,
|
|
126
|
+
{ [SSH_PUBLIC_KEY_TAG]: publicKeyBlob(key.publicKey) },
|
|
127
|
+
);
|
|
128
|
+
return { capture, publicKey: key.publicKey, error: null };
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (signal.aborted) return tookTooLong();
|
|
131
|
+
if (err instanceof ImmutableParameterConflictError) {
|
|
132
|
+
// This revision already holds a key (an earlier attempt whose answer
|
|
133
|
+
// was lost): answer its public half. The key just made is dropped.
|
|
134
|
+
// Under the mint's own deadline: the recovery is part of the step.
|
|
135
|
+
const earlier = await keyAlreadyMinted(deps.store, flow, signal).catch(() => null);
|
|
136
|
+
if (signal.aborted) return tookTooLong();
|
|
137
|
+
if (earlier) {
|
|
138
|
+
return {
|
|
139
|
+
capture: { credentialId: flow.credentialId, revision: flow.revision, leaseVersion: 1, expiresAt: null },
|
|
140
|
+
publicKey: `ssh-ed25519 ${earlier} ${comment}`,
|
|
141
|
+
error: null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
console.warn(`[person-capture] ssh ${flow.credentialId}/${flow.revision}: write failed (${storeFailureKind(err)})`);
|
|
146
|
+
return refused("The credential store refused the write; nothing was kept.");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sshComment(flow: ParsedCaptureFlow): string {
|
|
151
|
+
return `uai-${flow.location.ownerId}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The base64 key blob of an `ssh-ed25519 <blob> <comment>` line. */
|
|
155
|
+
function publicKeyBlob(publicKey: string): string {
|
|
156
|
+
return publicKey.split(" ")[1] ?? "";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The key blob an SSH revision's parameter already carries, when that
|
|
160
|
+
* parameter is exactly this revision's (every identity tag agrees) and
|
|
161
|
+
* the blob is an ed25519 public key; null otherwise. Metadata only. */
|
|
162
|
+
async function keyAlreadyMinted(
|
|
163
|
+
store: CredentialStore,
|
|
164
|
+
flow: ParsedCaptureFlow,
|
|
165
|
+
signal: AbortSignal,
|
|
166
|
+
): Promise<string | null> {
|
|
167
|
+
const observed = await store.listTags(flow.leaseParameter, { signal });
|
|
168
|
+
for (const [tag, value] of Object.entries(captureTags(flow, "lease"))) {
|
|
169
|
+
if (observed[tag] !== value) return null;
|
|
170
|
+
}
|
|
171
|
+
const blob = observed[SSH_PUBLIC_KEY_TAG];
|
|
172
|
+
return typeof blob === "string" && isEd25519Blob(blob) ? blob : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isEd25519Blob(blob: string): boolean {
|
|
176
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) return false;
|
|
177
|
+
const bytes = Buffer.from(blob, "base64");
|
|
178
|
+
const type = Buffer.from("ssh-ed25519");
|
|
179
|
+
return (
|
|
180
|
+
bytes.length === 4 + type.length + 4 + 32 &&
|
|
181
|
+
bytes.readUInt32BE(0) === type.length &&
|
|
182
|
+
bytes.subarray(4, 4 + type.length).equals(type) &&
|
|
183
|
+
bytes.readUInt32BE(4 + type.length) === 32
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The token of the GitHub revision at `parameter`, which must be this
|
|
189
|
+
* person's: null when it is not there. Throws when the store cannot answer,
|
|
190
|
+
* so "could not read" is never taken for "signed out".
|
|
191
|
+
*/
|
|
192
|
+
export async function readGithubRevision(
|
|
193
|
+
store: CredentialStore,
|
|
194
|
+
parameter: string,
|
|
195
|
+
userId: string,
|
|
196
|
+
signal?: AbortSignal,
|
|
197
|
+
): Promise<string | null> {
|
|
198
|
+
if (!isPersonGithubParameter(parameter, userId)) {
|
|
199
|
+
throw new Error("the revision named is not this person's GitHub sign-in");
|
|
200
|
+
}
|
|
201
|
+
const raw = await store.get(parameter, { signal });
|
|
202
|
+
if (raw === null) return null;
|
|
203
|
+
const location = parseLeaseParameterName(parameter)!;
|
|
204
|
+
const value = parseLeasedValue(raw, location);
|
|
205
|
+
if (!value || value.class !== "github") throw new Error("the GitHub revision is malformed");
|
|
206
|
+
return value.token;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Revoke a retired revision's token at GitHub. `absent` when the parameter
|
|
211
|
+
* is gone or GitHub no longer knows the token; `retry` for anything that
|
|
212
|
+
* might pass later. The answer never carries the token.
|
|
213
|
+
*/
|
|
214
|
+
export async function revokeGithub(
|
|
215
|
+
frame: GithubRevokeFrame,
|
|
216
|
+
deps: { store: CredentialStore | null; fetch?: typeof fetch; apiOrigin?: string; deadlineMs?: number },
|
|
217
|
+
): Promise<GithubRevokeAnswer> {
|
|
218
|
+
if (!deps.store) return { outcome: "retry", error: "the sign-in box has no credential store" };
|
|
219
|
+
const location = parseLeaseParameterName(frame.parameter);
|
|
220
|
+
if (!location || location.class !== "github") {
|
|
221
|
+
return { outcome: "retry", error: "the revision named is not a GitHub sign-in" };
|
|
222
|
+
}
|
|
223
|
+
// One deadline over the whole revocation, the read and GitHub's answer
|
|
224
|
+
// together, inside the cloud's own: never two bounds stacked.
|
|
225
|
+
const signal = AbortSignal.timeout(deps.deadlineMs ?? PERSON_STEP_DEADLINE_MS);
|
|
226
|
+
let token: string | null;
|
|
227
|
+
try {
|
|
228
|
+
token = await readGithubRevision(deps.store, frame.parameter, location.ownerId, signal);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
// The detail stays in the box's log; the cloud gets a fixed message.
|
|
231
|
+
console.warn(`[person-capture] revoke ${frame.parameter}: read failed (${storeFailureKind(err)})`);
|
|
232
|
+
return { outcome: "retry", error: "could not read the revision" };
|
|
233
|
+
}
|
|
234
|
+
if (token === null || !isGithubToken(token)) return { outcome: "absent", error: null };
|
|
235
|
+
const origin = deps.apiOrigin ?? githubApiOrigin();
|
|
236
|
+
const basic = Buffer.from(`${frame.clientId}:${frame.clientSecret}`).toString("base64");
|
|
237
|
+
let res: Response;
|
|
238
|
+
try {
|
|
239
|
+
res = await (deps.fetch ?? fetch)(`${origin}/applications/${encodeURIComponent(frame.clientId)}/token`, {
|
|
240
|
+
method: "DELETE",
|
|
241
|
+
headers: {
|
|
242
|
+
authorization: `Basic ${basic}`,
|
|
243
|
+
accept: "application/vnd.github+json",
|
|
244
|
+
"content-type": "application/json",
|
|
245
|
+
"user-agent": "uai-signin-box",
|
|
246
|
+
"x-github-api-version": "2022-11-28",
|
|
247
|
+
},
|
|
248
|
+
body: JSON.stringify({ access_token: token }),
|
|
249
|
+
redirect: "error",
|
|
250
|
+
signal,
|
|
251
|
+
});
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.warn(`[person-capture] revoke ${frame.parameter}: GitHub did not answer (${storeFailureKind(err)})`);
|
|
254
|
+
return { outcome: "retry", error: "GitHub did not answer" };
|
|
255
|
+
}
|
|
256
|
+
// Only the status matters; GitHub's words are not read, let alone relayed.
|
|
257
|
+
void res.body?.cancel().catch(() => {});
|
|
258
|
+
// 204: revoked. 404: GitHub knows no such token (revoked, expired, or its
|
|
259
|
+
// grant was removed): the same end state.
|
|
260
|
+
if (res.status === 204) return { outcome: "revoked", error: null };
|
|
261
|
+
if (res.status === 404) return { outcome: "absent", error: null };
|
|
262
|
+
return { outcome: "retry", error: `GitHub answered HTTP ${res.status}` };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// An ed25519 key in OpenSSH's own formats, made in memory.
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
export interface OpenSshKeyPair {
|
|
272
|
+
/** `-----BEGIN OPENSSH PRIVATE KEY-----`, unencrypted. */
|
|
273
|
+
privateKey: string;
|
|
274
|
+
/** `ssh-ed25519 <base64> <comment>`. */
|
|
275
|
+
publicKey: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function sshString(bytes: Buffer): Buffer {
|
|
279
|
+
const length = Buffer.alloc(4);
|
|
280
|
+
length.writeUInt32BE(bytes.length);
|
|
281
|
+
return Buffer.concat([length, bytes]);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function sshUint32(value: number): Buffer {
|
|
285
|
+
const out = Buffer.alloc(4);
|
|
286
|
+
out.writeUInt32BE(value >>> 0);
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The openssh-key-v1 container (PROTOCOL.key in OpenSSH) for one unencrypted
|
|
292
|
+
* ed25519 key: what `ssh-keygen -t ed25519 -N ""` writes, without a file.
|
|
293
|
+
*/
|
|
294
|
+
export function openSshEd25519(comment: string): OpenSshKeyPair {
|
|
295
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
296
|
+
const pub = Buffer.from(publicKey.export({ format: "jwk" }).x!, "base64url");
|
|
297
|
+
const seed = Buffer.from(privateKey.export({ format: "jwk" }).d!, "base64url");
|
|
298
|
+
const keyType = Buffer.from("ssh-ed25519");
|
|
299
|
+
const publicBlob = Buffer.concat([sshString(keyType), sshString(pub)]);
|
|
300
|
+
const check = randomBytes(4).readUInt32BE();
|
|
301
|
+
let secret = Buffer.concat([
|
|
302
|
+
sshUint32(check),
|
|
303
|
+
sshUint32(check),
|
|
304
|
+
sshString(keyType),
|
|
305
|
+
sshString(pub),
|
|
306
|
+
// OpenSSH stores the 32-byte seed followed by the public key.
|
|
307
|
+
sshString(Buffer.concat([seed, pub])),
|
|
308
|
+
sshString(Buffer.from(comment)),
|
|
309
|
+
]);
|
|
310
|
+
const padding: number[] = [];
|
|
311
|
+
for (let i = 1; (secret.length + padding.length) % 8 !== 0; i += 1) padding.push(i);
|
|
312
|
+
secret = Buffer.concat([secret, Buffer.from(padding)]);
|
|
313
|
+
const body = Buffer.concat([
|
|
314
|
+
Buffer.from("openssh-key-v1\0"),
|
|
315
|
+
sshString(Buffer.from("none")),
|
|
316
|
+
sshString(Buffer.from("none")),
|
|
317
|
+
sshString(Buffer.alloc(0)),
|
|
318
|
+
sshUint32(1),
|
|
319
|
+
sshString(publicBlob),
|
|
320
|
+
sshString(secret),
|
|
321
|
+
]);
|
|
322
|
+
const lines = body.toString("base64").match(/.{1,70}/g) ?? [];
|
|
323
|
+
return {
|
|
324
|
+
privateKey: ["-----BEGIN OPENSSH PRIVATE KEY-----", ...lines, "-----END OPENSSH PRIVATE KEY-----", ""].join("\n"),
|
|
325
|
+
publicKey: `ssh-ed25519 ${publicBlob.toString("base64")} ${comment}`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §9: the sign-in box tags pinned legacy env parameters.
|
|
3
|
+
*
|
|
4
|
+
* ADR-153's backfill pinned `<prefix>/projects/<projectId>/env/<KEY>` by
|
|
5
|
+
* version and LastModifiedDate without checking its tags, and a v1 lease
|
|
6
|
+
* reads `/uai/projects/*` only where the `org` tag is the task's workspace.
|
|
7
|
+
* The box puts the project's `org` and `project` tags on each parameter the
|
|
8
|
+
* cloud names and reads them back, with `ssm:AddTagsToResource` and
|
|
9
|
+
* `ssm:ListTagsForResource`, which its policy already grants on
|
|
10
|
+
* `/uai/projects/*`. It never reads a value; the cloud checks each pin,
|
|
11
|
+
* before and after, from its own metadata reads.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
isProjectEnvLegacyTagFrame,
|
|
16
|
+
type ProjectEnvLegacyTagAckFrame,
|
|
17
|
+
type ProjectEnvLegacyTagFrame,
|
|
18
|
+
type ProjectEnvLegacyTagOutcome,
|
|
19
|
+
} from "../src/protocol";
|
|
20
|
+
import {
|
|
21
|
+
CredentialStoreCommandError,
|
|
22
|
+
configuredCredentialStore,
|
|
23
|
+
credentialStorePrefix,
|
|
24
|
+
parameterName,
|
|
25
|
+
type CredentialStore,
|
|
26
|
+
} from "./credential-store";
|
|
27
|
+
import { PROJECT_ENV_STAGING_TAGS } from "./project-env-staging";
|
|
28
|
+
|
|
29
|
+
/** A frame starts no key after this; the rest are asked again. With one
|
|
30
|
+
* key's two CLI calls after it (30 seconds each at most), the answer still
|
|
31
|
+
* reaches the cloud inside its 90-second wait (lib/host-bridge.ts), and a
|
|
32
|
+
* managed restart, which waits for drained work, waits that long at most. */
|
|
33
|
+
export const LEGACY_TAG_BUDGET_MS = 20_000;
|
|
34
|
+
|
|
35
|
+
/** AWS's answers for a parameter that is not there. */
|
|
36
|
+
const MISSING_CODES = new Set(["InvalidResourceId", "ParameterNotFound"]);
|
|
37
|
+
/** AWS will put no more tags on it: nothing a retry changes. */
|
|
38
|
+
const REFUSED_CODES = new Set(["TooManyTagsError"]);
|
|
39
|
+
|
|
40
|
+
/** The legacy layout's name for one key, as the cloud's
|
|
41
|
+
* `projectEnvCloudLegacyParameterName` writes it. */
|
|
42
|
+
export function projectEnvLegacyParameterName(
|
|
43
|
+
projectId: string,
|
|
44
|
+
key: string,
|
|
45
|
+
prefix = credentialStorePrefix(),
|
|
46
|
+
): string {
|
|
47
|
+
return parameterName(prefix, "projects", projectId, "env", key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function tagOne(
|
|
51
|
+
store: CredentialStore,
|
|
52
|
+
name: string,
|
|
53
|
+
tags: Record<string, string>,
|
|
54
|
+
): Promise<ProjectEnvLegacyTagOutcome> {
|
|
55
|
+
try {
|
|
56
|
+
await store.tag(name, tags);
|
|
57
|
+
// Tags are eventually consistent: one not read back yet is a retry, and
|
|
58
|
+
// tagging again is harmless.
|
|
59
|
+
const read = await store.listTags(name);
|
|
60
|
+
return Object.entries(tags).every(([key, value]) => read[key] === value) ? "tagged" : "retry";
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const code = error instanceof CredentialStoreCommandError ? error.awsCode : null;
|
|
63
|
+
if (code !== null && MISSING_CODES.has(code)) return "missing";
|
|
64
|
+
if (code !== null && REFUSED_CODES.has(code)) return "refused";
|
|
65
|
+
return "retry";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ProjectEnvLegacyTagOptions {
|
|
70
|
+
store?: CredentialStore | null;
|
|
71
|
+
prefix?: string;
|
|
72
|
+
now?: () => number;
|
|
73
|
+
/** When the frame arrived: its budget runs from there. */
|
|
74
|
+
startedAt?: number;
|
|
75
|
+
budgetMs?: number;
|
|
76
|
+
stopping?: () => boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Tag each named parameter in turn; one key's failure is its own. Keys
|
|
80
|
+
* past the budget, or after the host begins shutting down, are asked again
|
|
81
|
+
* without a call. */
|
|
82
|
+
export async function tagProjectEnvLegacyParameters(
|
|
83
|
+
frame: ProjectEnvLegacyTagFrame,
|
|
84
|
+
options: ProjectEnvLegacyTagOptions = {},
|
|
85
|
+
): Promise<ProjectEnvLegacyTagAckFrame["results"]> {
|
|
86
|
+
if (!isProjectEnvLegacyTagFrame(frame)) {
|
|
87
|
+
throw new Error("invalid legacy environment tag request");
|
|
88
|
+
}
|
|
89
|
+
const store = Object.prototype.hasOwnProperty.call(options, "store")
|
|
90
|
+
? options.store
|
|
91
|
+
: configuredCredentialStore();
|
|
92
|
+
if (!store) return frame.keys.map((key) => ({ key, outcome: "retry" }));
|
|
93
|
+
const prefix = options.prefix ?? credentialStorePrefix();
|
|
94
|
+
const tags = {
|
|
95
|
+
[PROJECT_ENV_STAGING_TAGS.org]: frame.orgId,
|
|
96
|
+
[PROJECT_ENV_STAGING_TAGS.project]: frame.projectId,
|
|
97
|
+
};
|
|
98
|
+
const now = options.now ?? Date.now;
|
|
99
|
+
const startedAt = options.startedAt ?? now();
|
|
100
|
+
const budgetMs = options.budgetMs ?? LEGACY_TAG_BUDGET_MS;
|
|
101
|
+
const results: ProjectEnvLegacyTagAckFrame["results"] = [];
|
|
102
|
+
for (const key of frame.keys) {
|
|
103
|
+
if (now() - startedAt >= budgetMs || options.stopping?.() === true) {
|
|
104
|
+
results.push({ key, outcome: "retry" });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const name = projectEnvLegacyParameterName(frame.projectId, key, prefix);
|
|
108
|
+
results.push({ key, outcome: await tagOne(store, name, tags) });
|
|
109
|
+
}
|
|
110
|
+
return results;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One frame at a time, in arrival order: a frame whose key stalled in a CLI
|
|
117
|
+
* call (its budget stops it starting more, not the call it is in) cannot
|
|
118
|
+
* land its tags after a later frame's, which may name another workspace or
|
|
119
|
+
* have been read back already. A frame that waited out its budget in line
|
|
120
|
+
* answers at once, each key asked again.
|
|
121
|
+
*/
|
|
122
|
+
export function queueProjectEnvLegacyTag(
|
|
123
|
+
frame: ProjectEnvLegacyTagFrame,
|
|
124
|
+
options: ProjectEnvLegacyTagOptions = {},
|
|
125
|
+
): Promise<ProjectEnvLegacyTagAckFrame["results"]> {
|
|
126
|
+
const startedAt = options.startedAt ?? (options.now ?? Date.now)();
|
|
127
|
+
const run = queue.then(() => tagProjectEnvLegacyParameters(frame, { ...options, startedAt }));
|
|
128
|
+
queue = run.catch(() => undefined);
|
|
129
|
+
return run;
|
|
130
|
+
}
|