@runuai/host 0.9.110 → 0.9.111

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.
@@ -0,0 +1,3 @@
1
+ -- ADR-134 (host 0.9.111): the credential-store parameter an engine account
2
+ -- mirrors to. Null on owned hosts and before the first successful mirror.
3
+ ALTER TABLE `host_engine_accounts` ADD `store_ref` text;
@@ -134,6 +134,13 @@
134
134
  "when": 1788900000000,
135
135
  "tag": "0018_engine_account_position",
136
136
  "breakpoints": true
137
+ },
138
+ {
139
+ "idx": 19,
140
+ "version": "6",
141
+ "when": 1788900000001,
142
+ "tag": "0019_engine_account_store_ref",
143
+ "breakpoints": true
137
144
  }
138
145
  ]
139
146
  }
package/db/schema.ts CHANGED
@@ -281,6 +281,10 @@ export const engineAccounts = sqliteTable("host_engine_accounts", {
281
281
  // Manage-v1: priority within (scope, kind) — tasks pick the first healthy
282
282
  // account in ascending position; ties break on created_at.
283
283
  position: integer("position", { mode: "number" }).notNull().default(0),
284
+ // ADR-134: the credential-store parameter this row mirrors to (null on an
285
+ // owned host, or before the first successful mirror). The store is the
286
+ // truth when configured; this row is the local cache.
287
+ storeRef: text("store_ref"),
284
288
  createdAt: integer("created_at", { mode: "number" }).notNull(),
285
289
  });
