@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,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154, owner answer 3: what Uai Cloud already keeps in Parameter Store
|
|
3
|
+
* under today's layout (ADR-134), its workspaces' AI accounts
|
|
4
|
+
* (`<prefix>/orgs/<org>/engines/<kind>/<id>`), is adopted in place. The cloud
|
|
5
|
+
* lists the accounts through the broker (metadata only: the broker is the
|
|
6
|
+
* one identity that reads those values), opens a revision for each, and
|
|
7
|
+
* names it here. People's MCP connections follow separately, once a fence
|
|
8
|
+
* keeps the v0 task machines that refresh them themselves from doing so.
|
|
9
|
+
*
|
|
10
|
+
* One refresher per grant, always (§7):
|
|
11
|
+
* - before anything is copied, the adoption marker (`<prefix>/adopted/...`) is
|
|
12
|
+
* created under the scan's own lock, so no scan run is refreshing the
|
|
13
|
+
* account meanwhile and none refreshes it again;
|
|
14
|
+
* - the full material becomes the revision's broker-only part and what a
|
|
15
|
+
* task may hold its lease-readable part, both created (never overwritten)
|
|
16
|
+
* with the tags the role policy checks, as a capture's are;
|
|
17
|
+
* - today's parameter then keeps what a task may hold only (the brokered
|
|
18
|
+
* copy): the one refresh token
|
|
19
|
+
* left is the revision's, which only the cloud's scheduler names. Until
|
|
20
|
+
* the fleet audit each refresh of that revision writes the same there,
|
|
21
|
+
* for the runners still reading it (`syncLegacyCopy`).
|
|
22
|
+
*
|
|
23
|
+
* A value that can never be adopted as it is answers `unsupported`, before
|
|
24
|
+
* any marker: it keeps its refresher. A retry after a lost answer finds its
|
|
25
|
+
* own marker and creates, and carries on where it stopped. Nothing here
|
|
26
|
+
* returns or logs material, nor an error's message (only its kind).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
brokerErrorKind,
|
|
31
|
+
brokerParameterName,
|
|
32
|
+
DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
33
|
+
leaseParameterName,
|
|
34
|
+
legacyAdoptedRoot,
|
|
35
|
+
legacyAdoptionMarkerName,
|
|
36
|
+
MAX_LEGACY_ACCOUNTS,
|
|
37
|
+
parseLegacyEngineParameterName,
|
|
38
|
+
parseLegacyParameterName,
|
|
39
|
+
type BrokerAdoptAnswer,
|
|
40
|
+
type BrokerAdoptRequest,
|
|
41
|
+
type BrokerLegacyAccount,
|
|
42
|
+
type BrokerLegacyListAnswer,
|
|
43
|
+
type CredentialLocation,
|
|
44
|
+
type LegacyEngineParameter,
|
|
45
|
+
} from "../src/cloud-credentials";
|
|
46
|
+
import { buildEngineCapture, captureTags, parseCaptureFlow, type EngineCaptureMaterial } from "./capture-flow";
|
|
47
|
+
import type { RevisionStore, StoredParameter } from "./token-broker-revision";
|
|
48
|
+
import { laneFor } from "./token-lanes/registry";
|
|
49
|
+
import type { CredentialFiles, TokenLane } from "./token-lanes/types";
|
|
50
|
+
|
|
51
|
+
/** What adoption needs beyond a refresh: create-only writes, today's
|
|
52
|
+
* accounts and markers, and the scan's lock. */
|
|
53
|
+
export interface AdoptionStore extends RevisionStore {
|
|
54
|
+
/** The exact ADR-134 root this store reads and writes. */
|
|
55
|
+
readonly legacyPrefix: string;
|
|
56
|
+
/**
|
|
57
|
+
* Create a parameter with its identity tags, never overwriting:
|
|
58
|
+
* `matched` when it exists already with exactly these tags (this create,
|
|
59
|
+
* retried). Throws when it exists as anything else.
|
|
60
|
+
*/
|
|
61
|
+
create(name: string, value: string, tags: Record<string, string>): Promise<"created" | "matched">;
|
|
62
|
+
/** Every parameter under today's workspace accounts root, decrypted. */
|
|
63
|
+
listLegacy(): Promise<Array<{ name: string; value: string }>>;
|
|
64
|
+
/** Every adoption marker of a workspace account, by the marker's name. */
|
|
65
|
+
listMarkers(): Promise<Array<{ name: string; value: string }>>;
|
|
66
|
+
/** The scan's lock (one run at a time), or null while a run holds it. */
|
|
67
|
+
scanLock(): Promise<(() => Promise<void>) | null>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** An AI account's value under today's layout, as the shared host wrote it. */
|
|
71
|
+
export interface LegacyEngineValue {
|
|
72
|
+
/** The whole envelope: rewritten with only its files changed. */
|
|
73
|
+
raw: Record<string, unknown>;
|
|
74
|
+
label: string;
|
|
75
|
+
authKind: "env" | "config-dir";
|
|
76
|
+
position: number;
|
|
77
|
+
env: Record<string, string> | null;
|
|
78
|
+
files: CredentialFiles | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stringMap(value: unknown): Record<string, string> | null {
|
|
82
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
83
|
+
const out: Record<string, string> = {};
|
|
84
|
+
for (const [key, text] of Object.entries(value as Record<string, unknown>)) {
|
|
85
|
+
if (typeof text !== "string") return null;
|
|
86
|
+
out[key] = text;
|
|
87
|
+
}
|
|
88
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function jsonObject(value: string): Record<string, unknown> | null {
|
|
92
|
+
try {
|
|
93
|
+
const parsed: unknown = JSON.parse(value);
|
|
94
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Today's value for exactly this account, or null. */
|
|
101
|
+
export function parseLegacyEngineValue(value: string, legacy: LegacyEngineParameter): LegacyEngineValue | null {
|
|
102
|
+
const o = jsonObject(value);
|
|
103
|
+
if (
|
|
104
|
+
!o ||
|
|
105
|
+
o.v !== 1 ||
|
|
106
|
+
o.id !== legacy.accountId ||
|
|
107
|
+
o.kind !== legacy.engineKind ||
|
|
108
|
+
o.scope !== `org:${legacy.orgId}` ||
|
|
109
|
+
typeof o.label !== "string" ||
|
|
110
|
+
o.label.trim().length === 0 ||
|
|
111
|
+
(o.authKind !== "env" && o.authKind !== "config-dir")
|
|
112
|
+
) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const env = o.authKind === "env" ? stringMap(o.env) : null;
|
|
116
|
+
const files = o.authKind === "config-dir" ? stringMap(o.files) : null;
|
|
117
|
+
if (o.authKind === "env" ? env === null : files === null) return null;
|
|
118
|
+
return {
|
|
119
|
+
raw: o,
|
|
120
|
+
label: o.label.trim(),
|
|
121
|
+
authKind: o.authKind,
|
|
122
|
+
position: typeof o.position === "number" && Number.isSafeInteger(o.position) ? o.position : 0,
|
|
123
|
+
env,
|
|
124
|
+
files,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function laneOf(legacy: LegacyEngineParameter, value: LegacyEngineValue): TokenLane | null {
|
|
129
|
+
if (value.authKind !== "config-dir" || !value.files) return null;
|
|
130
|
+
return laneFor(
|
|
131
|
+
{ kind: legacy.engineKind, authKind: "config-dir", id: legacy.accountId, scope: "org" },
|
|
132
|
+
value.files,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether an account's material is one a revision can hold, without a
|
|
137
|
+
* revision: the capture's own checks, on a stand-in destination. */
|
|
138
|
+
function engineUnsupported(legacy: LegacyEngineParameter, value: LegacyEngineValue): string | null {
|
|
139
|
+
const lane = laneOf(legacy, value);
|
|
140
|
+
const location: CredentialLocation = {
|
|
141
|
+
class: "engine",
|
|
142
|
+
scope: "org",
|
|
143
|
+
ownerId: legacy.orgId,
|
|
144
|
+
engineKind: legacy.engineKind,
|
|
145
|
+
credentialId: "adoption-check",
|
|
146
|
+
revision: "adoption-check",
|
|
147
|
+
};
|
|
148
|
+
const flow = parseCaptureFlow({
|
|
149
|
+
credentialId: "adoption-check",
|
|
150
|
+
revision: "adoption-check",
|
|
151
|
+
leaseParameter: leaseParameterName(location),
|
|
152
|
+
brokerParameter: lane ? brokerParameterName(location) : null,
|
|
153
|
+
orgId: legacy.orgId,
|
|
154
|
+
userId: null,
|
|
155
|
+
});
|
|
156
|
+
if (!flow) return "destination";
|
|
157
|
+
const built = buildEngineCapture(flow, material(value));
|
|
158
|
+
return "error" in built ? (value.authKind === "env" ? "env_keys" : "files") : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function material(value: LegacyEngineValue): EngineCaptureMaterial {
|
|
162
|
+
return value.authKind === "env" ? { authKind: "env", env: value.env! } : { authKind: "config-dir", files: value.files! };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function legacyNameOfMarker(markerName: string, prefix: string): string | null {
|
|
166
|
+
const adoptedRoot = legacyAdoptedRoot(prefix);
|
|
167
|
+
if (!markerName.startsWith(`${adoptedRoot}/`)) return null;
|
|
168
|
+
const name = `${prefix}${markerName.slice(adoptedRoot.length)}`;
|
|
169
|
+
return parseLegacyParameterName(name, prefix) ? name : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseMarker(value: string): { credentialId: string; revision: string } | null {
|
|
173
|
+
const o = jsonObject(value);
|
|
174
|
+
return typeof o?.credentialId === "string" && typeof o.revision === "string"
|
|
175
|
+
? { credentialId: o.credentialId, revision: o.revision }
|
|
176
|
+
: null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function sameFiles(a: CredentialFiles, b: CredentialFiles): boolean {
|
|
180
|
+
const keys = Object.keys(a).sort();
|
|
181
|
+
const other = Object.keys(b).sort();
|
|
182
|
+
return keys.length === other.length && keys.every((k, i) => k === other[i] && a[k] === b[k]);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const unavailable = (error: unknown): { outcome: "unavailable"; error: string } => ({
|
|
186
|
+
outcome: "unavailable",
|
|
187
|
+
error: typeof error === "string" ? error : brokerErrorKind(error),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* One page of today's workspace AI accounts, metadata only (never a value),
|
|
192
|
+
* in parameter-name order after `after`: each with its lane, the revision it
|
|
193
|
+
* was adopted into once it was, and why it cannot be adopted as it is.
|
|
194
|
+
*/
|
|
195
|
+
export async function listLegacyAccounts(store: AdoptionStore, after: string | null = null): Promise<BrokerLegacyListAnswer> {
|
|
196
|
+
try {
|
|
197
|
+
const [values, markers] = await Promise.all([store.listLegacy(), store.listMarkers()]);
|
|
198
|
+
const adopted = new Map<string, { credentialId: string; revision: string }>();
|
|
199
|
+
for (const marker of markers) {
|
|
200
|
+
const name = legacyNameOfMarker(marker.name, store.legacyPrefix);
|
|
201
|
+
const named = parseMarker(marker.value);
|
|
202
|
+
if (name && named) adopted.set(name, named);
|
|
203
|
+
}
|
|
204
|
+
const ordered = values
|
|
205
|
+
.filter(
|
|
206
|
+
({ name }) =>
|
|
207
|
+
parseLegacyEngineParameterName(name, store.legacyPrefix) !== null && (after === null || name > after),
|
|
208
|
+
)
|
|
209
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
210
|
+
const accounts: BrokerLegacyAccount[] = [];
|
|
211
|
+
let next: string | null = null;
|
|
212
|
+
for (const { name, value } of ordered) {
|
|
213
|
+
if (accounts.length >= MAX_LEGACY_ACCOUNTS) {
|
|
214
|
+
next = accounts.at(-1)!.parameter;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
const legacy = parseLegacyEngineParameterName(name, store.legacyPrefix)!;
|
|
218
|
+
const parsed = parseLegacyEngineValue(value, legacy);
|
|
219
|
+
accounts.push({
|
|
220
|
+
parameter: name,
|
|
221
|
+
orgId: legacy.orgId,
|
|
222
|
+
engineKind: legacy.engineKind,
|
|
223
|
+
accountId: legacy.accountId,
|
|
224
|
+
label: parsed ? parsed.label.slice(0, 256) : legacy.accountId,
|
|
225
|
+
authKind: parsed?.authKind ?? "config-dir",
|
|
226
|
+
position: parsed?.position ?? 0,
|
|
227
|
+
lane: parsed ? (laneOf(legacy, parsed)?.id ?? null) : null,
|
|
228
|
+
adopted: adopted.get(name) ?? null,
|
|
229
|
+
unsupported: parsed ? engineUnsupported(legacy, parsed) : "value",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return { outcome: "listed", accounts, next };
|
|
233
|
+
} catch (error) {
|
|
234
|
+
return unavailable(error);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Adopt one parameter as the revision the cloud named. */
|
|
239
|
+
export async function adoptLegacyAccount(request: BrokerAdoptRequest, store: AdoptionStore): Promise<BrokerAdoptAnswer> {
|
|
240
|
+
const legacy = parseLegacyParameterName(request.legacyParameter, store.legacyPrefix)!;
|
|
241
|
+
let releaseCredential: (() => Promise<void>) | null = null;
|
|
242
|
+
let releaseScan: (() => Promise<void>) | null = null;
|
|
243
|
+
try {
|
|
244
|
+
releaseCredential = await store.lock(request.credentialId);
|
|
245
|
+
if (!releaseCredential) return { outcome: "busy" };
|
|
246
|
+
// No scan run is refreshing today's parameter while it is copied.
|
|
247
|
+
releaseScan = await store.scanLock();
|
|
248
|
+
if (!releaseScan) return { outcome: "busy" };
|
|
249
|
+
|
|
250
|
+
const current = await store.get(request.legacyParameter);
|
|
251
|
+
if (!current) return { outcome: "absent" };
|
|
252
|
+
const flow = parseCaptureFlow({
|
|
253
|
+
credentialId: request.credentialId,
|
|
254
|
+
revision: request.revision,
|
|
255
|
+
leaseParameter: request.leaseParameter,
|
|
256
|
+
brokerParameter: request.brokerParameter,
|
|
257
|
+
orgId: request.orgId,
|
|
258
|
+
userId: request.userId,
|
|
259
|
+
});
|
|
260
|
+
if (!flow) return { outcome: "unsupported", reason: "destination" };
|
|
261
|
+
|
|
262
|
+
// Built and checked before the marker: what cannot be adopted keeps its
|
|
263
|
+
// refresher.
|
|
264
|
+
const value = parseLegacyEngineValue(current.value, legacy);
|
|
265
|
+
if (!value) return { outcome: "unsupported", reason: "value" };
|
|
266
|
+
const parts = buildEngineCapture(flow, material(value));
|
|
267
|
+
if ("error" in parts) return { outcome: "unsupported", reason: value.authKind === "env" ? "env_keys" : "files" };
|
|
268
|
+
// Today's parameter keeps the brokered copy only.
|
|
269
|
+
let rewrite: string | null = null;
|
|
270
|
+
const lane = laneOf(legacy, value);
|
|
271
|
+
if (lane && parts.broker !== null && value.files) {
|
|
272
|
+
const brokered = lane.brokeredCopy(value.files);
|
|
273
|
+
if (!sameFiles(brokered, value.files)) rewrite = JSON.stringify({ ...value.raw, files: brokered });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 1. The marker: from here nothing refreshes today's parameter.
|
|
277
|
+
const markerName = legacyAdoptionMarkerName(request.legacyParameter, store.legacyPrefix);
|
|
278
|
+
const marker = await store.get(markerName);
|
|
279
|
+
if (marker) {
|
|
280
|
+
const named = parseMarker(marker.value);
|
|
281
|
+
if (!named || named.credentialId !== request.credentialId || named.revision !== request.revision) {
|
|
282
|
+
return unavailable("adopted into another revision");
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
await store.create(
|
|
286
|
+
markerName,
|
|
287
|
+
JSON.stringify({ credentialId: request.credentialId, revision: request.revision }),
|
|
288
|
+
{ uai_credential: request.credentialId, uai_revision: request.revision, uai_part: "adopted" },
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 2. Both parts, broker-only first, as a capture writes them. A retry
|
|
293
|
+
// matches its own creates and never rebuilds the broker part from what
|
|
294
|
+
// step 3 left in today's parameter.
|
|
295
|
+
if (parts.broker !== null) await store.create(request.brokerParameter!, parts.broker, captureTags(flow, "broker"));
|
|
296
|
+
await store.create(request.leaseParameter, parts.lease, captureTags(flow, "lease"));
|
|
297
|
+
|
|
298
|
+
// 3. Today's parameter keeps what a task may hold only.
|
|
299
|
+
if (rewrite !== null) await store.overwrite(request.legacyParameter, rewrite);
|
|
300
|
+
|
|
301
|
+
const lease = await store.get(request.leaseParameter);
|
|
302
|
+
if (!lease) return unavailable("the lease-readable part is missing");
|
|
303
|
+
return { outcome: "adopted", version: lease.version, expiresAt: parts.expiresAt };
|
|
304
|
+
} catch (error) {
|
|
305
|
+
return unavailable(error);
|
|
306
|
+
} finally {
|
|
307
|
+
if (releaseScan) await releaseScan();
|
|
308
|
+
if (releaseCredential) await releaseCredential();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Whether today's parameter of an adopted revision now agrees with it. */
|
|
313
|
+
export type LegacySync = "synced" | "behind";
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Until the fleet audit: keep today's parameter of an adopted AI account
|
|
317
|
+
* agreeing with its revision, the brokered copy only, for the runners still
|
|
318
|
+
* reading it. Never creates it, and never fails the refresh it rides on: it
|
|
319
|
+
* says whether the write landed, and the cloud asks again when it did not.
|
|
320
|
+
*/
|
|
321
|
+
export async function syncLegacyCopy(
|
|
322
|
+
store: Pick<RevisionStore, "get" | "overwrite">,
|
|
323
|
+
legacyParameter: string,
|
|
324
|
+
lane: TokenLane,
|
|
325
|
+
files: CredentialFiles,
|
|
326
|
+
legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
327
|
+
): Promise<LegacySync> {
|
|
328
|
+
const legacy = parseLegacyEngineParameterName(legacyParameter, legacyPrefix);
|
|
329
|
+
if (!legacy) return "synced";
|
|
330
|
+
try {
|
|
331
|
+
const current: StoredParameter | null = await store.get(legacyParameter);
|
|
332
|
+
if (!current) return "synced";
|
|
333
|
+
const value = parseLegacyEngineValue(current.value, legacy);
|
|
334
|
+
if (!value || value.authKind !== "config-dir" || !value.files) return "synced";
|
|
335
|
+
const brokered = lane.brokeredCopy(files);
|
|
336
|
+
if (sameFiles(brokered, value.files)) return "synced";
|
|
337
|
+
await store.overwrite(legacyParameter, JSON.stringify({ ...value.raw, files: brokered }));
|
|
338
|
+
return "synced";
|
|
339
|
+
} catch (error) {
|
|
340
|
+
console.warn(`[token-broker] today's copy of ${legacyParameter} was not updated (${brokerErrorKind(error)})`);
|
|
341
|
+
return "behind";
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
* (parameter names to refresh now). Returns the cycle summary; logs one
|
|
6
6
|
* audit line per decision and never a value.
|
|
7
7
|
*
|
|
8
|
+
* ADR-154 §7: the cloud also invokes it with one revision to refresh
|
|
9
|
+
* (`{ v: 1, credentialId, revision, brokerParameter, leaseParameter, force }`),
|
|
10
|
+
* which never scans and never takes the cycle's lock: one lock per credential.
|
|
11
|
+
* Owner answer 3: `{ v: 1, op: "legacy.list", after }` lists a page of
|
|
12
|
+
* today's workspace accounts (metadata only) and `{ v: 1, op:
|
|
13
|
+
* "legacy.adopt", … }` adopts one account into a revision;
|
|
14
|
+
* an adoption takes its credential's lock and the cycle's.
|
|
15
|
+
*
|
|
8
16
|
* Lives in lib/ (the package import boundary) and is bundled by
|
|
9
17
|
* host-agent/scripts/token-broker/build.sh (esbuild, AWS SDK left external —
|
|
10
18
|
* the Node runtime provides it). Nothing in the host imports it.
|
|
@@ -13,7 +21,16 @@
|
|
|
13
21
|
import { SSMClient } from "@aws-sdk/client-ssm";
|
|
14
22
|
|
|
15
23
|
import { runBrokerCycle } from "./token-broker";
|
|
16
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
26
|
+
parseBrokerAdoptRequest,
|
|
27
|
+
parseBrokerLegacyListRequest,
|
|
28
|
+
parseBrokerRevisionRequest,
|
|
29
|
+
parseCredentialStorePrefix,
|
|
30
|
+
} from "../src/cloud-credentials";
|
|
31
|
+
import { adoptLegacyAccount, listLegacyAccounts } from "./token-broker-adopt";
|
|
32
|
+
import { refreshRevision } from "./token-broker-revision";
|
|
33
|
+
import { acquireBrokerLock, SsmAdoptionStore, SsmBrokerStorage, SsmRevisionStore } from "./token-broker-ssm";
|
|
17
34
|
|
|
18
35
|
interface BrokerEvent {
|
|
19
36
|
names?: unknown;
|
|
@@ -22,8 +39,45 @@ interface BrokerEvent {
|
|
|
22
39
|
const MAX_FORCED_NAMES = 50;
|
|
23
40
|
|
|
24
41
|
export async function handler(event: BrokerEvent = {}): Promise<Record<string, unknown>> {
|
|
25
|
-
const
|
|
42
|
+
const configuredPrefix = process.env.UAI_CREDENTIAL_STORE_PREFIX || DEFAULT_CREDENTIAL_STORE_PREFIX;
|
|
43
|
+
const prefix = parseCredentialStorePrefix(configuredPrefix);
|
|
44
|
+
if (!prefix) throw new Error("invalid UAI_CREDENTIAL_STORE_PREFIX");
|
|
26
45
|
const kmsKeyId = process.env.UAI_CREDENTIAL_STORE_KMS_KEY || "alias/uai-credentials";
|
|
46
|
+
// ADR-154 §7: the cloud names one revision; the broker refreshes exactly
|
|
47
|
+
// that one and answers for it. Anything shaped like a request but not one
|
|
48
|
+
// is refused, never read as a scan.
|
|
49
|
+
if ((event as { v?: unknown }).v !== undefined) {
|
|
50
|
+
// Owner answer 3: today's workspace accounts, listed (metadata only)
|
|
51
|
+
// and adopted one at a time into the revision the cloud names.
|
|
52
|
+
const op = (event as { op?: unknown }).op;
|
|
53
|
+
if (op === "legacy.list") {
|
|
54
|
+
const list = parseBrokerLegacyListRequest(event, prefix);
|
|
55
|
+
if (!list) return { outcome: "unavailable", error: "malformed request" };
|
|
56
|
+
const listed = await listLegacyAccounts(new SsmAdoptionStore({ client: new SSMClient({}), kmsKeyId, prefix }), list.after);
|
|
57
|
+
console.log(
|
|
58
|
+
`[token-broker] legacy.list ${JSON.stringify({ outcome: listed.outcome, accounts: listed.outcome === "listed" ? listed.accounts.length : 0 })}`,
|
|
59
|
+
);
|
|
60
|
+
return listed;
|
|
61
|
+
}
|
|
62
|
+
if (op === "legacy.adopt") {
|
|
63
|
+
const adopt = parseBrokerAdoptRequest(event, prefix);
|
|
64
|
+
if (!adopt) return { outcome: "unavailable", error: "malformed request" };
|
|
65
|
+
const adopted = await adoptLegacyAccount(adopt, new SsmAdoptionStore({ client: new SSMClient({}), kmsKeyId, prefix }));
|
|
66
|
+
console.log(
|
|
67
|
+
`[token-broker] legacy.adopt ${JSON.stringify({ credentialId: adopt.credentialId, revision: adopt.revision, outcome: adopted.outcome })}`,
|
|
68
|
+
);
|
|
69
|
+
return adopted;
|
|
70
|
+
}
|
|
71
|
+
const request = parseBrokerRevisionRequest(event, prefix);
|
|
72
|
+
if (!request) return { outcome: "unavailable", error: "malformed request" };
|
|
73
|
+
const answer = await refreshRevision(request, new SsmRevisionStore({ client: new SSMClient({}), kmsKeyId }), {
|
|
74
|
+
legacyPrefix: prefix,
|
|
75
|
+
});
|
|
76
|
+
console.log(
|
|
77
|
+
`[token-broker] revision ${JSON.stringify({ credentialId: request.credentialId, revision: request.revision, force: request.force, outcome: answer.outcome })}`,
|
|
78
|
+
);
|
|
79
|
+
return answer;
|
|
80
|
+
}
|
|
27
81
|
const names = Array.isArray(event.names)
|
|
28
82
|
? event.names.filter((n): n is string => typeof n === "string" && n.startsWith(`${prefix}/`)).slice(0, MAX_FORCED_NAMES)
|
|
29
83
|
: [];
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* The loop runs ONLY on owned hosts (`configuredCredentialStore() === null`).
|
|
12
12
|
* A Uai-operated host must not refresh — the broker Lambda does — it picks
|
|
13
13
|
* up refreshed parameters instead (credential-store-pickup.ts).
|
|
14
|
+
*
|
|
15
|
+
* ADR-154 §7: and only while the runner is on its own credentials. No runner
|
|
16
|
+
* refreshes once it leaves them: the loop stops, a cycle under way ends, and
|
|
17
|
+
* no reactive request refreshes or invokes the broker Lambda (a leased
|
|
18
|
+
* account's refresh goes through the cloud).
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
21
|
import {
|
|
@@ -27,6 +32,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
27
32
|
import { join } from "node:path";
|
|
28
33
|
import { spawn } from "node:child_process";
|
|
29
34
|
|
|
35
|
+
import { onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
|
|
30
36
|
import { configuredCredentialStore, credentialStorePrefix } from "./credential-store";
|
|
31
37
|
import { getDb, schema } from "./db";
|
|
32
38
|
import {
|
|
@@ -212,6 +218,8 @@ function log(line: string): void {
|
|
|
212
218
|
*/
|
|
213
219
|
export async function runLocalBrokerCycle(opts: { force?: ReadonlySet<string>; seams?: LocalBrokerSeams } = {}): Promise<void> {
|
|
214
220
|
const work = async (): Promise<void> => {
|
|
221
|
+
// A cycle queued behind the one leaving waits for refreshes nothing.
|
|
222
|
+
if (!onOwnCredentials()) return;
|
|
215
223
|
const storage = new LocalBrokerStorage(opts.seams);
|
|
216
224
|
const refreshedNames = new Set<string>();
|
|
217
225
|
const summary = await runBrokerCycle({
|
|
@@ -244,9 +252,10 @@ export async function runLocalBrokerCycle(opts: { force?: ReadonlySet<string>; s
|
|
|
244
252
|
}
|
|
245
253
|
}
|
|
246
254
|
|
|
247
|
-
/** Owned hosts only: refresh now, then hourly.
|
|
255
|
+
/** Owned hosts on their own credentials only: refresh now, then hourly.
|
|
256
|
+
* Idempotent. */
|
|
248
257
|
export function startTokenBrokerLoop(): void {
|
|
249
|
-
if (timer || !ownedHostRefreshes()) return;
|
|
258
|
+
if (timer || !ownedHostRefreshes() || !onOwnCredentials()) return;
|
|
250
259
|
void runLocalBrokerCycle().catch((err) => log(`cycle failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
251
260
|
timer = setInterval(() => {
|
|
252
261
|
void runLocalBrokerCycle().catch((err) => log(`cycle failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
@@ -254,9 +263,12 @@ export function startTokenBrokerLoop(): void {
|
|
|
254
263
|
timer.unref?.();
|
|
255
264
|
}
|
|
256
265
|
|
|
257
|
-
|
|
266
|
+
/** Stop the loop at once (ADR-154 §7: the runner leaves its own
|
|
267
|
+
* credentials); resolves once a cycle under way has ended. */
|
|
268
|
+
export async function stopTokenBrokerLoop(): Promise<void> {
|
|
258
269
|
if (timer) clearInterval(timer);
|
|
259
270
|
timer = null;
|
|
271
|
+
while (running !== null) await running.catch(() => undefined);
|
|
260
272
|
}
|
|
261
273
|
|
|
262
274
|
/** Whether an engine's error output means its access token is dead now. */
|
|
@@ -275,6 +287,18 @@ export async function requestCredentialRefresh(input: {
|
|
|
275
287
|
kind: string;
|
|
276
288
|
accountId: string | null;
|
|
277
289
|
scope: string;
|
|
290
|
+
}): Promise<boolean> {
|
|
291
|
+
// ADR-154 §7: off its own credentials the runner refreshes nothing and
|
|
292
|
+
// invokes no broker; the cloud refreshes, through the task's lease. A
|
|
293
|
+
// request on them is counted whole (its cycle or its Lambda invocation,
|
|
294
|
+
// and the pickup after): leaving them waits for it.
|
|
295
|
+
return (await whileOnOwnCredentials(() => refreshOnOwnCredentials(input))) ?? false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function refreshOnOwnCredentials(input: {
|
|
299
|
+
kind: string;
|
|
300
|
+
accountId: string | null;
|
|
301
|
+
scope: string;
|
|
278
302
|
}): Promise<boolean> {
|
|
279
303
|
const key = `${input.kind}:${input.accountId ?? "default"}`;
|
|
280
304
|
const now = Date.now();
|
|
@@ -306,7 +330,7 @@ export function setPickupNow(fn: (() => Promise<void>) | null): void {
|
|
|
306
330
|
}
|
|
307
331
|
|
|
308
332
|
/** `aws lambda invoke` on the broker function named by UAI_TOKEN_BROKER_FUNCTION. */
|
|
309
|
-
|
|
333
|
+
async function invokeBrokerLambda(names: string[]): Promise<boolean> {
|
|
310
334
|
const fn = env.UAI_TOKEN_BROKER_FUNCTION;
|
|
311
335
|
const region = env.UAI_AWS_REGION;
|
|
312
336
|
if (!fn || !region) {
|