toolroll 0.9.42 → 0.9.44

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 (68) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/action-ledger.d.ts +5 -2
  3. package/dist/action-ledger.js +13 -3
  4. package/dist/chat-actions.js +1 -1
  5. package/dist/chat-approval.d.ts +45 -0
  6. package/dist/chat-approval.js +81 -0
  7. package/dist/chat-channel.js +5 -0
  8. package/dist/chat-decide.d.ts +18 -6
  9. package/dist/chat-decide.js +54 -20
  10. package/dist/chat-flow.js +2 -2
  11. package/dist/cli-http.d.ts +64 -0
  12. package/dist/cli-http.js +143 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.js +8 -1
  15. package/dist/contracts/cli.d.ts +13 -0
  16. package/dist/contracts/cli.js +7 -0
  17. package/dist/contracts/console-api.d.ts +1 -0
  18. package/dist/contracts/console-api.js +1 -0
  19. package/dist/contracts/gateway-tools.d.ts +82 -0
  20. package/dist/contracts/gateway-tools.js +33 -0
  21. package/dist/contracts/lead-tools.d.ts +11 -0
  22. package/dist/contracts/lead-tools.js +2 -0
  23. package/dist/desktop-update.d.ts +3 -1
  24. package/dist/desktop-update.js +7 -6
  25. package/dist/flow-engine.d.ts +2 -0
  26. package/dist/flow-engine.js +10 -2
  27. package/dist/flows-cli.d.ts +28 -0
  28. package/dist/flows-cli.js +43 -1
  29. package/dist/guides.js +2 -0
  30. package/dist/mate-contract.js +1 -1
  31. package/dist/mate-tools.js +43 -1
  32. package/dist/mcp-core.d.ts +110 -0
  33. package/dist/mcp-core.js +311 -0
  34. package/dist/mcp-http.d.ts +26 -0
  35. package/dist/mcp-http.js +151 -0
  36. package/dist/mcp-person.d.ts +31 -0
  37. package/dist/mcp-person.js +96 -0
  38. package/dist/mcp.d.ts +1 -8
  39. package/dist/mcp.js +21 -322
  40. package/dist/operate-remote-arguments.d.ts +36 -0
  41. package/dist/operate-remote-arguments.js +185 -0
  42. package/dist/operate-remote.d.ts +86 -0
  43. package/dist/operate-remote.js +125 -0
  44. package/dist/operate.d.ts +21 -1
  45. package/dist/operate.js +229 -15
  46. package/dist/process-recovery-eligibility.d.ts +1 -1
  47. package/dist/remote-command.d.ts +45 -0
  48. package/dist/remote-command.js +124 -0
  49. package/dist/remote-exec.d.ts +18 -0
  50. package/dist/remote-exec.js +241 -0
  51. package/dist/remote-run.d.ts +16 -0
  52. package/dist/remote-run.js +13 -0
  53. package/dist/scope.d.ts +4 -2
  54. package/dist/scope.js +10 -0
  55. package/dist/serve.d.ts +7 -0
  56. package/dist/serve.js +75 -2
  57. package/dist/store.d.ts +23 -2
  58. package/dist/store.js +76 -9
  59. package/dist/surface.d.ts +10 -3
  60. package/dist/surface.js +71 -3
  61. package/dist/team-cli.d.ts +19 -0
  62. package/dist/team-cli.js +20 -3
  63. package/dist/teammate-work.js +1 -1
  64. package/dist/telegram-flow.d.ts +41 -1
  65. package/dist/telegram-flow.js +96 -12
  66. package/dist/telegram.js +27 -3
  67. package/dist/toolroll-update.js +4 -1
  68. 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, 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";
@@ -204,6 +206,7 @@ import { readPulls } from "./pulls.js";
204
206
  import { startMaintenance } from "./maintenance.js";
205
207
  import { livePin, modelWatchPass } from "./model-catalog.js";
206
208
  import { runModelsCommand } from "./models-cli.js";
209
+ import { ALL_PROJECTS, chatApprovalSettings, chatApprovalWords, setChatApproval } from "./chat-approval.js";
207
210
  import { updateAdmissionPaused, UPDATE_PAUSED } from "./desktop-update-gate.js";
