@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
package/lib/token-broker-ssm.ts
CHANGED
|
@@ -8,14 +8,23 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
-
DeleteParameterCommand,
|
|
12
11
|
GetParameterCommand,
|
|
12
|
+
GetParameterHistoryCommand,
|
|
13
13
|
GetParametersByPathCommand,
|
|
14
|
+
ListTagsForResourceCommand,
|
|
14
15
|
PutParameterCommand,
|
|
15
16
|
SSMClient,
|
|
16
17
|
} from "@aws-sdk/client-ssm";
|
|
17
18
|
|
|
19
|
+
import {
|
|
20
|
+
BROKER_PARAMETER_ROOT,
|
|
21
|
+
idPathSegment,
|
|
22
|
+
legacyAdoptedRoot,
|
|
23
|
+
legacyEngineParameterRoot,
|
|
24
|
+
} from "../src/cloud-credentials";
|
|
18
25
|
import type { BrokerCredential, BrokerStorage } from "./token-broker";
|
|
26
|
+
import type { AdoptionStore } from "./token-broker-adopt";
|
|
27
|
+
import type { RevisionStore, StoredParameter } from "./token-broker-revision";
|
|
19
28
|
import { refreshMetaFrom, type CredentialFiles, type RefreshMeta } from "./token-lanes/types";
|
|
20
29
|
|
|
21
30
|
export interface SsmBrokerStorageOptions {
|
|
@@ -30,7 +39,7 @@ interface StoredValue {
|
|
|
30
39
|
version: number | undefined;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
|
-
/**
|
|
42
|
+
/** `<prefix>/orgs/<o>/engines/<kind>/<id>` and the corresponding host path. */
|
|
34
43
|
export function engineParameterHeader(
|
|
35
44
|
name: string,
|
|
36
45
|
prefix: string,
|
|
@@ -53,6 +62,10 @@ export class SsmBrokerStorage implements BrokerStorage {
|
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
async list(): Promise<BrokerCredential[]> {
|
|
65
|
+
// ADR-154, owner answer 3: an adopted account has one refresher, the
|
|
66
|
+
// cloud's scheduler, never this scan. Markers that cannot be listed stop
|
|
67
|
+
// the run rather than risk a second refresher.
|
|
68
|
+
const adopted = await adoptedLegacyNames(this.client, this.opts.prefix);
|
|
56
69
|
const out: BrokerCredential[] = [];
|
|
57
70
|
for (const path of [`${this.opts.prefix}/orgs`, `${this.opts.prefix}/hosts`]) {
|
|
58
71
|
let nextToken: string | undefined;
|
|
@@ -67,6 +80,7 @@ export class SsmBrokerStorage implements BrokerStorage {
|
|
|
67
80
|
);
|
|
68
81
|
for (const p of page.Parameters ?? []) {
|
|
69
82
|
if (!p.Name || typeof p.Value !== "string") continue;
|
|
83
|
+
if (adopted.has(p.Name)) continue;
|
|
70
84
|
const header = engineParameterHeader(p.Name, this.opts.prefix);
|
|
71
85
|
if (!header) continue;
|
|
72
86
|
let raw: unknown;
|
|
@@ -123,12 +137,132 @@ export class SsmBrokerStorage implements BrokerStorage {
|
|
|
123
137
|
// ---------------------------------------------------------------------------
|
|
124
138
|
// Mutual exclusion between runs. Reserved concurrency 1 is the first line
|
|
125
139
|
// (deploy-aws.sh sets it when the account's limit allows); this lock is the
|
|
126
|
-
// one that always holds
|
|
127
|
-
//
|
|
128
|
-
//
|
|
140
|
+
// one that always holds. A lock is one parameter that is never deleted: AWS
|
|
141
|
+
// asks for 30 seconds between deleting a parameter and creating one of the
|
|
142
|
+
// same name (DeleteParameter), and adoptions take the scan lock one after
|
|
143
|
+
// another. Free, it holds RELEASED_LOCK; held, its holder's
|
|
144
|
+
// `<time>:<nonce>`. A holder that died keeps it at most LOCK_STALE_MS; a
|
|
145
|
+
// later run takes it over.
|
|
129
146
|
// ---------------------------------------------------------------------------
|
|
130
147
|
|
|
131
148
|
export const LOCK_STALE_MS = 5 * 60_000;
|
|
149
|
+
/** A holder frees the lock only this far inside the stale window: past it,
|
|
150
|
+
* a run that found the lock stale may hold it now. The broker's 120-second
|
|
151
|
+
* Lambda timeout (deploy-aws.sh) keeps every holder well inside. */
|
|
152
|
+
const LOCK_RELEASE_MARGIN_MS = 60_000;
|
|
153
|
+
/** A free lock: time zero, which every reader takes for stale (the build
|
|
154
|
+
* before this one too, which deleted the parameter instead). */
|
|
155
|
+
export const RELEASED_LOCK = "0:released";
|
|
156
|
+
|
|
157
|
+
interface LockToken {
|
|
158
|
+
heldAt: number;
|
|
159
|
+
/** The SSM version this claimant had to create. Null for the previous
|
|
160
|
+
* build's `<time>:<nonce>` tokens and recovery fences, which stay
|
|
161
|
+
* conservatively held until released or stale. */
|
|
162
|
+
expectedVersion: number | null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function lockToken(value: string | undefined): LockToken | null {
|
|
166
|
+
if (typeof value !== "string") return null;
|
|
167
|
+
const parts = value.split(":");
|
|
168
|
+
const [heldAtText, expectedText] = parts;
|
|
169
|
+
const heldAt = Number(heldAtText);
|
|
170
|
+
if (!Number.isSafeInteger(heldAt) || heldAt <= 0) return null;
|
|
171
|
+
const expectedVersion = Number(expectedText);
|
|
172
|
+
return {
|
|
173
|
+
heldAt,
|
|
174
|
+
expectedVersion:
|
|
175
|
+
parts.length === 3 && Number.isSafeInteger(expectedVersion) && expectedVersion > 0
|
|
176
|
+
? expectedVersion
|
|
177
|
+
: null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A contender can land after the elected holder has already released. Its
|
|
182
|
+
* token is not a lock (its returned SSM version missed the version it named),
|
|
183
|
+
* but it may also have overwritten a newer holder that is still working.
|
|
184
|
+
* Replay every intervening version: an elected token opens a hold, RELEASED
|
|
185
|
+
* closes it, and a losing token changes neither. Only a complete chain ending
|
|
186
|
+
* with no open hold proves that the current false token is free. */
|
|
187
|
+
async function holderBeforeFalseToken(
|
|
188
|
+
client: SSMClient,
|
|
189
|
+
name: string,
|
|
190
|
+
expectedVersion: number,
|
|
191
|
+
currentVersion: number,
|
|
192
|
+
): Promise<LockToken | null | undefined> {
|
|
193
|
+
let nextToken: string | undefined;
|
|
194
|
+
const history = new Map<number, string>();
|
|
195
|
+
try {
|
|
196
|
+
do {
|
|
197
|
+
const page = await client.send(
|
|
198
|
+
new GetParameterHistoryCommand({
|
|
199
|
+
Name: name,
|
|
200
|
+
WithDecryption: false,
|
|
201
|
+
MaxResults: 50,
|
|
202
|
+
NextToken: nextToken,
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
for (const item of page.Parameters ?? []) {
|
|
206
|
+
if (
|
|
207
|
+
item.Version !== undefined &&
|
|
208
|
+
item.Version >= expectedVersion &&
|
|
209
|
+
item.Version < currentVersion &&
|
|
210
|
+
item.Value !== undefined
|
|
211
|
+
) {
|
|
212
|
+
history.set(item.Version, item.Value);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
nextToken = page.NextToken;
|
|
216
|
+
} while (nextToken);
|
|
217
|
+
} catch {
|
|
218
|
+
// Missing IAM or a transient history read fails closed. The false token
|
|
219
|
+
// becomes stale normally; it must never be mistaken for a free live lock.
|
|
220
|
+
}
|
|
221
|
+
let holder: LockToken | null = null;
|
|
222
|
+
for (let version = expectedVersion; version < currentVersion; version += 1) {
|
|
223
|
+
const value = history.get(version);
|
|
224
|
+
// A missing/expired history entry cannot prove the holder is gone.
|
|
225
|
+
if (value === undefined) return undefined;
|
|
226
|
+
if (value === RELEASED_LOCK) {
|
|
227
|
+
holder = null;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const claim = lockToken(value);
|
|
231
|
+
// The previous build's two-field token won its own election; for a new
|
|
232
|
+
// token, only the exact SSM version it named did. Losing writes do not
|
|
233
|
+
// erase an already-open hold.
|
|
234
|
+
if (claim && (claim.expectedVersion === null || claim.expectedVersion === version)) {
|
|
235
|
+
holder = claim;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return holder;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function lockWriteConflict(error: unknown): boolean {
|
|
242
|
+
const name = (error as { name?: string }).name;
|
|
243
|
+
return name === "ParameterAlreadyExists" || name === "TooManyUpdates";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** History can age out (SSM retains 100 versions) or be temporarily
|
|
247
|
+
* unavailable. Replace an unprovable false token with a fresh fence and admit
|
|
248
|
+
* nobody for one stale window. Any hidden holder predates this write and is
|
|
249
|
+
* therefore stale when the fence is; a normal holder release may free it
|
|
250
|
+
* sooner. This is deliberately not an elected holder and never returns a
|
|
251
|
+
* release callback. */
|
|
252
|
+
async function installRecoveryFence(
|
|
253
|
+
client: SSMClient,
|
|
254
|
+
name: string,
|
|
255
|
+
now: () => number,
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
const token = `${now()}:recovery:${Math.random().toString(36).slice(2)}`;
|
|
258
|
+
try {
|
|
259
|
+
await client.send(
|
|
260
|
+
new PutParameterCommand({ Name: name, Value: token, Type: "String", Overwrite: true }),
|
|
261
|
+
);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (!lockWriteConflict(error)) throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
132
266
|
|
|
133
267
|
export function brokerLockName(prefix: string): string {
|
|
134
268
|
return `${prefix}/locks/token-broker`;
|
|
@@ -144,42 +278,248 @@ export interface BrokerLockOptions {
|
|
|
144
278
|
/** Try to take the lock. Returns a release function, or null when another
|
|
145
279
|
* run holds a fresh one. */
|
|
146
280
|
export async function acquireBrokerLock(opts: BrokerLockOptions): Promise<(() => Promise<void>) | null> {
|
|
147
|
-
|
|
281
|
+
return acquireParameterLock({ ...opts, name: brokerLockName(opts.prefix) });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The same lock, at any parameter name. */
|
|
285
|
+
export async function acquireParameterLock(opts: {
|
|
286
|
+
client: SSMClient;
|
|
287
|
+
name: string;
|
|
288
|
+
now?: () => number;
|
|
289
|
+
staleMs?: number;
|
|
290
|
+
}): Promise<(() => Promise<void>) | null> {
|
|
291
|
+
const name = opts.name;
|
|
148
292
|
const now = opts.now ?? Date.now;
|
|
149
293
|
const staleMs = opts.staleMs ?? LOCK_STALE_MS;
|
|
150
|
-
|
|
151
|
-
|
|
294
|
+
let current: { Parameter?: { Value?: string; Version?: number } } | null;
|
|
295
|
+
try {
|
|
296
|
+
current = await opts.client.send(new GetParameterCommand({ Name: name }));
|
|
297
|
+
} catch (err) {
|
|
298
|
+
if ((err as { name?: string }).name !== "ParameterNotFound") throw err;
|
|
299
|
+
current = null;
|
|
300
|
+
}
|
|
301
|
+
if (current !== null) {
|
|
302
|
+
const claim = lockToken(current.Parameter?.Value);
|
|
303
|
+
const currentVersion = current.Parameter?.Version;
|
|
304
|
+
if (claim) {
|
|
305
|
+
const isElected =
|
|
306
|
+
claim.expectedVersion === null ||
|
|
307
|
+
currentVersion === undefined ||
|
|
308
|
+
claim.expectedVersion === currentVersion;
|
|
309
|
+
if (isElected) {
|
|
310
|
+
if (now() - claim.heldAt < staleMs) return null;
|
|
311
|
+
} else {
|
|
312
|
+
// Versions only rise. A token naming a future version is malformed,
|
|
313
|
+
// not evidence that a previous holder released. Fence recovery rather
|
|
314
|
+
// than leaving the malformed lineage wedged forever.
|
|
315
|
+
if (claim.expectedVersion! > currentVersion) {
|
|
316
|
+
await installRecoveryFence(opts.client, name, now);
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
// Replay a mismatched token even when that token's own timestamp is
|
|
320
|
+
// stale: it may have landed late over a much newer, live holder.
|
|
321
|
+
const holder = await holderBeforeFalseToken(
|
|
322
|
+
opts.client,
|
|
323
|
+
name,
|
|
324
|
+
claim.expectedVersion!,
|
|
325
|
+
currentVersion,
|
|
326
|
+
);
|
|
327
|
+
if (holder === undefined) {
|
|
328
|
+
await installRecoveryFence(opts.client, name, now);
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
if (holder !== null && now() - holder.heldAt < staleMs) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Timed at the write, so a run that lost the election below leaves a token
|
|
338
|
+
// no older than ours.
|
|
339
|
+
const takenAt = now();
|
|
340
|
+
const read = current?.Parameter?.Version;
|
|
341
|
+
const expectedVersion = current === null ? 1 : read === undefined ? null : read + 1;
|
|
342
|
+
if (expectedVersion === null) return null;
|
|
343
|
+
const token = `${takenAt}:${expectedVersion}:${Math.random().toString(36).slice(2)}`;
|
|
344
|
+
if (current === null) {
|
|
345
|
+
// Never taken (or deleted by the build before this one): created once.
|
|
152
346
|
try {
|
|
153
|
-
await opts.client.send(
|
|
154
|
-
new PutParameterCommand({ Name: name, Value: token, Type: "String", Overwrite: overwrite }),
|
|
155
|
-
);
|
|
156
|
-
return true;
|
|
347
|
+
await opts.client.send(new PutParameterCommand({ Name: name, Value: token, Type: "String", Overwrite: false }));
|
|
157
348
|
} catch (err) {
|
|
158
|
-
if (
|
|
349
|
+
if (lockWriteConflict(err)) return null;
|
|
159
350
|
throw err;
|
|
160
351
|
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
352
|
+
} else {
|
|
353
|
+
/**
|
|
354
|
+
* SSM has no conditional overwrite, but it numbers every write: ours is
|
|
355
|
+
* the lock only if it is the very next version after the one we read
|
|
356
|
+
* free or stale. Of runs that read the same version, exactly one gets
|
|
357
|
+
* the next; the others may leave their tokens in the value after it, but
|
|
358
|
+
* a token is only written over a lock read free or stale, so none of
|
|
359
|
+
* them holds it meanwhile.
|
|
360
|
+
*/
|
|
361
|
+
let written: { Version?: number };
|
|
165
362
|
try {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
363
|
+
written = await opts.client.send(
|
|
364
|
+
new PutParameterCommand({ Name: name, Value: token, Type: "String", Overwrite: true }),
|
|
365
|
+
);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
if (lockWriteConflict(error)) return null;
|
|
368
|
+
throw error;
|
|
170
369
|
}
|
|
171
|
-
if (
|
|
172
|
-
await tryCreate(true);
|
|
370
|
+
if (written.Version !== expectedVersion) return null;
|
|
173
371
|
}
|
|
174
372
|
return async () => {
|
|
373
|
+
// Inside the hold no other run can hold the lock, whatever token a run
|
|
374
|
+
// that lost the election left in the value: free it. Late in it, a run
|
|
375
|
+
// that found it stale may hold it already; leave it, stale anyway.
|
|
376
|
+
if (now() - takenAt >= Math.max(0, staleMs - LOCK_RELEASE_MARGIN_MS)) return;
|
|
175
377
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (current.Parameter?.Value !== token) return;
|
|
180
|
-
await opts.client.send(new DeleteParameterCommand({ Name: name }));
|
|
378
|
+
await opts.client.send(
|
|
379
|
+
new PutParameterCommand({ Name: name, Value: RELEASED_LOCK, Type: "String", Overwrite: true }),
|
|
380
|
+
);
|
|
181
381
|
} catch {
|
|
182
382
|
/* the stale window covers a failed release */
|
|
183
383
|
}
|
|
184
384
|
};
|
|
185
385
|
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// ADR-154 §7: one named revision, for the exact-name entry point
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
/** One credential's lock: one invoke at a time per credential, whichever
|
|
392
|
+
* revision it names, since an invoke the cloud gave up on may still run. */
|
|
393
|
+
export function revisionLockName(credentialId: string): string {
|
|
394
|
+
return `${BROKER_PARAMETER_ROOT}/locks/${idPathSegment(credentialId)}`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export class SsmRevisionStore implements RevisionStore {
|
|
398
|
+
constructor(
|
|
399
|
+
private readonly opts: { client: SSMClient; kmsKeyId: string; now?: () => number },
|
|
400
|
+
) {}
|
|
401
|
+
|
|
402
|
+
async get(name: string): Promise<StoredParameter | null> {
|
|
403
|
+
try {
|
|
404
|
+
const out = await this.opts.client.send(new GetParameterCommand({ Name: name, WithDecryption: true }));
|
|
405
|
+
const parameter = out.Parameter;
|
|
406
|
+
if (parameter?.Value === undefined || parameter.Version === undefined) return null;
|
|
407
|
+
return { value: parameter.Value, version: parameter.Version };
|
|
408
|
+
} catch (err) {
|
|
409
|
+
if ((err as { name?: string }).name === "ParameterNotFound") return null;
|
|
410
|
+
throw err;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async overwrite(name: string, value: string): Promise<number> {
|
|
415
|
+
const out = await this.opts.client.send(
|
|
416
|
+
new PutParameterCommand({
|
|
417
|
+
Name: name,
|
|
418
|
+
Value: value,
|
|
419
|
+
Type: "SecureString",
|
|
420
|
+
KeyId: this.opts.kmsKeyId,
|
|
421
|
+
Overwrite: true,
|
|
422
|
+
}),
|
|
423
|
+
);
|
|
424
|
+
if (out.Version === undefined) throw new Error("PutParameter returned no version");
|
|
425
|
+
return out.Version;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
lock(credentialId: string): Promise<(() => Promise<void>) | null> {
|
|
429
|
+
return acquireParameterLock({
|
|
430
|
+
client: this.opts.client,
|
|
431
|
+
name: revisionLockName(credentialId),
|
|
432
|
+
now: this.opts.now,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// ADR-154, owner answer 3: adoption of today's workspace AI accounts
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
async function parametersUnder(
|
|
442
|
+
client: SSMClient,
|
|
443
|
+
path: string,
|
|
444
|
+
decrypt: boolean,
|
|
445
|
+
): Promise<Array<{ name: string; value: string }>> {
|
|
446
|
+
const out: Array<{ name: string; value: string }> = [];
|
|
447
|
+
let nextToken: string | undefined;
|
|
448
|
+
do {
|
|
449
|
+
const page = await client.send(
|
|
450
|
+
new GetParametersByPathCommand({ Path: path, Recursive: true, WithDecryption: decrypt, NextToken: nextToken }),
|
|
451
|
+
);
|
|
452
|
+
for (const p of page.Parameters ?? []) {
|
|
453
|
+
if (p.Name && typeof p.Value === "string") out.push({ name: p.Name, value: p.Value });
|
|
454
|
+
}
|
|
455
|
+
nextToken = page.NextToken;
|
|
456
|
+
} while (nextToken);
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** The legacy parameter names (under `prefix`) an adoption marker names. */
|
|
461
|
+
export async function adoptedLegacyNames(client: SSMClient, prefix: string): Promise<Set<string>> {
|
|
462
|
+
const names = new Set<string>();
|
|
463
|
+
const adoptedRoot = legacyAdoptedRoot(prefix);
|
|
464
|
+
for (const marker of await parametersUnder(client, `${adoptedRoot}/orgs`, false)) {
|
|
465
|
+
names.add(`${prefix}${marker.name.slice(adoptedRoot.length)}`);
|
|
466
|
+
}
|
|
467
|
+
return names;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export class SsmAdoptionStore extends SsmRevisionStore implements AdoptionStore {
|
|
471
|
+
constructor(
|
|
472
|
+
private readonly adoption: { client: SSMClient; kmsKeyId: string; prefix: string; now?: () => number },
|
|
473
|
+
) {
|
|
474
|
+
super(adoption);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
get legacyPrefix(): string {
|
|
478
|
+
return this.adoption.prefix;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async create(name: string, value: string, tags: Record<string, string>): Promise<"created" | "matched"> {
|
|
482
|
+
const entries = Object.entries(tags);
|
|
483
|
+
const marker = name.startsWith(`${legacyAdoptedRoot(this.adoption.prefix)}/`);
|
|
484
|
+
try {
|
|
485
|
+
await this.adoption.client.send(
|
|
486
|
+
new PutParameterCommand({
|
|
487
|
+
Name: name,
|
|
488
|
+
Value: value,
|
|
489
|
+
// A marker names ids only; every part is a secret.
|
|
490
|
+
Type: marker ? "String" : "SecureString",
|
|
491
|
+
...(marker ? {} : { KeyId: this.adoption.kmsKeyId }),
|
|
492
|
+
Tier: marker ? "Standard" : "Advanced",
|
|
493
|
+
Overwrite: false,
|
|
494
|
+
Tags: entries.map(([Key, Value]) => ({ Key, Value })),
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
497
|
+
return "created";
|
|
498
|
+
} catch (error) {
|
|
499
|
+
if ((error as { name?: string }).name !== "ParameterAlreadyExists") throw error;
|
|
500
|
+
}
|
|
501
|
+
// Exists: this create's retry only when its identity tags all match.
|
|
502
|
+
const listed = await this.adoption.client.send(
|
|
503
|
+
new ListTagsForResourceCommand({ ResourceType: "Parameter", ResourceId: name }),
|
|
504
|
+
);
|
|
505
|
+
const observed = new Map((listed.TagList ?? []).map((tag) => [tag.Key, tag.Value]));
|
|
506
|
+
for (const [key, expected] of entries) {
|
|
507
|
+
const actual = observed.get(key);
|
|
508
|
+
if (actual === undefined) throw new Error(`${name} exists and its tags are not visible yet`);
|
|
509
|
+
if (actual !== expected) throw new Error(`${name} exists as another credential's`);
|
|
510
|
+
}
|
|
511
|
+
return "matched";
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
listLegacy(): Promise<Array<{ name: string; value: string }>> {
|
|
515
|
+
return parametersUnder(this.adoption.client, legacyEngineParameterRoot(this.adoption.prefix), true);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
listMarkers(): Promise<Array<{ name: string; value: string }>> {
|
|
519
|
+
return parametersUnder(this.adoption.client, `${legacyAdoptedRoot(this.adoption.prefix)}/orgs`, false);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
scanLock(): Promise<(() => Promise<void>) | null> {
|
|
523
|
+
return acquireBrokerLock({ client: this.adoption.client, prefix: this.adoption.prefix, now: this.adoption.now });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type RefreshOutcome,
|
|
20
20
|
type TokenLane,
|
|
21
21
|
} from "./types";
|
|
22
|
+
import { brokerErrorKind, CREDENTIAL_LANE_SCHEDULES } from "../../src/cloud-credentials";
|
|
22
23
|
|
|
23
24
|
export const OPENAI_TOKEN_ENDPOINT = "https://auth.openai.com/oauth/token";
|
|
24
25
|
export const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
@@ -38,7 +39,8 @@ function tokensOf(files: CredentialFiles): {
|
|
|
38
39
|
export const openaiOAuthLane: TokenLane = {
|
|
39
40
|
id: "openai-oauth",
|
|
40
41
|
endpoints: [OPENAI_TOKEN_ENDPOINT],
|
|
41
|
-
|
|
42
|
+
// ADR-154 §7: the cloud names a revision on the same schedule.
|
|
43
|
+
schedule: CREDENTIAL_LANE_SCHEDULES["openai-oauth"]!,
|
|
42
44
|
reactiveSignals: [
|
|
43
45
|
/refresh token was revoked/i,
|
|
44
46
|
/refresh_token_reused/i,
|
|
@@ -81,11 +83,13 @@ export const openaiOAuthLane: TokenLane = {
|
|
|
81
83
|
refresh_token: current,
|
|
82
84
|
scope: "openid profile email",
|
|
83
85
|
});
|
|
84
|
-
} catch (
|
|
86
|
+
} catch (error) {
|
|
85
87
|
return {
|
|
86
88
|
ok: false,
|
|
87
89
|
terminal: false,
|
|
88
|
-
|
|
90
|
+
// A transport error can quote the request (and therefore the refresh
|
|
91
|
+
// token) in its message. Only its bounded kind may cross the lane.
|
|
92
|
+
reason: brokerErrorKind(error),
|
|
89
93
|
};
|
|
90
94
|
}
|
|
91
95
|
if (res.status < 200 || res.status >= 300) {
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type RefreshOutcome,
|
|
26
26
|
type TokenLane,
|
|
27
27
|
} from "./types";
|
|
28
|
+
import { brokerErrorKind, CREDENTIAL_LANE_SCHEDULES } from "../../src/cloud-credentials";
|
|
28
29
|
|
|
29
30
|
export const XAI_ISSUER = "https://auth.x.ai";
|
|
30
31
|
export const XAI_TOKEN_ENDPOINT = `${XAI_ISSUER}/oauth2/token`;
|
|
@@ -60,7 +61,8 @@ export const xaiOAuthLane: TokenLane = {
|
|
|
60
61
|
// Observed 2026-09-09: the token lives SIX hours (create_time → expires_at).
|
|
61
62
|
// Refresh with three hours left — the hourly cycle then renews it at about
|
|
62
63
|
// four hours of age — and every four hours when expiry cannot be read.
|
|
63
|
-
|
|
64
|
+
// ADR-154 §7: the cloud names a revision on the same schedule.
|
|
65
|
+
schedule: CREDENTIAL_LANE_SCHEDULES["xai-oauth"]!,
|
|
64
66
|
reactiveSignals: [/invalid_grant/i, /token (?:has )?expired/i, /log ?in again/i, /unauthorized/i],
|
|
65
67
|
|
|
66
68
|
matches(header: CredentialHeader, files: CredentialFiles): boolean {
|
|
@@ -117,11 +119,13 @@ export const xaiOAuthLane: TokenLane = {
|
|
|
117
119
|
refresh_token: current,
|
|
118
120
|
client_id: clientId,
|
|
119
121
|
});
|
|
120
|
-
} catch (
|
|
122
|
+
} catch (error) {
|
|
121
123
|
return {
|
|
122
124
|
ok: false,
|
|
123
125
|
terminal: false,
|
|
124
|
-
|
|
126
|
+
// A transport error can quote the request (and therefore the refresh
|
|
127
|
+
// token) in its message. Only its bounded kind may cross the lane.
|
|
128
|
+
reason: brokerErrorKind(error),
|
|
125
129
|
};
|
|
126
130
|
}
|
|
127
131
|
if (res.status < 200 || res.status >= 300) {
|
package/lib/transcript.ts
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
* instructions) only — no tool-call execution traces.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
13
|
-
import { resolve } from "node:path";
|
|
12
|
+
import { mkdirSync } from "node:fs";
|
|
14
13
|
|
|
15
14
|
import { taskWorkspaceDir } from "./env";
|
|
16
15
|
import { rewriteAttachmentRefs } from "./orchestrator";
|
|
17
16
|
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
17
|
+
import { appendContainedFileSync, makeContainedDirectoriesSync } from "./task-environment/workspace-files";
|
|
18
18
|
import type { TranscriptTarget } from "../src/protocol";
|
|
19
19
|
|
|
20
20
|
/** Container path agents are pointed at. */
|
|
@@ -43,10 +43,14 @@ export function appendTranscript(
|
|
|
43
43
|
): void {
|
|
44
44
|
const entry = transcriptEntry(author, text);
|
|
45
45
|
if (entry === null) return;
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
// Into the task's own .uai, never through a link the task put there
|
|
47
|
+
// (#cb3j0z). The workspace root is the runner's own path; .uai, directly
|
|
48
|
+
// below it, is made if gone without following a link.
|
|
49
|
+
const workspace = taskWorkspaceDir(taskId);
|
|
50
|
+
mkdirSync(workspace, { recursive: true });
|
|
51
|
+
makeContainedDirectoriesSync(workspace, [".uai"]);
|
|
48
52
|
for (const target of new Set(targets)) {
|
|
49
|
-
|
|
53
|
+
appendContainedFileSync(workspace, [".uai", TARGET_FILENAME[target]], Buffer.from(entry));
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
56
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runuai/host",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.151",
|
|
4
4
|
"description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Uai Tech <team@runuai.com>",
|
|
@@ -57,6 +57,10 @@
|
|
|
57
57
|
"types": "./src/protocol.ts",
|
|
58
58
|
"default": "./src/protocol.ts"
|
|
59
59
|
},
|
|
60
|
+
"./cloud-credentials": {
|
|
61
|
+
"types": "./src/cloud-credentials.ts",
|
|
62
|
+
"default": "./src/cloud-credentials.ts"
|
|
63
|
+
},
|
|
60
64
|
"./project-env-policy": {
|
|
61
65
|
"types": "./src/project-env-policy.ts",
|
|
62
66
|
"default": "./src/project-env-policy.ts"
|
|
@@ -78,6 +82,7 @@
|
|
|
78
82
|
"uai-host": "tsx src/cli.ts"
|
|
79
83
|
},
|
|
80
84
|
"dependencies": {
|
|
85
|
+
"@aws-sdk/client-ssm": "3.899.0",
|
|
81
86
|
"@sentry/node": "^10.66.0",
|
|
82
87
|
"better-sqlite3": "12.11.1",
|
|
83
88
|
"dotenv": "^16.4.5",
|
|
@@ -88,7 +93,6 @@
|
|
|
88
93
|
"zod": "^3.23.8"
|
|
89
94
|
},
|
|
90
95
|
"devDependencies": {
|
|
91
|
-
"@aws-sdk/client-ssm": "3.899.0",
|
|
92
96
|
"@types/node": "^22.9.0",
|
|
93
97
|
"@types/ws": "^8.18.1",
|
|
94
98
|
"@typescript-eslint/eslint-plugin": "^8.59.4",
|