@runuai/host 0.9.112 → 0.9.113
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/credential-store.ts +41 -0
- package/lib/engine-accounts.ts +3 -18
- package/lib/github-tokens.ts +117 -1
- package/lib/mcp-connections.ts +138 -4
- package/lib/ssh.ts +91 -0
- package/lib/user-credentials-store.ts +310 -0
- package/package.json +1 -1
- package/src/main.ts +13 -0
|
@@ -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/credential-store.ts
CHANGED
|
@@ -237,6 +237,47 @@ export class AwsSsmCredentialStore implements CredentialStore {
|
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Write queue shared by every mirrored table (engine accounts, GitHub tokens,
|
|
242
|
+
// SSH keys, MCP connections). Mirrors are async and best effort: a store
|
|
243
|
+
// outage delays a mirror, never a task-up or a UI action.
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
const inflight = new Set<Promise<void>>();
|
|
247
|
+
|
|
248
|
+
/** Run `work` against the configured store off the caller's path; a no-op on
|
|
249
|
+
* owned hosts. Failures are logged (never the value) and never thrown. */
|
|
250
|
+
export function queueStoreWrite(
|
|
251
|
+
what: string,
|
|
252
|
+
work: (store: CredentialStore) => Promise<void>,
|
|
253
|
+
): void {
|
|
254
|
+
const store = configuredCredentialStore();
|
|
255
|
+
if (!store) return;
|
|
256
|
+
const p: Promise<void> = work(store)
|
|
257
|
+
.catch((err: unknown) => {
|
|
258
|
+
console.warn(
|
|
259
|
+
`[credential-store] ${what} failed: ${
|
|
260
|
+
err instanceof Error ? err.message : String(err)
|
|
261
|
+
}`,
|
|
262
|
+
);
|
|
263
|
+
})
|
|
264
|
+
.finally(() => {
|
|
265
|
+
inflight.delete(p);
|
|
266
|
+
});
|
|
267
|
+
inflight.add(p);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Tests / shutdown: wait for every queued mirror or delete to settle. */
|
|
271
|
+
export async function settleStoreWrites(): Promise<void> {
|
|
272
|
+
while (inflight.size > 0) await Promise.allSettled([...inflight]);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** `<prefix>/users/<userId>/<…>` — the per-user subtree (GitHub token, SSH
|
|
276
|
+
* key, MCP connections). The STS `user` session tag scopes it later. */
|
|
277
|
+
export function userParameterName(userId: string, ...segments: string[]): string {
|
|
278
|
+
return parameterName(credentialStorePrefix(), "users", userId, ...segments);
|
|
279
|
+
}
|
|
280
|
+
|
|
240
281
|
// ---------------------------------------------------------------------------
|
|
241
282
|
// Process-wide configuration.
|
|
242
283
|
// ---------------------------------------------------------------------------
|
package/lib/engine-accounts.ts
CHANGED
|
@@ -46,6 +46,8 @@ import {
|
|
|
46
46
|
credentialStorePrefix,
|
|
47
47
|
isParameterPathSegment,
|
|
48
48
|
parameterName,
|
|
49
|
+
queueStoreWrite,
|
|
50
|
+
settleStoreWrites,
|
|
49
51
|
type CredentialStore,
|
|
50
52
|
} from "./credential-store";
|
|
51
53
|
import { getDb, schema } from "./db";
|
|
@@ -1127,7 +1129,6 @@ type StoreRow = {
|
|
|
1127
1129
|
|
|
1128
1130
|
let storeHostId: string | null = null;
|
|
1129
1131
|
let warnedNoHostId = false;
|
|
1130
|
-
const inflight = new Set<Promise<void>>();
|
|
1131
1132
|
|
|
1132
1133
|
/** main.ts: the host id that names `host`-scope parameters. */
|
|
1133
1134
|
export function configureEngineAccountStore(opts: { hostId: string | null }): void {
|
|
@@ -1136,7 +1137,7 @@ export function configureEngineAccountStore(opts: { hostId: string | null }): vo
|
|
|
1136
1137
|
|
|
1137
1138
|
/** Tests: wait for every queued mirror/delete to settle. */
|
|
1138
1139
|
export async function settleEngineAccountStoreWrites(): Promise<void> {
|
|
1139
|
-
|
|
1140
|
+
await settleStoreWrites();
|
|
1140
1141
|
}
|
|
1141
1142
|
|
|
1142
1143
|
/**
|
|
@@ -1240,22 +1241,6 @@ function parseStoredAccount(value: string): StoredEngineAccount {
|
|
|
1240
1241
|
};
|
|
1241
1242
|
}
|
|
1242
1243
|
|
|
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
1244
|
|
|
1260
1245
|
function selectRow(id: string): StoreRow | undefined {
|
|
1261
1246
|
return getDb()
|
package/lib/github-tokens.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { and, eq } from "drizzle-orm";
|
|
11
11
|
|
|
12
|
+
import { queueStoreWrite, userParameterName } from "./credential-store";
|
|
12
13
|
import { getDb, schema } from "./db";
|
|
13
14
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
14
15
|
import { dockerCli } from "./docker-exec";
|
|
@@ -446,10 +447,13 @@ export async function onConnectSet(
|
|
|
446
447
|
}
|
|
447
448
|
|
|
448
449
|
function deleteStoredTokenRow(userId: string): void {
|
|
449
|
-
getDb()
|
|
450
|
+
const result = getDb()
|
|
450
451
|
.delete(schema.githubTokens)
|
|
451
452
|
.where(eq(schema.githubTokens.userId, userId))
|
|
452
453
|
.run();
|
|
454
|
+
// A connect clears any prior grant first; with none stored there is no
|
|
455
|
+
// parameter to delete either.
|
|
456
|
+
if (result.changes > 0) deleteGithubTokenFromStore(userId);
|
|
453
457
|
}
|
|
454
458
|
|
|
455
459
|
function storeTokenRow(
|
|
@@ -484,6 +488,117 @@ function storeTokenRow(
|
|
|
484
488
|
.onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
|
|
485
489
|
.run();
|
|
486
490
|
});
|
|
491
|
+
mirrorGithubTokenToStore(userId);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Credential store mirror (ADR-134 slice 2): `<prefix>/users/<userId>/github`.
|
|
496
|
+
// The generation rides along so a replacement host imports the row behind the
|
|
497
|
+
// same durable fence (a later disconnect this host never saw still wins).
|
|
498
|
+
// Owned hosts: no-ops.
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
export interface StoredGithubToken {
|
|
502
|
+
v: 1;
|
|
503
|
+
kind: "github";
|
|
504
|
+
userId: string;
|
|
505
|
+
installationId: number;
|
|
506
|
+
generation: number;
|
|
507
|
+
tokenKind: string;
|
|
508
|
+
token: string;
|
|
509
|
+
expiresAt: number | null;
|
|
510
|
+
updatedAt: number;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function githubTokenParameterName(userId: string): string {
|
|
514
|
+
return userParameterName(userId, "github");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** The row as it is mirrored (token decrypted). Never logged. */
|
|
518
|
+
export function githubTokenStoreValue(userId: string): StoredGithubToken | null {
|
|
519
|
+
const row = getDb()
|
|
520
|
+
.select()
|
|
521
|
+
.from(schema.githubTokens)
|
|
522
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
523
|
+
.get();
|
|
524
|
+
if (!row) return null;
|
|
525
|
+
return {
|
|
526
|
+
v: 1,
|
|
527
|
+
kind: "github",
|
|
528
|
+
userId,
|
|
529
|
+
installationId: row.installationId,
|
|
530
|
+
generation: row.generation,
|
|
531
|
+
tokenKind: row.kind,
|
|
532
|
+
token: openAesGcm(
|
|
533
|
+
Buffer.from(row.refreshTokenCt as Uint8Array),
|
|
534
|
+
Buffer.from(row.refreshTokenNonce as Uint8Array),
|
|
535
|
+
),
|
|
536
|
+
expiresAt: row.refreshTokenExpiresAt ?? null,
|
|
537
|
+
updatedAt: row.updatedAt,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function setGithubTokenStoreRef(userId: string, storeRef: string): void {
|
|
542
|
+
getDb()
|
|
543
|
+
.update(schema.githubTokens)
|
|
544
|
+
.set({ storeRef })
|
|
545
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
546
|
+
.run();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function listGithubTokenStoreRows(): Array<{ userId: string; storeRef: string | null }> {
|
|
550
|
+
return getDb()
|
|
551
|
+
.select({ userId: schema.githubTokens.userId, storeRef: schema.githubTokens.storeRef })
|
|
552
|
+
.from(schema.githubTokens)
|
|
553
|
+
.all();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function mirrorGithubTokenToStore(userId: string): void {
|
|
557
|
+
queueStoreWrite(`mirror github token ${userId}`, async (store) => {
|
|
558
|
+
const value = githubTokenStoreValue(userId);
|
|
559
|
+
if (!value) return;
|
|
560
|
+
const name = githubTokenParameterName(userId);
|
|
561
|
+
await store.put(name, JSON.stringify(value));
|
|
562
|
+
setGithubTokenStoreRef(userId, name);
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function deleteGithubTokenFromStore(userId: string): void {
|
|
567
|
+
queueStoreWrite(`delete github token ${userId}`, (store) =>
|
|
568
|
+
store.delete(githubTokenParameterName(userId)),
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Reconcile import: a token this host lacks. Claims the stored generation as
|
|
574
|
+
* a `set` first — refused (false) when this host's fence already moved past
|
|
575
|
+
* it, so a disconnect that outran the store's delete can never resurrect a
|
|
576
|
+
* token here.
|
|
577
|
+
*/
|
|
578
|
+
export function importGithubTokenFromStore(
|
|
579
|
+
value: StoredGithubToken,
|
|
580
|
+
storeRef: string,
|
|
581
|
+
): boolean {
|
|
582
|
+
const claim = claimGithubCredentialGeneration(value.userId, value.generation, "set");
|
|
583
|
+
if (!claim) return false;
|
|
584
|
+
const sealed = sealAesGcm(value.token);
|
|
585
|
+
const fields = {
|
|
586
|
+
installationId: value.installationId,
|
|
587
|
+
generation: value.generation,
|
|
588
|
+
refreshTokenCt: sealed.ct,
|
|
589
|
+
refreshTokenNonce: sealed.nonce,
|
|
590
|
+
refreshTokenExpiresAt: value.expiresAt,
|
|
591
|
+
kind: value.tokenKind,
|
|
592
|
+
updatedAt: value.updatedAt,
|
|
593
|
+
storeRef,
|
|
594
|
+
};
|
|
595
|
+
getDb()
|
|
596
|
+
.insert(schema.githubTokens)
|
|
597
|
+
.values({ userId: value.userId, ...fields })
|
|
598
|
+
.onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
|
|
599
|
+
.run();
|
|
600
|
+
notifyGithubChange();
|
|
601
|
+
return true;
|
|
487
602
|
}
|
|
488
603
|
|
|
489
604
|
async function bestEffortRevoke(
|
|
@@ -663,6 +778,7 @@ export function deleteToken(userId: string): void {
|
|
|
663
778
|
.delete(schema.githubTokens)
|
|
664
779
|
.where(eq(schema.githubTokens.userId, userId))
|
|
665
780
|
.run();
|
|
781
|
+
deleteGithubTokenFromStore(userId);
|
|
666
782
|
advanceGithubAuthGeneration(userId);
|
|
667
783
|
notifyGithubChange();
|
|
668
784
|
}
|
package/lib/mcp-connections.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { createHash, randomBytes } from "node:crypto";
|
|
15
15
|
import { eq } from "drizzle-orm";
|
|
16
16
|
|
|
17
|
+
import { queueStoreWrite, userParameterName } from "./credential-store";
|
|
17
18
|
import { getDb, schema } from "./db";
|
|
18
19
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
19
20
|
import type { McpOp } from "../src/protocol";
|
|
@@ -276,10 +277,7 @@ export async function handleMcpOp(op: McpOp): Promise<McpAck> {
|
|
|
276
277
|
case "oauth.complete":
|
|
277
278
|
return oauthComplete(op.connectionId, op.code);
|
|
278
279
|
case "disconnect": {
|
|
279
|
-
|
|
280
|
-
.delete(schema.mcpConnections)
|
|
281
|
-
.where(eq(schema.mcpConnections.id, op.connectionId))
|
|
282
|
-
.run();
|
|
280
|
+
deleteMcpConnectionLocal(op.connectionId);
|
|
283
281
|
return { status: "disconnected" };
|
|
284
282
|
}
|
|
285
283
|
}
|
|
@@ -334,6 +332,7 @@ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
|
|
|
334
332
|
},
|
|
335
333
|
})
|
|
336
334
|
.run();
|
|
335
|
+
mirrorMcpConnectionToStore(op.connectionId);
|
|
337
336
|
return { status: "connected" };
|
|
338
337
|
}
|
|
339
338
|
|
|
@@ -354,6 +353,7 @@ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
|
|
|
354
353
|
set: { url: op.url, authKind: "none", status: "connected", updatedAt: now },
|
|
355
354
|
})
|
|
356
355
|
.run();
|
|
356
|
+
mirrorMcpConnectionToStore(op.connectionId);
|
|
357
357
|
return { status: "connected" };
|
|
358
358
|
}
|
|
359
359
|
if (res.status !== 401 && res.status !== 403) {
|
|
@@ -414,6 +414,7 @@ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
|
|
|
414
414
|
.values({ id: op.connectionId, ...fields })
|
|
415
415
|
.onConflictDoUpdate({ target: schema.mcpConnections.id, set: fields })
|
|
416
416
|
.run();
|
|
417
|
+
mirrorMcpConnectionToStore(op.connectionId);
|
|
417
418
|
return { status: "auth_required", authorizeUrl: authorize.toString() };
|
|
418
419
|
}
|
|
419
420
|
|
|
@@ -472,6 +473,7 @@ async function oauthComplete(connectionId: string, code: string): Promise<McpAck
|
|
|
472
473
|
})
|
|
473
474
|
.where(eq(schema.mcpConnections.id, connectionId))
|
|
474
475
|
.run();
|
|
476
|
+
mirrorMcpConnectionToStore(connectionId);
|
|
475
477
|
return {
|
|
476
478
|
status: "connected",
|
|
477
479
|
scopes: scopes ? scopes.split(" ").filter(Boolean) : undefined,
|
|
@@ -611,5 +613,137 @@ async function refreshTokens(
|
|
|
611
613
|
.set({ secretEnc: pack(JSON.stringify(next)), updatedAt: Date.now() })
|
|
612
614
|
.where(eq(schema.mcpConnections.id, row.id))
|
|
613
615
|
.run();
|
|
616
|
+
mirrorMcpConnectionToStore(row.id);
|
|
614
617
|
return next;
|
|
615
618
|
}
|
|
619
|
+
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
// Credential store mirror (ADR-134 slice 2):
|
|
622
|
+
// `<prefix>/users/<userId>/mcp/<connectionId>`. The connection id is the
|
|
623
|
+
// cloud's, which is org-bound there; on the host the owner is the user.
|
|
624
|
+
// Owned hosts: no-ops.
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
export interface StoredMcpConnection {
|
|
628
|
+
v: 1;
|
|
629
|
+
kind: "mcp";
|
|
630
|
+
id: string;
|
|
631
|
+
userId: string;
|
|
632
|
+
url: string;
|
|
633
|
+
authKind: string;
|
|
634
|
+
tokenEndpoint: string | null;
|
|
635
|
+
redirectUri: string | null;
|
|
636
|
+
clientId: string | null;
|
|
637
|
+
clientSecret: string | null;
|
|
638
|
+
pkceVerifier: string | null;
|
|
639
|
+
/** The sealed JSON, decrypted: header or OAuth tokens. */
|
|
640
|
+
secret: string | null;
|
|
641
|
+
status: string;
|
|
642
|
+
scopes: string | null;
|
|
643
|
+
updatedAt: number;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export function mcpConnectionParameterName(userId: string, id: string): string {
|
|
647
|
+
return userParameterName(userId, "mcp", id);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** The row as it is mirrored (secrets decrypted). Never logged. */
|
|
651
|
+
export function mcpConnectionStoreValue(id: string): StoredMcpConnection | null {
|
|
652
|
+
const row = getDb()
|
|
653
|
+
.select()
|
|
654
|
+
.from(schema.mcpConnections)
|
|
655
|
+
.where(eq(schema.mcpConnections.id, id))
|
|
656
|
+
.get();
|
|
657
|
+
if (!row) return null;
|
|
658
|
+
return {
|
|
659
|
+
v: 1,
|
|
660
|
+
kind: "mcp",
|
|
661
|
+
id: row.id,
|
|
662
|
+
userId: row.userId,
|
|
663
|
+
url: row.url,
|
|
664
|
+
authKind: row.authKind,
|
|
665
|
+
tokenEndpoint: row.tokenEndpoint,
|
|
666
|
+
redirectUri: row.redirectUri,
|
|
667
|
+
clientId: row.clientId,
|
|
668
|
+
clientSecret: row.clientSecretEnc ? unpack(row.clientSecretEnc) : null,
|
|
669
|
+
pkceVerifier: row.pkceVerifierEnc ? unpack(row.pkceVerifierEnc) : null,
|
|
670
|
+
secret: row.secretEnc ? unpack(row.secretEnc) : null,
|
|
671
|
+
status: row.status,
|
|
672
|
+
scopes: row.scopes,
|
|
673
|
+
updatedAt: row.updatedAt,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export function setMcpConnectionStoreRef(id: string, storeRef: string): void {
|
|
678
|
+
getDb()
|
|
679
|
+
.update(schema.mcpConnections)
|
|
680
|
+
.set({ storeRef })
|
|
681
|
+
.where(eq(schema.mcpConnections.id, id))
|
|
682
|
+
.run();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export function listMcpConnectionStoreRows(): Array<{
|
|
686
|
+
id: string;
|
|
687
|
+
userId: string;
|
|
688
|
+
storeRef: string | null;
|
|
689
|
+
}> {
|
|
690
|
+
return getDb()
|
|
691
|
+
.select({
|
|
692
|
+
id: schema.mcpConnections.id,
|
|
693
|
+
userId: schema.mcpConnections.userId,
|
|
694
|
+
storeRef: schema.mcpConnections.storeRef,
|
|
695
|
+
})
|
|
696
|
+
.from(schema.mcpConnections)
|
|
697
|
+
.all();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export function mirrorMcpConnectionToStore(id: string): void {
|
|
701
|
+
queueStoreWrite(`mirror mcp connection ${id}`, async (store) => {
|
|
702
|
+
const value = mcpConnectionStoreValue(id);
|
|
703
|
+
if (!value) return;
|
|
704
|
+
const name = mcpConnectionParameterName(value.userId, id);
|
|
705
|
+
await store.put(name, JSON.stringify(value));
|
|
706
|
+
setMcpConnectionStoreRef(id, name);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Delete the row and its parameter (the disconnect op and reconcile). */
|
|
711
|
+
export function deleteMcpConnectionLocal(id: string): void {
|
|
712
|
+
const row = getDb()
|
|
713
|
+
.select({ userId: schema.mcpConnections.userId, storeRef: schema.mcpConnections.storeRef })
|
|
714
|
+
.from(schema.mcpConnections)
|
|
715
|
+
.where(eq(schema.mcpConnections.id, id))
|
|
716
|
+
.get();
|
|
717
|
+
getDb().delete(schema.mcpConnections).where(eq(schema.mcpConnections.id, id)).run();
|
|
718
|
+
if (!row) return;
|
|
719
|
+
const name = row.storeRef ?? mcpConnectionParameterName(row.userId, id);
|
|
720
|
+
queueStoreWrite(`delete mcp connection ${id}`, (store) => store.delete(name));
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Reconcile import: a connection this host lacks. Existing rows win. */
|
|
724
|
+
export function importMcpConnectionFromStore(
|
|
725
|
+
value: StoredMcpConnection,
|
|
726
|
+
storeRef: string,
|
|
727
|
+
): boolean {
|
|
728
|
+
const result = getDb()
|
|
729
|
+
.insert(schema.mcpConnections)
|
|
730
|
+
.values({
|
|
731
|
+
id: value.id,
|
|
732
|
+
userId: value.userId,
|
|
733
|
+
url: value.url,
|
|
734
|
+
authKind: value.authKind,
|
|
735
|
+
tokenEndpoint: value.tokenEndpoint,
|
|
736
|
+
redirectUri: value.redirectUri,
|
|
737
|
+
clientId: value.clientId,
|
|
738
|
+
clientSecretEnc: value.clientSecret === null ? null : pack(value.clientSecret),
|
|
739
|
+
pkceVerifierEnc: value.pkceVerifier === null ? null : pack(value.pkceVerifier),
|
|
740
|
+
secretEnc: value.secret === null ? null : pack(value.secret),
|
|
741
|
+
status: value.status,
|
|
742
|
+
scopes: value.scopes,
|
|
743
|
+
updatedAt: value.updatedAt,
|
|
744
|
+
storeRef,
|
|
745
|
+
})
|
|
746
|
+
.onConflictDoNothing({ target: schema.mcpConnections.id })
|
|
747
|
+
.run();
|
|
748
|
+
return result.changes > 0;
|
|
749
|
+
}
|
package/lib/ssh.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { join, resolve } from "node:path";
|
|
|
23
23
|
|
|
24
24
|
import { eq } from "drizzle-orm";
|
|
25
25
|
|
|
26
|
+
import { queueStoreWrite, userParameterName } from "./credential-store";
|
|
26
27
|
import { getDb, schema } from "./db";
|
|
27
28
|
import { env } from "./env";
|
|
28
29
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
@@ -104,12 +105,102 @@ export function ensureKeyForUser(userId: string): string {
|
|
|
104
105
|
})
|
|
105
106
|
.onConflictDoNothing({ target: schema.sshKeys.userId })
|
|
106
107
|
.run();
|
|
108
|
+
mirrorSshKeyToStore(userId);
|
|
107
109
|
// Re-read in case a concurrent task-up won the insert race.
|
|
108
110
|
return getPublicKey(userId) ?? publicKey;
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
export function deleteKey(userId: string): void {
|
|
112
114
|
getDb().delete(schema.sshKeys).where(eq(schema.sshKeys.userId, userId)).run();
|
|
115
|
+
queueStoreWrite(`delete ssh key ${userId}`, (store) =>
|
|
116
|
+
store.delete(sshKeyParameterName(userId)),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Credential store mirror (ADR-134 slice 2): `<prefix>/users/<userId>/ssh`.
|
|
122
|
+
// A replacement Uai-operated host imports the SAME keypair, so the public key
|
|
123
|
+
// the user pasted into GitHub keeps working. Owned hosts: no-ops.
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
export interface StoredSshKey {
|
|
127
|
+
v: 1;
|
|
128
|
+
kind: "ssh";
|
|
129
|
+
userId: string;
|
|
130
|
+
publicKey: string;
|
|
131
|
+
privateKey: string;
|
|
132
|
+
createdAt: number;
|
|
133
|
+
updatedAt: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function sshKeyParameterName(userId: string): string {
|
|
137
|
+
return userParameterName(userId, "ssh");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The row as it is mirrored (private key decrypted). Never logged. */
|
|
141
|
+
export function sshKeyStoreValue(userId: string): StoredSshKey | null {
|
|
142
|
+
const row = getDb()
|
|
143
|
+
.select()
|
|
144
|
+
.from(schema.sshKeys)
|
|
145
|
+
.where(eq(schema.sshKeys.userId, userId))
|
|
146
|
+
.get();
|
|
147
|
+
if (!row) return null;
|
|
148
|
+
return {
|
|
149
|
+
v: 1,
|
|
150
|
+
kind: "ssh",
|
|
151
|
+
userId,
|
|
152
|
+
publicKey: row.publicKey,
|
|
153
|
+
privateKey: openAesGcm(
|
|
154
|
+
Buffer.from(row.privateKeyCt as Uint8Array),
|
|
155
|
+
Buffer.from(row.privateKeyNonce as Uint8Array),
|
|
156
|
+
),
|
|
157
|
+
createdAt: row.createdAt,
|
|
158
|
+
updatedAt: row.updatedAt,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function setSshKeyStoreRef(userId: string, storeRef: string): void {
|
|
163
|
+
getDb()
|
|
164
|
+
.update(schema.sshKeys)
|
|
165
|
+
.set({ storeRef })
|
|
166
|
+
.where(eq(schema.sshKeys.userId, userId))
|
|
167
|
+
.run();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function listSshKeyStoreRows(): Array<{ userId: string; storeRef: string | null }> {
|
|
171
|
+
return getDb()
|
|
172
|
+
.select({ userId: schema.sshKeys.userId, storeRef: schema.sshKeys.storeRef })
|
|
173
|
+
.from(schema.sshKeys)
|
|
174
|
+
.all();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function mirrorSshKeyToStore(userId: string): void {
|
|
178
|
+
queueStoreWrite(`mirror ssh key ${userId}`, async (store) => {
|
|
179
|
+
const value = sshKeyStoreValue(userId);
|
|
180
|
+
if (!value) return;
|
|
181
|
+
const name = sshKeyParameterName(userId);
|
|
182
|
+
await store.put(name, JSON.stringify(value));
|
|
183
|
+
setSshKeyStoreRef(userId, name);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Reconcile import: a keypair this host lacks. Existing rows win. */
|
|
188
|
+
export function importSshKeyFromStore(value: StoredSshKey, storeRef: string): boolean {
|
|
189
|
+
const sealed = sealAesGcm(value.privateKey);
|
|
190
|
+
const result = getDb()
|
|
191
|
+
.insert(schema.sshKeys)
|
|
192
|
+
.values({
|
|
193
|
+
userId: value.userId,
|
|
194
|
+
publicKey: value.publicKey,
|
|
195
|
+
privateKeyCt: sealed.ct,
|
|
196
|
+
privateKeyNonce: sealed.nonce,
|
|
197
|
+
createdAt: value.createdAt,
|
|
198
|
+
updatedAt: value.updatedAt,
|
|
199
|
+
storeRef,
|
|
200
|
+
})
|
|
201
|
+
.onConflictDoNothing({ target: schema.sshKeys.userId })
|
|
202
|
+
.run();
|
|
203
|
+
return result.changes > 0;
|
|
113
204
|
}
|
|
114
205
|
|
|
115
206
|
/** Decrypt + return the user's private key, or null if they have none. */
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-134 slice 2 — per-user credentials on a Uai-operated host reconcile
|
|
3
|
+
* with the credential store at start, the same way engine accounts do
|
|
4
|
+
* (`reconcileEngineAccountsFromStore`):
|
|
5
|
+
*
|
|
6
|
+
* <prefix>/users/<userId>/github GitHub App user token
|
|
7
|
+
* <prefix>/users/<userId>/ssh the user's host-minted keypair
|
|
8
|
+
* <prefix>/users/<userId>/mcp/<connId> an MCP connection
|
|
9
|
+
*
|
|
10
|
+
* - a parameter with no local row is imported (a replacement host inherits
|
|
11
|
+
* every user's credentials without anyone reconnecting);
|
|
12
|
+
* - a local row with no `store_ref` is uploaded (first start with the flag);
|
|
13
|
+
* - a local row whose `store_ref` is under the listed path but absent from
|
|
14
|
+
* it was removed elsewhere — it goes locally too.
|
|
15
|
+
* The write-through mirrors live next to each table's writes (ssh.ts,
|
|
16
|
+
* github-tokens.ts, mcp-connections.ts). Owned hosts: `configuredCredentialStore()`
|
|
17
|
+
* is null and nothing here runs.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
configuredCredentialStore,
|
|
22
|
+
credentialStorePrefix,
|
|
23
|
+
isParameterPathSegment,
|
|
24
|
+
type CredentialStore,
|
|
25
|
+
} from "./credential-store";
|
|
26
|
+
import {
|
|
27
|
+
deleteToken as deleteGithubTokenLocal,
|
|
28
|
+
githubTokenParameterName,
|
|
29
|
+
githubTokenStoreValue,
|
|
30
|
+
importGithubTokenFromStore,
|
|
31
|
+
listGithubTokenStoreRows,
|
|
32
|
+
setGithubTokenStoreRef,
|
|
33
|
+
type StoredGithubToken,
|
|
34
|
+
} from "./github-tokens";
|
|
35
|
+
import {
|
|
36
|
+
deleteMcpConnectionLocal,
|
|
37
|
+
importMcpConnectionFromStore,
|
|
38
|
+
listMcpConnectionStoreRows,
|
|
39
|
+
mcpConnectionParameterName,
|
|
40
|
+
mcpConnectionStoreValue,
|
|
41
|
+
setMcpConnectionStoreRef,
|
|
42
|
+
type StoredMcpConnection,
|
|
43
|
+
} from "./mcp-connections";
|
|
44
|
+
import {
|
|
45
|
+
deleteKey as deleteSshKeyLocal,
|
|
46
|
+
importSshKeyFromStore,
|
|
47
|
+
listSshKeyStoreRows,
|
|
48
|
+
setSshKeyStoreRef,
|
|
49
|
+
sshKeyParameterName,
|
|
50
|
+
sshKeyStoreValue,
|
|
51
|
+
type StoredSshKey,
|
|
52
|
+
} from "./ssh";
|
|
53
|
+
|
|
54
|
+
export interface ReconcileUserCredentialsResult {
|
|
55
|
+
skipped: boolean;
|
|
56
|
+
imported: number;
|
|
57
|
+
uploaded: number;
|
|
58
|
+
removed: number;
|
|
59
|
+
errors: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type ParsedName =
|
|
63
|
+
| { kind: "github"; userId: string }
|
|
64
|
+
| { kind: "ssh"; userId: string }
|
|
65
|
+
| { kind: "mcp"; userId: string; id: string };
|
|
66
|
+
|
|
67
|
+
/** `<prefix>/users/<u>/github|ssh|mcp/<id>`; null for anything else. */
|
|
68
|
+
export function parseUserParameterName(
|
|
69
|
+
name: string,
|
|
70
|
+
prefix: string = credentialStorePrefix(),
|
|
71
|
+
): ParsedName | null {
|
|
72
|
+
if (!name.startsWith(`${prefix}/users/`)) return null;
|
|
73
|
+
const parts = name.slice(prefix.length + 1).split("/");
|
|
74
|
+
if (!parts.every((p) => isParameterPathSegment(p))) return null;
|
|
75
|
+
const [, userId, what, id] = parts;
|
|
76
|
+
if (!userId || !what) return null;
|
|
77
|
+
if (parts.length === 3 && what === "github") return { kind: "github", userId };
|
|
78
|
+
if (parts.length === 3 && what === "ssh") return { kind: "ssh", userId };
|
|
79
|
+
if (parts.length === 4 && what === "mcp" && id) return { kind: "mcp", userId, id };
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function obj(value: string): Record<string, unknown> {
|
|
84
|
+
const parsed: unknown = JSON.parse(value);
|
|
85
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
86
|
+
throw new Error("not an object");
|
|
87
|
+
}
|
|
88
|
+
const o = parsed as Record<string, unknown>;
|
|
89
|
+
if (o.v !== 1) throw new Error(`unsupported version ${String(o.v)}`);
|
|
90
|
+
return o;
|
|
91
|
+
}
|
|
92
|
+
function str(o: Record<string, unknown>, key: string): string {
|
|
93
|
+
const v = o[key];
|
|
94
|
+
if (typeof v !== "string") throw new Error(`bad ${key}`);
|
|
95
|
+
return v;
|
|
96
|
+
}
|
|
97
|
+
function strOrNull(o: Record<string, unknown>, key: string): string | null {
|
|
98
|
+
const v = o[key];
|
|
99
|
+
if (v === null || v === undefined) return null;
|
|
100
|
+
if (typeof v !== "string") throw new Error(`bad ${key}`);
|
|
101
|
+
return v;
|
|
102
|
+
}
|
|
103
|
+
function num(o: Record<string, unknown>, key: string): number {
|
|
104
|
+
const v = o[key];
|
|
105
|
+
if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`bad ${key}`);
|
|
106
|
+
return v;
|
|
107
|
+
}
|
|
108
|
+
function numOrNull(o: Record<string, unknown>, key: string): number | null {
|
|
109
|
+
const v = o[key];
|
|
110
|
+
if (v === null || v === undefined) return null;
|
|
111
|
+
if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`bad ${key}`);
|
|
112
|
+
return v;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function parseStoredGithubToken(value: string): StoredGithubToken {
|
|
116
|
+
const o = obj(value);
|
|
117
|
+
if (o.kind !== "github") throw new Error("not a github token");
|
|
118
|
+
const generation = num(o, "generation");
|
|
119
|
+
if (!Number.isSafeInteger(generation) || generation < 0) throw new Error("bad generation");
|
|
120
|
+
return {
|
|
121
|
+
v: 1,
|
|
122
|
+
kind: "github",
|
|
123
|
+
userId: str(o, "userId"),
|
|
124
|
+
installationId: num(o, "installationId"),
|
|
125
|
+
generation,
|
|
126
|
+
tokenKind: str(o, "tokenKind"),
|
|
127
|
+
token: str(o, "token"),
|
|
128
|
+
expiresAt: numOrNull(o, "expiresAt"),
|
|
129
|
+
updatedAt: num(o, "updatedAt"),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function parseStoredSshKey(value: string): StoredSshKey {
|
|
134
|
+
const o = obj(value);
|
|
135
|
+
if (o.kind !== "ssh") throw new Error("not an ssh key");
|
|
136
|
+
return {
|
|
137
|
+
v: 1,
|
|
138
|
+
kind: "ssh",
|
|
139
|
+
userId: str(o, "userId"),
|
|
140
|
+
publicKey: str(o, "publicKey"),
|
|
141
|
+
privateKey: str(o, "privateKey"),
|
|
142
|
+
createdAt: num(o, "createdAt"),
|
|
143
|
+
updatedAt: num(o, "updatedAt"),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function parseStoredMcpConnection(value: string): StoredMcpConnection {
|
|
148
|
+
const o = obj(value);
|
|
149
|
+
if (o.kind !== "mcp") throw new Error("not an mcp connection");
|
|
150
|
+
return {
|
|
151
|
+
v: 1,
|
|
152
|
+
kind: "mcp",
|
|
153
|
+
id: str(o, "id"),
|
|
154
|
+
userId: str(o, "userId"),
|
|
155
|
+
url: str(o, "url"),
|
|
156
|
+
authKind: str(o, "authKind"),
|
|
157
|
+
tokenEndpoint: strOrNull(o, "tokenEndpoint"),
|
|
158
|
+
redirectUri: strOrNull(o, "redirectUri"),
|
|
159
|
+
clientId: strOrNull(o, "clientId"),
|
|
160
|
+
clientSecret: strOrNull(o, "clientSecret"),
|
|
161
|
+
pkceVerifier: strOrNull(o, "pkceVerifier"),
|
|
162
|
+
secret: strOrNull(o, "secret"),
|
|
163
|
+
status: str(o, "status"),
|
|
164
|
+
scopes: strOrNull(o, "scopes"),
|
|
165
|
+
updatedAt: num(o, "updatedAt"),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function upload(
|
|
170
|
+
store: CredentialStore,
|
|
171
|
+
name: string,
|
|
172
|
+
value: unknown,
|
|
173
|
+
setRef: (ref: string) => void,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
await store.put(name, JSON.stringify(value));
|
|
176
|
+
setRef(name);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function reconcileUserCredentialsFromStore(): Promise<ReconcileUserCredentialsResult> {
|
|
180
|
+
const result: ReconcileUserCredentialsResult = {
|
|
181
|
+
skipped: false,
|
|
182
|
+
imported: 0,
|
|
183
|
+
uploaded: 0,
|
|
184
|
+
removed: 0,
|
|
185
|
+
errors: 0,
|
|
186
|
+
};
|
|
187
|
+
const store = configuredCredentialStore();
|
|
188
|
+
if (!store) return { ...result, skipped: true };
|
|
189
|
+
const prefix = credentialStorePrefix();
|
|
190
|
+
const path = `${prefix}/users`;
|
|
191
|
+
const warn = (line: string): void =>
|
|
192
|
+
console.warn(`[user-credentials] credential store: ${line}`);
|
|
193
|
+
|
|
194
|
+
let listed: Array<{ name: string; value: string }>;
|
|
195
|
+
try {
|
|
196
|
+
listed = await store.list(path);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
result.errors++;
|
|
199
|
+
warn(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
200
|
+
// Nothing else is safe to decide without the inventory.
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const github = new Map(listGithubTokenStoreRows().map((r) => [r.userId, r.storeRef]));
|
|
205
|
+
const ssh = new Map(listSshKeyStoreRows().map((r) => [r.userId, r.storeRef]));
|
|
206
|
+
const mcp = new Map(listMcpConnectionStoreRows().map((r) => [r.id, r]));
|
|
207
|
+
const seen = new Set<string>();
|
|
208
|
+
|
|
209
|
+
for (const param of listed) {
|
|
210
|
+
const at = parseUserParameterName(param.name, prefix);
|
|
211
|
+
if (!at) continue; // a neighbour from a later slice
|
|
212
|
+
seen.add(param.name);
|
|
213
|
+
try {
|
|
214
|
+
if (at.kind === "github") {
|
|
215
|
+
const value = parseStoredGithubToken(param.value);
|
|
216
|
+
if (value.userId !== at.userId) throw new Error("payload names another user");
|
|
217
|
+
if (github.has(at.userId)) {
|
|
218
|
+
if (github.get(at.userId) !== param.name) setGithubTokenStoreRef(at.userId, param.name);
|
|
219
|
+
} else if (importGithubTokenFromStore(value, param.name)) {
|
|
220
|
+
result.imported++;
|
|
221
|
+
}
|
|
222
|
+
} else if (at.kind === "ssh") {
|
|
223
|
+
const value = parseStoredSshKey(param.value);
|
|
224
|
+
if (value.userId !== at.userId) throw new Error("payload names another user");
|
|
225
|
+
if (ssh.has(at.userId)) {
|
|
226
|
+
if (ssh.get(at.userId) !== param.name) setSshKeyStoreRef(at.userId, param.name);
|
|
227
|
+
} else if (importSshKeyFromStore(value, param.name)) {
|
|
228
|
+
result.imported++;
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
const value = parseStoredMcpConnection(param.value);
|
|
232
|
+
if (value.userId !== at.userId || value.id !== at.id) {
|
|
233
|
+
throw new Error("payload does not describe the connection its path names");
|
|
234
|
+
}
|
|
235
|
+
const local = mcp.get(at.id);
|
|
236
|
+
if (local) {
|
|
237
|
+
if (local.storeRef !== param.name) setMcpConnectionStoreRef(at.id, param.name);
|
|
238
|
+
} else if (importMcpConnectionFromStore(value, param.name)) {
|
|
239
|
+
result.imported++;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
result.errors++;
|
|
244
|
+
warn(`${param.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const under = (ref: string): boolean => ref.startsWith(`${path}/`);
|
|
249
|
+
for (const [userId, storeRef] of github) {
|
|
250
|
+
try {
|
|
251
|
+
if (storeRef) {
|
|
252
|
+
if (under(storeRef) && !seen.has(storeRef)) {
|
|
253
|
+
deleteGithubTokenLocal(userId);
|
|
254
|
+
result.removed++;
|
|
255
|
+
}
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const value = githubTokenStoreValue(userId);
|
|
259
|
+
if (!value) continue;
|
|
260
|
+
await upload(store, githubTokenParameterName(userId), value, (ref) =>
|
|
261
|
+
setGithubTokenStoreRef(userId, ref),
|
|
262
|
+
);
|
|
263
|
+
result.uploaded++;
|
|
264
|
+
} catch (err) {
|
|
265
|
+
result.errors++;
|
|
266
|
+
warn(`github token ${userId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const [userId, storeRef] of ssh) {
|
|
270
|
+
try {
|
|
271
|
+
if (storeRef) {
|
|
272
|
+
if (under(storeRef) && !seen.has(storeRef)) {
|
|
273
|
+
deleteSshKeyLocal(userId);
|
|
274
|
+
result.removed++;
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const value = sshKeyStoreValue(userId);
|
|
279
|
+
if (!value) continue;
|
|
280
|
+
await upload(store, sshKeyParameterName(userId), value, (ref) =>
|
|
281
|
+
setSshKeyStoreRef(userId, ref),
|
|
282
|
+
);
|
|
283
|
+
result.uploaded++;
|
|
284
|
+
} catch (err) {
|
|
285
|
+
result.errors++;
|
|
286
|
+
warn(`ssh key ${userId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const [id, row] of mcp) {
|
|
290
|
+
try {
|
|
291
|
+
if (row.storeRef) {
|
|
292
|
+
if (under(row.storeRef) && !seen.has(row.storeRef)) {
|
|
293
|
+
deleteMcpConnectionLocal(id);
|
|
294
|
+
result.removed++;
|
|
295
|
+
}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const value = mcpConnectionStoreValue(id);
|
|
299
|
+
if (!value) continue;
|
|
300
|
+
await upload(store, mcpConnectionParameterName(row.userId, id), value, (ref) =>
|
|
301
|
+
setMcpConnectionStoreRef(id, ref),
|
|
302
|
+
);
|
|
303
|
+
result.uploaded++;
|
|
304
|
+
} catch (err) {
|
|
305
|
+
result.errors++;
|
|
306
|
+
warn(`mcp connection ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return result;
|
|
310
|
+
}
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -157,6 +157,7 @@ import {
|
|
|
157
157
|
listEngineAccountsAllScopes,
|
|
158
158
|
reconcileEngineAccountsFromStore,
|
|
159
159
|
} from "../lib/engine-accounts";
|
|
160
|
+
import { reconcileUserCredentialsFromStore } from "../lib/user-credentials-store";
|
|
160
161
|
import { createEngineConnectManager } from "../lib/engine-connect";
|
|
161
162
|
import { createEngineLoginManager } from "../lib/engine-login";
|
|
162
163
|
import {
|
|
@@ -458,6 +459,18 @@ void reconcileEngineAccountsFromStore({ hostId })
|
|
|
458
459
|
`[engine-accounts] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
459
460
|
);
|
|
460
461
|
});
|
|
462
|
+
void reconcileUserCredentialsFromStore()
|
|
463
|
+
.then((r) => {
|
|
464
|
+
if (r.skipped) return;
|
|
465
|
+
console.log(
|
|
466
|
+
`[user-credentials] credential store reconciled: ${r.imported} imported, ${r.uploaded} uploaded, ${r.removed} removed, ${r.errors} errors`,
|
|
467
|
+
);
|
|
468
|
+
})
|
|
469
|
+
.catch((error: unknown) => {
|
|
470
|
+
console.warn(
|
|
471
|
+
`[user-credentials] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
472
|
+
);
|
|
473
|
+
});
|
|
461
474
|
// Reconcile a durable retention-off setting before recovery/connect can make
|
|
462
475
|
// crash-left owner-facing terminal history observable again. Correctness
|
|
463
476
|
// tombstones remain intact inside applyTerminalHistoryRetention.
|