toolroll 0.9.43 → 0.9.45

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/action-ledger.d.ts +5 -2
  3. package/dist/action-ledger.js +13 -3
  4. package/dist/cli-http.d.ts +53 -0
  5. package/dist/cli-http.js +143 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.js +7 -0
  8. package/dist/contracts/cli.d.ts +13 -0
  9. package/dist/contracts/cli.js +7 -0
  10. package/dist/contracts/gateway-tools.d.ts +82 -0
  11. package/dist/contracts/gateway-tools.js +33 -0
  12. package/dist/desktop-update.d.ts +4 -2
  13. package/dist/desktop-update.js +7 -6
  14. package/dist/dispatch.d.ts +2 -2
  15. package/dist/dispatch.js +8 -4
  16. package/dist/lead-status.d.ts +7 -3
  17. package/dist/lead-status.js +21 -12
  18. package/dist/mcp-core.d.ts +110 -0
  19. package/dist/mcp-core.js +311 -0
  20. package/dist/mcp-http.d.ts +26 -0
  21. package/dist/mcp-http.js +151 -0
  22. package/dist/mcp-person.d.ts +31 -0
  23. package/dist/mcp-person.js +96 -0
  24. package/dist/mcp.d.ts +1 -8
  25. package/dist/mcp.js +21 -322
  26. package/dist/operate-remote-arguments.d.ts +36 -0
  27. package/dist/operate-remote-arguments.js +185 -0
  28. package/dist/operate-remote.d.ts +98 -0
  29. package/dist/operate-remote.js +145 -0
  30. package/dist/operate.d.ts +21 -1
  31. package/dist/operate.js +206 -19
  32. package/dist/remote-command.d.ts +45 -0
  33. package/dist/remote-command.js +124 -0
  34. package/dist/remote-exec.d.ts +18 -0
  35. package/dist/remote-exec.js +241 -0
  36. package/dist/remote-run.d.ts +20 -0
  37. package/dist/remote-run.js +13 -0
  38. package/dist/scope.js +10 -0
  39. package/dist/serve.d.ts +7 -0
  40. package/dist/serve.js +24 -0
  41. package/dist/store.d.ts +15 -1
  42. package/dist/store.js +34 -7
  43. package/dist/surface.d.ts +10 -3
  44. package/dist/surface.js +67 -2
  45. package/dist/team-cli.d.ts +19 -0
  46. package/dist/team-cli.js +20 -3
  47. package/dist/toolroll-update.js +4 -1
  48. package/dist/work-summary.d.ts +4 -1
  49. package/dist/work-summary.js +1 -1
  50. package/package.json +1 -1
package/dist/operate.js CHANGED
@@ -84,7 +84,7 @@ import { homedir, hostname, tmpdir } from "node:os";
84
84
  import { basename, dirname, join, resolve } from "node:path";
85
85
  import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
86
86
  import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, DEFAULT_ACTOR, parseCapabilityKey, verifiedAuthor, contestantProfileOf, isDigestTime, RESULT_SCREENSHOTS, } from "./store.js";
87
- import { randomBytes, randomInt, randomUUID } from "node:crypto";
87
+ import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
88
88
  import { authorizePlanUnderMode } from "./plan-auto.js";
89
89
  import { ghDispatchAdapter, mirrorTaskId, syncPass } from "./sync.js";
90
90
  import { sweepLiveLogs } from "./live.js";
@@ -150,10 +150,12 @@ import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLeg
150
150
  import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, networkFence, networkFenceLine, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
151
151
  import { scanRepo } from "./capscan.js";
152
152
  import { computeGaps, describeCapability } from "./gaps.js";
153
- import { ask, askHidden, confirm, interactive, underAgent } from "./prompt.js";
153
+ import { ask as promptAsk, askHidden as promptHidden, confirm as promptConfirm, interactive as promptInteractive, underAgent } from "./prompt.js";
154
154
  import { runMateCli, answerContextLines } from "./mate-cli.js";
155
155
  import { confirmCoordinatorProposal, dismissCoordinatorProposal } from "./mate-doors.js";
156
- import { verifyApproverByPassword } from "./principal.js";
156
+ import { verifyApproverByPassword as approverByPassword } from "./principal.js";
157
+ import { activeRemote, REMOTE_MESSAGES, REMOTE_REFUSED_FLAGS, REMOTE_SCOPES, RemoteRefusal, remoteAllows, remoteLensOf, remoteProjectOf, remoteRowOf, remoteSecret, reproveRemote, withRemote } from "./operate-remote.js";
158
+ import { projectAuthority } from "./project-access.js";
157
159
  import { authorizedProject, canonicalProject, projectName, resolveCeiling } from "./project.js";
158
160
  import { tally, spendLine } from "./summary.js";
159
161
  import { bodyHashOf, describePublicationGrant, observeChecks, publicationBody, publishPass, sweepMerges, } from "./publish.js";
