toolroll 0.9.44 → 0.9.46

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 (57) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/api-tokens.d.ts +33 -0
  3. package/dist/api-tokens.js +39 -0
  4. package/dist/audit-cli.d.ts +17 -0
  5. package/dist/audit-cli.js +86 -0
  6. package/dist/cli-http.d.ts +10 -16
  7. package/dist/cli-http.js +12 -2
  8. package/dist/cli.js +7 -1
  9. package/dist/contracts/cli.d.ts +47 -0
  10. package/dist/contracts/cli.js +11 -0
  11. package/dist/contracts/console-api.d.ts +1 -0
  12. package/dist/contracts/console-api.js +1 -0
  13. package/dist/contracts/mcp-oauth.d.ts +53 -0
  14. package/dist/contracts/mcp-oauth.js +40 -0
  15. package/dist/credentials-ui.d.ts +5 -0
  16. package/dist/credentials-ui.js +16 -4
  17. package/dist/desktop-update.d.ts +2 -2
  18. package/dist/dispatch.d.ts +2 -2
  19. package/dist/dispatch.js +8 -4
  20. package/dist/export.js +4 -3
  21. package/dist/lead-status.d.ts +7 -3
  22. package/dist/lead-status.js +21 -12
  23. package/dist/mcp-http.d.ts +6 -1
  24. package/dist/mcp-http.js +22 -4
  25. package/dist/mcp-oauth.d.ts +82 -0
  26. package/dist/mcp-oauth.js +479 -0
  27. package/dist/mcp-person.js +1 -1
  28. package/dist/operate-remote-arguments.js +2 -0
  29. package/dist/operate-remote.d.ts +13 -1
  30. package/dist/operate-remote.js +30 -4
  31. package/dist/operate.d.ts +3 -0
  32. package/dist/operate.js +86 -12
  33. package/dist/people-audit-ui.d.ts +14 -0
  34. package/dist/people-audit-ui.js +38 -0
  35. package/dist/public-access.d.ts +50 -0
  36. package/dist/public-access.js +87 -0
  37. package/dist/public-check.d.ts +37 -0
  38. package/dist/public-check.js +170 -0
  39. package/dist/remote-audit.d.ts +79 -0
  40. package/dist/remote-audit.js +161 -0
  41. package/dist/remote-run.d.ts +4 -0
  42. package/dist/request-budget-ui.d.ts +11 -0
  43. package/dist/request-budget-ui.js +31 -0
  44. package/dist/request-budget.d.ts +96 -0
  45. package/dist/request-budget.js +227 -0
  46. package/dist/serve.d.ts +4 -1
  47. package/dist/serve.js +120 -13
  48. package/dist/store.d.ts +118 -2
  49. package/dist/store.js +236 -7
  50. package/dist/surface.js +19 -2
  51. package/dist/token-notices.d.ts +22 -0
  52. package/dist/token-notices.js +52 -0
  53. package/dist/tokens-cli.d.ts +21 -0
  54. package/dist/tokens-cli.js +184 -0
  55. package/dist/work-summary.d.ts +4 -1
  56. package/dist/work-summary.js +1 -1
  57. package/package.json +1 -1
