@runuai/host 0.9.112 → 0.9.114
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/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +6 -0
- package/lib/codex-auth.ts +79 -3
- package/lib/credential-store-pickup.ts +135 -0
- package/lib/credential-store.ts +77 -0
- package/lib/engine-accounts.ts +137 -20
- package/lib/env.ts +3 -0
- package/lib/github-tokens.ts +117 -1
- package/lib/mcp-connections.ts +138 -4
- package/lib/orchestrator.ts +52 -0
- package/lib/ssh.ts +91 -0
- package/lib/token-broker-lambda.ts +51 -0
- package/lib/token-broker-local.ts +368 -0
- package/lib/token-broker-ssm.ts +185 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +119 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +161 -0
- package/lib/user-credentials-store.ts +310 -0
- package/package.json +3 -1
- package/scripts/agent/task-up.sh +31 -1
- package/src/main.ts +31 -1
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- ADR-134 slice 2 (host 0.9.113): per-user credentials mirror to the
|
|
2
|
+
-- credential store on Uai-operated hosts. Null on owned hosts and before the
|
|
3
|
+
-- first successful mirror.
|
|
4
|
+
ALTER TABLE `host_github_tokens` ADD `store_ref` text;--> statement-breakpoint
|
|
5
|
+
ALTER TABLE `host_ssh_keys` ADD `store_ref` text;--> statement-breakpoint
|
|
6
|
+
ALTER TABLE `host_mcp_connections` ADD `store_ref` text;
|
|
@@ -141,6 +141,13 @@
|
|
|
141
141
|
"when": 1788900000001,
|
|
142
142
|
"tag": "0019_engine_account_store_ref",
|
|
143
143
|
"breakpoints": true
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"idx": 20,
|
|
147
|
+
"version": "6",
|
|
148
|
+
"when": 1788900000002,
|
|
149
|
+
"tag": "0020_user_credential_store_ref",
|
|
150
|
+
"breakpoints": true
|
|
144
151
|
}
|
|
145
152
|
]
|
|
146
153
|
}
|
package/db/schema.ts
CHANGED
|
@@ -129,6 +129,8 @@ export const githubTokens = sqliteTable("host_github_tokens", {
|
|
|
129
129
|
// "access" (non-expiring, ADR-033) | "refresh" (expiring, ADR-027).
|
|
130
130
|
kind: text("kind").notNull().default("refresh"),
|
|
131
131
|
updatedAt: integer("updated_at").notNull(),
|
|
132
|
+
// ADR-134 slice 2: the credential-store parameter this row mirrors to.
|
|
133
|
+
storeRef: text("store_ref"),
|
|
132
134
|
});
|
|
133
135
|
|
|
134
136
|
export type GithubToken = typeof githubTokens.$inferSelect;
|
|
@@ -158,6 +160,8 @@ export const sshKeys = sqliteTable("host_ssh_keys", {
|
|
|
158
160
|
privateKeyNonce: blob("private_key_nonce").notNull(),
|
|
159
161
|
createdAt: integer("created_at").notNull(),
|
|
160
162
|
updatedAt: integer("updated_at").notNull(),
|
|
163
|
+
// ADR-134 slice 2: the credential-store parameter this row mirrors to.
|
|
164
|
+
storeRef: text("store_ref"),
|
|
161
165
|
});
|
|
162
166
|
|
|
163
167
|
export type SshKey = typeof sshKeys.$inferSelect;
|
|
@@ -253,6 +257,8 @@ export const mcpConnections = sqliteTable("host_mcp_connections", {
|
|
|
253
257
|
status: text("status").notNull().default("pending"), // pending|connected
|
|
254
258
|
scopes: text("scopes"),
|
|
255
259
|
updatedAt: integer("updated_at", { mode: "number" }).notNull(),
|
|
260
|
+
// ADR-134 slice 2: the credential-store parameter this row mirrors to.
|
|
261
|
+
storeRef: text("store_ref"),
|
|
256
262
|
});
|
|
257
263
|
|
|
258
264
|
export type McpConnection = typeof mcpConnections.$inferSelect;
|
package/lib/codex-auth.ts
CHANGED
|
@@ -53,14 +53,15 @@
|
|
|
53
53
|
* this targets ALL running tasks — matching task-up, which copies it into every
|
|
54
54
|
* container regardless of roster.
|
|
55
55
|
*/
|
|
56
|
-
import { existsSync, watch } from "node:fs";
|
|
57
|
-
import { homedir } from "node:os";
|
|
58
|
-
import { join } from "node:path";
|
|
56
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
57
|
+
import { homedir, tmpdir } from "node:os";
|
|
58
|
+
import { dirname, join } from "node:path";
|
|
59
59
|
|
|
60
60
|
import { isNull } from "drizzle-orm";
|
|
61
61
|
|
|
62
62
|
import { getDb, schema } from "./db";
|
|
63
63
|
import { dockerCli } from "./docker-exec";
|
|
64
|
+
import { brokeredFilesFor } from "./token-broker";
|
|
64
65
|
import {
|
|
65
66
|
listRunningTaskAppContainerNames,
|
|
66
67
|
taskAppContainerName,
|
|
@@ -90,6 +91,8 @@ export interface CodexDeps {
|
|
|
90
91
|
/** DB-active task containers — may include stopped ones; docker decides. */
|
|
91
92
|
activeTaskContainers?: () => string[];
|
|
92
93
|
fileExists?: (path: string) => boolean;
|
|
94
|
+
/** Reads the host's auth.json for brokering (tests inject; never the real one). */
|
|
95
|
+
readFile?: (path: string) => string;
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
function ownerCodexDir(): string {
|
|
@@ -176,6 +179,32 @@ async function copyCodexInto(
|
|
|
176
179
|
for (const item of items) {
|
|
177
180
|
const src = join(dir, item);
|
|
178
181
|
if (!exists(src)) continue;
|
|
182
|
+
// ADR-135: the task's copy never carries the rotating refresh token — N
|
|
183
|
+
// copies racing to refresh it revoke the whole family. cp needs a real
|
|
184
|
+
// file, so write the brokered variant to a private temp file and cp that.
|
|
185
|
+
if (item === "auth.json") {
|
|
186
|
+
let brokered: string | null = null;
|
|
187
|
+
try {
|
|
188
|
+
const raw = (deps.readFile ?? ((p: string) => readFileSync(p, "utf8")))(src);
|
|
189
|
+
const out = brokeredFilesFor(
|
|
190
|
+
{ kind: "codex", authKind: "config-dir", id: "default", scope: "host" },
|
|
191
|
+
{ "auth.json": raw },
|
|
192
|
+
);
|
|
193
|
+
brokered = out["auth.json"] !== raw ? out["auth.json"]! : null;
|
|
194
|
+
} catch {
|
|
195
|
+
brokered = null;
|
|
196
|
+
}
|
|
197
|
+
if (brokered !== null) {
|
|
198
|
+
const tmp = join(mkdtempSync(join(tmpdir(), "uai-codex-auth-")), "auth.json");
|
|
199
|
+
writeFileSync(tmp, brokered, { mode: 0o600 });
|
|
200
|
+
try {
|
|
201
|
+
await must(["cp", tmp, `${container}:/home/node/.codex/auth.json`], "docker cp auth.json (brokered)");
|
|
202
|
+
} finally {
|
|
203
|
+
rmSync(dirname(tmp), { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
179
208
|
await must(["cp", src, `${container}:/home/node/.codex/`], `docker cp ${item}`);
|
|
180
209
|
}
|
|
181
210
|
await must(
|
|
@@ -356,3 +385,50 @@ export function stopWatchingCodexAuth(): void {
|
|
|
356
385
|
debounceTimer = null;
|
|
357
386
|
}
|
|
358
387
|
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* ADR-135: re-push the host's default Grok sign-in (`~/.grok/auth.json`,
|
|
391
|
+
* brokered) into every running task, after the owned-host loop refreshed it.
|
|
392
|
+
* Same sweep shape as the Codex reinject; only auth.json travels.
|
|
393
|
+
*/
|
|
394
|
+
export async function reinjectGrokRunningTasks(deps: CodexDeps = {}): Promise<void> {
|
|
395
|
+
const exists = deps.fileExists ?? existsSync;
|
|
396
|
+
const src = join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".grok", "auth.json");
|
|
397
|
+
if (!exists(src)) return;
|
|
398
|
+
const active = (deps.activeTaskContainers ?? defaultActiveTaskContainers)();
|
|
399
|
+
if (active.length === 0) return;
|
|
400
|
+
const running = await dockerRunningNames(deps.exec ?? defaultExec);
|
|
401
|
+
if (running === null) return;
|
|
402
|
+
const exec = deps.exec ?? defaultExec;
|
|
403
|
+
let brokered: string;
|
|
404
|
+
try {
|
|
405
|
+
const raw = readFileSync(src, "utf8");
|
|
406
|
+
brokered = brokeredFilesFor(
|
|
407
|
+
{ kind: "grok", authKind: "config-dir", id: "default", scope: "host" },
|
|
408
|
+
{ "auth.json": raw },
|
|
409
|
+
)["auth.json"]!;
|
|
410
|
+
} catch {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const dir = mkdtempSync(join(tmpdir(), "uai-grok-auth-"));
|
|
414
|
+
const tmp = join(dir, "auth.json");
|
|
415
|
+
try {
|
|
416
|
+
writeFileSync(tmp, brokered, { mode: 0o600 });
|
|
417
|
+
for (const container of active.filter((c) => running.has(c))) {
|
|
418
|
+
const steps: Array<[string[], string]> = [
|
|
419
|
+
[["exec", "-u", "root", ...runtimeAuthorityDockerExecArgs(), container, "/bin/mkdir", "-p", "/home/node/.grok"], "mkdir"],
|
|
420
|
+
[["cp", tmp, `${container}:/home/node/.grok/auth.json`], "docker cp auth.json (brokered)"],
|
|
421
|
+
[["exec", "-u", "root", ...runtimeAuthorityDockerExecArgs(), container, "/bin/chown", "-R", "node:node", "/home/node/.grok"], "chown"],
|
|
422
|
+
];
|
|
423
|
+
for (const [args, step] of steps) {
|
|
424
|
+
const res = await exec(args);
|
|
425
|
+
if (res.status !== 0) {
|
|
426
|
+
console.error(`[grok] reinject into ${container}: ${step} failed (exit ${res.status ?? "none"})`);
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} finally {
|
|
432
|
+
rmSync(dir, { recursive: true, force: true });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uai-operated hosts: pick up credentials the broker Lambda refreshed
|
|
3
|
+
* (ADR-135). Every 15 minutes list the engine parameters under this host's
|
|
4
|
+
* scopes — names and versions only, no decryption — and re-import whatever
|
|
5
|
+
* moved, then re-push brokered copies of that kind into running tasks. The
|
|
6
|
+
* reactive path calls `pickupNow` right after a forced refresh.
|
|
7
|
+
*
|
|
8
|
+
* Never refreshes anything: on a Uai-operated host the Lambda is the one
|
|
9
|
+
* refresher. Owned hosts never run this (no store).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { configuredCredentialStore, credentialStorePrefix } from "./credential-store";
|
|
13
|
+
import {
|
|
14
|
+
applyStoredEngineAccount,
|
|
15
|
+
engineAccountHostId,
|
|
16
|
+
parseEngineAccountParameterName,
|
|
17
|
+
} from "./engine-accounts";
|
|
18
|
+
import { setPickupNow } from "./token-broker-local";
|
|
19
|
+
|
|
20
|
+
const PICKUP_INTERVAL_MS = 15 * 60_000;
|
|
21
|
+
|
|
22
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
23
|
+
let known: Map<string, number> | null = null;
|
|
24
|
+
let inflight: Promise<PickupSummary> | null = null;
|
|
25
|
+
let repushHook: ((kind: string) => Promise<void>) | null = null;
|
|
26
|
+
|
|
27
|
+
export interface PickupSummary {
|
|
28
|
+
skipped: boolean;
|
|
29
|
+
listed: number;
|
|
30
|
+
changed: number;
|
|
31
|
+
applied: number;
|
|
32
|
+
errors: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function log(line: string): void {
|
|
36
|
+
console.log(`[credential-pickup] ${line}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One pass. The first pass records the baseline (the start-up reconcile
|
|
41
|
+
* already imported current values); later passes apply changed versions.
|
|
42
|
+
*/
|
|
43
|
+
export async function pickupChangedCredentials(): Promise<PickupSummary> {
|
|
44
|
+
const store = configuredCredentialStore();
|
|
45
|
+
const summary: PickupSummary = { skipped: false, listed: 0, changed: 0, applied: 0, errors: 0 };
|
|
46
|
+
if (!store) return { ...summary, skipped: true };
|
|
47
|
+
const prefix = credentialStorePrefix();
|
|
48
|
+
const hostId = engineAccountHostId();
|
|
49
|
+
const paths = [`${prefix}/orgs`, ...(hostId ? [`${prefix}/hosts/${hostId}`] : [])];
|
|
50
|
+
const current = new Map<string, number>();
|
|
51
|
+
for (const path of paths) {
|
|
52
|
+
try {
|
|
53
|
+
for (const entry of await store.listVersions(path)) {
|
|
54
|
+
if (parseEngineAccountParameterName(entry.name, prefix)) current.set(entry.name, entry.version);
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
summary.errors++;
|
|
58
|
+
log(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
59
|
+
// An incomplete listing must not look like deletions; keep the baseline.
|
|
60
|
+
return summary;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
summary.listed = current.size;
|
|
64
|
+
if (known === null) {
|
|
65
|
+
known = current;
|
|
66
|
+
return summary;
|
|
67
|
+
}
|
|
68
|
+
const kinds = new Set<string>();
|
|
69
|
+
for (const [name, version] of current) {
|
|
70
|
+
if (known.get(name) === version) continue;
|
|
71
|
+
summary.changed++;
|
|
72
|
+
try {
|
|
73
|
+
const value = await store.get(name);
|
|
74
|
+
if (value === null) continue;
|
|
75
|
+
const applied = applyStoredEngineAccount(name, value);
|
|
76
|
+
if (applied) {
|
|
77
|
+
summary.applied++;
|
|
78
|
+
kinds.add(applied.kind);
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
summary.errors++;
|
|
82
|
+
log(`applying ${name} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
known.set(name, version);
|
|
86
|
+
}
|
|
87
|
+
for (const name of [...known.keys()]) if (!current.has(name)) known.delete(name);
|
|
88
|
+
for (const kind of kinds) {
|
|
89
|
+
try {
|
|
90
|
+
await repushHook?.(kind);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log(`re-push of ${kind} copies failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (summary.changed > 0) {
|
|
96
|
+
log(`${summary.changed} parameter(s) changed, ${summary.applied} applied, ${summary.errors} errors`);
|
|
97
|
+
}
|
|
98
|
+
return summary;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function pickupSerialized(): Promise<PickupSummary> {
|
|
102
|
+
if (inflight) return inflight;
|
|
103
|
+
inflight = pickupChangedCredentials().finally(() => {
|
|
104
|
+
inflight = null;
|
|
105
|
+
});
|
|
106
|
+
return inflight;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Uai-operated hosts only: baseline now, then poll every 15 minutes. */
|
|
110
|
+
export function startCredentialStorePickup(hooks: { repush: (kind: string) => Promise<void> }): void {
|
|
111
|
+
if (timer || !configuredCredentialStore()) return;
|
|
112
|
+
repushHook = hooks.repush;
|
|
113
|
+
setPickupNow(async () => {
|
|
114
|
+
await pickupSerialized();
|
|
115
|
+
});
|
|
116
|
+
void pickupSerialized().catch((err) =>
|
|
117
|
+
log(`baseline failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
118
|
+
);
|
|
119
|
+
timer = setInterval(() => {
|
|
120
|
+
void pickupSerialized().catch((err) =>
|
|
121
|
+
log(`poll failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
122
|
+
);
|
|
123
|
+
}, PICKUP_INTERVAL_MS);
|
|
124
|
+
timer.unref?.();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Tests. */
|
|
128
|
+
export function resetCredentialStorePickup(): void {
|
|
129
|
+
if (timer) clearInterval(timer);
|
|
130
|
+
timer = null;
|
|
131
|
+
known = null;
|
|
132
|
+
inflight = null;
|
|
133
|
+
repushHook = null;
|
|
134
|
+
setPickupNow(null);
|
|
135
|
+
}
|
package/lib/credential-store.ts
CHANGED
|
@@ -45,6 +45,9 @@ export interface CredentialStore {
|
|
|
45
45
|
delete(name: string): Promise<void>;
|
|
46
46
|
/** Every credential under `path` (recursive), decrypted. Throws on failure. */
|
|
47
47
|
list(path: string): Promise<StoredParameter[]>;
|
|
48
|
+
/** Names and versions under `path` (recursive), NO decryption — the cheap
|
|
49
|
+
* poll a Uai-operated host runs to notice a broker refresh. */
|
|
50
|
+
listVersions(path: string): Promise<Array<{ name: string; version: number }>>;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
export interface CliResult {
|
|
@@ -235,6 +238,80 @@ export class AwsSsmCredentialStore implements CredentialStore {
|
|
|
235
238
|
}
|
|
236
239
|
return out;
|
|
237
240
|
}
|
|
241
|
+
|
|
242
|
+
async listVersions(path: string): Promise<Array<{ name: string; version: number }>> {
|
|
243
|
+
if (!isParameterName(path)) throw new Error(`invalid parameter path: ${path}`);
|
|
244
|
+
const out: Array<{ name: string; version: number }> = [];
|
|
245
|
+
let nextToken: string | undefined;
|
|
246
|
+
for (let page = 0; page < 100; page++) {
|
|
247
|
+
const result = await this.run([
|
|
248
|
+
"ssm",
|
|
249
|
+
"get-parameters-by-path",
|
|
250
|
+
"--path",
|
|
251
|
+
path,
|
|
252
|
+
"--recursive",
|
|
253
|
+
"--query",
|
|
254
|
+
"{Parameters: Parameters[].{Name: Name, Version: Version}, NextToken: NextToken}",
|
|
255
|
+
...(nextToken ? ["--next-token", nextToken] : []),
|
|
256
|
+
]);
|
|
257
|
+
if (result.status !== 0) throw failure("get-parameters-by-path", result);
|
|
258
|
+
const parsed = JSON.parse(result.stdout) as {
|
|
259
|
+
Parameters?: Array<{ Name?: unknown; Version?: unknown }> | null;
|
|
260
|
+
NextToken?: unknown;
|
|
261
|
+
};
|
|
262
|
+
for (const p of parsed.Parameters ?? []) {
|
|
263
|
+
if (typeof p.Name === "string" && typeof p.Version === "number") {
|
|
264
|
+
out.push({ name: p.Name, version: p.Version });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (typeof parsed.NextToken === "string" && parsed.NextToken) nextToken = parsed.NextToken;
|
|
268
|
+
else break;
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// (listVersions lives on the class below.)
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Write queue shared by every mirrored table (engine accounts, GitHub tokens,
|
|
278
|
+
// SSH keys, MCP connections). Mirrors are async and best effort: a store
|
|
279
|
+
// outage delays a mirror, never a task-up or a UI action.
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
const inflight = new Set<Promise<void>>();
|
|
283
|
+
|
|
284
|
+
/** Run `work` against the configured store off the caller's path; a no-op on
|
|
285
|
+
* owned hosts. Failures are logged (never the value) and never thrown. */
|
|
286
|
+
export function queueStoreWrite(
|
|
287
|
+
what: string,
|
|
288
|
+
work: (store: CredentialStore) => Promise<void>,
|
|
289
|
+
): void {
|
|
290
|
+
const store = configuredCredentialStore();
|
|
291
|
+
if (!store) return;
|
|
292
|
+
const p: Promise<void> = work(store)
|
|
293
|
+
.catch((err: unknown) => {
|
|
294
|
+
console.warn(
|
|
295
|
+
`[credential-store] ${what} failed: ${
|
|
296
|
+
err instanceof Error ? err.message : String(err)
|
|
297
|
+
}`,
|
|
298
|
+
);
|
|
299
|
+
})
|
|
300
|
+
.finally(() => {
|
|
301
|
+
inflight.delete(p);
|
|
302
|
+
});
|
|
303
|
+
inflight.add(p);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Tests / shutdown: wait for every queued mirror or delete to settle. */
|
|
307
|
+
export async function settleStoreWrites(): Promise<void> {
|
|
308
|
+
while (inflight.size > 0) await Promise.allSettled([...inflight]);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** `<prefix>/users/<userId>/<…>` — the per-user subtree (GitHub token, SSH
|
|
312
|
+
* key, MCP connections). The STS `user` session tag scopes it later. */
|
|
313
|
+
export function userParameterName(userId: string, ...segments: string[]): string {
|
|
314
|
+
return parameterName(credentialStorePrefix(), "users", userId, ...segments);
|
|
238
315
|
}
|
|
239
316
|
|
|
240
317
|
// ---------------------------------------------------------------------------
|
package/lib/engine-accounts.ts
CHANGED
|
@@ -31,12 +31,15 @@ import {
|
|
|
31
31
|
chmodSync,
|
|
32
32
|
existsSync,
|
|
33
33
|
mkdirSync,
|
|
34
|
+
mkdtempSync,
|
|
34
35
|
readdirSync,
|
|
35
36
|
readFileSync,
|
|
37
|
+
renameSync,
|
|
36
38
|
rmSync,
|
|
39
|
+
statSync,
|
|
37
40
|
writeFileSync,
|
|
38
41
|
} from "node:fs";
|
|
39
|
-
import { homedir } from "node:os";
|
|
42
|
+
import { homedir, tmpdir } from "node:os";
|
|
40
43
|
import { join } from "node:path";
|
|
41
44
|
|
|
42
45
|
import { eq } from "drizzle-orm";
|
|
@@ -46,9 +49,12 @@ import {
|
|
|
46
49
|
credentialStorePrefix,
|
|
47
50
|
isParameterPathSegment,
|
|
48
51
|
parameterName,
|
|
52
|
+
queueStoreWrite,
|
|
53
|
+
settleStoreWrites,
|
|
49
54
|
type CredentialStore,
|
|
50
55
|
} from "./credential-store";
|
|
51
56
|
import { getDb, schema } from "./db";
|
|
57
|
+
import { brokeredFilesFor } from "./token-broker";
|
|
52
58
|
import {
|
|
53
59
|
taskContainerBackend,
|
|
54
60
|
taskContainerCli,
|
|
@@ -548,11 +554,88 @@ export function resetRotationState(): void {
|
|
|
548
554
|
// dirs at session start, since task-up.sh (bash) can't read the sealed DB.
|
|
549
555
|
// ---------------------------------------------------------------------------
|
|
550
556
|
|
|
557
|
+
/**
|
|
558
|
+
* ADR-135: what a task receives for a config-dir account. The host dir is
|
|
559
|
+
* copied verbatim EXCEPT that a lane-owned `auth.json` gets its rotating
|
|
560
|
+
* refresh token replaced by the broker placeholder, and the broker's own
|
|
561
|
+
* sidecar never travels. Returns a private temp dir (0700) the caller copies
|
|
562
|
+
* from and must remove; null means "copy the host dir as it is".
|
|
563
|
+
*/
|
|
564
|
+
export function stageBrokeredAccountDir(
|
|
565
|
+
kind: string,
|
|
566
|
+
hostDir: string,
|
|
567
|
+
): { dir: string; cleanup: () => void } | null {
|
|
568
|
+
let entries: string[];
|
|
569
|
+
try {
|
|
570
|
+
entries = readdirSync(hostDir);
|
|
571
|
+
} catch {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
const files: Record<string, string> = {};
|
|
575
|
+
const binaries = new Map<string, Buffer>();
|
|
576
|
+
for (const name of entries) {
|
|
577
|
+
if (name === BROKER_SIDECAR_FILE) continue;
|
|
578
|
+
const path = join(hostDir, name);
|
|
579
|
+
let content: Buffer;
|
|
580
|
+
try {
|
|
581
|
+
if (!statSync(path).isFile()) continue;
|
|
582
|
+
content = readFileSync(path);
|
|
583
|
+
} catch {
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (name === "auth.json") files[name] = content.toString("utf8");
|
|
587
|
+
else binaries.set(name, content);
|
|
588
|
+
}
|
|
589
|
+
if (!("auth.json" in files)) return null;
|
|
590
|
+
const brokered = brokeredFilesFor(
|
|
591
|
+
{ kind, authKind: "config-dir", id: "staging", scope: HOST_ACCOUNT_SCOPE },
|
|
592
|
+
files,
|
|
593
|
+
);
|
|
594
|
+
if (brokered === files) return null;
|
|
595
|
+
const dir = mkdtempSync(join(tmpdir(), "uai-brokered-"));
|
|
596
|
+
try {
|
|
597
|
+
chmodSync(dir, 0o700);
|
|
598
|
+
for (const [name, content] of Object.entries(brokered)) {
|
|
599
|
+
writeFileSync(join(dir, name), content, { mode: 0o600 });
|
|
600
|
+
}
|
|
601
|
+
for (const [name, content] of binaries) {
|
|
602
|
+
writeFileSync(join(dir, name), content, { mode: 0o600 });
|
|
603
|
+
}
|
|
604
|
+
} catch (err) {
|
|
605
|
+
rmSync(dir, { recursive: true, force: true });
|
|
606
|
+
throw err;
|
|
607
|
+
}
|
|
608
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
609
|
+
}
|
|
610
|
+
|
|
551
611
|
async function copyDirInto(
|
|
552
612
|
hostDir: string,
|
|
553
613
|
containerDir: string,
|
|
554
614
|
container: string,
|
|
555
615
|
environment?: Pick<TaskEnvironmentHandle, "exec" | "copy">,
|
|
616
|
+
kind?: string,
|
|
617
|
+
): Promise<void> {
|
|
618
|
+
// ADR-135: never hand a task the rotating refresh token.
|
|
619
|
+
const staged = kind ? stageBrokeredAccountDir(kind, hostDir) : null;
|
|
620
|
+
if (staged) {
|
|
621
|
+
try {
|
|
622
|
+
await copyDirIntoRaw(staged.dir, containerDir, container, environment);
|
|
623
|
+
} finally {
|
|
624
|
+
staged.cleanup();
|
|
625
|
+
}
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
await copyDirIntoRaw(hostDir, containerDir, container, environment);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** The broker's per-dir metadata (docs/token-broker.md); never copied. */
|
|
632
|
+
export const BROKER_SIDECAR_FILE = "uai-broker.json";
|
|
633
|
+
|
|
634
|
+
async function copyDirIntoRaw(
|
|
635
|
+
hostDir: string,
|
|
636
|
+
containerDir: string,
|
|
637
|
+
container: string,
|
|
638
|
+
environment?: Pick<TaskEnvironmentHandle, "exec" | "copy">,
|
|
556
639
|
): Promise<void> {
|
|
557
640
|
if (environment) {
|
|
558
641
|
const run = async (argv: [string, ...string[]], step: string) => {
|
|
@@ -680,6 +763,7 @@ export async function provisionEngineAccounts(
|
|
|
680
763
|
account.configDir.containerDir,
|
|
681
764
|
container,
|
|
682
765
|
environment,
|
|
766
|
+
kind,
|
|
683
767
|
);
|
|
684
768
|
copied += 1;
|
|
685
769
|
} catch (err) {
|
|
@@ -1127,16 +1211,19 @@ type StoreRow = {
|
|
|
1127
1211
|
|
|
1128
1212
|
let storeHostId: string | null = null;
|
|
1129
1213
|
let warnedNoHostId = false;
|
|
1130
|
-
const inflight = new Set<Promise<void>>();
|
|
1131
1214
|
|
|
1132
1215
|
/** main.ts: the host id that names `host`-scope parameters. */
|
|
1133
1216
|
export function configureEngineAccountStore(opts: { hostId: string | null }): void {
|
|
1134
1217
|
storeHostId = opts.hostId;
|
|
1135
1218
|
}
|
|
1136
1219
|
|
|
1220
|
+
export function engineAccountHostId(): string | null {
|
|
1221
|
+
return storeHostId;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1137
1224
|
/** Tests: wait for every queued mirror/delete to settle. */
|
|
1138
1225
|
export async function settleEngineAccountStoreWrites(): Promise<void> {
|
|
1139
|
-
|
|
1226
|
+
await settleStoreWrites();
|
|
1140
1227
|
}
|
|
1141
1228
|
|
|
1142
1229
|
/**
|
|
@@ -1196,7 +1283,7 @@ function storedAccountFromRow(row: StoreRow): StoredEngineAccount {
|
|
|
1196
1283
|
}
|
|
1197
1284
|
const files: Record<string, string> = {};
|
|
1198
1285
|
for (const entry of readdirSync(row.configDir, { withFileTypes: true })) {
|
|
1199
|
-
if (!entry.isFile()) continue;
|
|
1286
|
+
if (!entry.isFile() || entry.name === BROKER_SIDECAR_FILE) continue;
|
|
1200
1287
|
const content = readFileSync(join(row.configDir, entry.name));
|
|
1201
1288
|
if (content.byteLength > MAX_STORED_FILE_BYTES) continue;
|
|
1202
1289
|
files[entry.name] = content.toString("utf8");
|
|
@@ -1240,22 +1327,6 @@ function parseStoredAccount(value: string): StoredEngineAccount {
|
|
|
1240
1327
|
};
|
|
1241
1328
|
}
|
|
1242
1329
|
|
|
1243
|
-
function queueStoreWrite(what: string, work: (store: CredentialStore) => Promise<void>): void {
|
|
1244
|
-
const store = configuredCredentialStore();
|
|
1245
|
-
if (!store) return;
|
|
1246
|
-
const p: Promise<void> = work(store)
|
|
1247
|
-
.catch((err: unknown) => {
|
|
1248
|
-
console.warn(
|
|
1249
|
-
`[engine-accounts] credential store ${what} failed: ${
|
|
1250
|
-
err instanceof Error ? err.message : String(err)
|
|
1251
|
-
}`,
|
|
1252
|
-
);
|
|
1253
|
-
})
|
|
1254
|
-
.finally(() => {
|
|
1255
|
-
inflight.delete(p);
|
|
1256
|
-
});
|
|
1257
|
-
inflight.add(p);
|
|
1258
|
-
}
|
|
1259
1330
|
|
|
1260
1331
|
function selectRow(id: string): StoreRow | undefined {
|
|
1261
1332
|
return getDb()
|
|
@@ -1448,3 +1519,49 @@ export async function reconcileEngineAccountsFromStore(
|
|
|
1448
1519
|
}
|
|
1449
1520
|
return result;
|
|
1450
1521
|
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* ADR-135 pickup: a parameter changed in the store (the broker refreshed it,
|
|
1525
|
+
* or a sibling host re-mirrored it). Update the local row's material in
|
|
1526
|
+
* place — config-dir files rewritten atomically, env secret re-sealed — or
|
|
1527
|
+
* import the row when this host lacks it. Returns the kind touched, or null
|
|
1528
|
+
* when the value is not an engine account this host should hold.
|
|
1529
|
+
*/
|
|
1530
|
+
export function applyStoredEngineAccount(
|
|
1531
|
+
name: string,
|
|
1532
|
+
value: string,
|
|
1533
|
+
seams: Partial<AccountSeams> = {},
|
|
1534
|
+
): { kind: string; id: string; imported: boolean } | null {
|
|
1535
|
+
const at = parseEngineAccountParameterName(name);
|
|
1536
|
+
if (!at) return null;
|
|
1537
|
+
if (at.scope === HOST_ACCOUNT_SCOPE && at.hostId !== storeHostId) return null;
|
|
1538
|
+
const stored = parseStoredAccount(value);
|
|
1539
|
+
if (stored.id !== at.id || stored.kind !== at.kind || stored.scope !== at.scope) {
|
|
1540
|
+
throw new Error("payload does not describe the account its path names");
|
|
1541
|
+
}
|
|
1542
|
+
const row = selectRow(stored.id);
|
|
1543
|
+
if (!row) {
|
|
1544
|
+
importStoredAccount(stored, name, withDefaults(seams));
|
|
1545
|
+
return { kind: stored.kind, id: stored.id, imported: true };
|
|
1546
|
+
}
|
|
1547
|
+
if (row.authKind === "env" && stored.authKind === "env") {
|
|
1548
|
+
getDb()
|
|
1549
|
+
.update(schema.engineAccounts)
|
|
1550
|
+
.set({ secretEnc: packSecret(JSON.stringify(stored.env ?? {})), storeRef: name })
|
|
1551
|
+
.where(eq(schema.engineAccounts.id, row.id))
|
|
1552
|
+
.run();
|
|
1553
|
+
} else if (row.authKind === "config-dir" && stored.authKind === "config-dir" && row.configDir) {
|
|
1554
|
+
mkdirSync(row.configDir, { recursive: true, mode: 0o700 });
|
|
1555
|
+
for (const [basename, content] of Object.entries(stored.files ?? {})) {
|
|
1556
|
+
if (basename.includes("/") || basename.includes("\\") || basename === "..") continue;
|
|
1557
|
+
const file = join(row.configDir, basename);
|
|
1558
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
1559
|
+
writeFileSync(tmp, content, { mode: 0o600 });
|
|
1560
|
+
renameSync(tmp, file);
|
|
1561
|
+
}
|
|
1562
|
+
if (row.storeRef !== name) setStoreRef(row.id, name);
|
|
1563
|
+
} else {
|
|
1564
|
+
throw new Error("stored auth kind does not match the local row");
|
|
1565
|
+
}
|
|
1566
|
+
return { kind: stored.kind, id: stored.id, imported: false };
|
|
1567
|
+
}
|
package/lib/env.ts
CHANGED
|
@@ -54,6 +54,9 @@ const schema = z.object({
|
|
|
54
54
|
// Shared with the ADR-121 machine provider (read raw there); the store
|
|
55
55
|
// needs it too, so surface it here for one validated read.
|
|
56
56
|
UAI_AWS_REGION: z.string().min(1).optional(),
|
|
57
|
+
// ADR-135: the broker Lambda a Uai-operated host invokes for a reactive
|
|
58
|
+
// refresh (name or ARN). Unset = scheduled refreshes only.
|
|
59
|
+
UAI_TOKEN_BROKER_FUNCTION: z.string().min(1).optional(),
|
|
57
60
|
NODE_ENV: z
|
|
58
61
|
.enum(["development", "production", "test"])
|
|
59
62
|
.default("development"),
|