@runuai/host 0.9.138 → 0.9.139
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/credential-store.ts +34 -2
- package/lib/engine-accounts.ts +438 -133
- package/lib/engine-connect.ts +35 -7
- package/lib/engine-login.ts +38 -8
- package/lib/env-file.ts +30 -0
- package/lib/signin-box.ts +114 -0
- package/package.json +1 -1
- package/src/cli.ts +4 -0
- package/src/main.ts +125 -3
package/lib/credential-store.ts
CHANGED
|
@@ -61,6 +61,13 @@ export interface CredentialStore {
|
|
|
61
61
|
* with Overwrite cannot carry tags). Throws on failure — including when
|
|
62
62
|
* the identity lacks `ssm:AddTagsToResource`, which older host policies do. */
|
|
63
63
|
tag(name: string, tags: Record<string, string>): Promise<void>;
|
|
64
|
+
/** The tags on one parameter. Metadata only: `ssm:ListTagsForResource`
|
|
65
|
+
* reveals no value and needs no KMS grant — the sign-in box (ADR-136)
|
|
66
|
+
* rebuilds an account's label from here without ever reading the secret. */
|
|
67
|
+
listTags(name: string): Promise<Record<string, string>>;
|
|
68
|
+
/** Every parameter NAME under `path` (recursive), no values — the
|
|
69
|
+
* `describe-parameters` listing that is allowed on `*` and never decrypts. */
|
|
70
|
+
describeNames(path: string): Promise<string[]>;
|
|
64
71
|
/** Names and versions under `path` (recursive), NO decryption — the cheap
|
|
65
72
|
* poll a Uai-operated host runs to notice a broker refresh. */
|
|
66
73
|
listVersions(path: string): Promise<Array<{ name: string; version: number }>>;
|
|
@@ -284,7 +291,7 @@ export class AwsSsmCredentialStore implements CredentialStore {
|
|
|
284
291
|
return out;
|
|
285
292
|
}
|
|
286
293
|
|
|
287
|
-
async
|
|
294
|
+
async describeNames(path: string): Promise<string[]> {
|
|
288
295
|
if (!isParameterName(path)) throw new Error(`invalid parameter path: ${path}`);
|
|
289
296
|
const described = await this.run([
|
|
290
297
|
"ssm",
|
|
@@ -296,9 +303,34 @@ export class AwsSsmCredentialStore implements CredentialStore {
|
|
|
296
303
|
]);
|
|
297
304
|
if (described.status !== 0) throw failure("describe-parameters", described);
|
|
298
305
|
const parsed: unknown = JSON.parse(described.stdout || "[]");
|
|
299
|
-
|
|
306
|
+
return (Array.isArray(parsed) ? parsed : []).filter(
|
|
300
307
|
(name): name is string => typeof name === "string" && name.startsWith(`${path}/`),
|
|
301
308
|
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async listTags(name: string): Promise<Record<string, string>> {
|
|
312
|
+
if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
|
|
313
|
+
const result = await this.run([
|
|
314
|
+
"ssm",
|
|
315
|
+
"list-tags-for-resource",
|
|
316
|
+
"--resource-type",
|
|
317
|
+
"Parameter",
|
|
318
|
+
"--resource-id",
|
|
319
|
+
name,
|
|
320
|
+
]);
|
|
321
|
+
if (result.status !== 0) throw failure("list-tags-for-resource", result);
|
|
322
|
+
const parsed = JSON.parse(result.stdout || "{}") as {
|
|
323
|
+
TagList?: Array<{ Key?: unknown; Value?: unknown }> | null;
|
|
324
|
+
};
|
|
325
|
+
const out: Record<string, string> = {};
|
|
326
|
+
for (const tag of parsed.TagList ?? []) {
|
|
327
|
+
if (typeof tag.Key === "string" && typeof tag.Value === "string") out[tag.Key] = tag.Value;
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async listByName(path: string): Promise<StoredParameter[]> {
|
|
333
|
+
const names = await this.describeNames(path);
|
|
302
334
|
const out: StoredParameter[] = [];
|
|
303
335
|
for (const name of names) {
|
|
304
336
|
const result = await this.run(["ssm", "get-parameter", "--name", name, "--with-decryption"]);
|
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
|
+
}
|
package/lib/engine-connect.ts
CHANGED
|
@@ -27,6 +27,12 @@ const MAX_CONCURRENT_CONNECTS = 1;
|
|
|
27
27
|
|
|
28
28
|
export type EmitEngineConnectEvent = (frame: EngineConnectEventFrame) => void;
|
|
29
29
|
|
|
30
|
+
export interface AddAccountOutcome {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
message: string;
|
|
33
|
+
id?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
30
36
|
export interface EngineConnectManagerOptions {
|
|
31
37
|
/** Re-advertise capabilities after a successful mutation (main.ts). */
|
|
32
38
|
readvertise(): void;
|
|
@@ -34,6 +40,17 @@ export interface EngineConnectManagerOptions {
|
|
|
34
40
|
refreshEngineAccountAgents(kind: string): Promise<void>;
|
|
35
41
|
/** Best-effort post-connect image maintenance (optional engines bake in). */
|
|
36
42
|
ensureStandardImage(): Promise<unknown>;
|
|
43
|
+
/** ADR-136 sign-in box: only labeled, workspace-scoped connects are
|
|
44
|
+
* admitted — nothing here may write the host's default slot. */
|
|
45
|
+
workspaceOnly?: boolean;
|
|
46
|
+
/** The labeled-account sink. Defaults to the local `addEngineAccount`; the
|
|
47
|
+
* sign-in box supplies the store-first one and awaits its write. */
|
|
48
|
+
addLabeledAccount?: (
|
|
49
|
+
kind: EngineConnectKind,
|
|
50
|
+
label: string,
|
|
51
|
+
secret: string,
|
|
52
|
+
scope: string,
|
|
53
|
+
) => AddAccountOutcome | Promise<AddAccountOutcome>;
|
|
37
54
|
timers?: {
|
|
38
55
|
setTimeout(handler: () => void, ms: number): NodeJS.Timeout;
|
|
39
56
|
clearTimeout(timer: NodeJS.Timeout): void;
|
|
@@ -156,15 +173,26 @@ export class EngineConnectManager {
|
|
|
156
173
|
return;
|
|
157
174
|
}
|
|
158
175
|
|
|
176
|
+
// ADR-136 sign-in box: a workspace account or nothing.
|
|
177
|
+
if (
|
|
178
|
+
this.options.workspaceOnly &&
|
|
179
|
+
(label === undefined || scope === undefined || scope === HOST_ACCOUNT_SCOPE)
|
|
180
|
+
) {
|
|
181
|
+
this.finish(
|
|
182
|
+
operation,
|
|
183
|
+
false,
|
|
184
|
+
"The Uai Cloud sign-in box connects workspace accounts only.",
|
|
185
|
+
);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
159
188
|
// ADR-116 labeled extra account — the sink validates kind support.
|
|
160
189
|
if (label !== undefined && secret !== undefined) {
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
);
|
|
190
|
+
const sink =
|
|
191
|
+
this.options.addLabeledAccount ??
|
|
192
|
+
((kind, accountLabel, value, accountScope) =>
|
|
193
|
+
addEngineAccount(kind, accountLabel, { token: value }, {}, accountScope));
|
|
194
|
+
const added = await sink(engine, label, secret, scope ?? HOST_ACCOUNT_SCOPE);
|
|
195
|
+
if (operation.done) return;
|
|
168
196
|
if (added.ok) this.afterMutation(engine, false);
|
|
169
197
|
this.finish(operation, added.ok, added.message, added.id);
|
|
170
198
|
return;
|
package/lib/engine-login.ts
CHANGED
|
@@ -136,6 +136,12 @@ export interface EngineLoginTimers {
|
|
|
136
136
|
clearTimeout(timer: unknown): void;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
export interface AccountAddOutcome {
|
|
140
|
+
ok: boolean;
|
|
141
|
+
message: string;
|
|
142
|
+
id?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
139
145
|
export interface EngineLoginSeams {
|
|
140
146
|
createTempDir(): Promise<string>;
|
|
141
147
|
removeTempDir(path: string): Promise<void>;
|
|
@@ -158,22 +164,24 @@ export interface EngineLoginSeams {
|
|
|
158
164
|
readTranscript(tempDir: string): Promise<string | null>;
|
|
159
165
|
persistClaudeToken(token: string): Promise<void>;
|
|
160
166
|
persistCodexAuth(sourceFile: string): Promise<void>;
|
|
161
|
-
/** ADR-116: labeled extra-account captures (never the default slots).
|
|
167
|
+
/** ADR-116: labeled extra-account captures (never the default slots).
|
|
168
|
+
* Sync on a host that seals the capture locally; a promise on the ADR-136
|
|
169
|
+
* sign-in box, whose sink awaits the credential store's write. */
|
|
162
170
|
addClaudeAccount(
|
|
163
171
|
label: string,
|
|
164
172
|
token: string,
|
|
165
173
|
scope: string,
|
|
166
|
-
):
|
|
174
|
+
): AccountAddOutcome | Promise<AccountAddOutcome>;
|
|
167
175
|
addCodexAccount(
|
|
168
176
|
label: string,
|
|
169
177
|
authJson: string,
|
|
170
178
|
scope: string,
|
|
171
|
-
):
|
|
179
|
+
): AccountAddOutcome | Promise<AccountAddOutcome>;
|
|
172
180
|
addGrokAccount(
|
|
173
181
|
label: string,
|
|
174
182
|
authJson: string,
|
|
175
183
|
scope: string,
|
|
176
|
-
):
|
|
184
|
+
): AccountAddOutcome | Promise<AccountAddOutcome>;
|
|
177
185
|
persistGrokAuth(sourceFile: string): Promise<void>;
|
|
178
186
|
/** Optional engines ride the standard image only when configured; a fresh
|
|
179
187
|
* account may be the first configuration → rebuild (queue-deduped). */
|
|
@@ -197,6 +205,9 @@ export interface EngineLoginManagerOptions {
|
|
|
197
205
|
tempRoot?: string;
|
|
198
206
|
/** Defer Docker inventory until the first start when no runtime is selected. */
|
|
199
207
|
reconcileOnCreate?: boolean;
|
|
208
|
+
/** ADR-136 sign-in box: only labeled, workspace-scoped sign-ins are
|
|
209
|
+
* admitted — the default slots are the host owner's and the box has none. */
|
|
210
|
+
workspaceOnly?: boolean;
|
|
200
211
|
seams?: Partial<EngineLoginSeams>;
|
|
201
212
|
/** Rebuild the standard image after a successful capture (optional engines). */
|
|
202
213
|
ensureStandardImage?: () => Promise<unknown>;
|
|
@@ -389,12 +400,14 @@ function withDefaults(options: EngineLoginManagerOptions): EngineLoginSeams {
|
|
|
389
400
|
*/
|
|
390
401
|
export class EngineLoginManager {
|
|
391
402
|
private readonly seams: EngineLoginSeams;
|
|
403
|
+
private readonly workspaceOnly: boolean;
|
|
392
404
|
private readonly operations = new Map<string, LoginOperation>();
|
|
393
405
|
private readonly engineOperations = new Map<EngineLoginKind, LoginOperation>();
|
|
394
406
|
private reconciliation: Promise<boolean>;
|
|
395
407
|
|
|
396
408
|
constructor(options: EngineLoginManagerOptions) {
|
|
397
409
|
this.seams = withDefaults(options);
|
|
410
|
+
this.workspaceOnly = options.workspaceOnly === true;
|
|
398
411
|
this.reconciliation =
|
|
399
412
|
options.reconcileOnCreate === false
|
|
400
413
|
? Promise.resolve(false)
|
|
@@ -449,6 +462,23 @@ export class EngineLoginManager {
|
|
|
449
462
|
);
|
|
450
463
|
return false;
|
|
451
464
|
}
|
|
465
|
+
// ADR-136 sign-in box: nothing here may write a default slot, and a
|
|
466
|
+
// host-scope account would belong to a runner that owns no credential.
|
|
467
|
+
if (
|
|
468
|
+
this.workspaceOnly &&
|
|
469
|
+
(scope === undefined || scope === HOST_ACCOUNT_SCOPE || label === undefined)
|
|
470
|
+
) {
|
|
471
|
+
safeEmit(
|
|
472
|
+
emit,
|
|
473
|
+
failedEvent(
|
|
474
|
+
opId,
|
|
475
|
+
engine,
|
|
476
|
+
"start_failed",
|
|
477
|
+
"The Uai Cloud sign-in box signs in workspace accounts only.",
|
|
478
|
+
),
|
|
479
|
+
);
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
452
482
|
// A workspace's sign-in is always a labeled account: the default slot is
|
|
453
483
|
// the host owner's and must never be written on a tenant's behalf.
|
|
454
484
|
if (scope !== undefined && scope !== HOST_ACCOUNT_SCOPE && label === undefined) {
|
|
@@ -1043,7 +1073,7 @@ export class EngineLoginManager {
|
|
|
1043
1073
|
// ADR-116: labeled capture — an extra account, not the default slot.
|
|
1044
1074
|
// Extras are picked up at the next agent spawn / rotation, so no
|
|
1045
1075
|
// running-agent refresh is needed (mirrors the local accounts API).
|
|
1046
|
-
const added = this.seams.addClaudeAccount(
|
|
1076
|
+
const added = await this.seams.addClaudeAccount(
|
|
1047
1077
|
operation.accountLabel,
|
|
1048
1078
|
token,
|
|
1049
1079
|
operation.accountScope,
|
|
@@ -1118,10 +1148,10 @@ export class EngineLoginManager {
|
|
|
1118
1148
|
if (operation.accountLabel !== null) {
|
|
1119
1149
|
// ADR-116: labeled capture — the auth.json this login produced becomes
|
|
1120
1150
|
// an isolated extra account instead of overwriting ~/.codex.
|
|
1121
|
-
let added:
|
|
1151
|
+
let added: AccountAddOutcome;
|
|
1122
1152
|
try {
|
|
1123
1153
|
const contents = await readFile(authFile, "utf8");
|
|
1124
|
-
added = grok
|
|
1154
|
+
added = await (grok
|
|
1125
1155
|
? this.seams.addGrokAccount(
|
|
1126
1156
|
operation.accountLabel,
|
|
1127
1157
|
contents,
|
|
@@ -1131,7 +1161,7 @@ export class EngineLoginManager {
|
|
|
1131
1161
|
operation.accountLabel,
|
|
1132
1162
|
contents,
|
|
1133
1163
|
operation.accountScope,
|
|
1134
|
-
);
|
|
1164
|
+
));
|
|
1135
1165
|
} catch {
|
|
1136
1166
|
added = {
|
|
1137
1167
|
ok: false,
|
package/lib/env-file.ts
CHANGED
|
@@ -79,6 +79,36 @@ export function writeEnvValuesAtomic(
|
|
|
79
79
|
replaceEnvFileAtomic(file, `${kept.join("\n")}\n`);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Atomically drop `keys` from a dotenv file (a missing file or key is fine).
|
|
84
|
+
* The sign-in box (ADR-136) strips engine credentials from `.env.local` at
|
|
85
|
+
* start so no default-slot secret survives on that machine.
|
|
86
|
+
*/
|
|
87
|
+
export function removeEnvValuesAtomic(
|
|
88
|
+
file: string,
|
|
89
|
+
keys: readonly string[],
|
|
90
|
+
): string[] {
|
|
91
|
+
if (keys.length === 0 || !existsSync(file)) return [];
|
|
92
|
+
for (const key of keys) {
|
|
93
|
+
if (!ENV_KEY.test(key)) throw new Error(`invalid environment key: ${key}`);
|
|
94
|
+
}
|
|
95
|
+
const drop = new Set(keys);
|
|
96
|
+
const removed: string[] = [];
|
|
97
|
+
const current = readFileSync(file, "utf8");
|
|
98
|
+
const kept = current.split(/\r?\n/).filter((line, index, lines) => {
|
|
99
|
+
if (index === lines.length - 1 && line === "") return false;
|
|
100
|
+
const equals = line.indexOf("=");
|
|
101
|
+
if (equals <= 0) return true;
|
|
102
|
+
const key = line.slice(0, equals);
|
|
103
|
+
if (!drop.has(key)) return true;
|
|
104
|
+
removed.push(key);
|
|
105
|
+
return false;
|
|
106
|
+
});
|
|
107
|
+
if (removed.length === 0) return [];
|
|
108
|
+
replaceEnvFileAtomic(file, kept.length === 0 ? "" : `${kept.join("\n")}\n`);
|
|
109
|
+
return [...new Set(removed)];
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
/** Capture the exact bytes needed to roll back a multi-store config write. */
|
|
83
113
|
export function readEnvFileSnapshot(file: string): EnvFileSnapshot {
|
|
84
114
|
return existsSync(file)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-136 sign-in box: the small always-on machine behind the "Uai Cloud"
|
|
3
|
+
* runner row (docs/shared-host-retirement.md). Selected by
|
|
4
|
+
* `uai-host run --signin-box` (env `UAI_SIGNIN_BOX=1`), which cli.ts sets
|
|
5
|
+
* before main.ts loads. Same package as every runner, one flag.
|
|
6
|
+
*
|
|
7
|
+
* The box has exactly one job — run engine sign-ins and connects for
|
|
8
|
+
* workspaces and write the result into the credential store — and holds no
|
|
9
|
+
* credential at rest: captures go to the store first and only a metadata row
|
|
10
|
+
* stays (lib/engine-accounts.ts, store-only accounts), nothing is imported
|
|
11
|
+
* from the store at start, the default engine slots are scrubbed on every
|
|
12
|
+
* start, and its store identity is write-only. Everything else a runner does
|
|
13
|
+
* is refused here: task commands and tunnels get a definite non-retryable
|
|
14
|
+
* answer, the local UI and the pickup/broker loops never start.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readdirSync, rmSync, type Dirent } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { removeEnvValuesAtomic } from "./env-file";
|
|
21
|
+
|
|
22
|
+
export function signInBoxMode(): boolean {
|
|
23
|
+
return process.env.UAI_SIGNIN_BOX === "1";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const SIGN_IN_BOX_TASK_REFUSAL =
|
|
27
|
+
"This runner is the Uai Cloud sign-in box; tasks run on their own machines.";
|
|
28
|
+
|
|
29
|
+
/** Host commands the box still serves: its own managed lifecycle and the
|
|
30
|
+
* account management the engines pane drives. Every task-shaped command
|
|
31
|
+
* (taskUp, channels, files, previews, attachments) is refused. */
|
|
32
|
+
const ALLOWED_COMMANDS: ReadonlySet<string> = new Set([
|
|
33
|
+
"hostUpdate",
|
|
34
|
+
"hostRestart",
|
|
35
|
+
"engineAccountRemove",
|
|
36
|
+
"engineAccountRename",
|
|
37
|
+
"engineAccountReorder",
|
|
38
|
+
"engineDisconnect",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export function signInBoxAllowsCommand(command: string): boolean {
|
|
42
|
+
return ALLOWED_COMMANDS.has(command);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The env keys the adapters read as the host's OWN engine credential
|
|
46
|
+
* (lib/engines.ts `detect`). None may survive on the box. */
|
|
47
|
+
export const ENGINE_CREDENTIAL_ENV_KEYS = [
|
|
48
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
49
|
+
"ANTHROPIC_API_KEY",
|
|
50
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
51
|
+
"OPENAI_API_KEY",
|
|
52
|
+
"XAI_API_KEY",
|
|
53
|
+
"CURSOR_API_KEY",
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
/** Default-slot credential files under the owner's home (engine-accounts
|
|
57
|
+
* KINDS `defaultHostDir`), and the isolated per-account dirs
|
|
58
|
+
* (`extraHostDir`) — swept as strays in case no row references them. */
|
|
59
|
+
const DEFAULT_SLOT_FILES: readonly (readonly string[])[] = [
|
|
60
|
+
[".codex", "auth.json"],
|
|
61
|
+
[".grok", "auth.json"],
|
|
62
|
+
[".local", "share", "opencode", "auth.json"],
|
|
63
|
+
];
|
|
64
|
+
const ACCOUNT_DIR_PREFIXES: readonly { parent: readonly string[]; prefix: string }[] = [
|
|
65
|
+
{ parent: [], prefix: ".codex-acct-" },
|
|
66
|
+
{ parent: [], prefix: ".grok-acct-" },
|
|
67
|
+
{ parent: [".local", "share"], prefix: "opencode-acct-" },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
export interface PurgeDefaultEngineSlotsResult {
|
|
71
|
+
files: string[];
|
|
72
|
+
dirs: string[];
|
|
73
|
+
envKeys: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Remove every host-level engine credential from this machine: the default
|
|
78
|
+
* slot files, stray isolated account dirs, and the credential keys in
|
|
79
|
+
* `.env.local` plus the live process env. Idempotent; a fresh box removes
|
|
80
|
+
* nothing.
|
|
81
|
+
*/
|
|
82
|
+
export function purgeDefaultEngineSlots(opts: {
|
|
83
|
+
ownerHome: string;
|
|
84
|
+
envFile: string;
|
|
85
|
+
procEnv?: NodeJS.ProcessEnv;
|
|
86
|
+
}): PurgeDefaultEngineSlotsResult {
|
|
87
|
+
const files: string[] = [];
|
|
88
|
+
const dirs: string[] = [];
|
|
89
|
+
for (const parts of DEFAULT_SLOT_FILES) {
|
|
90
|
+
const file = join(opts.ownerHome, ...parts);
|
|
91
|
+
if (!existsSync(file)) continue;
|
|
92
|
+
rmSync(file, { force: true });
|
|
93
|
+
files.push(file);
|
|
94
|
+
}
|
|
95
|
+
for (const { parent, prefix } of ACCOUNT_DIR_PREFIXES) {
|
|
96
|
+
const dir = join(opts.ownerHome, ...parent);
|
|
97
|
+
let entries: Dirent[];
|
|
98
|
+
try {
|
|
99
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
|
|
105
|
+
const path = join(dir, entry.name);
|
|
106
|
+
rmSync(path, { recursive: true, force: true });
|
|
107
|
+
dirs.push(path);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const envKeys = removeEnvValuesAtomic(opts.envFile, ENGINE_CREDENTIAL_ENV_KEYS);
|
|
111
|
+
const procEnv = opts.procEnv ?? process.env;
|
|
112
|
+
for (const key of ENGINE_CREDENTIAL_ENV_KEYS) delete procEnv[key];
|
|
113
|
+
return { files, dirs, envKeys };
|
|
114
|
+
}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -204,6 +204,10 @@ async function cmdRun(rest: string[] = []): Promise<void> {
|
|
|
204
204
|
// (lib/sidecar.ts) — set before main.ts loads, since its boot reads it.
|
|
205
205
|
const sidecarTask = flagValue(rest, "--task");
|
|
206
206
|
if (sidecarTask) process.env.UAI_SIDECAR_TASK = sidecarTask;
|
|
207
|
+
// ADR-136 sign-in box: `uai-host run --signin-box` on the small always-on
|
|
208
|
+
// machine behind the "Uai Cloud" row. Same package, one flag
|
|
209
|
+
// (lib/signin-box.ts); set before main.ts loads for the same reason.
|
|
210
|
+
if (rest.includes("--signin-box")) process.env.UAI_SIGNIN_BOX = "1";
|
|
207
211
|
// Timestamped service logs: the ADR-106 live smoke burned an hour dating
|
|
208
212
|
// undated scrollback. Applies to the service process only, not one-shot CLI
|
|
209
213
|
// commands.
|
package/src/main.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type OutgoingHttpHeaders,
|
|
11
11
|
} from "node:http";
|
|
12
12
|
import { Socket } from "node:net";
|
|
13
|
+
import { homedir } from "node:os";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
|
|
15
16
|
import WebSocket, { type RawData } from "ws";
|
|
@@ -159,9 +160,13 @@ import {
|
|
|
159
160
|
type HostConfigResult,
|
|
160
161
|
} from "../lib/host-config";
|
|
161
162
|
import {
|
|
163
|
+
addCapturedConfigDirAccountToStore,
|
|
164
|
+
addEngineAccountToStore,
|
|
162
165
|
configureEngineAccountStore,
|
|
163
166
|
hasStoredEngineAccounts,
|
|
164
167
|
listEngineAccountsAllScopes,
|
|
168
|
+
purgeLocalEngineAccountSecrets,
|
|
169
|
+
reconcileEngineAccountMetadataFromStore,
|
|
165
170
|
reconcileEngineAccountsFromStore,
|
|
166
171
|
} from "../lib/engine-accounts";
|
|
167
172
|
import {
|
|
@@ -253,6 +258,12 @@ import {
|
|
|
253
258
|
import { isEnvironmentTransportProvider } from "../lib/task-environment/types";
|
|
254
259
|
import { LeaseManager } from "../lib/lease";
|
|
255
260
|
import { markSidecarReady, sidecarMode, sidecarTaskId } from "../lib/sidecar";
|
|
261
|
+
import {
|
|
262
|
+
purgeDefaultEngineSlots,
|
|
263
|
+
SIGN_IN_BOX_TASK_REFUSAL,
|
|
264
|
+
signInBoxAllowsCommand,
|
|
265
|
+
signInBoxMode,
|
|
266
|
+
} from "../lib/signin-box";
|
|
256
267
|
import { resumeSidecarTask } from "../lib/sidecar-resume";
|
|
257
268
|
import { declareAgentClisReady } from "../lib/standard-image";
|
|
258
269
|
|
|
@@ -438,6 +449,11 @@ interface PausableSource {
|
|
|
438
449
|
}
|
|
439
450
|
|
|
440
451
|
console.log(`[host-agent] starting host ${hostId}`);
|
|
452
|
+
if (signInBoxMode() && sidecarMode()) {
|
|
453
|
+
console.error("[host-agent] --signin-box and --task are exclusive; refusing to start");
|
|
454
|
+
process.exit(2);
|
|
455
|
+
}
|
|
456
|
+
if (signInBoxMode()) console.log("[host-agent] sign-in box mode (ADR-136): no tasks, no credential at rest");
|
|
441
457
|
// Self-heal the recurring silent admission gate BEFORE anything can refuse
|
|
442
458
|
// work: an uninstall/update helper that died at phase "checking" (live
|
|
443
459
|
// 2026-08-11 + twice 2026-08-18) left every taskUp/channelEnsure bouncing
|
|
@@ -526,9 +542,59 @@ const repushBrokeredCopies = async (kind: string): Promise<void> => {
|
|
|
526
542
|
await getOrchestrator().refreshEngineAccountCopies(kind);
|
|
527
543
|
};
|
|
528
544
|
configureTokenBroker({ repush: repushBrokeredCopies });
|
|
545
|
+
/**
|
|
546
|
+
* ADR-136 sign-in box start. Strip every credential a previous release or an
|
|
547
|
+
* operator left on this machine, then rebuild the workspace account LIST from
|
|
548
|
+
* parameter names and tags — never a value — before the first capability
|
|
549
|
+
* frame is built, so a replacement box does not advertise an empty list and
|
|
550
|
+
* overwrite the cloud's snapshot with it. Bounded: a store outage must not
|
|
551
|
+
* keep the box offline, only empty until the next start.
|
|
552
|
+
*/
|
|
553
|
+
const SIGN_IN_BOX_RECONCILE_TIMEOUT_MS = 30_000;
|
|
554
|
+
async function startSignInBox(): Promise<void> {
|
|
555
|
+
const secrets = purgeLocalEngineAccountSecrets();
|
|
556
|
+
const slots = purgeDefaultEngineSlots({
|
|
557
|
+
ownerHome: process.env.UAI_OWNER_HOME?.trim() || homedir(),
|
|
558
|
+
envFile: join(env.uaiHome, ".env.local"),
|
|
559
|
+
});
|
|
560
|
+
const purged =
|
|
561
|
+
secrets.stripped + secrets.removed + slots.files.length + slots.dirs.length + slots.envKeys.length;
|
|
562
|
+
if (purged > 0 || secrets.errors > 0) {
|
|
563
|
+
console.warn(
|
|
564
|
+
`[signin-box] purged local credentials: ${secrets.stripped} account rows stripped, ${secrets.removed} removed, ` +
|
|
565
|
+
`${slots.files.length} default-slot files, ${slots.dirs.length} stray dirs, ${slots.envKeys.length} env keys` +
|
|
566
|
+
(secrets.errors > 0 ? `, ${secrets.errors} errors` : ""),
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
let timer: NodeJS.Timeout | null = null;
|
|
570
|
+
const deadline = new Promise<"timeout">((resolve) => {
|
|
571
|
+
timer = setTimeout(() => resolve("timeout"), SIGN_IN_BOX_RECONCILE_TIMEOUT_MS);
|
|
572
|
+
timer.unref?.();
|
|
573
|
+
});
|
|
574
|
+
try {
|
|
575
|
+
const outcome = await Promise.race([reconcileEngineAccountMetadataFromStore(), deadline]);
|
|
576
|
+
if (outcome === "timeout") {
|
|
577
|
+
console.warn("[signin-box] account metadata reconcile did not finish in time; the list fills on the next start");
|
|
578
|
+
} else if (!outcome.skipped) {
|
|
579
|
+
console.log(
|
|
580
|
+
`[signin-box] account metadata reconciled: ${outcome.imported} imported, ${outcome.removed} removed, ${outcome.errors} errors`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
} catch (error) {
|
|
584
|
+
console.warn(
|
|
585
|
+
`[signin-box] account metadata reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
586
|
+
);
|
|
587
|
+
} finally {
|
|
588
|
+
if (timer) clearTimeout(timer);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
529
591
|
// ADR-133: a sidecar never refreshes sign-ins (the broker Lambda does), and
|
|
530
592
|
// reads the store only once it holds a lease — see leaseManager below.
|
|
531
|
-
|
|
593
|
+
// ADR-136: the sign-in box imports nothing and runs no pickup — it holds no
|
|
594
|
+
// copy for a refresh to update.
|
|
595
|
+
if (signInBoxMode()) {
|
|
596
|
+
await startSignInBox();
|
|
597
|
+
} else if (!sidecarMode()) {
|
|
532
598
|
startTokenBrokerLoop();
|
|
533
599
|
startCredentialStorePickup({ repush: repushBrokeredCopies });
|
|
534
600
|
void reconcileStores();
|
|
@@ -621,6 +687,21 @@ const engineLoginManager = createEngineLoginManager({
|
|
|
621
687
|
// A grok sign-in may be the first grok configuration on this host: the
|
|
622
688
|
// standard image needs the CLI before its tasks can run (host 0.9.105).
|
|
623
689
|
ensureStandardImage: () => ensureStandardImage(),
|
|
690
|
+
// ADR-136 sign-in box: workspace accounts only, written to the store first
|
|
691
|
+
// and never sealed here.
|
|
692
|
+
workspaceOnly: signInBoxMode(),
|
|
693
|
+
...(signInBoxMode()
|
|
694
|
+
? {
|
|
695
|
+
seams: {
|
|
696
|
+
addClaudeAccount: (label, token, scope) =>
|
|
697
|
+
addEngineAccountToStore("claude", label, { token }, scope),
|
|
698
|
+
addCodexAccount: (label, authJson, scope) =>
|
|
699
|
+
addCapturedConfigDirAccountToStore("codex", label, authJson, scope),
|
|
700
|
+
addGrokAccount: (label, authJson, scope) =>
|
|
701
|
+
addCapturedConfigDirAccountToStore("grok", label, authJson, scope),
|
|
702
|
+
},
|
|
703
|
+
}
|
|
704
|
+
: {}),
|
|
624
705
|
});
|
|
625
706
|
// ADR-117: one-shot connect (pasted key / OpenCode re-detect) over the bridge.
|
|
626
707
|
const engineConnectManager = createEngineConnectManager({
|
|
@@ -628,6 +709,13 @@ const engineConnectManager = createEngineConnectManager({
|
|
|
628
709
|
refreshEngineAccountAgents: (engine) =>
|
|
629
710
|
getOrchestrator().refreshEngineAccountAgents(engine),
|
|
630
711
|
ensureStandardImage: () => ensureStandardImage(),
|
|
712
|
+
workspaceOnly: signInBoxMode(),
|
|
713
|
+
...(signInBoxMode()
|
|
714
|
+
? {
|
|
715
|
+
addLabeledAccount: (kind, label, secret, scope) =>
|
|
716
|
+
addEngineAccountToStore(kind, label, { token: secret }, scope),
|
|
717
|
+
}
|
|
718
|
+
: {}),
|
|
631
719
|
});
|
|
632
720
|
if (initialRuntime.status === "ready") {
|
|
633
721
|
console.log(
|
|
@@ -693,10 +781,10 @@ void connect();
|
|
|
693
781
|
// Best-effort: a UI bind failure must not take the host service down.
|
|
694
782
|
// A task machine has no operator sitting at it (ADR-133): no local UI, no
|
|
695
783
|
// Mac vnode watch.
|
|
696
|
-
if (!sidecarMode()) void startLocalUi();
|
|
784
|
+
if (!sidecarMode() && !signInBoxMode()) void startLocalUi();
|
|
697
785
|
// ADR-118 follow-up: vnode-pressure observability (macOS/apple-container
|
|
698
786
|
// hosts). Logs only on band transitions; the sensor caches its sysctl read.
|
|
699
|
-
if (!sidecarMode()) {
|
|
787
|
+
if (!sidecarMode() && !signInBoxMode()) {
|
|
700
788
|
const vnodePressureTimer = setInterval(() => void logVnodePressure(), 60_000);
|
|
701
789
|
vnodePressureTimer.unref?.();
|
|
702
790
|
void logVnodePressure();
|
|
@@ -1334,6 +1422,7 @@ async function connect(): Promise<void> {
|
|
|
1334
1422
|
const frame = parseCloudFrame(data);
|
|
1335
1423
|
if (!frame) return;
|
|
1336
1424
|
if (stopping && rejectFrameDuringShutdown(socket, frame)) return;
|
|
1425
|
+
if (signInBoxMode() && rejectFrameOnSignInBox(socket, frame)) return;
|
|
1337
1426
|
|
|
1338
1427
|
switch (frame.kind) {
|
|
1339
1428
|
case "pong":
|
|
@@ -3260,6 +3349,39 @@ function rejectFrameDuringShutdown(
|
|
|
3260
3349
|
}
|
|
3261
3350
|
}
|
|
3262
3351
|
|
|
3352
|
+
/**
|
|
3353
|
+
* ADR-136 sign-in box: the runner behind the "Uai Cloud" row runs no task.
|
|
3354
|
+
* Task commands and tunnel opens get a definite, non-retryable refusal —
|
|
3355
|
+
* nothing here will ever accept them — while the managed lifecycle, engine
|
|
3356
|
+
* sign-ins and account management pass through untouched.
|
|
3357
|
+
*/
|
|
3358
|
+
function rejectFrameOnSignInBox(socket: WebSocket, frame: CloudToHost): boolean {
|
|
3359
|
+
switch (frame.kind) {
|
|
3360
|
+
case "command":
|
|
3361
|
+
if (signInBoxAllowsCommand(frame.command)) return false;
|
|
3362
|
+
send(socket, {
|
|
3363
|
+
kind: "result",
|
|
3364
|
+
commandId: frame.commandId,
|
|
3365
|
+
result: {
|
|
3366
|
+
ok: false,
|
|
3367
|
+
code: HostErrorCode.HostUnavailable,
|
|
3368
|
+
message: SIGN_IN_BOX_TASK_REFUSAL,
|
|
3369
|
+
retryable: false,
|
|
3370
|
+
},
|
|
3371
|
+
});
|
|
3372
|
+
return true;
|
|
3373
|
+
case "tunnel.open":
|
|
3374
|
+
send(socket, {
|
|
3375
|
+
kind: "tunnel.close",
|
|
3376
|
+
tunnelId: frame.tunnelId,
|
|
3377
|
+
reason: SIGN_IN_BOX_TASK_REFUSAL,
|
|
3378
|
+
});
|
|
3379
|
+
return true;
|
|
3380
|
+
default:
|
|
3381
|
+
return false;
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3263
3385
|
function rawDataToBuffer(data: RawData): Buffer {
|
|
3264
3386
|
if (Buffer.isBuffer(data)) return data;
|
|
3265
3387
|
if (data instanceof ArrayBuffer) return Buffer.from(data);
|