286
290
 
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Credential store (ADR-134) — where a Uai-operated host keeps tenant
3
+ * credentials: AWS Systems Manager Parameter Store, one SecureString per
4
+ * credential, encrypted with the `alias/uai-credentials` KMS key, read and
5
+ * written with the host's own IAM role through the `aws` CLI the host already
6
+ * uses for machines (ADR-121).
7
+ *
8
+ * Owned hosts never construct one of these: `configuredCredentialStore()`
9
+ * returns null unless `UAI_CREDENTIAL_STORE=aws-ssm`, and every caller treats
10
+ * null as "credentials stay on this machine".
11
+ *
12
+ * Secret material never travels on argv where `ps` would show it: a put
13
+ * writes its request to a 0600 file in a fresh 0700 temp dir and hands the CLI
14
+ * `--cli-input-json file://<path>`, removed as soon as the CLI exits. (Not
15
+ * `file:///dev/stdin`: the CLI opens the file twice and a pipe reads empty the
16
+ * second time — verified against aws-cli 2.33.) Nothing here logs a value.
17
+ */
18
+
19
+ import { spawn } from "node:child_process";
20
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ import { env } from "./env";
25
+
26
+ const DEFAULT_TIMEOUT_MS = 30_000;
27
+
28
+ /** Parameter names: absolute, slash-separated, the SSM-legal character set. */
29
+ const PARAMETER_NAME = /^\/[A-Za-z0-9_.\-]+(?:\/[A-Za-z0-9_.\-]+)*$/;
30
+ /** A single path segment (an org id, a host id, an engine kind, an account id). */
31
+ const PATH_SEGMENT = /^[A-Za-z0-9_.\-]+$/;
32
+
33
+ export interface StoredParameter {
34
+ name: string;
35
+ value: string;
36
+ }
37
+
38
+ export interface CredentialStore {
39
+ readonly kind: "aws-ssm";
40
+ /** Create or overwrite one credential. Throws on failure. */
41
+ put(name: string, value: string): Promise<void>;
42
+ /** The credential's plaintext, or null when it does not exist. */
43
+ get(name: string): Promise<string | null>;
44
+ /** Idempotent: a missing parameter is not an error. */
45
+ delete(name: string): Promise<void>;
46
+ /** Every credential under `path` (recursive), decrypted. Throws on failure. */
47
+ list(path: string): Promise<StoredParameter[]>;
48
+ }
49
+
50
+ export interface CliResult {
51
+ status: number | null;
52
+ stdout: string;
53
+ stderr: string;
54
+ }
55
+
56
+ /** `aws --region <r> --output json <args>`. */
57
+ export type AwsCliRunner = (args: string[]) => Promise<CliResult>;
58
+
59
+ export function isParameterName(name: string): boolean {
60
+ return PARAMETER_NAME.test(name);
61
+ }
62
+
63
+ export function isParameterPathSegment(segment: string): boolean {
64
+ return PATH_SEGMENT.test(segment);
65
+ }
66
+
67
+ /** Join path segments under `prefix`, refusing anything SSM would reject or
68
+ * that could escape its intended subtree (an empty segment, a `/` inside). */
69
+ export function parameterName(prefix: string, ...segments: string[]): string {
70
+ for (const segment of segments) {
71
+ if (!isParameterPathSegment(segment)) {
72
+ throw new Error(`invalid credential-store path segment: ${JSON.stringify(segment)}`);
73
+ }
74
+ }
75
+ const name = `${prefix}/${segments.join("/")}`;
76
+ if (!isParameterName(name)) {
77
+ throw new Error(`invalid credential-store parameter name: ${JSON.stringify(name)}`);
78
+ }
79
+ return name;
80
+ }
81
+
82
+ export function defaultAwsCliRunner(region: string): AwsCliRunner {
83
+ return (args) =>
84
+ new Promise<CliResult>((resolve) => {
85
+ let stdout = "";
86
+ let stderr = "";
87
+ let settled = false;
88
+ const child = spawn("aws", ["--region", region, "--output", "json", ...args], {
89
+ stdio: ["ignore", "pipe", "pipe"],
90
+ });
91
+ const settle = (status: number | null): void => {
92
+ if (settled) return;
93
+ settled = true;
94
+ clearTimeout(timer);
95
+ resolve({ status, stdout, stderr });
96
+ };
97
+ const timer = setTimeout(() => {
98
+ child.kill("SIGKILL");
99
+ const grace = setTimeout(() => settle(null), 2_000);
100
+ grace.unref?.();
101
+ }, DEFAULT_TIMEOUT_MS);
102
+ timer.unref?.();
103
+ child.stdout?.setEncoding("utf8");
104
+ child.stderr?.setEncoding("utf8");
105
+ child.stdout?.on("data", (chunk: string) => (stdout += chunk));
106
+ child.stderr?.on("data", (chunk: string) => (stderr += chunk));
107
+ child.once("error", () => settle(null));
108
+ child.once("close", (code) => settle(code));
109
+ });
110
+ }
111
+
112
+ /** Run `work` with a 0600 file holding `content`, inside a private temp dir
113
+ * that is removed afterwards whatever happens. */
114
+ async function withPrivateInputFile<T>(
115
+ content: string,
116
+ work: (path: string) => Promise<T>,
117
+ ): Promise<T> {
118
+ const dir = mkdtempSync(join(tmpdir(), "uai-credential-store-"));
119
+ try {
120
+ const path = join(dir, "input.json");
121
+ writeFileSync(path, content, { mode: 0o600 });
122
+ return await work(path);
123
+ } finally {
124
+ rmSync(dir, { recursive: true, force: true });
125
+ }
126
+ }
127
+
128
+ function isNotFound(result: CliResult): boolean {
129
+ return /ParameterNotFound/.test(result.stderr);
130
+ }
131
+
132
+ /** Summarize a CLI failure WITHOUT echoing stdout (which could carry a value). */
133
+ function failure(op: string, result: CliResult): Error {
134
+ const line =
135
+ result.stderr
136
+ .split("\n")
137
+ .map((l) => l.trim())
138
+ .find((l) => l.length > 0) ?? (result.status === null ? "spawn failed or timed out" : "");
139
+ return new Error(`aws ssm ${op} failed (exit ${result.status ?? "none"}): ${line}`);
140
+ }
141
+
142
+ export interface AwsSsmCredentialStoreOptions {
143
+ region: string;
144
+ kmsKeyId: string;
145
+ runner?: AwsCliRunner;
146
+ }
147
+
148
+ export class AwsSsmCredentialStore implements CredentialStore {
149
+ readonly kind = "aws-ssm" as const;
150
+ private readonly run: AwsCliRunner;
151
+ private readonly kmsKeyId: string;
152
+
153
+ constructor(opts: AwsSsmCredentialStoreOptions) {
154
+ this.run = opts.runner ?? defaultAwsCliRunner(opts.region);
155
+ this.kmsKeyId = opts.kmsKeyId;
156
+ }
157
+
158
+ async put(name: string, value: string): Promise<void> {
159
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
160
+ // Tier Advanced: a Codex auth.json is ~4 KB, past the 4 KB standard cap.
161
+ // Tags cannot ride along with Overwrite, and the path already encodes
162
+ // ownership, so none are set.
163
+ const input = JSON.stringify({
164
+ Name: name,
165
+ Value: value,
166
+ Type: "SecureString",
167
+ KeyId: this.kmsKeyId,
168
+ Tier: "Advanced",
169
+ Overwrite: true,
170
+ });
171
+ const result = await withPrivateInputFile(input, (path) =>
172
+ this.run(["ssm", "put-parameter", "--cli-input-json", `file://${path}`]),
173
+ );
174
+ if (result.status !== 0) throw failure("put-parameter", result);
175
+ }
176
+
177
+ async get(name: string): Promise<string | null> {
178
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
179
+ const result = await this.run([
180
+ "ssm",
181
+ "get-parameter",
182
+ "--name",
183
+ name,
184
+ "--with-decryption",
185
+ ]);
186
+ if (result.status !== 0) {
187
+ if (isNotFound(result)) return null;
188
+ throw failure("get-parameter", result);
189
+ }
190
+ const parsed = JSON.parse(result.stdout) as { Parameter?: { Value?: unknown } };
191
+ const value = parsed.Parameter?.Value;
192
+ if (typeof value !== "string") throw new Error("aws ssm get-parameter: malformed response");
193
+ return value;
194
+ }
195
+
196
+ async delete(name: string): Promise<void> {
197
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
198
+ const result = await this.run(["ssm", "delete-parameter", "--name", name]);
199
+ if (result.status !== 0 && !isNotFound(result)) {
200
+ throw failure("delete-parameter", result);
201
+ }
202
+ }
203
+
204
+ async list(path: string): Promise<StoredParameter[]> {
205
+ if (!isParameterName(path)) throw new Error(`invalid parameter path: ${path}`);
206
+ const out: StoredParameter[] = [];
207
+ let nextToken: string | undefined;
208
+ // The CLI auto-paginates JSON output, but a response may still carry a
209
+ // NextToken when the page cap is hit; follow it either way.
210
+ for (let page = 0; page < 100; page++) {
211
+ const result = await this.run([
212
+ "ssm",
213
+ "get-parameters-by-path",
214
+ "--path",
215
+ path,
216
+ "--recursive",
217
+ "--with-decryption",
218
+ ...(nextToken ? ["--next-token", nextToken] : []),
219
+ ]);
220
+ if (result.status !== 0) throw failure("get-parameters-by-path", result);
221
+ const parsed = JSON.parse(result.stdout) as {
222
+ Parameters?: Array<{ Name?: unknown; Value?: unknown }>;
223
+ NextToken?: unknown;
224
+ };
225
+ for (const p of parsed.Parameters ?? []) {
226
+ if (typeof p.Name === "string" && typeof p.Value === "string") {
227
+ out.push({ name: p.Name, value: p.Value });
228
+ }
229
+ }
230
+ if (typeof parsed.NextToken === "string" && parsed.NextToken) {
231
+ nextToken = parsed.NextToken;
232
+ } else {
233
+ break;
234
+ }
235
+ }
236
+ return out;
237
+ }
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Process-wide configuration.
242
+ // ---------------------------------------------------------------------------
243
+
244
+ let override: CredentialStore | null | undefined;
245
+ let configured: CredentialStore | null | undefined;
246
+ let warnedMissingRegion = false;
247
+
248
+ /** Tests: pin the store (`null` = behave as an owned host); `undefined` resets. */
249
+ export function setCredentialStoreOverride(store: CredentialStore | null | undefined): void {
250
+ override = store;
251
+ }
252
+
253
+ /** The parameter-name prefix every stored credential lives under. */
254
+ export function credentialStorePrefix(): string {
255
+ return env.UAI_CREDENTIAL_STORE_PREFIX;
256
+ }
257
+
258
+ /**
259
+ * The store this host mirrors to, or null on an owned host (the default).
260
+ * Memoized: the env does not change while the process runs.
261
+ */
262
+ export function configuredCredentialStore(): CredentialStore | null {
263
+ if (override !== undefined) return override;
264
+ if (configured !== undefined) return configured;
265
+ if (env.UAI_CREDENTIAL_STORE !== "aws-ssm") {
266
+ configured = null;
267
+ return configured;
268
+ }
269
+ const region = env.UAI_AWS_REGION;
270
+ if (!region) {
271
+ if (!warnedMissingRegion) {
272
+ warnedMissingRegion = true;
273
+ console.warn(
274
+ "[credential-store] UAI_CREDENTIAL_STORE=aws-ssm needs UAI_AWS_REGION; credentials stay local until it is set",
275
+ );
276
+ }
277
+ configured = null;
278
+ return configured;
279
+ }
280
+ configured = new AwsSsmCredentialStore({
281
+ region,
282
+ kmsKeyId: env.UAI_CREDENTIAL_STORE_KMS_KEY,
283
+ });
284
+ return configured;
285
+ }
@@ -27,12 +27,27 @@ import {
27
27
  isEngineAccountScope,
28
28
  isEngineAccountLabel,
29
29
  } from "../src/protocol";
30
- import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
30
+ import {
31
+ chmodSync,
32
+ existsSync,
33
+ mkdirSync,
34
+ readdirSync,
35
+ readFileSync,
36
+ rmSync,
37
+ writeFileSync,
38
+ } from "node:fs";
31
39
  import { homedir } from "node:os";
32
40
  import { join } from "node:path";
33
41
 
34
42
  import { eq } from "drizzle-orm";
35
43
 
44
+ import {
45
+ configuredCredentialStore,
46
+ credentialStorePrefix,
47
+ isParameterPathSegment,
48
+ parameterName,
49
+ type CredentialStore,
50
+ } from "./credential-store";
36
51
  import { getDb, schema } from "./db";
37
52
  import {
38
53
  taskContainerBackend,
@@ -762,6 +777,7 @@ export function addEngineAccount(
762
777
  position: nextPosition(kind, scope),
763
778
  })
764
779
  .run();
780
+ mirrorEngineAccount(id);
765
781
  return { ok: true, message: `Added Claude account "${trimmedLabel}".`, id };
766
782
  }
