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.
- package/CHANGELOG.md +29 -0
- package/dist/api-tokens.d.ts +33 -0
- package/dist/api-tokens.js +39 -0
- package/dist/audit-cli.d.ts +17 -0
- package/dist/audit-cli.js +86 -0
- package/dist/cli-http.d.ts +10 -16
- package/dist/cli-http.js +12 -2
- package/dist/cli.js +7 -1
- package/dist/contracts/cli.d.ts +47 -0
- package/dist/contracts/cli.js +11 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/mcp-oauth.d.ts +53 -0
- package/dist/contracts/mcp-oauth.js +40 -0
- package/dist/credentials-ui.d.ts +5 -0
- package/dist/credentials-ui.js +16 -4
- package/dist/desktop-update.d.ts +2 -2
- package/dist/dispatch.d.ts +2 -2
- package/dist/dispatch.js +8 -4
- package/dist/export.js +4 -3
- package/dist/lead-status.d.ts +7 -3
- package/dist/lead-status.js +21 -12
- package/dist/mcp-http.d.ts +6 -1
- package/dist/mcp-http.js +22 -4
- package/dist/mcp-oauth.d.ts +82 -0
- package/dist/mcp-oauth.js +479 -0
- package/dist/mcp-person.js +1 -1
- package/dist/operate-remote-arguments.js +2 -0
- package/dist/operate-remote.d.ts +13 -1
- package/dist/operate-remote.js +30 -4
- package/dist/operate.d.ts +3 -0
- package/dist/operate.js +86 -12
- package/dist/people-audit-ui.d.ts +14 -0
- package/dist/people-audit-ui.js +38 -0
- package/dist/public-access.d.ts +50 -0
- package/dist/public-access.js +87 -0
- package/dist/public-check.d.ts +37 -0
- package/dist/public-check.js +170 -0
- package/dist/remote-audit.d.ts +79 -0
- package/dist/remote-audit.js +161 -0
- package/dist/remote-run.d.ts +4 -0
- package/dist/request-budget-ui.d.ts +11 -0
- package/dist/request-budget-ui.js +31 -0
- package/dist/request-budget.d.ts +96 -0
- package/dist/request-budget.js +227 -0
- package/dist/serve.d.ts +4 -1
- package/dist/serve.js +120 -13
- package/dist/store.d.ts +118 -2
- package/dist/store.js +236 -7
- package/dist/surface.js +19 -2
- package/dist/token-notices.d.ts +22 -0
- package/dist/token-notices.js +52 -0
- package/dist/tokens-cli.d.ts +21 -0
- package/dist/tokens-cli.js +184 -0
- package/dist/work-summary.d.ts +4 -1
- package/dist/work-summary.js +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
10760
|
-
assignment: assignmentBrief(assignmentOf(store, id, now,
|
|
10814
|
+
work: taskWorkSummaryOf(store, id, now, access),
|
|
10815
|
+
assignment: assignmentBrief(assignmentOf(store, id, now, access, context.evidenceRoot)),
|
|
10761
10816
|
ref: ref.id,
|
|
10762
|
-
|
|
10763
|
-
|
|
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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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>;
|