208
211
  import { beads } from "./beads.js";
209
212
  import { githubIssues } from "./issues.js";
@@ -470,6 +473,8 @@ Flows — processes cards move through; the console's rules
470
473
  toolroll flows script save --repo <path> --name <name> (--file <path in project> | --body <file>)
471
474
  --about "<one line>" [--language shell|python|node] [--timeout-minutes <n>]
472
475
  toolroll flows card add <id> --title <t> [--description <d>] [--zone <zone>]
476
+ toolroll flows card approve <id> <card> [--option <words|number>]
477
+ toolroll flows card send-back <id> <card> --note <text>
473
478
  toolroll flows archive <id>
474
479
  writes take --as <you> --token <t> (or the remembered login); create,
475
480
  edit, archive and trigger add preview until --yes
@@ -639,7 +644,7 @@ export const OPERATE_VALUE_FLAGS = new Set([
639
644
  "max", "cap", "probe", "kind", "expires", "cmd", "since", "repair-model",
640
645
  "choose", "note", "max-open-decisions", "max-held-sessions", "name", "days", "publication", "auto-approve", "review-auto", "entries", "port", "host", "allow-host",
641
646
  "for", "tick-every", "bridge-every", "reconcile-every", "incarnation",
642
- "say", "ceiling-usd",
647
+ "say", "ceiling-usd", "cap-usd",
643
648
  "token-file", "bin", "poll", "github", "remote", "head-prefix", "password",
644
649
  // v102: project rules.
645
650
  "not-requester", "protect", "protect-paths",
@@ -658,7 +663,7 @@ export const OPERATE_VALUE_FLAGS = new Set([
658
663
  // onboard: the starter flows to switch on.
659
664
  "starter",
660
665
  // flows: templates, steps, scripts and cards.
661
- "template", "steps", "about", "language", "timeout-minutes", "body", "description", "zone",
666
+ "template", "steps", "about", "language", "timeout-minutes", "body", "description", "zone", "option",
662
667
  "token-env", "after", "repair-max-attempts", "consumer", "batch", "feedback", "source", "view", "cursor", "why", "supersedes", "decision", "sessions", "timeout",
663
668
  // pings follow responsibility: replacements.
664
669
  "replaced-by", "replaces",
@@ -671,7 +676,7 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
671
676
  "json", "yes", "all", "brief", "local", "history", "latest-watch", "dry-run", "file", "allow-paid-fallback",
672
677
  "clear", "follow", "ready", "all-tasks", "inbound-only", "help", "undo", "anyone", "allow-dispatch", "allow-merge", "merge-delete-branch",
673
678
  "no-open", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
674
- "self-heal", "plan-auto", "chat-approve", "repair-auto", "review-retry-auto", "no-local",
679
+ "self-heal", "plan-auto", "chat-approve", "full-access", "repair-auto", "review-retry-auto", "no-local",
675
680
  "html", "csv", "alerts-only",
676
681
  // v105: the full export.
677
682
  "zip",
@@ -820,6 +825,20 @@ export async function runOperate(command, argv, write, options = {}) {
820
825
  flags.delete("token");
821
826
  actor = { account: lead.owner, lead: true };
822
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;
823
842
  // v105: a Claude turn anywhere in this command says its plan's usage windows; keep the latest.
824
843
  const dropLimitSink = pushLimitSink(reading => store.recordProviderLimits(reading, new Date()));
825
844
  // Filed tasks are sized by the owner's own fast classifier (never in tests, the demo, or with NO_TASK_CLASSIFIER).
@@ -832,11 +851,11 @@ export async function runOperate(command, argv, write, options = {}) {
832
851
  const clock = options.now === undefined ? () => new Date() : () => now;
833
852
  try {
834
853
  const run = () => dispatch(command, positional, flags, {
835
- ...(actor === null ? {} : { leadToken: presented }),
854
+ ...(base.leadToken === undefined ? {} : { leadToken: base.leadToken }),
836
855
  store,
837
856
  write,
838
857
  json,
839
- repoList: parsed.repoList,
858
+ repoList,
840
859
  now,
841
860
  clock,
842
861
  // Evidence lives beside the database for the same reason the database
@@ -870,10 +889,13 @@ export async function runOperate(command, argv, write, options = {}) {
870
889
  ...(options.installBin === undefined ? {} : { installBin: options.installBin }),
871
890
  ...(options.onboardSeams === undefined ? {} : { onboardSeams: options.onboardSeams }),
872
891
  ...(options.upSeams === undefined ? {} : { upSeams: options.upSeams }),
892
+ ...(activeRemote() === null ? {} : { principal: activeRemote() }),
873
893
  });
874
- return await (LEAD_COMMANDS.has(command) ? withActor(actor, run) : run());
894
+ return await enter(run);
875
895
  }
876
896
  catch (error) {
897
+ if (error instanceof RemoteRefusal)
898
+ return fail(write, json, command, error.reason, error.message, EXIT.refused);
877
899
  if (isDatabaseBusy(error))
878
900
  return databaseFailure(write, json, command, file, error);
879
901
  return fail(write, json, command, "failed", describe(error), EXIT.failed);
@@ -883,9 +905,95 @@ export async function runOperate(command, argv, write, options = {}) {
883
905
  await settleSizings();
884
906
  dropSizer();
885
907
  dropLimitSink();
886
- store.close();
887
908
  }
888
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
+ const run = Object.freeze({ account: principal.account, scope: proved.scope, tokenName: proved.tokenName, source, secret: remoteSecret(), allows, files: Object.freeze({ ...(opts.files ?? {}) }) });
979
+ record("requested", project.repo, project.taskId, null);
980
+ let code;
981
+ try {
982
+ 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))));
983
+ }
984
+ catch (error) {
985
+ record("failed", project.repo, project.taskId, null);
986
+ throw error;
987
+ }
988
+ record(code === EXIT.ok ? "done" : code === EXIT.refused ? "refused" : code === EXIT.usage ? "usage" : "failed", project.repo, project.taskId, null);
989
+ return code;
990
+ }
991
+ /** A file a remote caller sent with the request, by the argument that names it; never a path on this machine. */
992
+ function remoteFile(remote, path) {
993
+ if (Object.prototype.hasOwnProperty.call(remote.files, path))
994
+ return remote.files[path];
995
+ throw new RemoteRefusal("usage", REMOTE_MESSAGES.files);
996
+ }
889
997
  /** The short commands a lead token works for, and where whoever signs in is recorded as the actor. */
890
998
  const LEAD_COMMANDS = new Set(["task", "assignment", "status", "ready", "brief", "peek", "check-progress", "notifications", "lead"]);
891
999
  /**
@@ -1088,6 +1196,8 @@ async function dispatch(command, positional, flags, context) {
1088
1196
  return proposalsCommand(positional, flags, context);
1089
1197
  case "mode":
1090
1198
  return modeCommand(positional, flags, context);
1199
+ case "chat-approval":
1200
+ return chatApprovalCommand(positional, flags, context);
1091
1201
  case "people":
1092
1202
  return peopleCommand(positional, flags, context);
1093
1203
  case "keys":
@@ -1153,6 +1263,9 @@ function openBackend(name, store, repo) {
1153
1263
  }
1154
1264
  /** The repository a backend command applies to, normalised like the grant is. */
1155
1265
  function repoFrom(flags) {
1266
+ // The server's working directory is nobody's project: a remote caller names theirs.
1267
+ if (activeRemote() !== null && text(flags, "repo") === undefined)
1268
+ throw new RemoteRefusal("unknown-project", REMOTE_MESSAGES.project);
1156
1269
  return resolve(text(flags, "repo") ?? process.cwd());
1157
1270
  }
1158
1271
  // ---- the dispatch loop ----------------------------------------------------
@@ -5755,6 +5868,51 @@ async function peopleCommand(positional, flags, context) {
5755
5868
  ]);
5756
5869
  }
5757
5870
  const MODE_ACTIONS = ["set", "show", "revoke"];
5871
+ /** `chat-approval show | on | off [--repo <path>]`: the person's lasting choice to approve plans and merges from their
5872
+ * paired chat (chat-approval.ts), for all their projects or one. On (or wider limits) previews until --yes and takes
5873
+ * the password, like Settings; off is one step. Either way a ledger line. */
5874
+ async function chatApprovalCommand(positional, flags, context) {
5875
+ const { store, write, json, clock } = context;
5876
+ const action = positional[0] ?? "show";
5877
+ const command = `chat-approval ${action}`;
5878
+ if (!["show", "on", "off"].includes(action))
5879
+ return fail(write, json, "chat-approval", "usage", "Use chat-approval show | on | off [--repo <path>] [--full-access] [--cap-usd <n>] [--yes].", EXIT.usage);
5880
+ const acting = await askCredentials(flags, context);
5881
+ if (acting === null)
5882
+ return fail(write, json, command, "unauthenticated", "Approving from chat is your own setting: pass --as <you> --token <your password> (or sign in once with toolroll up).", EXIT.refused);
5883
+ const authed = authenticateApprover(store, acting.name, acting.token);
5884
+ if (!authed.ok)
5885
+ return fail(write, json, command, "unauthenticated", "That name and password aren't an approver's. Nothing was changed.", EXIT.refused);
5886
+ const given = text(flags, "repo");
5887
+ const scope = given === undefined ? ALL_PROJECTS : canonicalProject(given) ?? resolve(given);
5888
+ if (scope !== ALL_PROJECTS && !store.accountCanAccess(acting.name, scope))
5889
+ return fail(write, json, command, "unknown-project", "That isn't one of your projects.", EXIT.refused);
5890
+ const where = scope === ALL_PROJECTS ? "all your projects" : projectName(scope);
5891
+ if (action === "show") {
5892
+ const rows = chatApprovalSettings(store, acting.name).map(one => ({ scope: one.scope === ALL_PROJECTS ? "all" : one.scope, enabled: one.enabled, fullAccess: one.fullAccess, capMicrousd: one.capMicrousd, updatedAt: one.updatedAt }));
5893
+ return succeed(write, json, command, { settings: rows }, () => rows.length === 0 ? ["Approving from chat is off everywhere. Turn it on: toolroll chat-approval on [--repo <path>]"]
5894
+ : rows.map(one => `${one.scope === "all" ? "All projects" : projectName(one.scope)}: ${one.enabled ? `on · ${chatApprovalWords(one)}` : "off"}`));
5895
+ }
5896
+ const now = clock();
5897
+ if (action === "off") {
5898
+ const saved = setChatApproval(store, { approver: acting.name, scope, enabled: false, via: "the command line" }, now);
5899
+ return saved.ok ? succeed(write, json, command, { applied: true, scope: scope === ALL_PROJECTS ? "all" : scope }, () => [saved.said]) : fail(write, json, command, "refused", saved.message, EXIT.refused);
5900
+ }
5901
+ const usd = text(flags, "cap-usd");
5902
+ const cap = usd === undefined ? null : Number(usd);
5903
+ if (cap !== null && !(Number.isFinite(cap) && cap >= 0 && cap <= 10_000))
5904
+ return fail(write, json, command, "usage", "Give --cap-usd in dollars, like 5.", EXIT.usage);
5905
+ const limits = { fullAccess: flag(flags, "full-access"), capMicrousd: cap === null ? null : Math.round(cap * 1_000_000) };
5906
+ const terms = [
5907
+ `Your own taps in your paired chat approve plans and merges in ${where}, until you turn it off: it doesn't expire.`,
5908
+ `Limits: ${chatApprovalWords(limits)}.`,
5909
+ "Plans written for you, protected paths, two-person rules and your organisation's policy still open in Toolroll.",
5910
+ ];
5911
+ if (!flag(flags, "yes"))
5912
+ return succeed(write, json, command, { applied: false, scope: scope === ALL_PROJECTS ? "all" : scope, terms }, () => [`Turn on approving from chat for ${where}`, ...terms.map(one => ` ${one}`), "", "Nothing has changed. Add --yes to make this change."]);
5913
+ const saved = setChatApproval(store, { approver: acting.name, scope, enabled: true, limits, via: "the command line" }, now);
5914
+ return saved.ok ? succeed(write, json, command, { applied: true, scope: scope === ALL_PROJECTS ? "all" : scope, terms }, () => [saved.said]) : fail(write, json, command, "refused", saved.message, EXIT.refused);
5915
+ }
5758
5916
  async function modeCommand(positional, flags, context) {
5759
5917
  const { store, write, json, clock } = context;
5760
5918
  const [action] = positional;
@@ -8166,6 +8324,9 @@ export function writeLoginFileDurably(path, name, password) {
8166
8324
  }
8167
8325
  }
8168
8326
  function readLoginFile(path) {
8327
+ // The owner's saved login is never anyone else's: a remote run that reaches here is a bug, and ends refused.
8328
+ if (activeRemote() !== null)
8329
+ throw new RemoteRefusal("unauthenticated", REMOTE_MESSAGES.credential);
8169
8330
  try {
8170
8331
  const raw = readFileSync(path, "utf8").trim();
8171
8332
  const cut = raw.indexOf(" ");
@@ -10233,7 +10394,8 @@ async function addTask(positional, flags, context) {
10233
10394
  ...(mirrored === null ? [] : [`Mirrored locally as ${mirrored} — scope and approve it, and this plane builds it.`]),
10234
10395
  ]);
10235
10396
  }
10236
- const id = text(flags, "id") ?? slug(title, now);
10397
+ // Remote creation cannot probe global IDs, including predictable title/time collisions.
10398
+ const id = text(flags, "id") ?? (activeRemote() === null ? slug(title, now) : `task-${randomBytes(16).toString("hex")}`);
10237
10399
  // Re-filing: the new task replaces an earlier one, which is cancelled and reads "Replaced by <id>".
10238
10400
  const replaces = text(flags, "replaces");
10239
10401
  if (replaces !== undefined && (replaces === id || store.getTask(replaces) === null)) {
@@ -10256,12 +10418,21 @@ async function addTask(positional, flags, context) {
10256
10418
  if (!outcome.ok) {
10257
10419
  return fail(write, json, "task add", "exists", `\`${id}\` already exists`, EXIT.refused);
10258
10420
  }
10259
- store.stampFiledVia(store.refFor(BUILT_IN, id).id, "cli");
10421
+ const filedId = outcome.task.id;
10422
+ const remote = activeRemote();
10423
+ if (remote !== null && filedId !== id) {
10424
+ // Re-prove a replayed creation too: it may have been moved since the original filing.
10425
+ const saved = store.lookupRef(filedId);
10426
+ if (saved === null || saved.repo !== text(flags, "repo") || !remote.allows(saved.repo)) {
10427
+ throw new RemoteRefusal("not-found", "Not found.");
10428
+ }
10429
+ }
10430
+ store.stampFiledVia(store.refFor(BUILT_IN, filedId).id, "cli");
10260
10431
  // Placement is explicit, never inferred from where the command happened to
10261
10432
  // run: a task filed from the wrong directory would silently bind to it.
10262
10433
  const placedIn = text(flags, "repo");
10263
10434
  if (placedIn !== undefined) {
10264
- const placed = store.placeTask(store.refFor(BUILT_IN, id).id, canonicalProject(placedIn) ?? resolve(placedIn));
10435
+ const placed = store.placeTask(store.refFor(BUILT_IN, filedId).id, canonicalProject(placedIn) ?? resolve(placedIn));
10265
10436
  if (typeof placed === "object" && !placed.ok) {
10266
10437
  return fail(write, json, "task add", "scoped", "this task already has a scope — placement is immutable once somebody could have approved it", EXIT.refused);
10267
10438
  }
@@ -11970,6 +12141,10 @@ async function chatCommand(flags, context) {
11970
12141
  * win, and a different --as than the remembered name is not answered.
11971
12142
  */
11972
12143
  function credentialsFrom(flags, context) {
12144
+ // A remote run is its token's person, whatever the flags or this machine's saved login say.
12145
+ const remote = activeRemote();
12146
+ if (remote !== null)
12147
+ return { name: remote.account, token: remote.secret };
11973
12148
  // The lead signs in as its owner with its own token, never the owner's password.
11974
12149
  const lead = currentActor();
11975
12150
  if (context.leadToken !== undefined && lead?.lead === true)
@@ -11995,16 +12170,55 @@ async function askCredentials(flags, context) {
11995
12170
  return null;
11996
12171
  return { name, token };
11997
12172
  }
12173
+ /*
12174
+ * Credential checks in this file. Locally they are the password checks in scope.ts and principal.ts. Inside a
12175
+ * remote run (`runOperateAs`) the only credential is the one credentialsFrom handed out for the token's person,
12176
+ * which scope.ts's authenticateAccount takes as that person (a viewer for a read token) and never checks as a
12177
+ * password; these add the token's own project limit.
12178
+ */
12179
+ function authenticateApprover(store, by, token, repo) {
12180
+ const remote = activeRemote();
12181
+ if (remote !== null && repo !== undefined && !remote.allows(repo))
12182
+ return { ok: false, reason: "not-an-approver" };
12183
+ return passwordApprover(store, by, token, repo);
12184
+ }
12185
+ function verifyApproverByPassword(store, name, token, repos) {
12186
+ const remote = activeRemote();
12187
+ return approverByPassword(store, name, token, remote === null ? repos : repos.filter(repo => remote.allows(repo)));
12188
+ }
12189
+ /** Prompts: never inside a remote run — there is nobody at this machine's terminal to answer for the person. */
12190
+ function interactive() {
12191
+ return activeRemote() === null && promptInteractive();
12192
+ }
12193
+ function refusePrompt() {
12194
+ if (activeRemote() !== null)
12195
+ throw new RemoteRefusal("unauthenticated", REMOTE_MESSAGES.credential);
12196
+ }
12197
+ async function ask(question) {
12198
+ refusePrompt();
12199
+ return promptAsk(question);
12200
+ }
12201
+ async function askHidden(question) {
12202
+ refusePrompt();
12203
+ return promptHidden(question);
12204
+ }
12205
+ async function confirm(question) {
12206
+ refusePrompt();
12207
+ return promptConfirm(question);
12208
+ }
11998
12209
  /** `flows …`: the console's flow rules from a terminal. Writes are an approver's (--as/--token or the remembered login). */
11999
12210
  async function flowsCommand(positional, flags, context) {
12000
12211
  const { store } = context;
12001
12212
  const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
12002
- const projects = [...new Set([...store.knownRepos(), ...store.listProjects().map(one => one.path), ...("error" in registered ? [] : registered.repos)])];
12213
+ const remote = activeRemote();
12214
+ // A remote person sees and changes flows only in their own projects, and sends any flow file with the request.
12215
+ const projects = [...new Set([...store.knownRepos(), ...store.listProjects().map(one => one.path), ...("error" in registered ? [] : registered.repos)])].filter(one => remote === null || remote.allows(one));
12003
12216
  const writes = positional[0] !== undefined && positional[0] !== "list" && positional[0] !== "show" && positional[0] !== "export";
12004
12217
  const acting = writes && !flags.has("help") ? await askCredentials(flags, context) : null;
12005
12218
  const dir = dirname(context.databaseFile);
12006
12219
  return runFlowsCommand(positional, flags, {
12007
12220
  store, write: context.write, json: context.json, clock: context.clock, credentials: acting, projects, configDir: dir, evidenceRoot: context.evidenceRoot,
12221
+ ...(remote === null ? {} : { readInput: (path) => remoteFile(remote, path) }),
12008
12222
  ...(context.flowFetch === undefined ? {} : { fetchFlow: context.flowFetch }),
12009
12223
  // "Check now": the same io the worker's pass checks triggers with.
12010
12224
  triggerIo: { gh: context.flowTriggerIo?.gh ?? run, fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dir,
@@ -50,7 +50,7 @@ export declare function preparedCandidateObserverGapEligibility(store: Store, in
50
50
  digest: string;
51
51
  approvedAt: string;
52
52
  approvedBy: string;
53
- approvalBasis: "mode" | "password";
53
+ approvalBasis: "chat" | "mode" | "password";
54
54
  modeDigest: string | null;
55
55
  };
56
56
  readonly command: {
@@ -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>;