@tokenoftrust/cli 2.2.9 → 2.2.11
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/package.json +1 -1
- package/src/commands/clone.mjs +16 -54
- package/src/commands/dev.mjs +110 -38
- package/src/commands/submit.mjs +2 -2
- package/src/tenant-scope.mjs +33 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.11",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/commands/clone.mjs
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* every store-only step. The checkout records its kind in local git config so
|
|
18
18
|
* `tot git-credential` re-mints it the same way.
|
|
19
19
|
*
|
|
20
|
-
* This is the SAME MCP code path a developer
|
|
21
|
-
*
|
|
20
|
+
* This is the SAME MCP code path a developer's agent takes: sign-in →
|
|
21
|
+
* tenant_checkout({ tenant }) — the tenant named on the call, no client_switch — where the MCP derives your
|
|
22
22
|
* per-tenant Git user and mints a FRESH, single-active, repo-scoped push
|
|
23
23
|
* credential (a later clone for the same tenant rotates it). `tot` performs NO
|
|
24
24
|
* privileged forge work itself — the MCP owns that. ("checkout" survives only as
|
|
@@ -238,9 +238,8 @@ export async function run(argv, ctx, deps = {}) {
|
|
|
238
238
|
return 0;
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
console.error(`~ client_switch → ${args.tenant}`);
|
|
242
241
|
console.error(
|
|
243
|
-
`~ tenant_checkout (
|
|
242
|
+
`~ tenant_checkout (tenant=${args.tenant}) — MCP mints a fresh repo-scoped credential`,
|
|
244
243
|
);
|
|
245
244
|
const res = await checkoutTenant(client, {
|
|
246
245
|
tenant: args.tenant,
|
|
@@ -308,7 +307,7 @@ function reportFailure(e, redact) {
|
|
|
308
307
|
/**
|
|
309
308
|
* `tot clone <alias> [dir]` for a read-only alias (the agency kit). Only the steps a
|
|
310
309
|
* non-store repo needs: sign-in, the read mint, the token-free clone, the helper. No store
|
|
311
|
-
* listing,
|
|
310
|
+
* listing, identity_bind, tenant_checkout, tenant inference, `.nvmrc` or
|
|
312
311
|
* `tot dev` guidance.
|
|
313
312
|
*/
|
|
314
313
|
async function runReadOnlyAlias(alias, args, { env, createClient, establish, signIn, cloneDeps }) {
|
|
@@ -376,23 +375,13 @@ export function checkoutPointsAtEntitlement(checkout) {
|
|
|
376
375
|
* cloned: boolean, dir: string|null, head: string|null }>}
|
|
377
376
|
*/
|
|
378
377
|
export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
|
|
379
|
-
// tenant_checkout is the SOLE AUTHORITATIVE checkout decision
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
// on any other pin failure (a broken session must not masquerade as an authorization
|
|
386
|
-
// problem).
|
|
387
|
-
//
|
|
388
|
-
// Two uses of the remembered refusal are BOTH wrong, and the second is the dangerous one:
|
|
389
|
-
// - discarding it loses the only good explanation when a tenant genuinely is not yours;
|
|
390
|
-
// - letting it OVERRIDE a checkout denial replaces an authoritative answer ("your staff
|
|
391
|
-
// authorization expired / was revoked / does not cover this resource") with grant
|
|
392
|
-
// guidance that sends you chasing the wrong thing entirely.
|
|
393
|
-
// So an expected pin refusal may never speak over a checkout denial (see below).
|
|
378
|
+
// tenant_checkout is the SOLE AUTHORITATIVE checkout decision, and it names the tenant
|
|
379
|
+
// (scopeToTenant injects it), so the server checks out that held tenant directly — or
|
|
380
|
+
// answers for a staff-preview authorization, or refuses naming the tenants the session
|
|
381
|
+
// holds. No client_switch is sent: it writes a selection shared by every windowless
|
|
382
|
+
// session on this login, so it can lose to another session's switch before the checkout
|
|
383
|
+
// lands, and it moves that other session's selection too.
|
|
394
384
|
const scoped = await scopeToTenant(client, tenant, { redact });
|
|
395
|
-
const expectedSwitchRefusal = scoped.pinRefusal;
|
|
396
385
|
// vc-app-binding safety-net (08-18, §5): ensure THIS developer is bound to the
|
|
397
386
|
// storefront version-control app BEFORE checkout, so tenant_checkout can resolve
|
|
398
387
|
// `appForCaller` for them instead of 403-ing "No version-control app is bound." The
|
|
@@ -409,45 +398,18 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
|
|
|
409
398
|
} catch {
|
|
410
399
|
/* fail-open — see above */
|
|
411
400
|
}
|
|
412
|
-
|
|
413
|
-
try {
|
|
414
|
-
checkout = await scoped.callTool("tenant_checkout", { tag });
|
|
415
|
-
} catch (e) {
|
|
416
|
-
// A THROWN checkout is not a denial — it never rendered an authorization decision. Only
|
|
417
|
-
// here may the expected switch refusal speak, because it is the only explanation either
|
|
418
|
-
// call produced.
|
|
419
|
-
if (expectedSwitchRefusal) {
|
|
420
|
-
throw new CliError(redact(String(expectedSwitchRefusal?.message || expectedSwitchRefusal)), {
|
|
421
|
-
next:
|
|
422
|
-
"confirm you're entitled to this store — `tot grants` lists what your session can " +
|
|
423
|
-
"act on, and `tot login` refreshes a grant issued after you signed in. " +
|
|
424
|
-
ACCESS_EXPLAIN_HINT(tenant),
|
|
425
|
-
cause: expectedSwitchRefusal,
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
throw e;
|
|
429
|
-
}
|
|
401
|
+
const checkout = await scoped.callTool("tenant_checkout", { tag });
|
|
430
402
|
|
|
431
403
|
// A non-checkout result (not provisioned / not entitled / failed) must surface
|
|
432
404
|
// the MCP's human message + a concrete next step, NEVER a raw JSON.stringify
|
|
433
405
|
// dump (James's 2026-07-19 first-experience failure).
|
|
434
406
|
const err = checkoutError(checkout);
|
|
435
407
|
if (err) {
|
|
436
|
-
// AUTHORITATIVE: the checkout rendered a decision, so it is the answer
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
// session resolved), and dropping it left only a downstream "Over-scoped" that reads like
|
|
442
|
-
// a selection error.
|
|
443
|
-
if (expectedSwitchRefusal && checkoutPointsAtEntitlement(checkout)) {
|
|
444
|
-
const switchMessage = String(expectedSwitchRefusal?.message || expectedSwitchRefusal);
|
|
445
|
-
throw new CliError(
|
|
446
|
-
redact(`${err.message}\n client_switch also refused: ${switchMessage}`),
|
|
447
|
-
{ next: `${err.next} ${ACCESS_EXPLAIN_HINT(tenant)}`, cause: expectedSwitchRefusal },
|
|
448
|
-
);
|
|
449
|
-
}
|
|
450
|
-
throw new CliError(redact(err.message), { next: err.next });
|
|
408
|
+
// AUTHORITATIVE: the checkout rendered a decision, so it is the answer. When that denial
|
|
409
|
+
// points at entitlement (the store is not among the tenants the session holds), the
|
|
410
|
+
// access_explain route rides below it: it names the exact broken step for an invitee.
|
|
411
|
+
const next = checkoutPointsAtEntitlement(checkout) ? `${err.next} ${ACCESS_EXPLAIN_HINT(tenant)}` : err.next;
|
|
412
|
+
throw new CliError(redact(err.message), { next });
|
|
451
413
|
}
|
|
452
414
|
const gitRemote = checkout.gitRemote;
|
|
453
415
|
const cloneUrl = checkout.cloneUrl ?? null;
|
package/src/commands/dev.mjs
CHANGED
|
@@ -760,65 +760,127 @@ export function pinnedPublicCacheDir(cacheRoot, version) {
|
|
|
760
760
|
}
|
|
761
761
|
|
|
762
762
|
/**
|
|
763
|
-
* List the installed
|
|
764
|
-
* normalised so a scoped package reads as
|
|
765
|
-
* store naming) regardless of the on-disk layout
|
|
766
|
-
*
|
|
767
|
-
*
|
|
763
|
+
* List the installed packages that could carry a platform-native binary, as
|
|
764
|
+
* `{ key, pkgJson }`: `key` is normalised so a scoped package reads as
|
|
765
|
+
* `@scope+name` (pnpm's virtual-store naming) regardless of the on-disk layout,
|
|
766
|
+
* and `pkgJson` is where that package's own manifest lives. Prefers the pnpm
|
|
767
|
+
* virtual store (`node_modules/.pnpm/<name>@<ver>/node_modules/<name>`); falls back
|
|
768
|
+
* to a shallow `node_modules` scan (one level into `@scope/`) for an npm/flat
|
|
769
|
+
* install. Best-effort — returns [] on any error.
|
|
768
770
|
*/
|
|
769
771
|
function collectNativePackageDirs(runnerDir) {
|
|
770
|
-
const pnpmDir = join(runnerDir, "node_modules", ".pnpm");
|
|
771
|
-
if (existsSync(pnpmDir)) return readdirSync(pnpmDir);
|
|
772
772
|
const nm = join(runnerDir, "node_modules");
|
|
773
|
+
const pnpmDir = join(nm, ".pnpm");
|
|
774
|
+
if (existsSync(pnpmDir)) {
|
|
775
|
+
return readdirSync(pnpmDir).map((entry) => {
|
|
776
|
+
const m = /^(@[^+]+)\+([^@]+)@/.exec(entry) || /^([^@]+)@/.exec(entry);
|
|
777
|
+
const pkgName = !m ? null : m[2] !== undefined ? `${m[1]}/${m[2]}` : m[1];
|
|
778
|
+
return {
|
|
779
|
+
key: entry,
|
|
780
|
+
pkgJson: pkgName ? join(pnpmDir, entry, "node_modules", pkgName, "package.json") : null,
|
|
781
|
+
};
|
|
782
|
+
});
|
|
783
|
+
}
|
|
773
784
|
if (!existsSync(nm)) return [];
|
|
774
|
-
const
|
|
785
|
+
const out = [];
|
|
775
786
|
for (const e of readdirSync(nm, { withFileTypes: true })) {
|
|
776
787
|
if (e.name.startsWith("@") && e.isDirectory()) {
|
|
777
|
-
for (const s of readdirSync(join(nm, e.name)))
|
|
788
|
+
for (const s of readdirSync(join(nm, e.name))) {
|
|
789
|
+
out.push({ key: `${e.name}+${s}`, pkgJson: join(nm, e.name, s, "package.json") });
|
|
790
|
+
}
|
|
778
791
|
} else {
|
|
779
|
-
|
|
792
|
+
out.push({ key: e.name, pkgJson: join(nm, e.name, "package.json") });
|
|
780
793
|
}
|
|
781
794
|
}
|
|
782
|
-
return
|
|
795
|
+
return out;
|
|
783
796
|
}
|
|
784
797
|
|
|
785
798
|
/**
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
* `
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
799
|
+
* The `<os>-<cpu>` targets a package's own manifest declares through its `os` and
|
|
800
|
+
* `cpu` fields — the authority npm and pnpm themselves install by. Null when the
|
|
801
|
+
* manifest is unreadable or declares no positive os AND cpu (negations such as
|
|
802
|
+
* `"!win32"` exclude rather than name a target).
|
|
803
|
+
*/
|
|
804
|
+
function declaredNativeTargets(pkgJsonPath) {
|
|
805
|
+
if (!pkgJsonPath) return null;
|
|
806
|
+
try {
|
|
807
|
+
const j = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
808
|
+
const positive = (v) =>
|
|
809
|
+
(Array.isArray(v) ? v : typeof v === "string" ? [v] : []).filter(
|
|
810
|
+
(x) => typeof x === "string" && x && !x.startsWith("!"),
|
|
811
|
+
);
|
|
812
|
+
const os = positive(j.os);
|
|
813
|
+
const cpu = positive(j.cpu);
|
|
814
|
+
if (!os.length || !cpu.length) return null;
|
|
815
|
+
return os.flatMap((o) => cpu.map((c) => `${o}-${c}`));
|
|
816
|
+
} catch {
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Package-name spellings of an OS / CPU that differ from Node's `process.platform`
|
|
823
|
+
* / `process.arch`. `@cloudflare/workerd-linux-64` is linux-x64; reading the name
|
|
824
|
+
* token literally makes every x64 host look like it is missing workerd.
|
|
825
|
+
*/
|
|
826
|
+
const NATIVE_OS_ALIASES = { windows: "win32" };
|
|
827
|
+
const NATIVE_CPU_ALIASES = { 64: "x64", amd64: "x64", x86_64: "x64", aarch64: "arm64", 32: "ia32", x86: "ia32", i386: "ia32" };
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Every native-binding family that ships a variant for this host's OS but not for
|
|
831
|
+
* its OS+CPU — the exact shape of astro's `Cannot find native binding` boot crash.
|
|
832
|
+
* Platform-native packages are named `<family>-<os>-<cpu>[-<abi>]` (e.g.
|
|
833
|
+
* `@esbuild/darwin-arm64`, `lightningcss-linux-x64-gnu`, `@cloudflare/workerd-linux-64`);
|
|
834
|
+
* the name only GROUPS a package into its family. Which target a package is for
|
|
835
|
+
* comes from its manifest's `os`/`cpu` fields, falling back to the name tokens
|
|
836
|
+
* (aliases normalised) only when the manifest is unreadable. npm's optional-deps bug
|
|
837
|
+
* (npm/cli#4828) can materialise a DIFFERENT arch's variant than the host needs
|
|
838
|
+
* (e.g. darwin-x64 on an arm64 Mac). A family with no variant for our OS at all is
|
|
839
|
+
* a cross-platform optional dep that's correctly absent, so it never counts.
|
|
797
840
|
*
|
|
798
|
-
*
|
|
841
|
+
* @returns {{ family: string, want: string, found: string[] }[]} — `family` in
|
|
842
|
+
* npm spelling (`@scope/name`), `found` the targets installed for our OS.
|
|
843
|
+
* [] on any probe error, so a health probe can't itself break `tot dev`.
|
|
799
844
|
*/
|
|
800
|
-
export function
|
|
845
|
+
export function rendererNativeBindingGaps(runnerDir, { platform = process.platform, arch = process.arch } = {}) {
|
|
801
846
|
try {
|
|
802
|
-
const re = /^(.*?)[-+](darwin|linux|win32|freebsd|android|openharmony)-([a-z0-
|
|
847
|
+
const re = /^(.*?)[-+](darwin|linux|win32|windows|freebsd|android|openharmony)-([a-z0-9_]+)/;
|
|
803
848
|
const families = new Map(); // family -> Set("<os>-<cpu>")
|
|
804
|
-
for (const
|
|
805
|
-
const m =
|
|
849
|
+
for (const { key, pkgJson } of collectNativePackageDirs(runnerDir)) {
|
|
850
|
+
const m = key.match(re);
|
|
806
851
|
if (!m) continue;
|
|
807
852
|
const [, family, os, cpu] = m;
|
|
853
|
+
const targets = declaredNativeTargets(pkgJson) || [
|
|
854
|
+
`${NATIVE_OS_ALIASES[os] || os}-${NATIVE_CPU_ALIASES[cpu] || cpu}`,
|
|
855
|
+
];
|
|
808
856
|
if (!families.has(family)) families.set(family, new Set());
|
|
809
|
-
families.get(family).add(
|
|
857
|
+
for (const t of targets) families.get(family).add(t);
|
|
810
858
|
}
|
|
811
859
|
const want = `${platform}-${arch}`;
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
860
|
+
const gaps = [];
|
|
861
|
+
for (const [family, variants] of families) {
|
|
862
|
+
const ours = [...variants].filter((v) => v.startsWith(`${platform}-`));
|
|
863
|
+
if (ours.length && !variants.has(want)) {
|
|
864
|
+
gaps.push({ family: family.replace(/^(@[^+]+)\+/, "$1/"), want, found: ours.sort() });
|
|
865
|
+
}
|
|
815
866
|
}
|
|
816
|
-
return
|
|
867
|
+
return gaps;
|
|
817
868
|
} catch {
|
|
818
|
-
return
|
|
869
|
+
return [];
|
|
819
870
|
}
|
|
820
871
|
}
|
|
821
872
|
|
|
873
|
+
/**
|
|
874
|
+
* True unless the installed renderer is POISONED for this host's CPU arch — the
|
|
875
|
+
* "the dev server didn't come up" root cause (see rendererNativeBindingGaps).
|
|
876
|
+
* Never throws; on any probe error it returns true (trust the cache).
|
|
877
|
+
*
|
|
878
|
+
* `platform`/`arch` are injectable so this is testable off the host's real arch.
|
|
879
|
+
*/
|
|
880
|
+
export function rendererCacheHealthy(runnerDir, opts = {}) {
|
|
881
|
+
return rendererNativeBindingGaps(runnerDir, opts).length === 0;
|
|
882
|
+
}
|
|
883
|
+
|
|
822
884
|
/**
|
|
823
885
|
* True unless the installed runner package is missing source the Astro config
|
|
824
886
|
* chain needs to even boot — the "renderer resolves but `tot dev` can't load
|
|
@@ -1217,8 +1279,9 @@ export async function installRunnerTarball(
|
|
|
1217
1279
|
const installSpin = startProgress("installing the store preview engine…", {
|
|
1218
1280
|
stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
|
|
1219
1281
|
});
|
|
1282
|
+
let installer;
|
|
1220
1283
|
try {
|
|
1221
|
-
await runPnpmInstall(stagingDir, { logPath: installLog });
|
|
1284
|
+
installer = await runPnpmInstall(stagingDir, { logPath: installLog });
|
|
1222
1285
|
} finally {
|
|
1223
1286
|
installSpin.stop();
|
|
1224
1287
|
}
|
|
@@ -1234,12 +1297,20 @@ export async function installRunnerTarball(
|
|
|
1234
1297
|
// rather than swallow it as "the dev server didn't come up". Permanent: the
|
|
1235
1298
|
// same installer on the same host would just produce the same result, so the
|
|
1236
1299
|
// auto-retry is skipped.
|
|
1237
|
-
|
|
1300
|
+
const gaps = rendererNativeBindingGaps(runnerDir);
|
|
1301
|
+
if (gaps.length) {
|
|
1238
1302
|
await emitObstacle("renderer-native-bindings-missing");
|
|
1239
1303
|
const err = new CliError(
|
|
1240
|
-
`the store preview engine installed but is missing its native components for ${process.platform}-${process.arch}
|
|
1304
|
+
`the store preview engine installed but is missing its native components for ${process.platform}-${process.arch}` +
|
|
1305
|
+
` (${gaps.map((g) => `${g.family}: only ${g.found.join(", ")} installed`).join("; ")})` +
|
|
1306
|
+
`\n details: ${installLog}`,
|
|
1241
1307
|
{
|
|
1242
|
-
|
|
1308
|
+
// Only npm (the last-resort launcher) has the optional-deps bug that
|
|
1309
|
+
// installing pnpm cures; a pnpm-installed gap is not the developer's to fix.
|
|
1310
|
+
next:
|
|
1311
|
+
installer === "npm"
|
|
1312
|
+
? "install pnpm (`npm i -g pnpm`, or `corepack enable`) and re-run `tot start` — pnpm installs the platform-native bits npm can skip (npm/cli#4828)"
|
|
1313
|
+
: `re-run \`tot start --reset-cache\`; if it repeats, file feedback naming preview engine ${version} and the packages above — this is not something on your machine`,
|
|
1243
1314
|
exitCode: 2,
|
|
1244
1315
|
},
|
|
1245
1316
|
);
|
|
@@ -1495,6 +1566,7 @@ function spawnAsyncResult(cmd, args, opts = {}) {
|
|
|
1495
1566
|
/**
|
|
1496
1567
|
* @param {string} runnerDir
|
|
1497
1568
|
* @param {{ logPath?: string, spawnFn?: (cmd: string, args: string[], opts?: any) => any }} [opts]
|
|
1569
|
+
* @returns {Promise<string>} the launcher that completed the install ("pnpm" | "corepack" | "npm").
|
|
1498
1570
|
*/
|
|
1499
1571
|
export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncResult } = {}) {
|
|
1500
1572
|
const fd = logPath ? openSync(logPath, "a") : null;
|
|
@@ -1532,7 +1604,7 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
|
|
|
1532
1604
|
// Send both streams to the log fd (or swallow them) — never inherit.
|
|
1533
1605
|
stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
|
|
1534
1606
|
});
|
|
1535
|
-
if (r.status === 0) return; // installed
|
|
1607
|
+
if (r.status === 0) return cmd; // installed — the launcher that did it
|
|
1536
1608
|
if (/** @type {any} */ (r.error)?.code === "ENOENT") {
|
|
1537
1609
|
// This launcher isn't on the machine — record it and try the next one.
|
|
1538
1610
|
if (fd !== null) writeSync(fd, `[tot] ${cmd} not found (ENOENT) — trying the next launcher\n`);
|
package/src/commands/submit.mjs
CHANGED
|
@@ -1545,7 +1545,7 @@ export async function run(argv, ctx, {
|
|
|
1545
1545
|
pointerScope = await scopeToTenant(client, tenant);
|
|
1546
1546
|
} catch (error) {
|
|
1547
1547
|
recordDiagnostic(error, {
|
|
1548
|
-
command: verb, operation: "
|
|
1548
|
+
command: verb, operation: "tenant_scope", required: false, degraded: true,
|
|
1549
1549
|
}, env);
|
|
1550
1550
|
}
|
|
1551
1551
|
const resolved = pointerScope
|
|
@@ -1661,7 +1661,7 @@ export async function run(argv, ctx, {
|
|
|
1661
1661
|
let progress = null;
|
|
1662
1662
|
let candidate = null;
|
|
1663
1663
|
let previewPrUrl = null;
|
|
1664
|
-
let operation = "
|
|
1664
|
+
let operation = "tenant_scope";
|
|
1665
1665
|
const candidateRequired = changedEntries.length > 0;
|
|
1666
1666
|
try {
|
|
1667
1667
|
// Every candidate/preview call below goes through this tenant-scoped handle
|
package/src/tenant-scope.mjs
CHANGED
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
* from a previous command. A command that acts on a store calls `scopeToTenant(client,
|
|
6
6
|
* tenant)` once and makes every store call through the handle it returns:
|
|
7
7
|
*
|
|
8
|
-
* - It pins the session with `client_switch`. A server that resolves the tenant of each
|
|
9
|
-
* call from its own `tenant` argument does not need the pin; one that still reads the
|
|
10
|
-
* session's active tenant does, and the pin is also what scopes the repo-addressed tools
|
|
11
|
-
* (PIN_SCOPED_TOOLS) for an invited developer.
|
|
12
8
|
* - It names the tenant on every call to a tool in TENANT_SCOPED_TOOLS — tools whose
|
|
13
9
|
* `tenant` argument means "the tenant this call acts on". The server uses a held tenant
|
|
14
10
|
* directly and refuses one the session does not hold; it never falls back to another.
|
|
11
|
+
* These calls never send `client_switch`.
|
|
12
|
+
* - It sends `client_switch` only before the first call to a tool in PIN_SCOPED_TOOLS, and
|
|
13
|
+
* at most once. `client_switch` writes a selection the server shares across every
|
|
14
|
+
* windowless session on the same login, so a pin can lose to another session's switch
|
|
15
|
+
* between the pin and the call, and it moves that other session's selection too. A
|
|
16
|
+
* command that needs no repo-addressed tool therefore never pins.
|
|
15
17
|
* - It refuses a result that reports acting on a different tenant than the one asked for.
|
|
16
18
|
*
|
|
17
19
|
* `test/tenant-scope-contract.test.mjs` fails the build if any file under `src/` calls a
|
|
@@ -41,7 +43,8 @@ export const TENANT_SCOPED_TOOLS = Object.freeze(new Set([
|
|
|
41
43
|
* argument (`candidate_status` / `candidate_list` accept `tenant` only as an alias FOR `repo`;
|
|
42
44
|
* `candidate_delete` / `candidate_refresh` have no `tenant` field and the server drops one).
|
|
43
45
|
* The server confines them to the session's held tenants — for an invited developer, the
|
|
44
|
-
* pinned one — so they go through the handle (which
|
|
46
|
+
* pinned one — so they go through the handle (which pins before the first of them) with
|
|
47
|
+
* their args untouched.
|
|
45
48
|
*/
|
|
46
49
|
export const PIN_SCOPED_TOOLS = Object.freeze(new Set([
|
|
47
50
|
"candidate_status",
|
|
@@ -109,22 +112,18 @@ export function isNotClientTenantRefusal(error) {
|
|
|
109
112
|
/**
|
|
110
113
|
* @typedef {{
|
|
111
114
|
* tenant: string,
|
|
112
|
-
* pinRefusal: any,
|
|
113
115
|
* callTool: (name: string, args?: Record<string, unknown>) => Promise<any>,
|
|
114
116
|
* }} ScopedClient
|
|
115
|
-
* `pinRefusal` is the expected client_switch refusal (see isNotClientTenantRefusal) when
|
|
116
|
-
* the pin was refused, else null. The calls still name the tenant, so the server answers
|
|
117
|
-
* them authoritatively; `pinRefusal` is only the explanation a caller may add when no
|
|
118
|
-
* call produced a better one.
|
|
119
117
|
*/
|
|
120
118
|
|
|
121
119
|
/**
|
|
122
|
-
*
|
|
120
|
+
* Return the handle every store call for `tenant` goes through. Sends nothing by itself.
|
|
123
121
|
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
122
|
+
* The pin (`client_switch`) is sent lazily, once, before the first PIN_SCOPED_TOOLS call.
|
|
123
|
+
* An expected refusal of it (the tenant is not one of the caller's clients — the normal
|
|
124
|
+
* case for a staff human under a staff-preview authorization) is tolerated and the call
|
|
125
|
+
* proceeds, so the server decides. Any other pin failure means the session is unusable and
|
|
126
|
+
* that call throws a CliError without being sent.
|
|
128
127
|
*
|
|
129
128
|
* @param {{ callTool: (name: string, args: unknown) => Promise<any> }} client
|
|
130
129
|
* @param {string} tenant
|
|
@@ -140,18 +139,20 @@ export async function scopeToTenant(client, tenant, { redact = (s) => s } = {})
|
|
|
140
139
|
}
|
|
141
140
|
const name = String(tenant).trim();
|
|
142
141
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
142
|
+
/** @type {Promise<void>|null} */
|
|
143
|
+
let pinned = null;
|
|
144
|
+
function pin() {
|
|
145
|
+
pinned ??= client.callTool("client_switch", { tenant: name }).then(
|
|
146
|
+
() => undefined,
|
|
147
|
+
(e) => {
|
|
148
|
+
if (isNotClientTenantRefusal(e)) return;
|
|
149
|
+
throw new CliError(redact(String(/** @type {any} */ (e)?.message || e)), {
|
|
150
|
+
next: "the session could not be scoped — check your connection and run `tot whoami`",
|
|
151
|
+
cause: e,
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
return pinned;
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
/**
|
|
@@ -159,6 +160,10 @@ export async function scopeToTenant(client, tenant, { redact = (s) => s } = {})
|
|
|
159
160
|
* @param {Record<string, unknown>} [args]
|
|
160
161
|
*/
|
|
161
162
|
async function callTool(tool, args = {}) {
|
|
163
|
+
if (PIN_SCOPED_TOOLS.has(tool)) {
|
|
164
|
+
await pin();
|
|
165
|
+
return client.callTool(tool, args);
|
|
166
|
+
}
|
|
162
167
|
if (!TENANT_SCOPED_TOOLS.has(tool)) return client.callTool(tool, args);
|
|
163
168
|
if (args && Object.hasOwn(args, "tenant") && normalizeTenant(args.tenant) !== requested) {
|
|
164
169
|
throw new CliError(
|
|
@@ -180,5 +185,5 @@ export async function scopeToTenant(client, tenant, { redact = (s) => s } = {})
|
|
|
180
185
|
return result;
|
|
181
186
|
}
|
|
182
187
|
|
|
183
|
-
return { tenant: name,
|
|
188
|
+
return { tenant: name, callTool };
|
|
184
189
|
}
|