@runuai/host 0.9.138 → 0.9.140
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/lib/browser-testing.ts +6 -1
- package/lib/credential-store.ts +34 -2
- package/lib/engine-accounts.ts +438 -133
- package/lib/engine-connect.ts +40 -9
- package/lib/engine-login.ts +64 -15
- package/lib/env-file.ts +30 -0
- package/lib/github-tokens.ts +281 -17
- package/lib/host-env.ts +4 -0
- package/lib/mcp-connections.ts +208 -5
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/ssh.ts +109 -6
- package/package.json +1 -1
- package/src/cli.ts +39 -6
- package/src/main.ts +210 -3
- package/src/protocol.ts +11 -5
package/lib/engine-accounts.ts
CHANGED
|
@@ -352,6 +352,19 @@ function resolveExtras(
|
|
|
352
352
|
execEnv: { [d.selectVar]: containerDir },
|
|
353
353
|
configDir: { hostDir: row.configDir, containerDir },
|
|
354
354
|
});
|
|
355
|
+
} else if (row.authKind === "config-dir" && isStoreHeldRow(row)) {
|
|
356
|
+
// ADR-136 sign-in box: the material lives in the store, not here. The
|
|
357
|
+
// row is listed (label, scope — what the capabilities frame carries)
|
|
358
|
+
// and never provisioned: the box runs no task.
|
|
359
|
+
out.push({
|
|
360
|
+
id: row.id,
|
|
361
|
+
kind,
|
|
362
|
+
label: row.label,
|
|
363
|
+
isDefault: false,
|
|
364
|
+
authKind: "config-dir",
|
|
365
|
+
scope,
|
|
366
|
+
execEnv: {},
|
|
367
|
+
});
|
|
355
368
|
}
|
|
356
369
|
}
|
|
357
370
|
return out;
|
|
@@ -822,14 +835,22 @@ export interface AddAccountResult {
|
|
|
822
835
|
* Claude accepts a `setup-token` token or an `sk-ant-…` key; Codex accepts an
|
|
823
836
|
* OpenAI API key (written into an isolated `~/.codex-acct-<id>/auth.json`).
|
|
824
837
|
*/
|
|
825
|
-
|
|
838
|
+
/** What a labeled account will hold once persisted — the validated shape
|
|
839
|
+
* shared by the local writer (owned and shared hosts) and the store-only
|
|
840
|
+
* writer (the ADR-136 sign-in box), so both refuse the same inputs with the
|
|
841
|
+
* same words. Never logged. */
|
|
842
|
+
type LabeledAccountPlan =
|
|
843
|
+
| { authKind: "env"; label: string; env: Record<string, string>; message: string }
|
|
844
|
+
| { authKind: "config-dir"; label: string; files: Record<string, string>; message: string };
|
|
845
|
+
|
|
846
|
+
type PlannedAccount = { ok: false; message: string } | { ok: true; plan: LabeledAccountPlan };
|
|
847
|
+
|
|
848
|
+
function planLabeledAccount(
|
|
826
849
|
kind: string,
|
|
827
850
|
label: string,
|
|
828
851
|
creds: { apiKey?: string; token?: string },
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
): AddAccountResult {
|
|
832
|
-
const s = withDefaults(seams);
|
|
852
|
+
scope: string,
|
|
853
|
+
): PlannedAccount {
|
|
833
854
|
if (!isEngineAccountScope(scope)) {
|
|
834
855
|
return { ok: false, message: "The account scope is invalid." };
|
|
835
856
|
}
|
|
@@ -840,72 +861,106 @@ export function addEngineAccount(
|
|
|
840
861
|
if (/\s/.test(secret) || secret.length < 12) {
|
|
841
862
|
return { ok: false, message: "That doesn't look like a token. Paste just the value." };
|
|
842
863
|
}
|
|
843
|
-
|
|
844
|
-
const id = newId();
|
|
845
|
-
const now = Date.now();
|
|
846
|
-
|
|
847
864
|
if (kind === "claude") {
|
|
848
865
|
const envVar = secret.startsWith("sk-ant-api")
|
|
849
866
|
? "ANTHROPIC_API_KEY"
|
|
850
867
|
: "CLAUDE_CODE_OAUTH_TOKEN";
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
id,
|
|
855
|
-
kind,
|
|
856
|
-
label: trimmedLabel,
|
|
868
|
+
return {
|
|
869
|
+
ok: true,
|
|
870
|
+
plan: {
|
|
857
871
|
authKind: "env",
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
})
|
|
864
|
-
.run();
|
|
865
|
-
mirrorEngineAccount(id);
|
|
866
|
-
return { ok: true, message: `Added Claude account "${trimmedLabel}".`, id };
|
|
872
|
+
label: trimmedLabel,
|
|
873
|
+
env: { [envVar]: secret },
|
|
874
|
+
message: `Added Claude account "${trimmedLabel}".`,
|
|
875
|
+
},
|
|
876
|
+
};
|
|
867
877
|
}
|
|
868
|
-
|
|
869
878
|
const envKeyVar = API_KEY_ENV_VARS[kind];
|
|
870
879
|
if (envKeyVar) {
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
id,
|
|
875
|
-
kind,
|
|
876
|
-
label: trimmedLabel,
|
|
880
|
+
return {
|
|
881
|
+
ok: true,
|
|
882
|
+
plan: {
|
|
877
883
|
authKind: "env",
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
mirrorEngineAccount(id);
|
|
884
|
+
label: trimmedLabel,
|
|
885
|
+
env: { [envKeyVar]: secret },
|
|
886
|
+
message: `Added ${API_KEY_ENGINE_LABELS[kind] ?? kind} account "${trimmedLabel}".`,
|
|
887
|
+
},
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
if (kind === "codex") {
|
|
886
891
|
return {
|
|
887
892
|
ok: true,
|
|
888
|
-
|
|
889
|
-
|
|
893
|
+
plan: {
|
|
894
|
+
authKind: "config-dir",
|
|
895
|
+
label: trimmedLabel,
|
|
896
|
+
files: { "auth.json": `${JSON.stringify({ OPENAI_API_KEY: secret }, null, 2)}\n` },
|
|
897
|
+
message: `Added Codex account "${trimmedLabel}".`,
|
|
898
|
+
},
|
|
890
899
|
};
|
|
891
900
|
}
|
|
901
|
+
return {
|
|
902
|
+
ok: false,
|
|
903
|
+
message: `Adding a second ${kind} account isn't supported yet — connect the primary account from the Engines panel.`,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
892
906
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
907
|
+
function planCapturedConfigDirAccount(
|
|
908
|
+
kind: "codex" | "grok",
|
|
909
|
+
label: string,
|
|
910
|
+
authJson: string,
|
|
911
|
+
scope: string,
|
|
912
|
+
): PlannedAccount {
|
|
913
|
+
const engineLabel = kind === "codex" ? "Codex" : "Grok";
|
|
914
|
+
if (!isEngineAccountScope(scope)) {
|
|
915
|
+
return { ok: false, message: "The account scope is invalid." };
|
|
916
|
+
}
|
|
917
|
+
const trimmedLabel = label.trim();
|
|
918
|
+
if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
|
|
919
|
+
try {
|
|
920
|
+
const parsed: unknown = JSON.parse(authJson);
|
|
921
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
922
|
+
throw new Error("not an object");
|
|
923
|
+
}
|
|
924
|
+
} catch {
|
|
925
|
+
return { ok: false, message: `The captured ${engineLabel} credential is not valid.` };
|
|
926
|
+
}
|
|
927
|
+
return {
|
|
928
|
+
ok: true,
|
|
929
|
+
plan: {
|
|
930
|
+
authKind: "config-dir",
|
|
931
|
+
label: trimmedLabel,
|
|
932
|
+
files: { "auth.json": authJson.endsWith("\n") ? authJson : `${authJson}\n` },
|
|
933
|
+
message: `Added ${engineLabel} account "${trimmedLabel}".`,
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Persist a plan on THIS host — a sealed env row or an isolated config dir
|
|
939
|
+
* under the owner's home — and mirror it to the store when one is configured. */
|
|
940
|
+
function persistPlannedAccountLocally(
|
|
941
|
+
kind: string,
|
|
942
|
+
scope: string,
|
|
943
|
+
plan: LabeledAccountPlan,
|
|
944
|
+
s: AccountSeams,
|
|
945
|
+
): AddAccountResult {
|
|
946
|
+
const id = newId();
|
|
947
|
+
let configDir: string | null = null;
|
|
948
|
+
if (plan.authKind === "config-dir") {
|
|
949
|
+
const d = KINDS[kind];
|
|
950
|
+
if (!d?.extraHostDir) {
|
|
951
|
+
return { ok: false, message: `${kind} has no config-dir layout.` };
|
|
952
|
+
}
|
|
953
|
+
const hostDir = d.extraHostDir(s.ownerHome(), id);
|
|
899
954
|
try {
|
|
900
|
-
const file = join(hostDir, "auth.json");
|
|
901
955
|
mkdirSync(hostDir, { recursive: true });
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
956
|
+
for (const [basename, content] of Object.entries(plan.files)) {
|
|
957
|
+
const file = join(hostDir, basename);
|
|
958
|
+
writeFileSync(file, content, { mode: 0o600 });
|
|
959
|
+
try {
|
|
960
|
+
chmodSync(file, 0o600);
|
|
961
|
+
} catch {
|
|
962
|
+
/* best effort */
|
|
963
|
+
}
|
|
909
964
|
}
|
|
910
965
|
} catch (err) {
|
|
911
966
|
return {
|
|
@@ -915,28 +970,36 @@ export function addEngineAccount(
|
|
|
915
970
|
}`,
|
|
916
971
|
};
|
|
917
972
|
}
|
|
918
|
-
|
|
919
|
-
.insert(schema.engineAccounts)
|
|
920
|
-
.values({
|
|
921
|
-
id,
|
|
922
|
-
kind,
|
|
923
|
-
label: trimmedLabel,
|
|
924
|
-
authKind: "config-dir",
|
|
925
|
-
secretEnc: null,
|
|
926
|
-
configDir: hostDir,
|
|
927
|
-
createdAt: now,
|
|
928
|
-
scope,
|
|
929
|
-
position: nextPosition(kind, scope),
|
|
930
|
-
})
|
|
931
|
-
.run();
|
|
932
|
-
mirrorEngineAccount(id);
|
|
933
|
-
return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
|
|
973
|
+
configDir = hostDir;
|
|
934
974
|
}
|
|
975
|
+
getDb()
|
|
976
|
+
.insert(schema.engineAccounts)
|
|
977
|
+
.values({
|
|
978
|
+
id,
|
|
979
|
+
kind,
|
|
980
|
+
label: plan.label,
|
|
981
|
+
authKind: plan.authKind,
|
|
982
|
+
secretEnc: plan.authKind === "env" ? packSecret(JSON.stringify(plan.env)) : null,
|
|
983
|
+
configDir,
|
|
984
|
+
createdAt: Date.now(),
|
|
985
|
+
scope,
|
|
986
|
+
position: nextPosition(kind, scope),
|
|
987
|
+
})
|
|
988
|
+
.run();
|
|
989
|
+
mirrorEngineAccount(id);
|
|
990
|
+
return { ok: true, message: plan.message, id };
|
|
991
|
+
}
|
|
935
992
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
}
|
|
993
|
+
export function addEngineAccount(
|
|
994
|
+
kind: string,
|
|
995
|
+
label: string,
|
|
996
|
+
creds: { apiKey?: string; token?: string },
|
|
997
|
+
seams: Partial<AccountSeams> = {},
|
|
998
|
+
scope: string = HOST_ACCOUNT_SCOPE,
|
|
999
|
+
): AddAccountResult {
|
|
1000
|
+
const planned = planLabeledAccount(kind, label, creds, scope);
|
|
1001
|
+
if (!planned.ok) return planned;
|
|
1002
|
+
return persistPlannedAccountLocally(kind, scope, planned.plan, withDefaults(seams));
|
|
940
1003
|
}
|
|
941
1004
|
|
|
942
1005
|
/**
|
|
@@ -972,63 +1035,9 @@ function addConfigDirAccountFromAuthJson(
|
|
|
972
1035
|
seams: Partial<AccountSeams>,
|
|
973
1036
|
scope: string,
|
|
974
1037
|
): AddAccountResult {
|
|
975
|
-
const
|
|
976
|
-
if (!
|
|
977
|
-
|
|
978
|
-
}
|
|
979
|
-
const s = withDefaults(seams);
|
|
980
|
-
const trimmedLabel = label.trim();
|
|
981
|
-
if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
|
|
982
|
-
try {
|
|
983
|
-
const parsed: unknown = JSON.parse(authJson);
|
|
984
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
985
|
-
throw new Error("not an object");
|
|
986
|
-
}
|
|
987
|
-
} catch {
|
|
988
|
-
return { ok: false, message: `The captured ${engineLabel} credential is not valid.` };
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
const id = newId();
|
|
992
|
-
const d = KINDS[kind] as KindDescriptor;
|
|
993
|
-
const hostDir = (d.extraHostDir as (o: string, i: string) => string)(
|
|
994
|
-
s.ownerHome(),
|
|
995
|
-
id,
|
|
996
|
-
);
|
|
997
|
-
try {
|
|
998
|
-
const file = join(hostDir, "auth.json");
|
|
999
|
-
mkdirSync(hostDir, { recursive: true });
|
|
1000
|
-
writeFileSync(file, authJson.endsWith("\n") ? authJson : `${authJson}\n`, {
|
|
1001
|
-
mode: 0o600,
|
|
1002
|
-
});
|
|
1003
|
-
try {
|
|
1004
|
-
chmodSync(file, 0o600);
|
|
1005
|
-
} catch {
|
|
1006
|
-
/* best effort */
|
|
1007
|
-
}
|
|
1008
|
-
} catch (err) {
|
|
1009
|
-
return {
|
|
1010
|
-
ok: false,
|
|
1011
|
-
message: `Could not write the account config: ${
|
|
1012
|
-
err instanceof Error ? err.message : String(err)
|
|
1013
|
-
}`,
|
|
1014
|
-
};
|
|
1015
|
-
}
|
|
1016
|
-
getDb()
|
|
1017
|
-
.insert(schema.engineAccounts)
|
|
1018
|
-
.values({
|
|
1019
|
-
id,
|
|
1020
|
-
kind,
|
|
1021
|
-
label: trimmedLabel,
|
|
1022
|
-
authKind: "config-dir",
|
|
1023
|
-
secretEnc: null,
|
|
1024
|
-
configDir: hostDir,
|
|
1025
|
-
createdAt: Date.now(),
|
|
1026
|
-
scope,
|
|
1027
|
-
position: nextPosition(kind, scope),
|
|
1028
|
-
})
|
|
1029
|
-
.run();
|
|
1030
|
-
mirrorEngineAccount(id);
|
|
1031
|
-
return { ok: true, message: `Added ${engineLabel} account "${trimmedLabel}".`, id };
|
|
1038
|
+
const planned = planCapturedConfigDirAccount(kind, label, authJson, scope);
|
|
1039
|
+
if (!planned.ok) return planned;
|
|
1040
|
+
return persistPlannedAccountLocally(kind, scope, planned.plan, withDefaults(seams));
|
|
1032
1041
|
}
|
|
1033
1042
|
|
|
1034
1043
|
/** Remove an EXTRA account (idempotent). The synthesized default can't be
|
|
@@ -1346,6 +1355,11 @@ function setStoreRef(id: string, storeRef: string): void {
|
|
|
1346
1355
|
}
|
|
1347
1356
|
|
|
1348
1357
|
async function putRow(store: CredentialStore, row: StoreRow, hostId: string | null): Promise<boolean> {
|
|
1358
|
+
if (isStoreHeldRow(row)) {
|
|
1359
|
+
// ADR-136: nothing here to mirror — an empty env put would overwrite the
|
|
1360
|
+
// only copy of the credential.
|
|
1361
|
+
throw new Error(`account ${row.id} is store-held; this runner holds no material to mirror`);
|
|
1362
|
+
}
|
|
1349
1363
|
const name = engineAccountParameterName(row, hostId);
|
|
1350
1364
|
if (!name) {
|
|
1351
1365
|
if (!warnedNoHostId) {
|
|
@@ -1368,6 +1382,13 @@ export function mirrorEngineAccount(id: string): void {
|
|
|
1368
1382
|
queueStoreWrite(`mirror ${id}`, async (store) => {
|
|
1369
1383
|
const row = selectRow(id);
|
|
1370
1384
|
if (!row) return;
|
|
1385
|
+
if (isStoreHeldRow(row)) {
|
|
1386
|
+
// ADR-136 sign-in box: a rename or reorder rides as metadata tags —
|
|
1387
|
+
// the secret is not here to re-put, and tags are what a replacement
|
|
1388
|
+
// box reads back.
|
|
1389
|
+
await store.tag(row.storeRef!, engineAccountMetadataTags(row));
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1371
1392
|
await putRow(store, row, storeHostId);
|
|
1372
1393
|
});
|
|
1373
1394
|
}
|
|
@@ -1565,4 +1586,288 @@ export function applyStoredEngineAccount(
|
|
|
1565
1586
|
throw new Error("stored auth kind does not match the local row");
|
|
1566
1587
|
}
|
|
1567
1588
|
return { kind: stored.kind, id: stored.id, imported: false };
|
|
1568
|
-
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// ---------------------------------------------------------------------------
|
|
1592
|
+
// Store-only accounts (ADR-136 sign-in box, docs/shared-host-retirement.md).
|
|
1593
|
+
//
|
|
1594
|
+
// The small always-on machine behind the "Uai Cloud" row writes every capture
|
|
1595
|
+
// STRAIGHT into the credential store and keeps only a metadata row — id, kind,
|
|
1596
|
+
// label, scope, position, store_ref — with no sealed secret and no config dir.
|
|
1597
|
+
// Its store identity is write-only (put, tag, delete, describe names; never
|
|
1598
|
+
// get, never decrypt), so a compromised box can break sign-ins but cannot read
|
|
1599
|
+
// one. Labels therefore also ride as parameter TAGS, which
|
|
1600
|
+
// `ssm:ListTagsForResource` returns without a KMS grant: a replacement box
|
|
1601
|
+
// rebuilds its list from names + tags alone (`reconcileEngineAccountMetadataFromStore`).
|
|
1602
|
+
// ---------------------------------------------------------------------------
|
|
1603
|
+
|
|
1604
|
+
export const ACCOUNT_LABEL_TAG = "uai_label_b64";
|
|
1605
|
+
export const ACCOUNT_POSITION_TAG = "uai_position";
|
|
1606
|
+
export const ACCOUNT_CREATED_AT_TAG = "uai_created_at";
|
|
1607
|
+
export const ACCOUNT_AUTH_KIND_TAG = "uai_auth_kind";
|
|
1608
|
+
/** SSM tag values: 256 chars of `[A-Za-z0-9_.:/=+@ -]`; base64url fits. */
|
|
1609
|
+
const MAX_TAG_VALUE_CHARS = 256;
|
|
1610
|
+
|
|
1611
|
+
/** A row whose material lives ONLY in the store: a store_ref and no secret. */
|
|
1612
|
+
export function isStoreHeldRow(row: {
|
|
1613
|
+
authKind: string;
|
|
1614
|
+
secretEnc: string | null;
|
|
1615
|
+
configDir: string | null;
|
|
1616
|
+
storeRef?: string | null;
|
|
1617
|
+
}): boolean {
|
|
1618
|
+
if (!row.storeRef) return false;
|
|
1619
|
+
return row.authKind === "env" ? row.secretEnc === null : row.configDir === null;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/** The secret-free metadata a parameter carries as tags. The label is
|
|
1623
|
+
* base64url so any label character survives SSM's tag charset; a label too
|
|
1624
|
+
* long to fit is shortened (the store value keeps the full one). */
|
|
1625
|
+
export function engineAccountMetadataTags(row: {
|
|
1626
|
+
label: string;
|
|
1627
|
+
authKind: string;
|
|
1628
|
+
position?: number | null;
|
|
1629
|
+
createdAt: number;
|
|
1630
|
+
}): Record<string, string> {
|
|
1631
|
+
let label = row.label;
|
|
1632
|
+
let encoded = Buffer.from(label, "utf8").toString("base64url");
|
|
1633
|
+
while (encoded.length > MAX_TAG_VALUE_CHARS && label.length > 1) {
|
|
1634
|
+
label = label.slice(0, -1);
|
|
1635
|
+
encoded = Buffer.from(label, "utf8").toString("base64url");
|
|
1636
|
+
}
|
|
1637
|
+
return {
|
|
1638
|
+
[ACCOUNT_LABEL_TAG]: encoded,
|
|
1639
|
+
[ACCOUNT_POSITION_TAG]: String(row.position ?? 0),
|
|
1640
|
+
[ACCOUNT_CREATED_AT_TAG]: String(row.createdAt),
|
|
1641
|
+
[ACCOUNT_AUTH_KIND_TAG]: row.authKind,
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function derivedAccountLabel(kind: string, id: string): string {
|
|
1646
|
+
const engine = kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
1647
|
+
return `${engine} ${id.slice(-6)}`;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function labelFromTags(tags: Record<string, string>, kind: string, id: string): string {
|
|
1651
|
+
const encoded = tags[ACCOUNT_LABEL_TAG];
|
|
1652
|
+
if (encoded) {
|
|
1653
|
+
try {
|
|
1654
|
+
const label = Buffer.from(encoded, "base64url").toString("utf8").trim();
|
|
1655
|
+
if (isEngineAccountLabel(label)) return label;
|
|
1656
|
+
} catch {
|
|
1657
|
+
/* fall through */
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
return derivedAccountLabel(kind, id);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
async function persistPlannedAccountToStore(
|
|
1664
|
+
kind: string,
|
|
1665
|
+
scope: string,
|
|
1666
|
+
plan: LabeledAccountPlan,
|
|
1667
|
+
): Promise<AddAccountResult> {
|
|
1668
|
+
const store = configuredCredentialStore();
|
|
1669
|
+
if (!store) {
|
|
1670
|
+
return { ok: false, message: "This runner has no credential store to hold the account." };
|
|
1671
|
+
}
|
|
1672
|
+
if (scope === HOST_ACCOUNT_SCOPE) {
|
|
1673
|
+
return { ok: false, message: "Store-only accounts belong to a workspace, not the runner." };
|
|
1674
|
+
}
|
|
1675
|
+
const id = newId();
|
|
1676
|
+
const now = Date.now();
|
|
1677
|
+
const position = nextPosition(kind, scope);
|
|
1678
|
+
const row = { id, kind, label: plan.label, authKind: plan.authKind, scope, position, createdAt: now };
|
|
1679
|
+
const name = engineAccountParameterName(row);
|
|
1680
|
+
if (!name) {
|
|
1681
|
+
return { ok: false, message: "Store-only accounts belong to a workspace, not the runner." };
|
|
1682
|
+
}
|
|
1683
|
+
const stored: StoredEngineAccount = {
|
|
1684
|
+
v: 1,
|
|
1685
|
+
...row,
|
|
1686
|
+
...(plan.authKind === "env" ? { env: plan.env } : { files: plan.files }),
|
|
1687
|
+
};
|
|
1688
|
+
try {
|
|
1689
|
+
await store.put(name, JSON.stringify(stored));
|
|
1690
|
+
} catch (err) {
|
|
1691
|
+
console.warn(
|
|
1692
|
+
`[engine-accounts] store-only ${kind}/${id}: put failed (${err instanceof Error ? err.message : String(err)})`,
|
|
1693
|
+
);
|
|
1694
|
+
return {
|
|
1695
|
+
ok: false,
|
|
1696
|
+
message: "The credential store refused the write; nothing was kept on this runner.",
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
try {
|
|
1700
|
+
await store.tag(name, engineAccountMetadataTags(row));
|
|
1701
|
+
} catch (err) {
|
|
1702
|
+
console.warn(
|
|
1703
|
+
`[engine-accounts] store-only ${kind}/${id}: tagging failed (${err instanceof Error ? err.message : String(err)}) — a replacement sign-in box lists it as "${derivedAccountLabel(kind, id)}"`,
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1706
|
+
getDb()
|
|
1707
|
+
.insert(schema.engineAccounts)
|
|
1708
|
+
.values({
|
|
1709
|
+
id,
|
|
1710
|
+
kind,
|
|
1711
|
+
label: plan.label,
|
|
1712
|
+
authKind: plan.authKind,
|
|
1713
|
+
secretEnc: null,
|
|
1714
|
+
configDir: null,
|
|
1715
|
+
createdAt: now,
|
|
1716
|
+
scope,
|
|
1717
|
+
position,
|
|
1718
|
+
storeRef: name,
|
|
1719
|
+
})
|
|
1720
|
+
.run();
|
|
1721
|
+
return { ok: true, message: plan.message, id };
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/** `addEngineAccount` for the sign-in box: the same validation, the store
|
|
1725
|
+
* written FIRST and awaited, then a metadata-only row. A store failure is the
|
|
1726
|
+
* caller's failure and leaves nothing behind. */
|
|
1727
|
+
export async function addEngineAccountToStore(
|
|
1728
|
+
kind: string,
|
|
1729
|
+
label: string,
|
|
1730
|
+
creds: { apiKey?: string; token?: string },
|
|
1731
|
+
scope: string,
|
|
1732
|
+
): Promise<AddAccountResult> {
|
|
1733
|
+
const planned = planLabeledAccount(kind, label, creds, scope);
|
|
1734
|
+
if (!planned.ok) return planned;
|
|
1735
|
+
return persistPlannedAccountToStore(kind, scope, planned.plan);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/** `addCodexAccountFromAuthJson` / `addGrokAccountFromAuthJson` for the
|
|
1739
|
+
* sign-in box: the captured auth.json goes to the store, never to a dir. */
|
|
1740
|
+
export async function addCapturedConfigDirAccountToStore(
|
|
1741
|
+
kind: "codex" | "grok",
|
|
1742
|
+
label: string,
|
|
1743
|
+
authJson: string,
|
|
1744
|
+
scope: string,
|
|
1745
|
+
): Promise<AddAccountResult> {
|
|
1746
|
+
const planned = planCapturedConfigDirAccount(kind, label, authJson, scope);
|
|
1747
|
+
if (!planned.ok) return planned;
|
|
1748
|
+
return persistPlannedAccountToStore(kind, scope, planned.plan);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
export interface PurgeLocalEngineAccountSecretsResult {
|
|
1752
|
+
/** Workspace rows whose sealed secret or config dir was removed. */
|
|
1753
|
+
stripped: number;
|
|
1754
|
+
/** Rows deleted outright: host-scope accounts and rows never mirrored. */
|
|
1755
|
+
removed: number;
|
|
1756
|
+
errors: number;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* Sign-in box start: nothing the previous release (or an operator) left on
|
|
1761
|
+
* this machine may hold a credential. Workspace rows keep their metadata and
|
|
1762
|
+
* lose their material; host-scope rows and rows the store never received are
|
|
1763
|
+
* deleted — the box holds workspace accounts only, and a secret with no copy
|
|
1764
|
+
* in the store still may not live here.
|
|
1765
|
+
*/
|
|
1766
|
+
export function purgeLocalEngineAccountSecrets(): PurgeLocalEngineAccountSecretsResult {
|
|
1767
|
+
const result: PurgeLocalEngineAccountSecretsResult = { stripped: 0, removed: 0, errors: 0 };
|
|
1768
|
+
const rows = getDb().select().from(schema.engineAccounts).all() as StoreRow[];
|
|
1769
|
+
for (const row of rows) {
|
|
1770
|
+
const scope = row.scope ?? HOST_ACCOUNT_SCOPE;
|
|
1771
|
+
if (scope === HOST_ACCOUNT_SCOPE || !row.storeRef) {
|
|
1772
|
+
removeLocalAccountRow(row);
|
|
1773
|
+
result.removed++;
|
|
1774
|
+
continue;
|
|
1775
|
+
}
|
|
1776
|
+
if (isStoreHeldRow(row)) continue;
|
|
1777
|
+
if (row.configDir) {
|
|
1778
|
+
try {
|
|
1779
|
+
rmSync(row.configDir, { recursive: true, force: true });
|
|
1780
|
+
} catch {
|
|
1781
|
+
result.errors++;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
getDb()
|
|
1785
|
+
.update(schema.engineAccounts)
|
|
1786
|
+
.set({ secretEnc: null, configDir: null })
|
|
1787
|
+
.where(eq(schema.engineAccounts.id, row.id))
|
|
1788
|
+
.run();
|
|
1789
|
+
result.stripped++;
|
|
1790
|
+
}
|
|
1791
|
+
return result;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
export interface ReconcileEngineAccountMetadataResult {
|
|
1795
|
+
skipped: boolean;
|
|
1796
|
+
imported: number;
|
|
1797
|
+
removed: number;
|
|
1798
|
+
errors: number;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/**
|
|
1802
|
+
* Sign-in box start: make the metadata rows agree with the store WITHOUT
|
|
1803
|
+
* reading a value. Names come from `describe-parameters` (allowed on `*`),
|
|
1804
|
+
* labels and order from each new parameter's tags. A workspace parameter this
|
|
1805
|
+
* box lacks becomes a metadata row; a row whose parameter is gone is dropped;
|
|
1806
|
+
* host-scope rows are dropped (the box has none). A failed listing touches
|
|
1807
|
+
* nothing.
|
|
1808
|
+
*/
|
|
1809
|
+
export async function reconcileEngineAccountMetadataFromStore(): Promise<ReconcileEngineAccountMetadataResult> {
|
|
1810
|
+
const result: ReconcileEngineAccountMetadataResult = { skipped: false, imported: 0, removed: 0, errors: 0 };
|
|
1811
|
+
const store = configuredCredentialStore();
|
|
1812
|
+
if (!store) return { ...result, skipped: true };
|
|
1813
|
+
const prefix = credentialStorePrefix();
|
|
1814
|
+
const warn = (line: string): void => console.warn(`[engine-accounts] metadata reconcile: ${line}`);
|
|
1815
|
+
let names: string[];
|
|
1816
|
+
try {
|
|
1817
|
+
names = await store.describeNames(`${prefix}/orgs`);
|
|
1818
|
+
} catch (err) {
|
|
1819
|
+
warn(`listing ${prefix}/orgs failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1820
|
+
return { ...result, errors: 1 };
|
|
1821
|
+
}
|
|
1822
|
+
const rows = getDb().select().from(schema.engineAccounts).all() as StoreRow[];
|
|
1823
|
+
const byRef = new Set(rows.map((r) => r.storeRef).filter((ref): ref is string => Boolean(ref)));
|
|
1824
|
+
const seen = new Set<string>();
|
|
1825
|
+
for (const name of names) {
|
|
1826
|
+
const at = parseEngineAccountParameterName(name, prefix);
|
|
1827
|
+
if (!at || at.scope === HOST_ACCOUNT_SCOPE) continue;
|
|
1828
|
+
seen.add(name);
|
|
1829
|
+
if (byRef.has(name)) continue;
|
|
1830
|
+
let tags: Record<string, string> = {};
|
|
1831
|
+
try {
|
|
1832
|
+
tags = await store.listTags(name);
|
|
1833
|
+
} catch (err) {
|
|
1834
|
+
result.errors++;
|
|
1835
|
+
warn(`tags of ${name} unreadable: ${err instanceof Error ? err.message : String(err)}`);
|
|
1836
|
+
}
|
|
1837
|
+
const taggedAuthKind = tags[ACCOUNT_AUTH_KIND_TAG];
|
|
1838
|
+
const authKind: AccountAuthKind =
|
|
1839
|
+
taggedAuthKind === "env" || taggedAuthKind === "config-dir"
|
|
1840
|
+
? taggedAuthKind
|
|
1841
|
+
: (KINDS[at.kind]?.authKind ?? "env");
|
|
1842
|
+
const position = Number.parseInt(tags[ACCOUNT_POSITION_TAG] ?? "", 10);
|
|
1843
|
+
const createdAt = Number.parseInt(tags[ACCOUNT_CREATED_AT_TAG] ?? "", 10);
|
|
1844
|
+
try {
|
|
1845
|
+
getDb()
|
|
1846
|
+
.insert(schema.engineAccounts)
|
|
1847
|
+
.values({
|
|
1848
|
+
id: at.id,
|
|
1849
|
+
kind: at.kind,
|
|
1850
|
+
label: labelFromTags(tags, at.kind, at.id),
|
|
1851
|
+
authKind,
|
|
1852
|
+
secretEnc: null,
|
|
1853
|
+
configDir: null,
|
|
1854
|
+
scope: at.scope,
|
|
1855
|
+
position: Number.isFinite(position) ? position : 0,
|
|
1856
|
+
storeRef: name,
|
|
1857
|
+
createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(),
|
|
1858
|
+
})
|
|
1859
|
+
.run();
|
|
1860
|
+
result.imported++;
|
|
1861
|
+
} catch (err) {
|
|
1862
|
+
result.errors++;
|
|
1863
|
+
warn(`importing ${name} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
for (const row of rows) {
|
|
1867
|
+
const scope = row.scope ?? HOST_ACCOUNT_SCOPE;
|
|
1868
|
+
if (scope !== HOST_ACCOUNT_SCOPE && row.storeRef && seen.has(row.storeRef)) continue;
|
|
1869
|
+
removeLocalAccountRow(row);
|
|
1870
|
+
result.removed++;
|
|
1871
|
+
}
|
|
1872
|
+
return result;
|
|
1873
|
+
}
|