767
783
 
@@ -781,6 +797,7 @@ export function addEngineAccount(
781
797
  position: nextPosition(kind, scope),
782
798
  })
783
799
  .run();
800
+ mirrorEngineAccount(id);
784
801
  return {
785
802
  ok: true,
786
803
  message: `Added ${API_KEY_ENGINE_LABELS[kind] ?? kind} account "${trimmedLabel}".`,
@@ -827,6 +844,7 @@ export function addEngineAccount(
827
844
  position: nextPosition(kind, scope),
828
845
  })
829
846
  .run();
847
+ mirrorEngineAccount(id);
830
848
  return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
831
849
  }
832
850
 
@@ -924,6 +942,7 @@ function addConfigDirAccountFromAuthJson(
924
942
  position: nextPosition(kind, scope),
925
943
  })
926
944
  .run();
945
+ mirrorEngineAccount(id);
927
946
  return { ok: true, message: `Added ${engineLabel} account "${trimmedLabel}".`, id };
928
947
  }
929
948
 
@@ -960,14 +979,12 @@ export function removeEngineAccount(
960
979
  if (!accountRemovableInScope(row, scope)) {
961
980
  return { ok: false, message: "That account belongs to a different scope." };
962
981
  }
963
- getDb().delete(schema.engineAccounts).where(eq(schema.engineAccounts.id, id)).run();
964
- if (row.authKind === "config-dir" && row.configDir) {
965
- try {
966
- rmSync(row.configDir, { recursive: true, force: true });
967
- } catch {
968
- /* best effort — the DB row is gone, which is what matters */
969
- }
970
- }
982
+ removeLocalAccountRow(row);
983
+ // ADR-134: the parameter goes too, best effort; the row is already gone.
984
+ queueStoreWrite(`delete ${id}`, async (store) => {
985
+ const name = row.storeRef ?? engineAccountParameterName(row);
986
+ if (name) await store.delete(name);
987
+ });
971
988
  return { ok: true, message: `Removed "${row.label}".` };
972
989
  }
