@runuai/host 0.9.114 → 0.9.115
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/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +2 -0
- package/lib/host-env.ts +106 -1
- package/lib/user-credentials-store.ts +104 -0
- package/package.json +1 -1
- package/src/main.ts +16 -1
|
@@ -148,6 +148,13 @@
|
|
|
148
148
|
"when": 1788900000002,
|
|
149
149
|
"tag": "0020_user_credential_store_ref",
|
|
150
150
|
"breakpoints": true
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"idx": 21,
|
|
154
|
+
"version": "6",
|
|
155
|
+
"when": 1788900000003,
|
|
156
|
+
"tag": "0021_project_env_store_ref",
|
|
157
|
+
"breakpoints": true
|
|
151
158
|
}
|
|
152
159
|
]
|
|
153
160
|
}
|
package/db/schema.ts
CHANGED
|
@@ -183,6 +183,8 @@ export const hostProjectEnv = sqliteTable(
|
|
|
183
183
|
updatedAt: integer("updated_at", { mode: "number" })
|
|
184
184
|
.notNull()
|
|
185
185
|
.default(sql`(unixepoch() * 1000)`),
|
|
186
|
+
// ADR-134 slice 3: the credential-store parameter this row mirrors to.
|
|
187
|
+
storeRef: text("store_ref"),
|
|
186
188
|
},
|
|
187
189
|
(t) => ({
|
|
188
190
|
pk: primaryKey({ columns: [t.projectId, t.key] }),
|
package/lib/host-env.ts
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
|
|
17
17
|
import { and, eq } from "drizzle-orm";
|
|
18
18
|
|
|
19
|
+
import {
|
|
20
|
+
credentialStorePrefix,
|
|
21
|
+
isParameterPathSegment,
|
|
22
|
+
parameterName,
|
|
23
|
+
queueStoreWrite,
|
|
24
|
+
} from "./credential-store";
|
|
19
25
|
import { getDb, schema } from "./db";
|
|
20
26
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
21
27
|
|
|
@@ -48,11 +54,12 @@ export function setProjectEnvVar(
|
|
|
48
54
|
set: { valueEnc: packValue(value), updatedAt: Date.now() },
|
|
49
55
|
})
|
|
50
56
|
.run();
|
|
57
|
+
mirrorProjectEnvToStore(projectId, key);
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
/** Remove a project env var (idempotent). */
|
|
54
61
|
export function deleteProjectEnvVar(projectId: string, key: string): void {
|
|
55
|
-
getDb()
|
|
62
|
+
const result = getDb()
|
|
56
63
|
.delete(schema.hostProjectEnv)
|
|
57
64
|
.where(
|
|
58
65
|
and(
|
|
@@ -61,6 +68,104 @@ export function deleteProjectEnvVar(projectId: string, key: string): void {
|
|
|
61
68
|
),
|
|
62
69
|
)
|
|
63
70
|
.run();
|
|
71
|
+
if (result.changes > 0) {
|
|
72
|
+
const name = projectEnvParameterName(projectId, key);
|
|
73
|
+
if (name) queueStoreWrite(`delete project env ${projectId}/${key}`, (store) => store.delete(name));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Credential store mirror (ADR-134 slice 3): `<prefix>/projects/<projectId>/env/<KEY>`.
|
|
79
|
+
// The set/delete frames carry no org, and a project id is org-bound in the
|
|
80
|
+
// cloud, so the project is the owner segment. Owned hosts: no-ops.
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export interface StoredProjectEnv {
|
|
84
|
+
v: 1;
|
|
85
|
+
kind: "project-env";
|
|
86
|
+
projectId: string;
|
|
87
|
+
key: string;
|
|
88
|
+
value: string;
|
|
89
|
+
updatedAt: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Null when the project id or key cannot be a parameter path segment (a
|
|
93
|
+
* key with a character SSM refuses is kept local and logged once). */
|
|
94
|
+
export function projectEnvParameterName(projectId: string, key: string): string | null {
|
|
95
|
+
if (!isParameterPathSegment(projectId) || !isParameterPathSegment(key)) return null;
|
|
96
|
+
return parameterName(credentialStorePrefix(), "projects", projectId, "env", key);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The row as it is mirrored (value decrypted). Never logged. */
|
|
100
|
+
export function projectEnvStoreValue(projectId: string, key: string): StoredProjectEnv | null {
|
|
101
|
+
const row = getDb()
|
|
102
|
+
.select()
|
|
103
|
+
.from(schema.hostProjectEnv)
|
|
104
|
+
.where(and(eq(schema.hostProjectEnv.projectId, projectId), eq(schema.hostProjectEnv.key, key)))
|
|
105
|
+
.get();
|
|
106
|
+
if (!row) return null;
|
|
107
|
+
return {
|
|
108
|
+
v: 1,
|
|
109
|
+
kind: "project-env",
|
|
110
|
+
projectId,
|
|
111
|
+
key,
|
|
112
|
+
value: unpackValue(row.valueEnc),
|
|
113
|
+
updatedAt: row.updatedAt,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function setProjectEnvStoreRef(projectId: string, key: string, storeRef: string): void {
|
|
118
|
+
getDb()
|
|
119
|
+
.update(schema.hostProjectEnv)
|
|
120
|
+
.set({ storeRef })
|
|
121
|
+
.where(and(eq(schema.hostProjectEnv.projectId, projectId), eq(schema.hostProjectEnv.key, key)))
|
|
122
|
+
.run();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function listProjectEnvStoreRows(): Array<{ projectId: string; key: string; storeRef: string | null }> {
|
|
126
|
+
return getDb()
|
|
127
|
+
.select({
|
|
128
|
+
projectId: schema.hostProjectEnv.projectId,
|
|
129
|
+
key: schema.hostProjectEnv.key,
|
|
130
|
+
storeRef: schema.hostProjectEnv.storeRef,
|
|
131
|
+
})
|
|
132
|
+
.from(schema.hostProjectEnv)
|
|
133
|
+
.all();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function mirrorProjectEnvToStore(projectId: string, key: string): void {
|
|
137
|
+
const name = projectEnvParameterName(projectId, key);
|
|
138
|
+
if (!name) return;
|
|
139
|
+
queueStoreWrite(`mirror project env ${projectId}/${key}`, async (store) => {
|
|
140
|
+
const value = projectEnvStoreValue(projectId, key);
|
|
141
|
+
if (!value) return;
|
|
142
|
+
await store.put(name, JSON.stringify(value));
|
|
143
|
+
setProjectEnvStoreRef(projectId, key, name);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Reconcile import: a value this host lacks. Existing rows win. */
|
|
148
|
+
export function importProjectEnvFromStore(value: StoredProjectEnv, storeRef: string): boolean {
|
|
149
|
+
const result = getDb()
|
|
150
|
+
.insert(schema.hostProjectEnv)
|
|
151
|
+
.values({
|
|
152
|
+
projectId: value.projectId,
|
|
153
|
+
key: value.key,
|
|
154
|
+
valueEnc: packValue(value.value),
|
|
155
|
+
updatedAt: value.updatedAt,
|
|
156
|
+
storeRef,
|
|
157
|
+
})
|
|
158
|
+
.onConflictDoNothing({ target: [schema.hostProjectEnv.projectId, schema.hostProjectEnv.key] })
|
|
159
|
+
.run();
|
|
160
|
+
return result.changes > 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Reconcile: the row's parameter vanished elsewhere; drop the row only. */
|
|
164
|
+
export function deleteProjectEnvLocal(projectId: string, key: string): void {
|
|
165
|
+
getDb()
|
|
166
|
+
.delete(schema.hostProjectEnv)
|
|
167
|
+
.where(and(eq(schema.hostProjectEnv.projectId, projectId), eq(schema.hostProjectEnv.key, key)))
|
|
168
|
+
.run();
|
|
64
169
|
}
|
|
65
170
|
|
|
66
171
|
/** Sorted list of a project's env var KEY NAMES (never the values). */
|
|
@@ -41,6 +41,15 @@ import {
|
|
|
41
41
|
setMcpConnectionStoreRef,
|
|
42
42
|
type StoredMcpConnection,
|
|
43
43
|
} from "./mcp-connections";
|
|
44
|
+
import {
|
|
45
|
+
deleteProjectEnvLocal,
|
|
46
|
+
importProjectEnvFromStore,
|
|
47
|
+
listProjectEnvStoreRows,
|
|
48
|
+
projectEnvParameterName,
|
|
49
|
+
projectEnvStoreValue,
|
|
50
|
+
setProjectEnvStoreRef,
|
|
51
|
+
type StoredProjectEnv,
|
|
52
|
+
} from "./host-env";
|
|
44
53
|
import {
|
|
45
54
|
deleteKey as deleteSshKeyLocal,
|
|
46
55
|
importSshKeyFromStore,
|
|
@@ -308,3 +317,98 @@ export async function reconcileUserCredentialsFromStore(): Promise<ReconcileUser
|
|
|
308
317
|
}
|
|
309
318
|
return result;
|
|
310
319
|
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Slice 3: project env values, `<prefix>/projects/<projectId>/env/<KEY>`.
|
|
323
|
+
// Same three moves as above, own listing.
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
export function parseProjectEnvParameterName(
|
|
327
|
+
name: string,
|
|
328
|
+
prefix: string = credentialStorePrefix(),
|
|
329
|
+
): { projectId: string; key: string } | null {
|
|
330
|
+
if (!name.startsWith(`${prefix}/projects/`)) return null;
|
|
331
|
+
const parts = name.slice(prefix.length + 1).split("/");
|
|
332
|
+
if (parts.length !== 4 || parts[2] !== "env") return null;
|
|
333
|
+
if (!parts.every((p) => isParameterPathSegment(p))) return null;
|
|
334
|
+
const [, projectId, , key] = parts as [string, string, string, string];
|
|
335
|
+
return { projectId, key };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function parseStoredProjectEnv(value: string): StoredProjectEnv {
|
|
339
|
+
const o = obj(value);
|
|
340
|
+
if (o.kind !== "project-env") throw new Error("not a project env value");
|
|
341
|
+
return {
|
|
342
|
+
v: 1,
|
|
343
|
+
kind: "project-env",
|
|
344
|
+
projectId: str(o, "projectId"),
|
|
345
|
+
key: str(o, "key"),
|
|
346
|
+
value: str(o, "value"),
|
|
347
|
+
updatedAt: num(o, "updatedAt"),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCredentialsResult> {
|
|
352
|
+
const result: ReconcileUserCredentialsResult = {
|
|
353
|
+
skipped: false,
|
|
354
|
+
imported: 0,
|
|
355
|
+
uploaded: 0,
|
|
356
|
+
removed: 0,
|
|
357
|
+
errors: 0,
|
|
358
|
+
};
|
|
359
|
+
const store = configuredCredentialStore();
|
|
360
|
+
if (!store) return { ...result, skipped: true };
|
|
361
|
+
const prefix = credentialStorePrefix();
|
|
362
|
+
const path = `${prefix}/projects`;
|
|
363
|
+
const warn = (line: string): void => console.warn(`[project-env] credential store: ${line}`);
|
|
364
|
+
let listed: Array<{ name: string; value: string }>;
|
|
365
|
+
try {
|
|
366
|
+
listed = await store.list(path);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
result.errors++;
|
|
369
|
+
warn(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
const local = new Map(listProjectEnvStoreRows().map((r) => [`${r.projectId}/${r.key}`, r]));
|
|
373
|
+
const seen = new Set<string>();
|
|
374
|
+
for (const param of listed) {
|
|
375
|
+
const at = parseProjectEnvParameterName(param.name, prefix);
|
|
376
|
+
if (!at) continue;
|
|
377
|
+
seen.add(param.name);
|
|
378
|
+
try {
|
|
379
|
+
const value = parseStoredProjectEnv(param.value);
|
|
380
|
+
if (value.projectId !== at.projectId || value.key !== at.key) {
|
|
381
|
+
throw new Error("payload does not describe the value its path names");
|
|
382
|
+
}
|
|
383
|
+
const row = local.get(`${at.projectId}/${at.key}`);
|
|
384
|
+
if (row) {
|
|
385
|
+
if (row.storeRef !== param.name) setProjectEnvStoreRef(at.projectId, at.key, param.name);
|
|
386
|
+
} else if (importProjectEnvFromStore(value, param.name)) {
|
|
387
|
+
result.imported++;
|
|
388
|
+
}
|
|
389
|
+
} catch (err) {
|
|
390
|
+
result.errors++;
|
|
391
|
+
warn(`${param.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const row of local.values()) {
|
|
395
|
+
try {
|
|
396
|
+
if (row.storeRef) {
|
|
397
|
+
if (row.storeRef.startsWith(`${path}/`) && !seen.has(row.storeRef)) {
|
|
398
|
+
deleteProjectEnvLocal(row.projectId, row.key);
|
|
399
|
+
result.removed++;
|
|
400
|
+
}
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const name = projectEnvParameterName(row.projectId, row.key);
|
|
404
|
+
const value = projectEnvStoreValue(row.projectId, row.key);
|
|
405
|
+
if (!name || !value) continue;
|
|
406
|
+
await upload(store, name, value, (ref) => setProjectEnvStoreRef(row.projectId, row.key, ref));
|
|
407
|
+
result.uploaded++;
|
|
408
|
+
} catch (err) {
|
|
409
|
+
result.errors++;
|
|
410
|
+
warn(`${row.projectId}/${row.key}: ${err instanceof Error ? err.message : String(err)}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return result;
|
|
414
|
+
}
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -163,7 +163,10 @@ import {
|
|
|
163
163
|
listEngineAccountsAllScopes,
|
|
164
164
|
reconcileEngineAccountsFromStore,
|
|
165
165
|
} from "../lib/engine-accounts";
|
|
166
|
-
import {
|
|
166
|
+
import {
|
|
167
|
+
reconcileProjectEnvFromStore,
|
|
168
|
+
reconcileUserCredentialsFromStore,
|
|
169
|
+
} from "../lib/user-credentials-store";
|
|
167
170
|
import { createEngineConnectManager } from "../lib/engine-connect";
|
|
168
171
|
import { createEngineLoginManager } from "../lib/engine-login";
|
|
169
172
|
import {
|
|
@@ -488,6 +491,18 @@ void reconcileUserCredentialsFromStore()
|
|
|
488
491
|
`[user-credentials] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
489
492
|
);
|
|
490
493
|
});
|
|
494
|
+
void reconcileProjectEnvFromStore()
|
|
495
|
+
.then((r) => {
|
|
496
|
+
if (r.skipped) return;
|
|
497
|
+
console.log(
|
|
498
|
+
`[project-env] credential store reconciled: ${r.imported} imported, ${r.uploaded} uploaded, ${r.removed} removed, ${r.errors} errors`,
|
|
499
|
+
);
|
|
500
|
+
})
|
|
501
|
+
.catch((error: unknown) => {
|
|
502
|
+
console.warn(
|
|
503
|
+
`[project-env] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
504
|
+
);
|
|
505
|
+
});
|
|
491
506
|
// Reconcile a durable retention-off setting before recovery/connect can make
|
|
492
507
|
// crash-left owner-facing terminal history observable again. Correctness
|
|
493
508
|
// tombstones remain intact inside applyTerminalHistoryRetention.
|