toolroll 0.9.45 → 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 (50) hide show
  1. package/CHANGELOG.md +18 -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 +5 -0
  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 +1 -1
  18. package/dist/export.js +4 -3
  19. package/dist/mcp-http.d.ts +6 -1
  20. package/dist/mcp-http.js +22 -4
  21. package/dist/mcp-oauth.d.ts +82 -0
  22. package/dist/mcp-oauth.js +479 -0
  23. package/dist/mcp-person.js +1 -1
  24. package/dist/operate-remote-arguments.js +2 -0
  25. package/dist/operate-remote.d.ts +1 -1
  26. package/dist/operate-remote.js +7 -1
  27. package/dist/operate.d.ts +3 -0
  28. package/dist/operate.js +52 -1
  29. package/dist/people-audit-ui.d.ts +14 -0
  30. package/dist/people-audit-ui.js +38 -0
  31. package/dist/public-access.d.ts +50 -0
  32. package/dist/public-access.js +87 -0
  33. package/dist/public-check.d.ts +37 -0
  34. package/dist/public-check.js +170 -0
  35. package/dist/remote-audit.d.ts +79 -0
  36. package/dist/remote-audit.js +161 -0
  37. package/dist/request-budget-ui.d.ts +11 -0
  38. package/dist/request-budget-ui.js +31 -0
  39. package/dist/request-budget.d.ts +96 -0
  40. package/dist/request-budget.js +227 -0
  41. package/dist/serve.d.ts +4 -1
  42. package/dist/serve.js +120 -13
  43. package/dist/store.d.ts +118 -2
  44. package/dist/store.js +236 -7
  45. package/dist/surface.js +19 -2
  46. package/dist/token-notices.d.ts +22 -0
  47. package/dist/token-notices.js +52 -0
  48. package/dist/tokens-cli.d.ts +21 -0
  49. package/dist/tokens-cli.js +184 -0
  50. package/package.json +1 -1
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";
@@ -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);
@@ -1070,6 +1089,8 @@ async function dispatch(command, positional, flags, context) {
1070
1089
  return taskCommand(positional, flags, context);
1071
1090
  case "ledger":
1072
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 }) });
1073
1094
  case "spend":
1074
1095
  return spendCommand(flags, context);
1075
1096
  case "budget":
@@ -1203,6 +1224,13 @@ async function dispatch(command, positional, flags, context) {
1203
1224
  return chatApprovalCommand(positional, flags, context);
1204
1225
  case "people":
1205
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
+ });
1206
1234
  case "keys":
1207
1235
  return keysCommand(positional, flags, context);
1208
1236
  case "setup":
@@ -5078,6 +5106,8 @@ async function startConsole(options) {
5078
5106
  server.on("close", stopMonitoring);
5079
5107
  // v105: budget alerts at 50/80/100 %, a pass a minute.
5080
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));
5081
5111
  // A paused provider's sign-in is checked every two minutes (no model, nothing spent); the pause lifts when it works.
5082
5112
  server.on("close", startSignInProbes(context.store, createConnectionChecker()));
5083
5113
  // Sprint 8: scheduled backups, a pass a minute; its lease also tells a restore that the console is running.
@@ -8637,6 +8667,8 @@ async function upCommand(flags, context) {
8637
8667
  return fail(write, json, "up", "usage", "--host is an address or a name", EXIT.usage);
8638
8668
  }