973
990
 
@@ -998,6 +1015,7 @@ export function renameEngineAccount(
998
1015
  .set({ label: trimmed })
999
1016
  .where(eq(schema.engineAccounts.id, id))
1000
1017
  .run();
1018
+ mirrorEngineAccount(id);
1001
1019
  return { ok: true, message: `Renamed to "${trimmed}".` };
1002
1020
  }
1003
1021
 
@@ -1043,5 +1061,390 @@ export function reorderEngineAccounts(
1043
1061
  .where(eq(schema.engineAccounts.id, id))
1044
1062
  .run();
1045
1063
  });
1064
+ for (const id of ids) mirrorEngineAccount(id);
1046
1065
  return { ok: true, message: "Order saved." };
1047
1066
  }
1067
+
1068
+ /** Delete a row and its isolated config dir (no store call). */
1069
+ function removeLocalAccountRow(row: {
1070
+ id: string;
1071
+ authKind: string;
1072
+ configDir: string | null;
1073
+ }): void {
1074
+ getDb().delete(schema.engineAccounts).where(eq(schema.engineAccounts.id, row.id)).run();
1075
+ if (row.authKind === "config-dir" && row.configDir) {
1076
+ try {
1077
+ rmSync(row.configDir, { recursive: true, force: true });
1078
+ } catch {
1079
+ /* best effort — the DB row is gone, which is what matters */
1080
+ }
1081
+ }
1082
+ }
1083
+
1084
+ // ---------------------------------------------------------------------------
1085
+ // Credential store mirror (ADR-134, docs/credential-store.md).
1086
+ //
1087
+ // On a Uai-operated host the rows above are a CACHE of AWS Parameter Store:
1088
+ // every mutation mirrors its row (metadata + secret material) to one
1089
+ // parameter, and `reconcileEngineAccountsFromStore` at host start imports
1090
+ // what this host lacks, uploads rows that were never mirrored, and drops rows
1091
+ // whose parameter is gone. Task-up resolution never waits on the store.
1092
+ // On an owned host `configuredCredentialStore()` is null and none of this
1093
+ // runs — credentials never leave the machine.
1094
+ // ---------------------------------------------------------------------------
1095
+
1096
+ /** The versioned JSON a parameter holds. Never logged. */
1097
+ export interface StoredEngineAccount {
1098
+ v: 1;
1099
+ id: string;
1100
+ kind: string;
1101
+ label: string;
1102
+ authKind: AccountAuthKind;
1103
+ scope: string;
1104
+ position: number;
1105
+ createdAt: number;
1106
+ /** `env` accounts: the exec env the row injects. */
1107
+ env?: Record<string, string>;
1108
+ /** `config-dir` accounts: the dir's files by basename (today: auth.json). */
1109
+ files?: Record<string, string>;
1110
+ }
1111
+
1112
+ /** Largest single file mirrored from a config dir (Advanced tier is 8 KB total). */
1113
+ const MAX_STORED_FILE_BYTES = 8 * 1024;
1114
+
1115
+ type StoreRow = {
1116
+ id: string;
1117
+ kind: string;
1118
+ label: string;
1119
+ authKind: string;
1120
+ secretEnc: string | null;
1121
+ configDir: string | null;
1122
+ scope?: string | null;
1123
+ position?: number | null;
1124
+ storeRef?: string | null;
1125
+ createdAt: number;
1126
+ };
1127
+
1128
+ let storeHostId: string | null = null;
1129
+ let warnedNoHostId = false;
1130
+ const inflight = new Set<Promise<void>>();
1131
+
1132
+ /** main.ts: the host id that names `host`-scope parameters. */
1133
+ export function configureEngineAccountStore(opts: { hostId: string | null }): void {
1134
+ storeHostId = opts.hostId;
1135
+ }
1136
+
1137
+ /** Tests: wait for every queued mirror/delete to settle. */
1138
+ export async function settleEngineAccountStoreWrites(): Promise<void> {
1139
+ while (inflight.size > 0) await Promise.allSettled([...inflight]);
1140
+ }
1141
+
1142
+ /**
1143
+ * The parameter a row mirrors to: `<prefix>/orgs/<orgId>/engines/<kind>/<id>`
1144
+ * for a workspace's account, `<prefix>/hosts/<hostId>/engines/<kind>/<id>` for
1145
+ * the host's own. Null when a host-scope row has no host id to file under.
1146
+ * Throws when an id would not be a legal path segment.
1147
+ */
1148
+ export function engineAccountParameterName(
1149
+ row: { id: string; kind: string; scope?: string | null },
1150
+ hostId: string | null = storeHostId,
1151
+ ): string | null {
1152
+ const scope = row.scope ?? HOST_ACCOUNT_SCOPE;
1153
+ const prefix = credentialStorePrefix();
1154
+ if (scope === HOST_ACCOUNT_SCOPE) {
1155
+ if (!hostId) return null;
1156
+ return parameterName(prefix, "hosts", hostId, "engines", row.kind, row.id);
1157
+ }
1158
+ if (!isEngineAccountScope(scope)) throw new Error(`invalid account scope: ${scope}`);
1159
+ return parameterName(prefix, "orgs", scope.slice("org:".length), "engines", row.kind, row.id);
1160
+ }
1161
+
1162
+ /** Inverse of `engineAccountParameterName`; null for parameters that are not
1163
+ * engine accounts (future slices file MCP/GitHub credentials nearby). */
1164
+ export function parseEngineAccountParameterName(
1165
+ name: string,
1166
+ prefix: string = credentialStorePrefix(),
1167
+ ): { scope: string; hostId?: string; kind: string; id: string } | null {
1168
+ if (!name.startsWith(`${prefix}/`)) return null;
1169
+ const parts = name.slice(prefix.length + 1).split("/");
1170
+ if (parts.length !== 5 || parts[2] !== "engines") return null;
1171
+ if (!parts.every((p) => isParameterPathSegment(p))) return null;
1172
+ const [kindOfOwner, owner, , kind, id] = parts as [string, string, string, string, string];
1173
+ if (kindOfOwner === "orgs") return { scope: `org:${owner}`, kind, id };
1174
+ if (kindOfOwner === "hosts") return { scope: HOST_ACCOUNT_SCOPE, hostId: owner, kind, id };
1175
+ return null;
1176
+ }
1177
+
1178
+ function storedAccountFromRow(row: StoreRow): StoredEngineAccount {
1179
+ const base = {
1180
+ v: 1 as const,
1181
+ id: row.id,
1182
+ kind: row.kind,
1183
+ label: row.label,
1184
+ scope: row.scope ?? HOST_ACCOUNT_SCOPE,
1185
+ position: row.position ?? 0,
1186
+ createdAt: row.createdAt,
1187
+ };
1188
+ if (row.authKind === "env") {
1189
+ const env = row.secretEnc
1190
+ ? (JSON.parse(unpackSecret(row.secretEnc)) as Record<string, string>)
1191
+ : {};
1192
+ return { ...base, authKind: "env", env };
1193
+ }
1194
+ if (row.authKind !== "config-dir" || !row.configDir) {
1195
+ throw new Error(`account ${row.id} has no mirrorable material`);
1196
+ }
1197
+ const files: Record<string, string> = {};
1198
+ for (const entry of readdirSync(row.configDir, { withFileTypes: true })) {
1199
+ if (!entry.isFile()) continue;
1200
+ const content = readFileSync(join(row.configDir, entry.name));
1201
+ if (content.byteLength > MAX_STORED_FILE_BYTES) continue;
1202
+ files[entry.name] = content.toString("utf8");
1203
+ }
1204
+ return { ...base, authKind: "config-dir", files };
1205
+ }
1206
+
1207
+ function parseStoredAccount(value: string): StoredEngineAccount {
1208
+ const parsed: unknown = JSON.parse(value);
1209
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1210
+ throw new Error("not an object");
1211
+ }
1212
+ const o = parsed as Record<string, unknown>;
1213
+ if (o.v !== 1) throw new Error(`unsupported version ${String(o.v)}`);
1214
+ if (typeof o.id !== "string" || !isParameterPathSegment(o.id)) throw new Error("bad id");
1215
+ if (typeof o.kind !== "string" || !isParameterPathSegment(o.kind)) throw new Error("bad kind");
1216
+ if (typeof o.label !== "string" || !isEngineAccountLabel(o.label)) throw new Error("bad label");
1217
+ if (typeof o.scope !== "string" || !isEngineAccountScope(o.scope)) throw new Error("bad scope");
1218
+ if (o.authKind !== "env" && o.authKind !== "config-dir") throw new Error("bad authKind");
1219
+ const stringMap = (v: unknown): Record<string, string> | undefined => {
1220
+ if (v === undefined) return undefined;
1221
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("bad map");
1222
+ const out: Record<string, string> = {};
1223
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
1224
+ if (typeof val !== "string") throw new Error("bad map value");
1225
+ out[k] = val;
1226
+ }
1227
+ return out;
1228
+ };
1229
+ return {
1230
+ v: 1,
1231
+ id: o.id,
1232
+ kind: o.kind,
1233
+ label: o.label,
1234
+ authKind: o.authKind,
1235
+ scope: o.scope,
1236
+ position: typeof o.position === "number" ? o.position : 0,
1237
+ createdAt: typeof o.createdAt === "number" ? o.createdAt : Date.now(),
1238
+ env: stringMap(o.env),
1239
+ files: stringMap(o.files),
1240
+ };
1241
+ }
1242
+
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
+
1260
+ function selectRow(id: string): StoreRow | undefined {
1261
+ return getDb()
1262
+ .select()
1263
+ .from(schema.engineAccounts)
1264
+ .where(eq(schema.engineAccounts.id, id))
1265
+ .get() as StoreRow | undefined;
1266
+ }
1267
+
1268
+ function setStoreRef(id: string, storeRef: string): void {
1269
+ getDb()
1270
+ .update(schema.engineAccounts)
1271
+ .set({ storeRef })
1272
+ .where(eq(schema.engineAccounts.id, id))
1273
+ .run();
1274
+ }
1275
+
1276
+ async function putRow(store: CredentialStore, row: StoreRow, hostId: string | null): Promise<boolean> {
1277
+ const name = engineAccountParameterName(row, hostId);
1278
+ if (!name) {
1279
+ if (!warnedNoHostId) {
1280
+ warnedNoHostId = true;
1281
+ console.warn(
1282
+ "[engine-accounts] host-scope accounts are not mirrored until the host id is known",
1283
+ );
1284
+ }
1285
+ return false;
1286
+ }
1287
+ await store.put(name, JSON.stringify(storedAccountFromRow(row)));
1288
+ if (row.storeRef !== name) setStoreRef(row.id, name);
1289
+ return true;
1290
+ }
1291
+
1292
+ /** Mirror one row to its parameter, asynchronously and best effort. A no-op
1293
+ * on owned hosts. Rows read the CURRENT state when the write runs, so a
1294
+ * burst of edits collapses to the latest content. */
1295
+ export function mirrorEngineAccount(id: string): void {
1296
+ queueStoreWrite(`mirror ${id}`, async (store) => {
1297
+ const row = selectRow(id);
1298
+ if (!row) return;
1299
+ await putRow(store, row, storeHostId);
1300
+ });
1301
+ }
1302
+
1303
+ function importStoredAccount(
1304
+ stored: StoredEngineAccount,
1305
+ storeRef: string,
1306
+ s: AccountSeams,
1307
+ ): void {
1308
+ const d = KINDS[stored.kind];
1309
+ if (!d) throw new Error(`unknown engine kind ${stored.kind}`);
1310
+ let secretEnc: string | null = null;
1311
+ let configDir: string | null = null;
1312
+ if (stored.authKind === "env") {
1313
+ secretEnc = packSecret(JSON.stringify(stored.env ?? {}));
1314
+ } else {
1315
+ if (!d.extraHostDir) throw new Error(`${stored.kind} has no config-dir layout`);
1316
+ const hostDir = d.extraHostDir(s.ownerHome(), stored.id);
1317
+ mkdirSync(hostDir, { recursive: true, mode: 0o700 });
1318
+ for (const [basename, content] of Object.entries(stored.files ?? {})) {
1319
+ if (basename.includes("/") || basename.includes("\\") || basename === "..") {
1320
+ throw new Error("bad file name");
1321
+ }
1322
+ const file = join(hostDir, basename);
1323
+ writeFileSync(file, content, { mode: 0o600 });
1324
+ try {
1325
+ chmodSync(file, 0o600);
1326
+ } catch {
1327
+ /* best effort */
1328
+ }
1329
+ }
1330
+ configDir = hostDir;
1331
+ }
1332
+ getDb()
1333
+ .insert(schema.engineAccounts)
1334
+ .values({
1335
+ id: stored.id,
1336
+ kind: stored.kind,
1337
+ label: stored.label,
1338
+ authKind: stored.authKind,
1339
+ secretEnc,
1340
+ configDir,
1341
+ scope: stored.scope,
1342
+ position: stored.position,
1343
+ storeRef,
1344
+ createdAt: stored.createdAt,
1345
+ })
1346
+ .run();
1347
+ }
1348
+
1349
+ export interface ReconcileEngineAccountsResult {
1350
+ /** No store is configured (an owned host); nothing was touched. */
1351
+ skipped: boolean;
1352
+ imported: number;
1353
+ uploaded: number;
1354
+ removed: number;
1355
+ errors: number;
1356
+ }
1357
+
1358
+ /**
1359
+ * Host start: make the local cache agree with the store.
1360
+ * - a parameter with no local row is imported (a replacement host inherits
1361
+ * every workspace's accounts);
1362
+ * - a local row with no `store_ref` is uploaded (first start with the flag);
1363
+ * - a local row whose `store_ref` is under a successfully listed path but
1364
+ * absent from it was removed elsewhere — it goes locally too.
1365
+ * Listing failures leave the rows under that path untouched.
1366
+ */
1367
+ export async function reconcileEngineAccountsFromStore(
1368
+ opts: { hostId?: string | null; seams?: Partial<AccountSeams> } = {},
1369
+ ): Promise<ReconcileEngineAccountsResult> {
1370
+ const result: ReconcileEngineAccountsResult = {
1371
+ skipped: false,
1372
+ imported: 0,
1373
+ uploaded: 0,
1374
+ removed: 0,
1375
+ errors: 0,
1376
+ };
1377
+ const store = configuredCredentialStore();
1378
+ if (!store) return { ...result, skipped: true };
1379
+ const s = withDefaults(opts.seams ?? {});
1380
+ const hostId = opts.hostId === undefined ? storeHostId : opts.hostId;
1381
+ const prefix = credentialStorePrefix();
1382
+ const warn = (line: string): void => console.warn(`[engine-accounts] credential store: ${line}`);
1383
+
1384
+ const paths = [`${prefix}/orgs`];
1385
+ if (hostId && isParameterPathSegment(hostId)) paths.push(`${prefix}/hosts/${hostId}`);
1386
+ const listed: Array<{ name: string; value: string }> = [];
1387
+ const listedPrefixes: string[] = [];
1388
+ for (const path of paths) {
1389
+ try {
1390
+ listed.push(...(await store.list(path)));
1391
+ listedPrefixes.push(`${path}/`);
1392
+ } catch (err) {
1393
+ result.errors++;
1394
+ warn(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
1395
+ }
1396
+ }
1397
+
1398
+ const rows = getDb().select().from(schema.engineAccounts).all() as StoreRow[];
1399
+ const byId = new Map(rows.map((r) => [r.id, r]));
1400
+ const seen = new Set<string>();
1401
+ for (const param of listed) {
1402
+ const at = parseEngineAccountParameterName(param.name, prefix);
1403
+ if (!at) continue; // not an engine account
1404
+ if (at.scope === HOST_ACCOUNT_SCOPE && at.hostId !== hostId) continue;
1405
+ seen.add(param.name);
1406
+ let stored: StoredEngineAccount;
1407
+ try {
1408
+ stored = parseStoredAccount(param.value);
1409
+ } catch (err) {
1410
+ result.errors++;
1411
+ warn(`${param.name} is unreadable: ${err instanceof Error ? err.message : String(err)}`);
1412
+ continue;
1413
+ }
1414
+ if (stored.id !== at.id || stored.kind !== at.kind || stored.scope !== at.scope) {
1415
+ result.errors++;
1416
+ warn(`${param.name} does not describe the account its path names; ignored`);
1417
+ continue;
1418
+ }
1419
+ const local = byId.get(stored.id);
1420
+ if (local) {
1421
+ if (local.storeRef !== param.name) setStoreRef(local.id, param.name);
1422
+ continue;
1423
+ }
1424
+ try {
1425
+ importStoredAccount(stored, param.name, s);
1426
+ result.imported++;
1427
+ } catch (err) {
1428
+ result.errors++;
1429
+ warn(`importing ${param.name} failed: ${err instanceof Error ? err.message : String(err)}`);
1430
+ }
1431
+ }
1432
+
1433
+ for (const row of rows) {
1434
+ if (row.storeRef) {
1435
+ const under = listedPrefixes.some((p) => row.storeRef!.startsWith(p));
1436
+ if (under && !seen.has(row.storeRef)) {
1437
+ removeLocalAccountRow(row);
1438
+ result.removed++;
1439
+ }
1440
+ continue;
1441
+ }
1442
+ try {
1443
+ if (await putRow(store, row, hostId)) result.uploaded++;
1444
+ } catch (err) {
1445
+ result.errors++;
1446
+ warn(`uploading ${row.kind}/${row.id} failed: ${err instanceof Error ? err.message : String(err)}`);
1447
+ }
1448
+ }
1449
+ return result;
1450
+ }
package/lib/env.ts CHANGED
@@ -41,6 +41,19 @@ const schema = z.object({
41
41
  // off entirely — zero telemetry bytes leave the machine.
42
42
  UAI_SENTRY_DSN: z.string().min(1).optional(),
43
43
  UAI_TELEMETRY_DISABLED: z.string().optional(),
44
+ // ADR-134 credential store. ONLY Uai-operated hosts set `aws-ssm`: engine
45
+ // accounts are then mirrored to AWS Parameter Store (SecureString, KMS) and
46
+ // reconciled at start. Owned hosts keep `local` — credentials never leave
47
+ // the machine. See docs/credential-store.md.
48
+ UAI_CREDENTIAL_STORE: z.enum(["local", "aws-ssm"]).default("local"),
49
+ UAI_CREDENTIAL_STORE_PREFIX: z
50
+ .string()
51
+ .regex(/^\/[A-Za-z0-9_.\-]+(?:\/[A-Za-z0-9_.\-]+)*$/)
52
+ .default("/uai"),
53
+ UAI_CREDENTIAL_STORE_KMS_KEY: z.string().min(1).default("alias/uai-credentials"),
54
+ // Shared with the ADR-121 machine provider (read raw there); the store
55
+ // needs it too, so surface it here for one validated read.
56
+ UAI_AWS_REGION: z.string().min(1).optional(),
44
57
  NODE_ENV: z
45
58
  .enum(["development", "production", "test"])
46
59
  .default("development"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.110",
3
+ "version": "0.9.111",
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>",
package/src/main.ts CHANGED
@@ -152,8 +152,10 @@ import {
152
152
  type HostConfigResult,
153
153
  } from "../lib/host-config";
154
154
  import {
155
+ configureEngineAccountStore,
155
156
  hasStoredEngineAccounts,
156
157
  listEngineAccountsAllScopes,
158
+ reconcileEngineAccountsFromStore,
157
159
  } from "../lib/engine-accounts";
158
160
  import { createEngineConnectManager } from "../lib/engine-connect";
159
161
  import { createEngineLoginManager } from "../lib/engine-login";
@@ -440,6 +442,22 @@ try {
440
442
  );
441
443
  }
442
444
  migrateHostDb();
445
+ // ADR-134: on a Uai-operated host the engine-account rows are a cache of the
446
+ // credential store. Import what this host lacks and upload what was never
447
+ // mirrored, off the startup path — task-up resolves locally either way.
448
+ configureEngineAccountStore({ hostId });
449
+ void reconcileEngineAccountsFromStore({ hostId })
450
+ .then((r) => {
451
+ if (r.skipped) return;
452
+ console.log(
453
+ `[engine-accounts] credential store reconciled: ${r.imported} imported, ${r.uploaded} uploaded, ${r.removed} removed, ${r.errors} errors`,
454
+ );
455
+ })
456
+ .catch((error: unknown) => {
457
+ console.warn(
458
+ `[engine-accounts] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
459
+ );
460
+ });
443
461
  // Reconcile a durable retention-off setting before recovery/connect can make
444
462
  // crash-left owner-facing terminal history observable again. Correctness
445
463
  // tombstones remain intact inside applyTerminalHistoryRetention.