package/dist/operate.d.ts CHANGED
@@ -39,6 +39,8 @@ export type OperateOptions = {
39
39
  releaseIo?: ReleaseIo;
40
40
  /** Injected by tests: the bin whose real path says how Toolroll was installed. */
41
41
  installBin?: string;
42
+ /** Tests: what `serve check-public` fetches with instead of the network. */
43
+ publicProbe?: import("./public-check.js").Probe;
42
44
  /** Injected by tests: where `storage` looks for leftover test temp folders (default: the temp folder and /tmp). */
43
45
  tempRoots?: readonly string[];
44
46
  openDatabase?: (file: string) => Store;
@@ -142,6 +144,7 @@ export declare function runOperateAs(argv: string[], opts: {
142
144
  write: (s: string) => void;
143
145
  files?: Record<string, string>;
144
146
  source?: RemoteSource;
147
+ tool?: string;
145
148
  options?: OperateOptions;
146
149
  }): Promise<number>;
147
150
  /** Durably create the login file BEFORE the bootstrap commits (arc 2
package/dist/operate.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { UNSENT_REPLY_MS } from "./telegram-settings.js";
2
+ import { checkPublicCommand } from "./public-check.js";
2
3
  import { leadNameOf } from "./lead-identity.js";
3
4
  import { maybeTriggerRepair } from "./dispose.js";
4
5
  import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
@@ -23,6 +24,8 @@ import { billingOf, budgetHoldWords, budgetLabel, budgetStates, monthNamed, mont
23
24
  import { spendCsv } from "./spend-ui.js";
24
25
  import { buildExport, exportSummary, exportZip, writeExportFolder } from "./export.js";
25
26
  import { startBudgetAlerts } from "./budget-alerts.js";
27
+ import { runTokensCommand } from "./tokens-cli.js";
28
+ import { startTokenNotices } from "./token-notices.js";
26
29
  import { liftAuthPause, openAuthPauses, signInGate, signInWords, startSignInProbes } from "./provider-auth.js";
27
30
  import { createConnectionChecker } from "./provider-connection.js";
28
31
  import { checkIntegrations, integrationsBrokenLine, integrationsNow, renderIntegrations } from "./integrations.js";
@@ -100,6 +103,7 @@ import { createServer as createNetServer } from "node:net";
100
103
  import { spawn as spawnChild } from "node:child_process";
101
104
  // Every envelope is checked against its command's schema (logged, never refused), then serialized unchanged.
102
105
  import { checkedEnvelopeJson as envelopeJson } from "./contracts/cli.js";
106
+ import { auditCommand } from "./audit-cli.js";
103
107
  import { hasDisguisedText, hasForbiddenControls, validateNote } from "./decision.js";
104
108
  import { readVerifiedArtifact, readVerifiedReport, storeEvidence } from "./evidence.js";
105
109
  import { contractChangesOf, decodePlanContractRecord, describeContractChanges, encodePlannerSource, plannerSourceOf } from "./planner-source.js";
@@ -154,7 +158,7 @@ import { ask as promptAsk, askHidden as promptHidden, confirm as promptConfirm,
154
158
  import { runMateCli, answerContextLines } from "./mate-cli.js";
155
159
  import { confirmCoordinatorProposal, dismissCoordinatorProposal } from "./mate-doors.js";
156
160
  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";
161
+ import { activeRemote, REMOTE_MESSAGES, REMOTE_REFUSED_FLAGS, REMOTE_SCOPES, RemoteRefusal, remoteAllows, remoteLensOf, remoteProjectOf, remoteRowOf, remoteSecret, reproveRemote, withRemote } from "./operate-remote.js";
158
162
  import { projectAuthority } from "./project-access.js";
159
163
  import { authorizedProject, canonicalProject, projectName, resolveCeiling } from "./project.js";
160
164
  import { tally, spendLine } from "./summary.js";
@@ -273,6 +277,9 @@ export const OPERATE_HELP = `toolroll — operating the queue
273
277
  toolroll ledger checkpoint record the chain's head to copy off this machine (instance operator)
274
278
  toolroll ledger export --from <YYYY-MM-DD> --to <YYYY-MM-DD> [--out <file>]
275
279
  every sealed entry in the range, with an evidence pack per task
280
+ toolroll audit [--person <p>] [--token <name>] [--source api|mcp] [--since 7d]
281
+ what people did here with their API tokens, newest first (--limit, --cursor;
282
+ remotely, name a token with --token-name)
276
283
  toolroll task complete <id> mark the current result complete (--digest for JSON/agents);
277
284
  [--pull-request] --pull-request also opens its pull request
278
285
  toolroll task merge <id> --as <you> --token <t>
@@ -671,6 +678,10 @@ export const OPERATE_VALUE_FLAGS = new Set([
671
678
  "quick", "level", "checks",
672
679
  // lead say: the task the lead's words are about.
673
680
  "task",
681
+ // tokens: an API token's terms, and a rotation's overlap in minutes.
682
+ "access", "projects", "overlap",
683
+ // audit: a token by name where --token is a credential (remote).
684
+ "token-name",
674
685
  ]);
675
686
  export const OPERATE_BOOLEAN_FLAGS = new Set([
676
687
  "json", "yes", "all", "brief", "local", "history", "latest-watch", "dry-run", "file", "allow-paid-fallback",
@@ -692,6 +703,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
692
703
  "anyway",
693
704
  // config set/clear: another provider's candidate on the same tier.
694
705
  "also",
706
+ // tokens: the person's password, piped (never an argument).
707
+ "password-stdin",
695
708
  ]);
696
709
  export function parseOperateArgs(argv, ownValues = new Set()) {
697
710
  const positional = [];
@@ -793,6 +806,10 @@ export async function runOperate(command, argv, write, options = {}) {
793
806
  if (command === "mcp") {
794
807
  return mcpCommand(file, flags, write, json);
795
808
  }
809
+ // The public-address readiness check reads no database: it only asks the network.
810
+ if (command === "serve" && positional[0] === "check-public") {
811
+ return checkPublicCommand(flags, { write, json, envelope: envelopeJson, ...(options.publicProbe === undefined ? {} : { probe: options.publicProbe }) });
812
+ }
796
813
  // Restore replaces the database file itself, so it opens (and closes) the database on its own.
797
814
  if (command === "restore") {
798
815
  return restoreCommand(file, positional, flags, write, json, now);
@@ -927,10 +944,12 @@ export async function runOperateAs(argv, opts) {
927
944
  const json = rest.includes("--json");
928
945
  let invocation = root;
929
946
  let tokenName = null;
947
+ // An MCP call names the gateway tool it came from, so the history tells a tool from the command it ran.
948
+ const tool = source === "mcp" && typeof opts.tool === "string" && /^[a-z][a-z0-9_]{0,40}$/.test(opts.tool) ? opts.tool : null;
930
949
  const record = (outcome, repo, taskId, why) => {
931
950
  store.recordAction({ at: (options.now ?? new Date()).toISOString(), actor: principal.account, repo, taskId, runId: null,
932
951
  action: `remote command: ${invocation === "" ? "(none)" : invocation}`.slice(0, 200), outcome, source,
933
- detail: `token ${tokenName ?? principal.tokenId}${why === null ? "" : ` · ${why}`}` });
952
+ detail: `token ${tokenName ?? principal.tokenId}${why === null ? "" : ` · ${why}`}${tool === null ? "" : ` · tool ${tool}`}` });
934
953
  };
935
954
  const refuse = (reason, message, repo = null, taskId = null) => {
936
955
  record("refused", repo, taskId, reason);
@@ -975,17 +994,20 @@ export async function runOperateAs(argv, opts) {
975
994
  const file = store.databaseFile() ?? options.databaseFile;
976
995
  if (file === undefined)
977
996
  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 ?? {}) }) });
997
+ // The task a `task add` filed is only known once it runs; the final history line names it.
998
+ let filed = null;
999
+ const run = Object.freeze({ account: principal.account, scope: proved.scope, tokenName: proved.tokenName, source, secret: remoteSecret(), allows,
1000
+ lens: remoteLensOf(store, scope, allows), noteTask: (taskId) => { filed = taskId; }, files: Object.freeze({ ...(opts.files ?? {}) }) });
979
1001
  record("requested", project.repo, project.taskId, null);
980
1002
  let code;
981
1003
  try {
982
1004
  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
1005
  }
984
1006
  catch (error) {
985
- record("failed", project.repo, project.taskId, null);
1007
+ record("failed", project.repo, filed ?? project.taskId, null);
986
1008
  throw error;
987
1009
  }
988
- record(code === EXIT.ok ? "done" : code === EXIT.refused ? "refused" : code === EXIT.usage ? "usage" : "failed", project.repo, project.taskId, null);
1010
+ record(code === EXIT.ok ? "done" : code === EXIT.refused ? "refused" : code === EXIT.usage ? "usage" : "failed", project.repo, filed ?? project.taskId, null);
989
1011
  return code;
990
1012
  }
991
1013
  /** A file a remote caller sent with the request, by the argument that names it; never a path on this machine. */
@@ -1067,6 +1089,8 @@ async function dispatch(command, positional, flags, context) {
1067
1089
  return taskCommand(positional, flags, context);
1068
1090
  case "ledger":
1069
1091
  return ledgerCommand(positional, flags, context);
1092
+ case "audit":
1093
+ return auditCommand(positional, flags, { store: context.store, write: context.write, json: context.json, now: context.clock(), ...(context.principal === undefined ? {} : { principal: context.principal }) });
1070
1094
  case "spend":
1071
1095
  return spendCommand(flags, context);
1072
1096
  case "budget":
@@ -1200,6 +1224,13 @@ async function dispatch(command, positional, flags, context) {
1200
1224
  return chatApprovalCommand(positional, flags, context);
1201
1225
  case "people":
1202
1226
  return peopleCommand(positional, flags, context);
1227
+ case "tokens":
1228
+ return runTokensCommand(positional, flags, {
1229
+ store: context.store, write: context.write, json: context.json, clock: context.clock,
1230
+ caller: activeRemote() !== null ? "remote" : currentActor()?.lead === true || context.leadToken !== undefined ? "lead" : "local",
1231
+ rememberedName: readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE))?.name ?? null,
1232
+ interactive, ask, askHidden, readStdin: readBoundedStdin,
1233
+ });
1203
1234
  case "keys":
1204
1235
  return keysCommand(positional, flags, context);
1205
1236
  case "setup":
@@ -1307,7 +1338,9 @@ async function readyCommand(flags, context) {
1307
1338
  write(` ${task.id} ${task.title}`);
1308
1339
  return EXIT.ok;
1309
1340
  }
1310
- const ready = store.listReady(now);
1341
+ // A remote person limited to some projects (remoteLensOf) sees only theirs, counted over those alone.
1342
+ const lens = context.principal?.lens ?? null;
1343
+ const ready = store.listReady(now).filter(ref => lens === null || ref.repo !== null && lens.includes(ref.repo));
1311
1344
  const described = ready.map(ref => describeRef(store, ref, now));
1312
1345
  const dispatchableCount = described.filter(one => diagnosisIsDispatchable(one.dispatch)).length;
1313
1346
  const pauses = openAuthPauses(store);
@@ -5073,6 +5106,8 @@ async function startConsole(options) {
5073
5106
  server.on("close", stopMonitoring);
5074
5107
  // v105: budget alerts at 50/80/100 %, a pass a minute.
5075
5108
  server.on("close", startBudgetAlerts(context.store));
5109
+ // v111: API tokens: 7- and 1-day expiry notices to their person, and the end of each rotated token, a pass a minute.
5110
+ server.on("close", startTokenNotices(context.store));
5076
5111
  // A paused provider's sign-in is checked every two minutes (no model, nothing spent); the pause lifts when it works.
5077
5112
  server.on("close", startSignInProbes(context.store, createConnectionChecker()));
5078
5113
  // Sprint 8: scheduled backups, a pass a minute; its lease also tells a restore that the console is running.
@@ -8632,6 +8667,8 @@ async function upCommand(flags, context) {
8632
8667
  return fail(write, json, "up", "usage", "--host is an address or a name", EXIT.usage);
8633
8668
  }
8634
8669
  const allowFlag = text(flags, "allow-host");
8670
+ // --public-url: `up` serves a real domain exactly as `serve` does (one process is the whole team server).
8671
+ const publicUrlFlag = text(flags, "public-url");
8635
8672
  const progress = (line) => {
8636
8673
  // v99: with TOOLROLL_LOG_FORMAT=json, each line is one JSON event on stderr for a log shipper.
8637
8674
  if (envValue(process.env, "LOG_FORMAT") === "json")
@@ -8858,6 +8895,7 @@ async function upCommand(flags, context) {
8858
8895
  host: hostFlag,
8859
8896
  port,
8860
8897
  ...(allowFlag === undefined ? {} : { allowedHosts: allowFlag.split(",").map(one => one.trim()).filter(one => one !== "") }),
8898
+ ...(publicUrlFlag === undefined ? {} : { publicUrl: publicUrlFlag }),
8861
8899
  localRunner: runnerName,
8862
8900
  poolRoot: pool,
8863
8901
  repos,
@@ -10097,6 +10135,13 @@ async function statusCommand(positional, flags, context) {
10097
10135
  return fail(context.write, context.json, command, "usage", "Use `toolroll status [--json]`.", EXIT.usage);
10098
10136
  // Whose lead the status line follows: the lead's own person, else the remembered login; nobody shows no lead.
10099
10137
  const viewer = currentActor()?.account ?? readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE))?.name ?? null;
10138
+ // A remote person limited to some projects sees only those (remoteLensOf): their tasks and counts, and nothing
10139
+ // that belongs to the installation or other projects (updates, integrations, builds by project, unsent replies).
10140
+ const lens = context.principal?.lens ?? null;
10141
+ if (lens !== null) {
10142
+ const status = installationStatus(context.store, context.clock(), viewer, lens);
10143
+ return succeed(context.write, context.json, command, { ...status, projects: [] }, () => renderInstallationStatus(status, true));
10144
+ }
10100
10145
  const status = installationStatus(context.store, context.clock(), viewer);
10101
10146
  // One line, only when a newer Toolroll exists; offline or switched off says nothing.
10102
10147
  const current = PACKAGE_VERSION;
@@ -10427,6 +10472,7 @@ async function addTask(positional, flags, context) {
10427
10472
  throw new RemoteRefusal("not-found", "Not found.");
10428
10473
  }
10429
10474
  }
10475
+ remote?.noteTask(filedId);
10430
10476
  store.stampFiledVia(store.refFor(BUILT_IN, filedId).id, "cli");
10431
10477
  // Placement is explicit, never inferred from where the command happened to
10432
10478
  // run: a task filed from the wrong directory would silently bind to it.
@@ -10707,7 +10753,9 @@ function listTasks(flags, context) {
10707
10753
  }
10708
10754
  let page;
10709
10755
  try {
10710
- page = workIndexPage(store, now, { principal: 'operator', repos: null, includeUnplaced: true }, {
10756
+ // A remote person limited to some projects (remoteLensOf) pages and counts only theirs.
10757
+ const lens = context.principal?.lens ?? null;
10758
+ page = workIndexPage(store, now, { principal: 'operator', repos: lens, includeUnplaced: lens === null }, {
10711
10759
  view: parseWorkView(rawView ?? null), limit, cursor: text(flags, 'cursor') ?? null,
10712
10760
  ...(wanted === undefined ? {} : { state: wanted }),
10713
10761
  ...(text(flags, 'repo') === undefined ? {} : { project: text(flags, 'repo') }),
@@ -10754,13 +10802,21 @@ function showTask(positional, context) {
10754
10802
  // whatever readiness this task's runners have reported.
10755
10803
  const routed = routeOfTask(store, id, ref, now);
10756
10804
  const readiness = store.readinessLookupFor(ref.repo, ref.assignedRunner, now);
10805
+ const lens = context.principal?.lens ?? null;
10806
+ const access = { principal: "operator", repos: lens, includeUnplaced: lens === null, dependencyRepos: lens };
10807
+ const blockers = store.blockers(id);
10808
+ const visibleBlockers = lens === null ? blockers : blockers.filter(blocker => {
10809
+ const repo = store.lookupRef(blocker)?.repo;
10810
+ return repo != null && lens.includes(repo);
10811
+ });
10757
10812
  const detail = {
10758
10813
  task,
10759
- work: taskWorkSummaryOf(store, id, now, { principal: "operator", repos: null, includeUnplaced: true }),
10760
- assignment: assignmentBrief(assignmentOf(store, id, now, { principal: "operator", repos: null, includeUnplaced: true }, context.evidenceRoot)),
10814
+ work: taskWorkSummaryOf(store, id, now, access),
10815
+ assignment: assignmentBrief(assignmentOf(store, id, now, access, context.evidenceRoot)),
10761
10816
  ref: ref.id,
10762
- blockedBy: store.blockers(id),
10763
- position: store.queuePosition(id),
10817
+ // One phrase for every inaccessible blocker, so even their number stays private.
10818
+ blockedBy: visibleBlockers.length === blockers.length ? visibleBlockers : [...visibleBlockers, "a task in another project"],
10819
+ position: lens === null ? store.queuePosition(id) : null,
10764
10820
  reservedFor: ref.assignedRunner,
10765
10821
  hold: store.activeHold(ref.id, now),
10766
10822
  claim: currentClaim(store, ref.id, now),
@@ -10785,7 +10841,7 @@ function showTask(positional, context) {
10785
10841
  // The one automatic review of the latest build: HIGH findings, suggested
10786
10842
  // follow-ups (MEDIUM/LOW), or why it was not reviewed.
10787
10843
  automaticReview: latestFinished === null ? null : buildReviewOf(store, latestFinished.id),
10788
- dispatch: diagnoseTaskDispatch(store, id, now),
10844
+ dispatch: diagnoseTaskDispatch(store, id, now, lens),
10789
10845
  // v52: the exact-run control the console shows — Stop, Stopping,
10790
10846
  // Paused (resume), or the review-retry door — and every stop on record.
10791
10847
  control: taskControlOf(store, ref.id, now),
@@ -12186,6 +12242,24 @@ function verifyApproverByPassword(store, name, token, repos) {
12186
12242
  const remote = activeRemote();
12187
12243
  return approverByPassword(store, name, token, remote === null ? repos : repos.filter(repo => remote.allows(repo)));
12188
12244
  }
12245
+ /** Standard input, whole, up to `limit` bytes (null past it): never from a terminal, and never inside a remote run. */
12246
+ async function readBoundedStdin(limit) {
12247
+ refusePrompt();
12248
+ if (process.stdin.isTTY)
12249
+ return null;
12250
+ const chunks = [];
12251
+ let size = 0;
12252
+ for await (const chunk of process.stdin) {
12253
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
12254
+ size += bytes.length;
12255
+ if (size > limit) {
12256
+ process.stdin.pause();
12257
+ return null;
12258
+ }
12259
+ chunks.push(bytes);
12260
+ }
12261
+ return Buffer.concat(chunks).toString("utf8");
12262
+ }
12189
12263
  /** Prompts: never inside a remote run — there is nobody at this machine's terminal to answer for the person. */
12190
12264
  function interactive() {
12191
12265
  return activeRemote() === null && promptInteractive();
@@ -0,0 +1,14 @@
1
+ import { type AuditAction } from "./remote-audit.js";
2
+ import type { ApiTokenRow } from "./store.js";
3
+ export declare const PEOPLE_AUDIT_CSS: string;
4
+ export type PersonAuditView = {
5
+ name: string;
6
+ tokens: readonly ApiTokenRow[];
7
+ actions: readonly AuditAction[];
8
+ /** The token name the actions are filtered to, if any. */
9
+ token: string | null;
10
+ nextCursor: string | null;
11
+ now: Date;
12
+ };
13
+ export declare const personHref: (name: string, more?: Record<string, string>) => string;
14
+ export declare function personAuditHtml(view: PersonAuditView): string;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * People → a person: their API tokens, each with its standing and last use, and their recent remote actions
3
+ * (remote-audit.ts). A token's name filters the actions to it (`token-name`: a `token` query is always refused). Read-only: making, revoking and rotating tokens stay
4
+ * in Settings → Sessions & tokens. Never a token's secret or a ledger line's raw detail.
5
+ */
6
+ import { basename } from "node:path";
7
+ import { tokenStatus } from "./remote-audit.js";
8
+ const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
9
+ const stamp = (value) => `${value.slice(0, 16).replace("T", " ")} UTC`;
10
+ export const PEOPLE_AUDIT_CSS = `.person-audit{max-width:820px;min-width:0;overflow-wrap:anywhere}.person-audit h2{font-size:1.05rem;margin:1.25rem 0 8px}` +
11
+ `.person-audit .rows{display:grid;border:1px solid var(--so-line);border-radius:10px;overflow:hidden}.person-audit .row-item{padding:10px 14px;border-bottom:1px solid var(--so-line);min-width:0}` +
12
+ `.person-audit .row-item:last-child{border-bottom:0}.person-audit .row-item p{margin:0}.person-audit .row-item .meta{font-size:.85rem}` +
13
+ `.person-audit a{display:inline-flex;align-items:center;min-height:44px}.person-audit .row-item a{min-height:32px}.person-audit a:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px;border-radius:4px}` +
14
+ `.person-audit .outcome{display:inline-block;font-size:.8rem;font-weight:600;padding:1px 8px;border-radius:999px;margin-right:8px}` +
15
+ `.person-audit .outcome-ok{background:var(--so-success-soft);color:var(--so-success)}.person-audit .outcome-refused{background:var(--so-warning-soft);color:var(--so-ink)}` +
16
+ `.person-audit .outcome-error{background:var(--so-danger-soft);color:var(--so-danger)}` +
17
+ `@media(max-width:600px){.person-audit .row-item a{min-height:44px}}`;
18
+ export const personHref = (name, more = {}) => `/people?${new URLSearchParams({ person: name, ...more }).toString()}`;
19
+ export function personAuditHtml(view) {
20
+ const tokens = view.tokens.length === 0 ? `<p class="meta">No API tokens.</p>` : `<div class="rows">${view.tokens.map(one => {
21
+ const status = tokenStatus(one, view.now);
22
+ return `<div class="row-item" data-token-name="${e(one.name)}"><p><a href="${e(personHref(view.name, { "token-name": one.name }))}">${e(one.name)}</a></p>` +
23
+ `<p class="meta">${status === "active" ? "Active" : status === "expired" ? "Expired" : "Revoked"} · ${one.access === "act" ? "Can act" : "Read only"} · ` +
24
+ `last used ${one.lastUsedAt === null ? "never" : e(stamp(one.lastUsedAt))}</p></div>`;
25
+ }).join("")}</div>`;
26
+ const filtered = view.token === null ? "" : `<p class="meta">With ${e(view.token)}</p><p><a href="${e(personHref(view.name))}">All tokens</a></p>`;
27
+ const actions = view.actions.length === 0 ? `<p class="meta">No remote actions${view.token === null ? "" : " with this token"}.</p>` : `<div class="rows">${view.actions.map(actionHtml).join("")}</div>`;
28
+ const older = view.nextCursor === null ? "" : `<p><a href="${e(personHref(view.name, { ...(view.token === null ? {} : { "token-name": view.token }), before: view.nextCursor }))}">Older actions</a></p>`;
29
+ return `<p><a href="/people">People</a></p><h1>${e(view.name)}</h1><section class="person-audit"><h2>API tokens</h2>${tokens}` +
30
+ `<h2>Remote activity</h2>${filtered}${actions}${older}</section>`;
31
+ }
32
+ function actionHtml(action) {
33
+ const label = action.outcome === "ok" ? "OK" : action.outcome === "refused" ? "Refused" : "Error";
34
+ const detail = [stamp(action.at), action.token ?? "unknown token", action.source === "mcp" ? "Agent (MCP)" : "CLI",
35
+ ...(action.repo === null ? [] : [basename(action.repo)]), ...(action.reason === null ? [] : [action.reason])];
36
+ return `<div class="row-item" data-action="${action.id}"><p><span class="outcome outcome-${action.outcome}">${label}</span>${e(action.name)}` +
37
+ `${action.taskId === null ? "" : ` · <a href="/t/${encodeURIComponent(action.taskId)}">${e(action.taskId)}</a>`}</p><p class="meta">${detail.map(e).join(" · ")}</p></div>`;
38
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Serving on a real domain (remote team, phase 3): which requests arrived safely enough to carry an API token,
3
+ * and when the console sends Strict-Transport-Security. Pure rules over what the socket and the one trusted proxy
4
+ * say; serve.ts applies them right after its Host check, and `toolroll serve check-public` probes for them.
5
+ *
6
+ * Trust comes from the socket first. Only a LOOPBACK peer is the documented same-host TLS proxy (Caddy, Cloudflare
7
+ * Tunnel), and only then are X-Forwarded-For (its last hop, via serve.ts's joinSourceOf) and X-Forwarded-Proto
8
+ * believed. Any other peer's forwarded headers are ignored: it reached us over plain HTTP from where it stands.
9
+ */
10
+ /** One year, this exact host only: no includeSubDomains, no preload, so sibling names are never pinned. */
11
+ export declare const HSTS_VALUE = "max-age=31536000";
12
+ /** What a token-bearing request over plain HTTP from elsewhere is told. */
13
+ export declare const USE_HTTPS = "Use HTTPS. API tokens are accepted over plain HTTP only from this computer or your tailnet; connect with the server's https:// address.";
14
+ /**
15
+ * The exact loopback peers serve.ts trusts as the same-host proxy (joinSourceOf, fromThisComputer). Not all of
16
+ * 127/8: a proxy listening on 127.0.0.2 must not make public callers look like this computer.
17
+ */
18
+ export declare const LOOPBACK_PEERS: ReadonlySet<string>;
19
+ /** One of LOOPBACK_PEERS (brackets, an IPv6 zone and case ignored). The only loopback test that grants trust. */
20
+ export declare function isLoopbackPeer(address: string): boolean;
21
+ /** 127.0.0.0/8, ::1 and their IPv4-mapped forms. For naming hosts only (public-check.ts); trust uses isLoopbackPeer. */
22
+ export declare function isLoopbackAddress(address: string): boolean;
23
+ /** Tailscale's ranges: 100.64.0.0/10 (and its IPv4-mapped form) and fd7a:115c:a1e0::/48. */
24
+ export declare function isTailnetAddress(address: string): boolean;
25
+ /** How one request reached the server, as far as it can be proved. */
26
+ export type Transport = {
27
+ /** The caller's address: the socket peer, or the trusted proxy's last X-Forwarded-For hop. */
28
+ source: string;
29
+ /** Proved to have arrived over HTTPS at a same-host proxy. */
30
+ https: boolean;
31
+ /** The caller is this computer or a tailnet address (WireGuard already encrypts it). */
32
+ privatePath: boolean;
33
+ };
34
+ /**
35
+ * The request's transport from its socket peer, the source serve.ts's joinSourceOf derived (`fwd:<hop>` only for a
36
+ * loopback peer) and the proxy headers. A loopback peer that forwards with `Forwarded` but no X-Forwarded-For names
37
+ * no caller we can read, so it is not treated as this computer.
38
+ */
39
+ export declare function transportOf(request: {
40
+ peer: string | undefined;
41
+ joinSource: string;
42
+ forwardedProto: string | string[] | undefined;
43
+ forwarded: string | string[] | undefined;
44
+ }): Transport;
45
+ /** Requests that carry, or exist to carry, an API token: the remote CLI, the MCP gateway, and any bearer credential. */
46
+ export declare function carriesToken(pathname: string, authorization: string | undefined): boolean;
47
+ /** The refusal for a token sent over plain HTTP from outside this computer and the tailnet, else null. */
48
+ export declare function plainHttpRefusal(transport: Transport, pathname: string, authorization: string | undefined): string | null;
49
+ /** The Strict-Transport-Security value for a request to the configured public https host that arrived over HTTPS. */
50
+ export declare function hstsFor(publicHost: string | null, host: string | undefined, transport: Transport): string | null;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Serving on a real domain (remote team, phase 3): which requests arrived safely enough to carry an API token,
3
+ * and when the console sends Strict-Transport-Security. Pure rules over what the socket and the one trusted proxy
4
+ * say; serve.ts applies them right after its Host check, and `toolroll serve check-public` probes for them.
5
+ *
6
+ * Trust comes from the socket first. Only a LOOPBACK peer is the documented same-host TLS proxy (Caddy, Cloudflare
7
+ * Tunnel), and only then are X-Forwarded-For (its last hop, via serve.ts's joinSourceOf) and X-Forwarded-Proto
8
+ * believed. Any other peer's forwarded headers are ignored: it reached us over plain HTTP from where it stands.
9
+ */
10
+ /** One year, this exact host only: no includeSubDomains, no preload, so sibling names are never pinned. */
11
+ export const HSTS_VALUE = "max-age=31536000";
12
+ /** What a token-bearing request over plain HTTP from elsewhere is told. */
13
+ export const USE_HTTPS = "Use HTTPS. API tokens are accepted over plain HTTP only from this computer or your tailnet; connect with the server's https:// address.";
14
+ /** IPv4 dotted quad from an IPv4 or IPv4-mapped IPv6 address, else null. */
15
+ function ipv4Of(address) {
16
+ const bare = address.replace(/^::ffff:/i, "");
17
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(bare);
18
+ if (match === null)
19
+ return null;
20
+ const parts = match.slice(1).map(Number);
21
+ return parts.every(part => part <= 255) ? parts : null;
22
+ }
23
+ /** Strips brackets and an IPv6 zone so `[::1]` and `fe80::1%en0` compare as addresses. */
24
+ const bareAddress = (address) => address.trim().replace(/^\[(.*)\]$/, "$1").replace(/%.*$/, "").toLowerCase();
25
+ /**
26
+ * The exact loopback peers serve.ts trusts as the same-host proxy (joinSourceOf, fromThisComputer). Not all of
27
+ * 127/8: a proxy listening on 127.0.0.2 must not make public callers look like this computer.
28
+ */
29
+ export const LOOPBACK_PEERS = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
30
+ /** One of LOOPBACK_PEERS (brackets, an IPv6 zone and case ignored). The only loopback test that grants trust. */
31
+ export function isLoopbackPeer(address) {
32
+ return LOOPBACK_PEERS.has(bareAddress(address));
33
+ }
34
+ /** 127.0.0.0/8, ::1 and their IPv4-mapped forms. For naming hosts only (public-check.ts); trust uses isLoopbackPeer. */
35
+ export function isLoopbackAddress(address) {
36
+ const bare = bareAddress(address);
37
+ const v4 = ipv4Of(bare);
38
+ if (v4 !== null)
39
+ return v4[0] === 127;
40
+ return bare === "::1" || bare === "0:0:0:0:0:0:0:1";
41
+ }
42
+ /** Tailscale's ranges: 100.64.0.0/10 (and its IPv4-mapped form) and fd7a:115c:a1e0::/48. */
43
+ export function isTailnetAddress(address) {
44
+ const bare = bareAddress(address);
45
+ const v4 = ipv4Of(bare);
46
+ if (v4 !== null)
47
+ return v4[0] === 100 && v4[1] >= 64 && v4[1] <= 127;
48
+ return /^fd7a:115c:a1e0(:|$)/.test(bare);
49
+ }
50
+ /** The last value of a possibly repeated, comma-separated header (the one the trusted proxy itself set). */
51
+ const lastValue = (header) => {
52
+ const joined = Array.isArray(header) ? header.join(",") : header ?? "";
53
+ return joined.split(",").pop()?.trim().toLowerCase() ?? "";
54
+ };
55
+ /**
56
+ * The request's transport from its socket peer, the source serve.ts's joinSourceOf derived (`fwd:<hop>` only for a
57
+ * loopback peer) and the proxy headers. A loopback peer that forwards with `Forwarded` but no X-Forwarded-For names
58
+ * no caller we can read, so it is not treated as this computer.
59
+ */
60
+ export function transportOf(request) {
61
+ const peer = request.peer ?? "";
62
+ if (!isLoopbackPeer(peer)) {
63
+ return { source: peer, https: false, privatePath: isTailnetAddress(peer) };
64
+ }
65
+ const relayed = request.joinSource.startsWith("fwd:");
66
+ const source = relayed ? request.joinSource.slice(4) : peer;
67
+ const https = lastValue(request.forwardedProto) === "https";
68
+ if (!relayed && request.forwarded !== undefined)
69
+ return { source: "unknown", https, privatePath: false };
70
+ return { source, https, privatePath: isLoopbackPeer(source) || isTailnetAddress(source) };
71
+ }
72
+ /** Requests that carry, or exist to carry, an API token: the remote CLI, the MCP gateway, and any bearer credential. */
73
+ export function carriesToken(pathname, authorization) {
74
+ if (pathname === "/api/cli" || pathname.startsWith("/api/cli/") || pathname === "/mcp" || pathname.startsWith("/mcp/"))
75
+ return true;
76
+ return authorization !== undefined && /^bearer\s/i.test(authorization);
77
+ }
78
+ /** The refusal for a token sent over plain HTTP from outside this computer and the tailnet, else null. */
79
+ export function plainHttpRefusal(transport, pathname, authorization) {
80
+ if (transport.https || transport.privatePath)
81
+ return null;
82
+ return carriesToken(pathname, authorization) ? USE_HTTPS : null;
83
+ }
84
+ /** The Strict-Transport-Security value for a request to the configured public https host that arrived over HTTPS. */
85
+ export function hstsFor(publicHost, host, transport) {
86
+ return publicHost !== null && host === publicHost && transport.https ? HSTS_VALUE : null;
87
+ }
@@ -0,0 +1,37 @@
1
+ export type ProbeRequest = {
2
+ url: string;
3
+ headers: Record<string, string>;
4
+ };
5
+ export type ProbeAnswer = {
6
+ status: number;
7
+ headers: Record<string, string | string[] | undefined>;
8
+ body: string;
9
+ } | {
10
+ error: string;
11
+ };
12
+ /** One bounded GET: no credentials, no redirects. */
13
+ export type Probe = (request: ProbeRequest) => Promise<ProbeAnswer>;
14
+ export type Finding = {
15
+ check: "public-url" | "allow-host" | "local" | "host" | "forwarding" | "certificate" | "proxy" | "hsts";
16
+ message: string;
17
+ };
18
+ /** The default probe: node's http/https with the platform trust store, a timeout and a 4 KB body cap. */
19
+ export declare const networkProbe: Probe;
20
+ /** The exact https origin --public-url must be, as serve.ts requires it; else what is wrong with it. */
21
+ export declare function publicOriginOf(value: string | undefined): URL | string;
22
+ /** Every problem with serving on --public-url, in a fixed order; an empty list means ready. */
23
+ export declare function checkPublic(options: {
24
+ publicUrl: string | undefined;
25
+ port: number;
26
+ allowHosts: readonly string[];
27
+ }, probe?: Probe): Promise<Finding[]>;
28
+ /** The command: findings as lines (or one envelope), exit 0 when ready, 1 when not, 2 for a malformed --port. */
29
+ export declare function checkPublicCommand(flags: Map<string, string | true>, context: {
30
+ write: (line: string) => void;
31
+ json: boolean;
32
+ envelope: (payload: {
33
+ ok: boolean;
34
+ command: string;
35
+ } & Record<string, unknown>) => string;
36
+ probe?: Probe;
37
+ }): Promise<number>;