8639
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");
8640
8672
  const progress = (line) => {
8641
8673
  // v99: with TOOLROLL_LOG_FORMAT=json, each line is one JSON event on stderr for a log shipper.
8642
8674
  if (envValue(process.env, "LOG_FORMAT") === "json")
@@ -8863,6 +8895,7 @@ async function upCommand(flags, context) {
8863
8895
  host: hostFlag,
8864
8896
  port,
8865
8897
  ...(allowFlag === undefined ? {} : { allowedHosts: allowFlag.split(",").map(one => one.trim()).filter(one => one !== "") }),
8898
+ ...(publicUrlFlag === undefined ? {} : { publicUrl: publicUrlFlag }),
8866
8899
  localRunner: runnerName,
8867
8900
  poolRoot: pool,
8868
8901
  repos,
@@ -12209,6 +12242,24 @@ function verifyApproverByPassword(store, name, token, repos) {
12209
12242
  const remote = activeRemote();
12210
12243
  return approverByPassword(store, name, token, remote === null ? repos : repos.filter(repo => remote.allows(repo)));
12211
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
+ }
12212
12263
  /** Prompts: never inside a remote run — there is nobody at this machine's terminal to answer for the person. */
12213
12264
  function interactive() {
12214
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>;
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `toolroll serve check-public` (remote team, phase 3): is this server ready to be reached on its real domain?
3
+ * Read-only: it sends no credentials, follows no redirects and changes nothing. It checks the --public-url and
4
+ * --allow-host values, asks the console on this computer what it does with relayed requests, then fetches the public
5
+ * address with the platform's own certificate checks and reads what comes back. Each problem is one line saying
6
+ * what to change.
7
+ */
8
+ import { request as httpRequest } from "node:http";
9
+ import { request as httpsRequest } from "node:https";
10
+ import { HSTS_VALUE, isLoopbackAddress, isTailnetAddress, USE_HTTPS } from "./public-access.js";
11
+ const TIMEOUT_MS = 10_000;
12
+ /** A documentation address (RFC 5737): the remote caller the local probe pretends to relay for. */
13
+ const SAMPLE_CALLER = "203.0.113.10";
14
+ const HOST_GRAMMAR = /^[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$|^\[[0-9A-Fa-f:.]{2,45}\](:[0-9]{1,5})?$/;
15
+ /** The default probe: node's http/https with the platform trust store, a timeout and a 4 KB body cap. */
16
+ export const networkProbe = ({ url, headers }) => new Promise(resolve => {
17
+ const target = new URL(url);
18
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
19
+ const outgoing = send(target, { method: "GET", headers, timeout: TIMEOUT_MS }, incoming => {
20
+ let body = "";
21
+ incoming.setEncoding("utf8");
22
+ incoming.on("data", (chunk) => { if (body.length < 4096)
23
+ body += chunk; });
24
+ incoming.on("end", () => resolve({ status: incoming.statusCode ?? 0, headers: incoming.headers, body }));
25
+ incoming.on("error", error => resolve({ error: codeOf(error) }));
26
+ });
27
+ outgoing.on("timeout", () => { outgoing.destroy(); resolve({ error: "ETIMEDOUT" }); });
28
+ outgoing.on("error", error => resolve({ error: codeOf(error) }));
29
+ outgoing.end();
30
+ });
31
+ const codeOf = (error) => typeof error === "object" && error !== null && typeof error.code === "string" ? error.code : "EUNKNOWN";
32
+ /** The exact https origin --public-url must be, as serve.ts requires it; else what is wrong with it. */
33
+ export function publicOriginOf(value) {
34
+ if (value === undefined)
35
+ return "--public-url is missing. Pass the address engineers will use, like --public-url https://toolroll.example.com.";
36
+ let parsed;
37
+ try {
38
+ parsed = new URL(value);
39
+ }
40
+ catch {
41
+ return `--public-url ${value} is not a URL. Use https://<your domain>.`;
42
+ }
43
+ if (parsed.protocol !== "https:")
44
+ return `--public-url must start with https:// (got ${parsed.protocol}//).`;
45
+ if (parsed.username !== "" || parsed.password !== "" || parsed.hash !== "" || parsed.search !== "" || (parsed.pathname !== "/" && parsed.pathname !== "")) {
46
+ return "--public-url must be just https://<host>, with no path, query or credentials.";
47
+ }
48
+ return parsed;
49
+ }
50
+ /** Names a plain-HTTP browser can reasonably use: this computer, the tailnet, a LAN or a single-label name. */
51
+ function privateName(host) {
52
+ const name = host.replace(/:[0-9]{1,5}$/, "").replace(/^\[(.*)\]$/, "$1").toLowerCase();
53
+ if (name === "localhost" || name.endsWith(".ts.net") || name.endsWith(".local") || !name.includes(".") && !name.includes(":"))
54
+ return true;
55
+ if (isLoopbackAddress(name) || isTailnetAddress(name))
56
+ return true;
57
+ const v4 = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(name);
58
+ if (v4 !== null) {
59
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
60
+ return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
61
+ }
62
+ return /^f[cd][0-9a-f]{2}:|^fe80:/.test(name);
63
+ }
64
+ const header = (answer, name) => {
65
+ const value = answer.headers[name];
66
+ return Array.isArray(value) ? value.join(", ") : value;
67
+ };
68
+ /** The HSTS max-age a response sets, or null when it sets none. */
69
+ const hstsAge = (value) => {
70
+ const match = value === undefined ? null : /(?:^|;)\s*max-age\s*=\s*"?(\d+)"?/i.exec(value);
71
+ return match === null ? null : Number(match[1]);
72
+ };
73
+ const refusedForHttp = (answer) => !("error" in answer) && answer.status === 403 && answer.body.trim() === USE_HTTPS;
74
+ function certificateProblem(host, code) {
75
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN")
76
+ return `${host} does not resolve. Point its DNS at this server or your tunnel.`;
77
+ if (code === "ECONNREFUSED")
78
+ return `Nothing answers HTTPS at ${host}. Start Caddy or the tunnel.`;
79
+ if (code === "ETIMEDOUT" || code === "ECONNRESET")
80
+ return `${host} did not answer over HTTPS within ${TIMEOUT_MS / 1000} seconds. Check the firewall, Caddy or the tunnel.`;
81
+ if (code === "CERT_HAS_EXPIRED")
82
+ return `The certificate for ${host} has expired. Renew it.`;
83
+ if (code === "ERR_TLS_CERT_ALTNAME_INVALID")
84
+ return `The certificate does not name ${host}. Issue one for this exact host.`;
85
+ if (/SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER|CERT_UNTRUSTED/.test(code))
86
+ return `The certificate for ${host} is not trusted (${code}). Use a publicly trusted certificate, such as Caddy's automatic one.`;
87
+ return `Could not reach https://${host} (${code}).`;
88
+ }
89
+ /** Every problem with serving on --public-url, in a fixed order; an empty list means ready. */
90
+ export async function checkPublic(options, probe = networkProbe) {
91
+ const origin = publicOriginOf(options.publicUrl);
92
+ if (typeof origin === "string")
93
+ return [{ check: "public-url", message: origin }];
94
+ const host = origin.host;
95
+ const findings = [];
96
+ for (const allowed of options.allowHosts) {
97
+ if (!HOST_GRAMMAR.test(allowed))
98
+ findings.push({ check: "allow-host", message: `--allow-host ${allowed} is not a host name or host:port.` });
99
+ else if (allowed !== host && !privateName(allowed)) {
100
+ findings.push({ check: "allow-host", message: `--allow-host ${allowed} is a public name served over plain HTTP, so passwords would cross the internet unencrypted. Remove it: --public-url already admits ${host}.` });
101
+ }
102
+ }
103
+ // The console on this computer, asked as the same-host proxy would ask it.
104
+ const local = `http://127.0.0.1:${options.port}`;
105
+ const relayed = { host, "x-forwarded-for": SAMPLE_CALLER };
106
+ const page = await probe({ url: `${local}/login`, headers: { ...relayed, "x-forwarded-proto": "https" } });
107
+ if ("error" in page) {
108
+ findings.push({ check: "local", message: `Nothing answers on 127.0.0.1:${options.port}. Start Toolroll there, or pass the --port it uses.` });
109
+ }
110
+ else if (page.status === 421) {
111
+ findings.push({ check: "host", message: `The server on port ${options.port} does not answer as ${host}. Restart it with --public-url ${origin.origin}.` });
112
+ }
113
+ else {
114
+ if (header(page, "strict-transport-security") !== HSTS_VALUE) {
115
+ findings.push({ check: "hsts", message: `The server sends no Strict-Transport-Security for ${host} over HTTPS. Update Toolroll and restart it with --public-url ${origin.origin}.` });
116
+ }
117
+ const plain = await probe({ url: `${local}/api/cli`, headers: { ...relayed, "x-forwarded-proto": "http" } });
118
+ if (!refusedForHttp(plain)) {
119
+ findings.push({ check: "forwarding", message: "The server does not refuse API tokens relayed over plain HTTP from another computer. Update Toolroll." });
120
+ }
121
+ }
122
+ // The public address, with the platform's certificate checks.
123
+ const outside = await probe({ url: `${origin.origin}/login`, headers: {} });
124
+ if ("error" in outside) {
125
+ findings.push({ check: "certificate", message: certificateProblem(host, outside.error) });
126
+ return findings;
127
+ }
128
+ if (outside.status === 421) {
129
+ findings.push({ check: "proxy", message: `The proxy changes the Host header, so Toolroll refuses ${host}. Pass the original Host through.` });
130
+ return findings;
131
+ }
132
+ const age = hstsAge(header(outside, "strict-transport-security"));
133
+ if (age === null) {
134
+ findings.push({ check: "hsts", message: `https://${host} sends no Strict-Transport-Security. Run the proxy on this computer and have it send X-Forwarded-Proto: https.` });
135
+ }
136
+ else if (age < 31_536_000) {
137
+ findings.push({ check: "hsts", message: `https://${host} sets Strict-Transport-Security max-age=${age}; use at least 31536000 (one year).` });
138
+ }
139
+ const api = await probe({ url: `${origin.origin}/api/cli`, headers: {} });
140
+ if (refusedForHttp(api)) {
141
+ findings.push({ check: "proxy", message: `Requests through https://${host} reach Toolroll as plain HTTP from another computer. Run the proxy on this computer, pointed at 127.0.0.1:${options.port}, sending X-Forwarded-For and X-Forwarded-Proto.` });
142
+ }
143
+ return findings;
144
+ }
145
+ /** The command: findings as lines (or one envelope), exit 0 when ready, 1 when not, 2 for a malformed --port. */
146
+ export async function checkPublicCommand(flags, context) {
147
+ const command = "serve check-public";
148
+ const value = (name) => { const one = flags.get(name); return typeof one === "string" ? one : undefined; };
149
+ const portGiven = value("port");
150
+ const port = Number(portGiven ?? 4180);
151
+ if (!Number.isInteger(port) || port < 1 || port >= 65536) {
152
+ const message = "--port is a whole number under 65536";
153
+ context.write(context.json ? context.envelope({ ok: false, command, reason: "usage", message }) : message);
154
+ return 2;
155
+ }
156
+ const publicUrl = value("public-url");
157
+ const allowHosts = (value("allow-host") ?? "").split(",").map(one => one.trim()).filter(one => one !== "");
158
+ const findings = await checkPublic({ publicUrl, port, allowHosts }, context.probe);
159
+ const ready = findings.length === 0;
160
+ const message = ready ? `${publicUrl} is ready: valid certificate, HSTS, and API tokens only over HTTPS.` : `${findings.length === 1 ? "1 problem" : `${findings.length} problems`} serving ${publicUrl ?? "on a public address"}:`;
161
+ if (context.json) {
162
+ context.write(context.envelope({ ok: ready, command, ...(ready ? {} : { reason: "not-ready", message }), publicUrl: publicUrl ?? null, port, findings }));
163
+ }
164
+ else {
165
+ context.write(message);
166
+ for (const finding of findings)
167
+ context.write(`- ${finding.message}`);
168
+ }
169
+ return ready ? 0 : 1;
170
+ }
@@ -0,0 +1,79 @@
1
+ import type { ApiTokenRow, Store } from "./store.js";
2
+ export declare const AUDIT_SOURCES: readonly ["api", "mcp"];
3
+ export type AuditSource = typeof AUDIT_SOURCES[number];
4
+ export type AuditOutcome = "ok" | "refused" | "error";
5
+ export declare const AUDIT_LIMIT: {
6
+ readonly default: 20;
7
+ readonly max: 100;
8
+ };
9
+ export type AuditFilters = {
10
+ person: string | null;
11
+ token: string | null;
12
+ source: AuditSource | null;
13
+ since: string | null;
14
+ };
15
+ /** One remote action. `kind` says whether a person's CLI ran a command or their agent called a tool over MCP. */
16
+ export type AuditAction = {
17
+ id: number;
18
+ at: string;
19
+ person: string;
20
+ token: string | null;
21
+ source: AuditSource;
22
+ kind: "command" | "tool";
23
+ /** The command row (`task show`), or for a tool call the tool's name when the line recorded it. */
24
+ name: string;
25
+ command: string;
26
+ tool: string | null;
27
+ repo: string | null;
28
+ taskId: string | null;
29
+ outcome: AuditOutcome;
30
+ /** A refusal's stable reason (`read-only`, `not-found`, ...), never free text. */
31
+ reason: string | null;
32
+ };
33
+ /**
34
+ * Who is reading. `self` null: the machine's own owner, reading the database here. Otherwise a person:
35
+ * `everyone` when they approve; `repos` their project ceiling (null: every project); `unplaced` whether lines in no
36
+ * project (refused before a project was known) are theirs to see for other people.
37
+ */
38
+ export type AuditReader = {
39
+ self: string | null;
40
+ everyone: boolean;
41
+ repos: readonly string[] | null;
42
+ unplaced: boolean;
43
+ };
44
+ export declare const OWNER_READER: AuditReader;
45
+ export type AuditPage = {
46
+ ok: true;
47
+ actions: AuditAction[];
48
+ nextCursor: string | null;
49
+ limit: number;
50
+ } | {
51
+ ok: false;
52
+ reason: "not-found";
53
+ message: string;
54
+ };
55
+ /** The old final words, in the vocabulary people read. Anything unrecognized reads as an error, never as ok. */
56
+ export declare function auditOutcome(outcome: string): AuditOutcome | null;
57
+ /** `7d` → the instant that many days before `now`; null for anything else (no zero, sign, fraction or other unit). */
58
+ export declare function sinceOf(value: string, now: Date): string | null;
59
+ export declare const cursorOf: (value: string) => number | null;
60
+ /**
61
+ * A line's token, tool and reason. The detail is `token <name>` then ` · `-separated parts: the token name is the
62
+ * longest of this person's token names (or ids, for a line written before the token was proved) that matches up to a
63
+ * separator, since a name may itself contain ` · `. Unmatched text is never echoed.
64
+ */
65
+ export declare function readDetail(detail: string | null, tokens: readonly Pick<ApiTokenRow, "id" | "name">[]): {
66
+ token: string | null;
67
+ tool: string | null;
68
+ reason: string | null;
69
+ };
70
+ /**
71
+ * One page of remote actions, newest first, keyed by entry id so new lines never shift an older page. The reader's
72
+ * visibility and every filter are applied before the page is cut; the token filter is an exact name match.
73
+ */
74
+ export declare function remoteAudit(store: Store, reader: AuditReader, filters: AuditFilters, page?: {
75
+ limit?: number;
76
+ before?: number | null;
77
+ }): AuditPage;
78
+ /** A token's standing, for People. */
79
+ export declare function tokenStatus(token: Pick<ApiTokenRow, "revokedAt" | "expiresAt">, now: Date): "active" | "expired" | "revoked";