@@ -169,7 +171,7 @@ import { register, authenticate, heartbeat as heartbeatRunner, isAlive, recoverD
169
171
  import { mintCoordinator, revokeCoordinator, listCoordinators } from "./coordinator.js";
170
172
  import { serveMcp } from "./mcp.js";
171
173
  import { createInterface } from "node:readline";
172
- import { propose, approve, addApprover, authenticateAccount, authenticateApprover, describeScope, approvalOf, hashToken as hashApproverToken, profileFromJson, fileAndSealUnderMode, modeFilingCoverage, acceptanceLinesToInput, parseAcceptanceCriteria, splitAcceptanceRubric, rubricIsPlaceholder, isCommitSha } from "./scope.js";
174
+ import { propose, approve, addApprover, authenticateAccount, authenticateApprover as passwordApprover, describeScope, approvalOf, hashToken as hashApproverToken, profileFromJson, fileAndSealUnderMode, modeFilingCoverage, acceptanceLinesToInput, parseAcceptanceCriteria, splitAcceptanceRubric, rubricIsPlaceholder, isCommitSha } from "./scope.js";
173
175
  import { presetTerms, modeTermsJson, modeDigestOf, modeTermsFromJson, modeWords, MODE_MAX_DAYS } from "./modes.js";
174
176
  import { WorktreePool } from "./worktree.js";
175
177
  import { requestTaskStop, resumeTaskStop, taskControlOf } from "./task-control.js";
@@ -823,6 +825,20 @@ export async function runOperate(command, argv, write, options = {}) {
823
825
  flags.delete("token");
824
826
  actor = { account: lead.owner, lead: true };
825
827
  }
828
+ try {
829
+ return await dispatchOn(command, positional, flags, parsed.repoList, { store, write, json, file, now, ...(actor === null ? {} : { leadToken: presented }) }, options, run => LEAD_COMMANDS.has(command) ? withActor(actor, run) : run());
830
+ }
831
+ finally {
832
+ store.close();
833
+ }
834
+ }
835
+ /**
836
+ * Run one command against an open store: the dispatcher both roads share. The caller owns the store (runOperate
837
+ * opens and closes its own; runOperateAs uses the server's and leaves it open) and says what the command runs
838
+ * inside (`enter`: the actor, and for a remote run its person and project).
839
+ */
840
+ async function dispatchOn(command, positional, flags, repoList, base, options, enter) {
841
+ const { store, write, json, file, now } = base;
826
842
  // v105: a Claude turn anywhere in this command says its plan's usage windows; keep the latest.
827
843
  const dropLimitSink = pushLimitSink(reading => store.recordProviderLimits(reading, new Date()));
828
844
  // Filed tasks are sized by the owner's own fast classifier (never in tests, the demo, or with NO_TASK_CLASSIFIER).
@@ -835,11 +851,11 @@ export async function runOperate(command, argv, write, options = {}) {
835
851
  const clock = options.now === undefined ? () => new Date() : () => now;
836
852
  try {
837
853
  const run = () => dispatch(command, positional, flags, {
838
- ...(actor === null ? {} : { leadToken: presented }),
854
+ ...(base.leadToken === undefined ? {} : { leadToken: base.leadToken }),
839
855
  store,
840
856
  write,
841
857
  json,
842
- repoList: parsed.repoList,
858
+ repoList,
843
859
  now,
844
860
  clock,
845
861
  // Evidence lives beside the database for the same reason the database
@@ -873,10 +889,13 @@ export async function runOperate(command, argv, write, options = {}) {
873
889
  ...(options.installBin === undefined ? {} : { installBin: options.installBin }),
874
890
  ...(options.onboardSeams === undefined ? {} : { onboardSeams: options.onboardSeams }),
875
891
  ...(options.upSeams === undefined ? {} : { upSeams: options.upSeams }),
892
+ ...(activeRemote() === null ? {} : { principal: activeRemote() }),
876
893
  });
877
- return await (LEAD_COMMANDS.has(command) ? withActor(actor, run) : run());
894
+ return await enter(run);
878
895
  }
879
896
  catch (error) {
897
+ if (error instanceof RemoteRefusal)
898
+ return fail(write, json, command, error.reason, error.message, EXIT.refused);
880
899
  if (isDatabaseBusy(error))
881
900
  return databaseFailure(write, json, command, file, error);
882
901
  return fail(write, json, command, "failed", describe(error), EXIT.failed);
@@ -886,9 +905,98 @@ export async function runOperate(command, argv, write, options = {}) {
886
905
  await settleSizings();
887
906
  dropSizer();
888
907
  dropLimitSink();
889
- store.close();
890
908
  }
891
909
  }
910
+ /**
911
+ * Run one command on this server for a person who signed in with their own API token (the transport and MCP
912
+ * gateway prove the `so_` token and refuse browser origins; this is everything after). The command's row in the
913
+ * machine contract decides first: "no" and "step-up" rows, and commands with no row, are refused before any
914
+ * command code runs. The principal is re-proved against the store, a read token runs only reads, and the project
915
+ * the command touches must be one the person may use, named by --repo where a local run would use the working
916
+ * directory. The command then runs as that person: every credential check resolves to them, nothing prompts, and
917
+ * this machine's saved login and lead token are never read. Files are only those sent in `files`, keyed by the
918
+ * argument that names them. Every call is in the action history as the person, from source `api` (or `mcp`),
919
+ * with the token's name. The store is the caller's and stays open.
920
+ */
921
+ export async function runOperateAs(argv, opts) {
922
+ const { principal, store, write } = opts;
923
+ const source = opts.source === "mcp" ? "mcp" : "api";
924
+ const options = opts.options ?? {};
925
+ const now = options.now ?? new Date();
926
+ const [root = "", ...rest] = argv;
927
+ const json = rest.includes("--json");
928
+ let invocation = root;
929
+ let tokenName = null;
930
+ const record = (outcome, repo, taskId, why) => {
931
+ store.recordAction({ at: (options.now ?? new Date()).toISOString(), actor: principal.account, repo, taskId, runId: null,
932
+ action: `remote command: ${invocation === "" ? "(none)" : invocation}`.slice(0, 200), outcome, source,
933
+ detail: `token ${tokenName ?? principal.tokenId}${why === null ? "" : ` · ${why}`}` });
934
+ };
935
+ const refuse = (reason, message, repo = null, taskId = null) => {
936
+ record("refused", repo, taskId, reason);
937
+ return fail(write, json, invocation === "" ? "remote" : invocation, reason, message, EXIT.refused);
938
+ };
939
+ const proved = reproveRemote(store, principal, now);
940
+ if (!proved.ok)
941
+ return refuse("unauthenticated", REMOTE_MESSAGES.stale);
942
+ tokenName = proved.tokenName;
943
+ // The row first, from the words before any flag: a refused command is refused whatever its flags say.
944
+ const firstFlag = rest.findIndex(one => one.startsWith("-"));
945
+ const row = remoteRowOf(root, firstFlag === -1 ? rest : rest.slice(0, firstFlag));
946
+ if (row === null)
947
+ return refuse("not-remote", REMOTE_MESSAGES.unknown);
948
+ invocation = row.invocation;
949
+ if (row.remote === "step-up")
950
+ return refuse("step-up", REMOTE_MESSAGES["step-up"]);
951
+ const scope = REMOTE_SCOPES.get(row.invocation);
952
+ if (row.remote !== "yes" || scope === undefined)
953
+ return refuse("not-remote", REMOTE_MESSAGES.no);
954
+ const parsed = parseOperateArgs(rest, root === "flows" ? FLOWS_VALUE_FLAGS : undefined);
955
+ if ("error" in parsed)
956
+ return refuse("usage", parsed.error);
957
+ // The parser's positionals must spell the same row (a value flag can't hide a subcommand).
958
+ if (remoteRowOf(root, parsed.positional)?.invocation !== row.invocation)
959
+ return refuse("not-remote", REMOTE_MESSAGES.unknown);
960
+ if (proved.scope === "read" && row.mutation !== "none")
961
+ return refuse("read-only", REMOTE_MESSAGES.read);
962
+ if ([...parsed.flags.keys()].some(name => REMOTE_REFUSED_FLAGS.has(name)))
963
+ return refuse("usage", REMOTE_MESSAGES.credential);
964
+ const allows = (repo) => remoteAllows(store, principal, repo);
965
+ const project = remoteProjectOf(store, { allows }, scope, parsed.positional, parsed.repoList, row.invocation, parsed.flags, opts.files ?? {});
966
+ if (!project.ok)
967
+ return refuse(project.reason, project.message);
968
+ // The local mutation cache has a global key namespace. A token must not use a chosen key to read
969
+ // another person's/project's cached result. Revision keys already bind the actor and result in
970
+ // task-outcome-cli.ts, which also validates their public 32-hex format; leave those intact.
971
+ const key = text(parsed.flags, "key");
972
+ if (key !== undefined && row.invocation !== "task revise") {
973
+ parsed.flags.set("key", `remote:${createHash("sha256").update(JSON.stringify([principal.account, row.invocation, project.repo, project.taskId, key])).digest("hex")}`);
974
+ }
975
+ const file = store.databaseFile() ?? options.databaseFile;
976
+ if (file === undefined)
977
+ return refuse("failed", "The server's database has no file.", project.repo, project.taskId);
978
+ // The task a `task add` filed is only known once it runs; the final history line names it.
979
+ let filed = null;
980
+ const run = Object.freeze({ account: principal.account, scope: proved.scope, tokenName: proved.tokenName, source, secret: remoteSecret(), allows,
981
+ lens: remoteLensOf(store, scope, allows), noteTask: (taskId) => { filed = taskId; }, files: Object.freeze({ ...(opts.files ?? {}) }) });
982
+ record("requested", project.repo, project.taskId, null);
983
+ let code;
984
+ try {
985
+ code = await withRemote(run, () => dispatchOn(root, parsed.positional, parsed.flags, parsed.repoList, { store, write, json, file, now }, options, go => withActor({ account: principal.account, lead: false }, () => projectAuthority.run({ actor: principal.account, repo: project.repo }, go))));
986
+ }
987
+ catch (error) {
988
+ record("failed", project.repo, filed ?? project.taskId, null);
989
+ throw error;
990
+ }
991
+ record(code === EXIT.ok ? "done" : code === EXIT.refused ? "refused" : code === EXIT.usage ? "usage" : "failed", project.repo, filed ?? project.taskId, null);
992
+ return code;
993
+ }
994
+ /** A file a remote caller sent with the request, by the argument that names it; never a path on this machine. */
995
+ function remoteFile(remote, path) {
996
+ if (Object.prototype.hasOwnProperty.call(remote.files, path))
997
+ return remote.files[path];
998
+ throw new RemoteRefusal("usage", REMOTE_MESSAGES.files);
999
+ }
892
1000
  /** The short commands a lead token works for, and where whoever signs in is recorded as the actor. */
893
1001
  const LEAD_COMMANDS = new Set(["task", "assignment", "status", "ready", "brief", "peek", "check-progress", "notifications", "lead"]);
894
1002
  /**
@@ -1158,6 +1266,9 @@ function openBackend(name, store, repo) {
1158
1266
  }
1159
1267
  /** The repository a backend command applies to, normalised like the grant is. */
1160
1268
  function repoFrom(flags) {
1269
+ // The server's working directory is nobody's project: a remote caller names theirs.
1270
+ if (activeRemote() !== null && text(flags, "repo") === undefined)
1271
+ throw new RemoteRefusal("unknown-project", REMOTE_MESSAGES.project);
1161
1272
  return resolve(text(flags, "repo") ?? process.cwd());
1162
1273
  }
1163
1274
  // ---- the dispatch loop ----------------------------------------------------
@@ -1199,7 +1310,9 @@ async function readyCommand(flags, context) {
1199
1310
  write(` ${task.id} ${task.title}`);
1200
1311
  return EXIT.ok;
1201
1312
  }
1202
- const ready = store.listReady(now);
1313
+ // A remote person limited to some projects (remoteLensOf) sees only theirs, counted over those alone.
1314
+ const lens = context.principal?.lens ?? null;
1315
+ const ready = store.listReady(now).filter(ref => lens === null || ref.repo !== null && lens.includes(ref.repo));
1203
1316
  const described = ready.map(ref => describeRef(store, ref, now));
1204
1317
  const dispatchableCount = described.filter(one => diagnosisIsDispatchable(one.dispatch)).length;
1205
1318
  const pauses = openAuthPauses(store);
@@ -8216,6 +8329,9 @@ export function writeLoginFileDurably(path, name, password) {
8216
8329
  }
8217
8330
  }
8218
8331
  function readLoginFile(path) {
8332
+ // The owner's saved login is never anyone else's: a remote run that reaches here is a bug, and ends refused.
8333
+ if (activeRemote() !== null)
8334
+ throw new RemoteRefusal("unauthenticated", REMOTE_MESSAGES.credential);
8219
8335
  try {
8220
8336
  const raw = readFileSync(path, "utf8").trim();
8221
8337
  const cut = raw.indexOf(" ");
@@ -9986,6 +10102,13 @@ async function statusCommand(positional, flags, context) {
9986
10102
  return fail(context.write, context.json, command, "usage", "Use `toolroll status [--json]`.", EXIT.usage);
9987
10103
  // Whose lead the status line follows: the lead's own person, else the remembered login; nobody shows no lead.
9988
10104
  const viewer = currentActor()?.account ?? readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE))?.name ?? null;
10105
+ // A remote person limited to some projects sees only those (remoteLensOf): their tasks and counts, and nothing
10106
+ // that belongs to the installation or other projects (updates, integrations, builds by project, unsent replies).
10107
+ const lens = context.principal?.lens ?? null;
10108
+ if (lens !== null) {
10109
+ const status = installationStatus(context.store, context.clock(), viewer, lens);
10110
+ return succeed(context.write, context.json, command, { ...status, projects: [] }, () => renderInstallationStatus(status, true));
10111
+ }
9989
10112
  const status = installationStatus(context.store, context.clock(), viewer);
9990
10113
  // One line, only when a newer Toolroll exists; offline or switched off says nothing.
9991
10114
  const current = PACKAGE_VERSION;
@@ -10283,7 +10406,8 @@ async function addTask(positional, flags, context) {
10283
10406
  ...(mirrored === null ? [] : [`Mirrored locally as ${mirrored} — scope and approve it, and this plane builds it.`]),
10284
10407
  ]);
10285
10408
  }
10286
- const id = text(flags, "id") ?? slug(title, now);
10409
+ // Remote creation cannot probe global IDs, including predictable title/time collisions.
10410
+ const id = text(flags, "id") ?? (activeRemote() === null ? slug(title, now) : `task-${randomBytes(16).toString("hex")}`);
10287
10411
  // Re-filing: the new task replaces an earlier one, which is cancelled and reads "Replaced by <id>".
10288
10412
  const replaces = text(flags, "replaces");
10289
10413
  if (replaces !== undefined && (replaces === id || store.getTask(replaces) === null)) {
@@ -10306,12 +10430,22 @@ async function addTask(positional, flags, context) {
10306
10430
  if (!outcome.ok) {
10307
10431
  return fail(write, json, "task add", "exists", `\`${id}\` already exists`, EXIT.refused);
10308
10432
  }
10309
- store.stampFiledVia(store.refFor(BUILT_IN, id).id, "cli");
10433
+ const filedId = outcome.task.id;
10434
+ const remote = activeRemote();
10435
+ if (remote !== null && filedId !== id) {
10436
+ // Re-prove a replayed creation too: it may have been moved since the original filing.
10437
+ const saved = store.lookupRef(filedId);
10438
+ if (saved === null || saved.repo !== text(flags, "repo") || !remote.allows(saved.repo)) {
10439
+ throw new RemoteRefusal("not-found", "Not found.");
10440
+ }
10441
+ }
10442
+ remote?.noteTask(filedId);
10443
+ store.stampFiledVia(store.refFor(BUILT_IN, filedId).id, "cli");
10310
10444
  // Placement is explicit, never inferred from where the command happened to
10311
10445
  // run: a task filed from the wrong directory would silently bind to it.
10312
10446
  const placedIn = text(flags, "repo");
10313
10447
  if (placedIn !== undefined) {
10314
- const placed = store.placeTask(store.refFor(BUILT_IN, id).id, canonicalProject(placedIn) ?? resolve(placedIn));
10448
+ const placed = store.placeTask(store.refFor(BUILT_IN, filedId).id, canonicalProject(placedIn) ?? resolve(placedIn));
10315
10449
  if (typeof placed === "object" && !placed.ok) {
10316
10450
  return fail(write, json, "task add", "scoped", "this task already has a scope — placement is immutable once somebody could have approved it", EXIT.refused);
10317
10451
  }
@@ -10586,7 +10720,9 @@ function listTasks(flags, context) {
10586
10720
  }
10587
10721
  let page;
10588
10722
  try {
10589
- page = workIndexPage(store, now, { principal: 'operator', repos: null, includeUnplaced: true }, {
10723
+ // A remote person limited to some projects (remoteLensOf) pages and counts only theirs.
10724
+ const lens = context.principal?.lens ?? null;
10725
+ page = workIndexPage(store, now, { principal: 'operator', repos: lens, includeUnplaced: lens === null }, {
10590
10726
  view: parseWorkView(rawView ?? null), limit, cursor: text(flags, 'cursor') ?? null,
10591
10727
  ...(wanted === undefined ? {} : { state: wanted }),
10592
10728
  ...(text(flags, 'repo') === undefined ? {} : { project: text(flags, 'repo') }),
@@ -10633,13 +10769,21 @@ function showTask(positional, context) {
10633
10769
  // whatever readiness this task's runners have reported.
10634
10770
  const routed = routeOfTask(store, id, ref, now);
10635
10771
  const readiness = store.readinessLookupFor(ref.repo, ref.assignedRunner, now);
10772
+ const lens = context.principal?.lens ?? null;
10773
+ const access = { principal: "operator", repos: lens, includeUnplaced: lens === null, dependencyRepos: lens };
10774
+ const blockers = store.blockers(id);
10775
+ const visibleBlockers = lens === null ? blockers : blockers.filter(blocker => {
10776
+ const repo = store.lookupRef(blocker)?.repo;
10777
+ return repo != null && lens.includes(repo);
10778
+ });
10636
10779
  const detail = {
10637
10780
  task,
10638
- work: taskWorkSummaryOf(store, id, now, { principal: "operator", repos: null, includeUnplaced: true }),
10639
- assignment: assignmentBrief(assignmentOf(store, id, now, { principal: "operator", repos: null, includeUnplaced: true }, context.evidenceRoot)),
10781
+ work: taskWorkSummaryOf(store, id, now, access),
10782
+ assignment: assignmentBrief(assignmentOf(store, id, now, access, context.evidenceRoot)),
10640
10783
  ref: ref.id,
10641
- blockedBy: store.blockers(id),
10642
- position: store.queuePosition(id),
10784
+ // One phrase for every inaccessible blocker, so even their number stays private.
10785
+ blockedBy: visibleBlockers.length === blockers.length ? visibleBlockers : [...visibleBlockers, "a task in another project"],
10786
+ position: lens === null ? store.queuePosition(id) : null,
10643
10787
  reservedFor: ref.assignedRunner,
10644
10788
  hold: store.activeHold(ref.id, now),
10645
10789
  claim: currentClaim(store, ref.id, now),
@@ -10664,7 +10808,7 @@ function showTask(positional, context) {
10664
10808
  // The one automatic review of the latest build: HIGH findings, suggested
10665
10809
  // follow-ups (MEDIUM/LOW), or why it was not reviewed.
10666
10810
  automaticReview: latestFinished === null ? null : buildReviewOf(store, latestFinished.id),
10667
- dispatch: diagnoseTaskDispatch(store, id, now),
10811
+ dispatch: diagnoseTaskDispatch(store, id, now, lens),
10668
10812
  // v52: the exact-run control the console shows — Stop, Stopping,
10669
10813
  // Paused (resume), or the review-retry door — and every stop on record.
10670
10814
  control: taskControlOf(store, ref.id, now),
@@ -12020,6 +12164,10 @@ async function chatCommand(flags, context) {
12020
12164
  * win, and a different --as than the remembered name is not answered.
12021
12165
  */
12022
12166
  function credentialsFrom(flags, context) {
12167
+ // A remote run is its token's person, whatever the flags or this machine's saved login say.
12168
+ const remote = activeRemote();
12169
+ if (remote !== null)
12170
+ return { name: remote.account, token: remote.secret };
12023
12171
  // The lead signs in as its owner with its own token, never the owner's password.
12024
12172
  const lead = currentActor();
12025
12173
  if (context.leadToken !== undefined && lead?.lead === true)
@@ -12045,16 +12193,55 @@ async function askCredentials(flags, context) {
12045
12193
  return null;
12046
12194
  return { name, token };
12047
12195
  }
12196
+ /*
12197
+ * Credential checks in this file. Locally they are the password checks in scope.ts and principal.ts. Inside a
12198
+ * remote run (`runOperateAs`) the only credential is the one credentialsFrom handed out for the token's person,
12199
+ * which scope.ts's authenticateAccount takes as that person (a viewer for a read token) and never checks as a
12200
+ * password; these add the token's own project limit.
12201
+ */
12202
+ function authenticateApprover(store, by, token, repo) {
12203
+ const remote = activeRemote();
12204
+ if (remote !== null && repo !== undefined && !remote.allows(repo))
12205
+ return { ok: false, reason: "not-an-approver" };
12206
+ return passwordApprover(store, by, token, repo);
12207
+ }
12208
+ function verifyApproverByPassword(store, name, token, repos) {
12209
+ const remote = activeRemote();
12210
+ return approverByPassword(store, name, token, remote === null ? repos : repos.filter(repo => remote.allows(repo)));
12211
+ }
12212
+ /** Prompts: never inside a remote run — there is nobody at this machine's terminal to answer for the person. */
12213
+ function interactive() {
12214
+ return activeRemote() === null && promptInteractive();
12215
+ }
12216
+ function refusePrompt() {
12217
+ if (activeRemote() !== null)
12218
+ throw new RemoteRefusal("unauthenticated", REMOTE_MESSAGES.credential);
12219
+ }
12220
+ async function ask(question) {
12221
+ refusePrompt();
12222
+ return promptAsk(question);
12223
+ }
12224
+ async function askHidden(question) {
12225
+ refusePrompt();
12226
+ return promptHidden(question);
12227
+ }
12228
+ async function confirm(question) {
12229
+ refusePrompt();
12230
+ return promptConfirm(question);
12231
+ }
12048
12232
  /** `flows …`: the console's flow rules from a terminal. Writes are an approver's (--as/--token or the remembered login). */
12049
12233
  async function flowsCommand(positional, flags, context) {
12050
12234
  const { store } = context;
12051
12235
  const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
12052
- const projects = [...new Set([...store.knownRepos(), ...store.listProjects().map(one => one.path), ...("error" in registered ? [] : registered.repos)])];
12236
+ const remote = activeRemote();
12237
+ // A remote person sees and changes flows only in their own projects, and sends any flow file with the request.
12238
+ const projects = [...new Set([...store.knownRepos(), ...store.listProjects().map(one => one.path), ...("error" in registered ? [] : registered.repos)])].filter(one => remote === null || remote.allows(one));
12053
12239
  const writes = positional[0] !== undefined && positional[0] !== "list" && positional[0] !== "show" && positional[0] !== "export";
12054
12240
  const acting = writes && !flags.has("help") ? await askCredentials(flags, context) : null;
12055
12241
  const dir = dirname(context.databaseFile);
12056
12242
  return runFlowsCommand(positional, flags, {
12057
12243
  store, write: context.write, json: context.json, clock: context.clock, credentials: acting, projects, configDir: dir, evidenceRoot: context.evidenceRoot,
12244
+ ...(remote === null ? {} : { readInput: (path) => remoteFile(remote, path) }),
12058
12245
  ...(context.flowFetch === undefined ? {} : { fetchFlow: context.flowFetch }),
12059
12246
  // "Check now": the same io the worker's pass checks triggers with.
12060
12247
  triggerIo: { gh: context.flowTriggerIo?.gh ?? run, fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dir,
@@ -0,0 +1,45 @@
1
+ import { type CommandFlag } from './surface.js';
2
+ export type RemoteMode = 'yes' | 'no' | 'step-up';
3
+ export type RemoteCommand = {
4
+ invocation: string;
5
+ mode: RemoteMode;
6
+ mutation: string;
7
+ flags?: readonly Pick<CommandFlag, 'name' | 'takesValue'>[];
8
+ };
9
+ export type RemoteCommandLookup = (argv: readonly string[]) => RemoteCommand | null;
10
+ export declare const STEP_UP_MESSAGE = "approve in the console or chat";
11
+ type PathPolicy = 'inline' | 'refuse';
12
+ export type RemotePathArgument = {
13
+ invocation: string;
14
+ argument: {
15
+ flag: string;
16
+ } | {
17
+ position: number;
18
+ name: string;
19
+ rest?: true;
20
+ };
21
+ policy: PathPolicy;
22
+ literal?: 'json' | 'url';
23
+ };
24
+ /**
25
+ * Filesystem arguments audited against operate.ts, flows-cli.ts and cli.ts's local dispatchers.
26
+ * Keep this inventory command-specific: --file runs a project executable for scripts, but is a
27
+ * boolean for template apply; --to is a directory for link and a date for ledger export.
28
+ * Project selectors (--repo), scope globs (--touches/--paths) and saved IDs are not input files;
29
+ * their authority belongs to runOperateAs. Local installers' explicit destinations are refused.
30
+ * '*' is reserved for the credentials/database and cli.ts's output redirection, on every command.
31
+ */
32
+ export declare const REMOTE_PATH_ARGUMENTS: readonly RemotePathArgument[];
33
+ type FileProblem = {
34
+ problem: string;
35
+ code: 'local-only-flag' | 'server-path' | 'missing-file' | 'invalid-arguments';
36
+ };
37
+ /**
38
+ * Parse once using declared arity, so a flag's value cannot be mistaken for another flag or a
39
+ * file positional. Unknown arity fails closed. File keys and argv stay exactly as typed.
40
+ * All failures describe the argument slot, never a supplied path, credential or other value.
41
+ */
42
+ export declare function fileArguments(argv: readonly string[], row: RemoteCommand): string[] | FileProblem;
43
+ /** The longest declared invocation named by the leading words. Missing remote metadata means local only. */
44
+ export declare const contractRow: RemoteCommandLookup;
45
+ export {};
@@ -0,0 +1,124 @@
1
+ import { COMMAND_GUIDE } from './surface.js';
2
+ export const STEP_UP_MESSAGE = 'approve in the console or chat';
3
+ /**
4
+ * Filesystem arguments audited against operate.ts, flows-cli.ts and cli.ts's local dispatchers.
5
+ * Keep this inventory command-specific: --file runs a project executable for scripts, but is a
6
+ * boolean for template apply; --to is a directory for link and a date for ledger export.
7
+ * Project selectors (--repo), scope globs (--touches/--paths) and saved IDs are not input files;
8
+ * their authority belongs to runOperateAs. Local installers' explicit destinations are refused.
9
+ * '*' is reserved for the credentials/database and cli.ts's output redirection, on every command.
10
+ */
11
+ export const REMOTE_PATH_ARGUMENTS = [
12
+ ...['--token', '--token-file', '--token-env', '--db', '-o', '--output'].map(flag => ({ invocation: '*', argument: { flag }, policy: 'refuse' })),
13
+ { invocation: 'flows create', argument: { flag: '--steps' }, policy: 'inline', literal: 'json' },
14
+ { invocation: 'flows edit', argument: { flag: '--steps' }, policy: 'inline', literal: 'json' },
15
+ { invocation: 'flows script save', argument: { flag: '--body' }, policy: 'inline' },
16
+ { invocation: 'flows script save', argument: { flag: '--file' }, policy: 'refuse' },
17
+ { invocation: 'flows export', argument: { flag: '--out' }, policy: 'refuse' },
18
+ // Import stats the original path before readInput. Inlining alone cannot prevent that server read.
19
+ { invocation: 'flows import', argument: { position: 0, name: 'file' }, policy: 'refuse', literal: 'url' },
20
+ { invocation: 'flows trigger add', argument: { position: 1, name: 'trigger' }, policy: 'inline', literal: 'json' },
21
+ { invocation: 'task evidence', argument: { flag: '--out' }, policy: 'refuse' },
22
+ { invocation: 'ledger export', argument: { flag: '--out' }, policy: 'refuse' },
23
+ { invocation: 'export', argument: { flag: '--out' }, policy: 'refuse' },
24
+ { invocation: 'restore', argument: { position: 0, name: 'backup file' }, policy: 'refuse' },
25
+ { invocation: 'storage discard', argument: { position: 0, name: 'checkout path' }, policy: 'refuse' },
26
+ { invocation: 'keys set', argument: { flag: '--key-file' }, policy: 'refuse' },
27
+ { invocation: 'daemon', argument: { flag: '--bin' }, policy: 'refuse' },
28
+ ...['build', 'tick', 'reconcile', 'watch', 'serve', 'up', 'daemon', 'task resume'].map(invocation => ({ invocation, argument: { flag: '--pool' }, policy: 'refuse' })),
29
+ ...['serve', 'up'].map(invocation => ({ invocation, argument: { flag: '--project-root' }, policy: 'refuse' })),
30
+ { invocation: 'skills install', argument: { flag: '--dir' }, policy: 'refuse' },
31
+ { invocation: 'skills install', argument: { flag: '--repo' }, policy: 'refuse' },
32
+ { invocation: 'onboard', argument: { flag: '--repo' }, policy: 'refuse' },
33
+ { invocation: 'repos add-from-github', argument: { flag: '--root' }, policy: 'refuse' },
34
+ ...['link', 'unlink'].map(invocation => ({ invocation, argument: { flag: '--to' }, policy: 'refuse' })),
35
+ ...['repos add', 'repos remove', 'pulls', 'graph', ''].map(invocation => ({ invocation, argument: { position: 0, name: 'path', rest: true }, policy: 'refuse' })),
36
+ { invocation: 'project use', argument: { position: 0, name: 'checkout path' }, policy: 'refuse' },
37
+ // These local HTTP clients read prompts themselves; they do not consume runOperateAs's files map.
38
+ ...['session start', 'session send'].map(invocation => ({ invocation, argument: { flag: '--file' }, policy: 'refuse' })),
39
+ ];
40
+ /**
41
+ * Parse once using declared arity, so a flag's value cannot be mistaken for another flag or a
42
+ * file positional. Unknown arity fails closed. File keys and argv stay exactly as typed.
43
+ * All failures describe the argument slot, never a supplied path, credential or other value.
44
+ */
45
+ export function fileArguments(argv, row) {
46
+ const paths = [];
47
+ const rules = REMOTE_PATH_ARGUMENTS.filter(one => one.invocation === '*' || one.invocation === row.invocation);
48
+ const words = row.invocation === '' ? [] : row.invocation.split(' ');
49
+ if (!words.every((word, index) => argv[index] === word))
50
+ return { problem: 'The command arguments do not match its invocation.', code: 'invalid-arguments' };
51
+ const inspect = (rule, value) => {
52
+ const label = 'flag' in rule.argument ? rule.argument.flag : `${row.invocation} ${rule.argument.name} argument`;
53
+ if (rule.invocation === '*' && 'flag' in rule.argument && rule.argument.flag !== '-o' && rule.argument.flag !== '--output') {
54
+ return { problem: `${label} cannot be used remotely. Sign in with your saved API token.`, code: 'local-only-flag' };
55
+ }
56
+ if (value !== undefined && (rule.literal === 'json' && /^[{[]/.test(value.trim()) || rule.literal === 'url' && /^[a-z][a-z0-9+.-]*:\/\//i.test(value)))
57
+ return;
58
+ if (rule.policy === 'refuse')
59
+ return { problem: `${label} cannot use a server path remotely. Use --local to run it here.`, code: 'server-path' };
60
+ if (value === undefined || value === '')
61
+ return { problem: `${label} needs a file path.`, code: 'missing-file' };
62
+ // '-' would otherwise read the server's stdin (or a client file literally named '-'). Never prompt.
63
+ if (value === '-')
64
+ return { problem: `${label} needs a file instead of stdin for a remote command.`, code: 'server-path' };
65
+ paths.push(value);
66
+ };
67
+ let position = 0;
68
+ for (let index = words.length; index < argv.length; index++) {
69
+ const argument = argv[index], equals = argument.indexOf('=');
70
+ const name = equals < 0 ? argument : argument.slice(0, equals);
71
+ if (argument.startsWith('--') || name === '-o' || name === '-h') {
72
+ const rule = rules.find(one => 'flag' in one.argument && one.argument.flag === name);
73
+ const takesValue = rule !== undefined ? true : name === '-h' || name === '--help' || name === '--json' ? false : row.flags?.find(flag => `--${flag.name}` === name)?.takesValue;
74
+ if (takesValue === undefined)
75
+ return { problem: 'This option is not declared for remote use. Use --local to run it here.', code: 'invalid-arguments' };
76
+ if (!takesValue) {
77
+ if (equals >= 0)
78
+ return { problem: 'This command switch does not take a value.', code: 'invalid-arguments' };
79
+ continue;
80
+ }
81
+ const next = equals < 0 ? argv[index + 1] : argument.slice(equals + 1);
82
+ const value = equals < 0 && next?.startsWith('--') ? undefined : next;
83
+ if (rule !== undefined) {
84
+ const problem = inspect(rule, value);
85
+ if (problem !== undefined)
86
+ return problem;
87
+ }
88
+ if (value === undefined)
89
+ return { problem: 'A command option needs a value.', code: 'invalid-arguments' };
90
+ if (equals < 0)
91
+ index++;
92
+ }
93
+ else {
94
+ const rule = rules.find(one => 'position' in one.argument && (one.argument.position === position || one.argument.rest && position >= one.argument.position));
95
+ if (rule !== undefined) {
96
+ const problem = inspect(rule, argument);
97
+ if (problem !== undefined)
98
+ return problem;
99
+ }
100
+ position++;
101
+ }
102
+ }
103
+ return paths;
104
+ }
105
+ /** The longest declared invocation named by the leading words. Missing remote metadata means local only. */
106
+ export const contractRow = argv => {
107
+ const words = [];
108
+ for (const one of argv) {
109
+ if (one.startsWith('-'))
110
+ break;
111
+ words.push(one);
112
+ }
113
+ let best = null;
114
+ for (const row of COMMAND_GUIDE) {
115
+ const parts = row.invocation.split(' ');
116
+ if (parts.length > words.length || !parts.every((part, index) => words[index] === part))
117
+ continue;
118
+ if (best !== null && best.invocation.split(' ').length >= parts.length)
119
+ continue;
120
+ const remote = row.remote;
121
+ best = { invocation: row.invocation, mode: remote === 'yes' || remote === 'step-up' ? remote : 'no', mutation: row.mutation, flags: row.flags ?? [] };
122
+ }
123
+ return best;
124
+ };
@@ -0,0 +1,18 @@
1
+ import { type TeamCliOptions } from './team-cli.js';
2
+ import { type RemoteCommandLookup } from './remote-command.js';
3
+ export { contractRow, STEP_UP_MESSAGE, type RemoteMode } from './remote-command.js';
4
+ export type RemoteExecOptions = TeamCliOptions & {
5
+ cwd?: string;
6
+ /** Raw output streams; remote bytes are written exactly as the server returned them. */
7
+ stdout?: (chunk: string) => void;
8
+ stderr?: (chunk: string) => void;
9
+ /** Injected by tests: the contract row lookup (default: COMMAND_GUIDE's `remote` field). */
10
+ modeOf?: RemoteCommandLookup;
11
+ };
12
+ /**
13
+ * null: run locally with argv unchanged. `{ local }`: run locally with this argv (`--local` removed).
14
+ * A number: the command ran remotely (or was refused before sending) and this is its exit code.
15
+ */
16
+ export declare function maybeRunRemoteCommand(argv: readonly string[], options?: RemoteExecOptions): Promise<number | {
17
+ local: string[];
18
+ } | null>;