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/dispatch.d.ts
CHANGED
|
@@ -94,8 +94,8 @@ export declare function scopeApprovedForDispatch(store: Store, taskRef: number,
|
|
|
94
94
|
export declare function dispatchRoleFor(store: Store, taskRef: number, now: Date): "builder" | "planner" | "scout";
|
|
95
95
|
/** The first requirement this task fails, in words, or null. */
|
|
96
96
|
export declare function missingCapability(store: Store, taskRef: number, dispatchRepo: string | null, now: Date): string | null;
|
|
97
|
-
/** The read-side lifecycle answer.
|
|
98
|
-
export declare function diagnoseTaskDispatch(store: Store, taskId: string, now: Date): DispatchDiagnosis | null;
|
|
97
|
+
/** The read-side lifecycle answer. Project access limits dependency detail, never the readiness gate. */
|
|
98
|
+
export declare function diagnoseTaskDispatch(store: Store, taskId: string, now: Date, readableRepos?: readonly string[] | null): DispatchDiagnosis | null;
|
|
99
99
|
export declare function diagnosisIsDispatchable(diagnosis: DispatchDiagnosis | null): boolean;
|
|
100
100
|
/** Attach the same lifecycle answer to the bounded fleet snapshot used by
|
|
101
101
|
* both direct chat and the long-running mate. */
|
package/dist/dispatch.js
CHANGED
|
@@ -163,8 +163,8 @@ function approvedProfile(scope) {
|
|
|
163
163
|
return null;
|
|
164
164
|
return scope.approvedProfile ?? scope.profile ?? null;
|
|
165
165
|
}
|
|
166
|
-
/** The read-side lifecycle answer.
|
|
167
|
-
export function diagnoseTaskDispatch(store, taskId, now) {
|
|
166
|
+
/** The read-side lifecycle answer. Project access limits dependency detail, never the readiness gate. */
|
|
167
|
+
export function diagnoseTaskDispatch(store, taskId, now, readableRepos = null) {
|
|
168
168
|
const task = store.getTask(taskId);
|
|
169
169
|
const ref = store.lookupRef(taskId);
|
|
170
170
|
if (task === null || ref === null)
|
|
@@ -251,9 +251,13 @@ export function diagnoseTaskDispatch(store, taskId, now) {
|
|
|
251
251
|
}
|
|
252
252
|
if (local?.code === "dependency") {
|
|
253
253
|
const terminal = local.blockerState === "failed" || local.blockerState === "cancelled";
|
|
254
|
+
const blockerRepo = readableRepos === null ? null : store.lookupRef(local.blockerId)?.repo;
|
|
255
|
+
const hidden = readableRepos !== null && (blockerRepo == null || !readableRepos.includes(blockerRepo));
|
|
256
|
+
const blocker = hidden ? "A task in another project" : local.blockerId;
|
|
257
|
+
const blockerTaskId = hidden ? null : local.blockerId;
|
|
254
258
|
return terminal
|
|
255
|
-
? answer("terminal-dependency", "waiting", "A required task did not finish", `${
|
|
256
|
-
: answer("waiting-dependency", "waiting", "Waiting for another task", `${
|
|
259
|
+
? answer("terminal-dependency", "waiting", "A required task did not finish", `${blocker} ${local.blockerState === "cancelled" ? "was cancelled" : "failed"} before it finished.`, { action: "repair-dependency", blockerTaskId })
|
|
260
|
+
: answer("waiting-dependency", "waiting", "Waiting for another task", `${blocker} must finish before this task can start.`, { blockerTaskId });
|
|
257
261
|
}
|
|
258
262
|
const role = dispatchRoleFor(store, ref.id, now);
|
|
259
263
|
const scope = store.getScope(taskId);
|
package/dist/export.js
CHANGED
|
@@ -29,14 +29,14 @@ import { readEmailSettings } from "./email-settings.js";
|
|
|
29
29
|
import { SUBSCRIPTION_CAPABLE, readAuthMode } from "./keys.js";
|
|
30
30
|
export const EXPORT_FORMAT = "standing-orders/export/v1";
|
|
31
31
|
/** Tables that hold nothing but secrets or short-lived sign-in material, and derived search indexes. */
|
|
32
|
-
const SECRET_TABLES = new Set(["web_session", "ceremony_nonce", "telegram_pairing", "slack_pair", "discord_pair", "teams_pair"]);
|
|
32
|
+
const SECRET_TABLES = new Set(["web_session", "ceremony_nonce", "telegram_pairing", "slack_pair", "discord_pair", "teams_pair", "oauth_code", "oauth_refresh"]);
|
|
33
33
|
const DERIVED_TABLE = /^(sqlite_|memory_search)/;
|
|
34
34
|
/** Columns that hold a secret (or what stands in for one): never exported. */
|
|
35
35
|
const SECRET_COLUMNS = new Set([
|
|
36
36
|
"api_token.secret_hash", "lead_credential.secret_hash", "approver.credential_hash", "coordinator_credential.credential_hash", "runner.credential_hash", "invite.token_hash",
|
|
37
37
|
"flow_trigger.hook_hash", "held_session.cookie", "push_subscription.endpoint", "push_subscription.p256dh", "push_subscription.auth",
|
|
38
38
|
"chat_turn.credential_key", "mate_session.credential_key", "mate_turn.credential_key", "bridge_lease.push_url", "workflow_preview.token",
|
|
39
|
-
"quota.credential_fp",
|
|
39
|
+
"quota.credential_fp", "oauth_grant.renew_hash", "oauth_client.source_hash",
|
|
40
40
|
]);
|
|
41
41
|
/** A column named like this holds a secret unless it is reviewed below; an unreviewed one is dropped. */
|
|
42
42
|
const SECRET_NAME = /hash|secret|token|passw|cookie|csrf|credential|p256dh|^auth$|key|nonce|endpoint|url/i;
|
|
@@ -56,7 +56,7 @@ export function categoryOf(table) {
|
|
|
56
56
|
[/^teammate/, "teammates"],
|
|
57
57
|
[/^(flow|routine|workflow_)/, "flows"],
|
|
58
58
|
[/^(chat_|mate_|team_|telegram_|slack_|discord_|teams_|notification|push_|bridge_lease)/, "chats"],
|
|
59
|
-
[/^(approver|api_token|lead_credential|invite|sso_identity|coordinator_)/, "people"],
|
|
59
|
+
[/^(approver|api_token|lead_credential|invite|sso_identity|coordinator_|oauth_)/, "people"],
|
|
60
60
|
[/^(run|artifact|claim|worktree$|execution_slot|contest|tournament_terms|fallback_cycle|fallback_transition|held_session|session_turn|attended_authorization|criterion_review|diff_comment|proof_|review_request|repair_chain|incident|publication$|merge_|side_spend|knowledge_snapshot|learning_snapshot|skill_snapshot)/, "runs"],
|
|
61
61
|
[/^(task|hold$|plan_|scope_|tool_seal|decision|external_|skill_test)/, "tasks"],
|
|
62
62
|
[/^(project|approval_policy|capability|verify_command|worktree_setup|backend_grant|intake_grant|publication_grant|operating_mode|knowledge_|learning_|memory_|skill_)/, "projects"],
|
|
@@ -67,6 +67,7 @@ export function categoryOf(table) {
|
|
|
67
67
|
/** Extra shapes beside the evidence scanner's: this installation's own tokens and password hashes, and bearer headers. */
|
|
68
68
|
const EXTRA_SHAPES = [
|
|
69
69
|
/\bso_[a-f0-9]{12}_[A-Za-z0-9_-]{43}/g,
|
|
70
|
+
/\bsor_[a-f0-9]{12}_[A-Za-z0-9_-]{43}/g,
|
|
70
71
|
/\blt_[a-f0-9]{12}_[A-Za-z0-9_-]{43}/g,
|
|
71
72
|
/\bscrypt\$[^\s"',;]+/g,
|
|
72
73
|
/\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/gi,
|
package/dist/lead-status.d.ts
CHANGED
|
@@ -101,7 +101,11 @@ export declare function repairStaleStatuses(store: Store, now: Date): {
|
|
|
101
101
|
exitsRecorded: number;
|
|
102
102
|
readyForReview: number;
|
|
103
103
|
};
|
|
104
|
-
/** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead.
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
/** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead.
|
|
105
|
+
* `repos`: the only projects to report (a remote person limited to some projects); null is the whole installation.
|
|
106
|
+
* A limited report counts only those projects' tasks and leaves out what belongs to the installation as a whole:
|
|
107
|
+
* the release check, plan windows and the lead line are empty, never another project's. */
|
|
108
|
+
export declare function installationStatus(store: Store, now: Date, viewer?: string | null, repos?: readonly string[] | null): InstallationStatus;
|
|
109
|
+
/** `limited`: a report over some projects (installationStatus's `repos`), which has no release check or plan windows to state. */
|
|
110
|
+
export declare function renderInstallationStatus(status: InstallationStatus, limited?: boolean): string[];
|
|
107
111
|
export declare function renderTaskWait(snapshot: TaskWaitSnapshot, outcome?: string): string;
|
package/dist/lead-status.js
CHANGED
|
@@ -181,15 +181,20 @@ export function repairStaleStatuses(store, now) {
|
|
|
181
181
|
const exitsRecorded = store.recordFinishedRunExits(now);
|
|
182
182
|
return { exitsRecorded, readyForReview: workReadyForReview(store, now, { principal: "operator", repos: null, includeUnplaced: true }, 0).count };
|
|
183
183
|
}
|
|
184
|
-
/** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead.
|
|
185
|
-
|
|
186
|
-
|
|
184
|
+
/** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead.
|
|
185
|
+
* `repos`: the only projects to report (a remote person limited to some projects); null is the whole installation.
|
|
186
|
+
* A limited report counts only those projects' tasks and leaves out what belongs to the installation as a whole:
|
|
187
|
+
* the release check, plan windows and the lead line are empty, never another project's. */
|
|
188
|
+
export function installationStatus(store, now, viewer = currentActor()?.account ?? null, repos = null) {
|
|
189
|
+
const admitted = (row) => repos === null || typeof row["repo"] === "string" && repos.includes(row["repo"]);
|
|
190
|
+
const access = { principal: "operator", repos, includeUnplaced: repos === null };
|
|
191
|
+
const runningRows = store.handle.prepare(`SELECT run.id, run.role, run.phase, ref.id AS task_ref, ref.external_id AS task, ref.repo
|
|
187
192
|
FROM run INDEXED BY work_unfinished
|
|
188
193
|
JOIN task_ref AS ref ON ref.id = run.task_ref
|
|
189
194
|
JOIN claim ON claim.lease_id = run.lease_id
|
|
190
195
|
WHERE run.outcome IS NULL AND claim.released_at IS NULL AND claim.expires_at > ?
|
|
191
196
|
AND claim.lease_generation = (SELECT MAX(newest.lease_generation) FROM claim AS newest INDEXED BY claim_by_task WHERE newest.task_ref = run.task_ref)
|
|
192
|
-
ORDER BY run.id`).all(now.toISOString());
|
|
197
|
+
ORDER BY run.id`).all(now.toISOString()).filter(admitted);
|
|
193
198
|
const phases = new Map();
|
|
194
199
|
const runningTasks = runningRows.map(row => {
|
|
195
200
|
const phase = phaseWords(phaseOf(row));
|
|
@@ -197,7 +202,7 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
197
202
|
return { task: String(row["task"]), run: Number(row["id"]), phase };
|
|
198
203
|
});
|
|
199
204
|
const runningRefs = new Set(runningRows.map(row => Number(row["task_ref"])));
|
|
200
|
-
const queuedRows = store.handle.prepare(`SELECT task.id, ref.id AS task_ref, ref.plan, ref.strikes, scope.digest AS scope_digest,
|
|
205
|
+
const queuedRows = store.handle.prepare(`SELECT task.id, ref.id AS task_ref, ref.repo, ref.plan, ref.strikes, scope.digest AS scope_digest,
|
|
201
206
|
scope.profile_state,
|
|
202
207
|
(scope.approved_at IS NOT NULL AND scope.approved_by IS NOT NULL AND scope.approved_digest = scope.digest) AS approved,
|
|
203
208
|
EXISTS (SELECT 1 FROM task_edge WHERE task_edge.blocked = task.id
|
|
@@ -208,7 +213,7 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
208
213
|
JOIN task_ref AS ref INDEXED BY sqlite_autoindex_task_ref_1 ON ref.backend = ? AND ref.external_id = task.id
|
|
209
214
|
LEFT JOIN task_scope AS scope ON scope.task_id = task.id
|
|
210
215
|
WHERE task.state = 'queued'`).all(now.toISOString(), BUILT_IN)
|
|
211
|
-
.filter(row => !runningRefs.has(Number(row["task_ref"])));
|
|
216
|
+
.filter(row => !runningRefs.has(Number(row["task_ref"])) && admitted(row));
|
|
212
217
|
const reasons = new Map();
|
|
213
218
|
const queuedTasks = [];
|
|
214
219
|
const pauses = openAuthPauses(store);
|
|
@@ -227,11 +232,11 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
227
232
|
}
|
|
228
233
|
// One per family, from the same projection as the Tasks list: a completed
|
|
229
234
|
// family (root or any revision) and a superseded version never count.
|
|
230
|
-
const ready = workReadyForReview(store, now,
|
|
235
|
+
const ready = workReadyForReview(store, now, access, 5);
|
|
231
236
|
const checkRow = (run) => store.handle.prepare(`SELECT ${CHECK_COLUMNS} FROM run_check AS rc WHERE rc.run = ?`).get(run);
|
|
232
237
|
// Run ids grow with time, so the newest finished release check is the
|
|
233
238
|
// first finished row down the partial index.
|
|
234
|
-
const release = store.handle.prepare(`SELECT run.id, run.finished_at, ${CHECK_COLUMNS}, ref.external_id AS task
|
|
239
|
+
const release = repos !== null ? undefined : store.handle.prepare(`SELECT run.id, run.finished_at, ${CHECK_COLUMNS}, ref.external_id AS task
|
|
235
240
|
FROM run_check AS rc INDEXED BY run_check_release
|
|
236
241
|
JOIN run ON run.id = rc.run
|
|
237
242
|
JOIN task_ref AS ref ON ref.id = run.task_ref
|
|
@@ -247,7 +252,7 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
247
252
|
},
|
|
248
253
|
waitingForReview: { count: ready.count, results: ready.results.map(one => ({ task: one.taskId, run: one.run, check: checkOf(one.run === null ? undefined : checkRow(one.run)) })) },
|
|
249
254
|
releaseCheck: release === undefined ? null : { task: String(release["task"]), run: Number(release["id"]), finishedAt: release["finished_at"] == null ? null : String(release["finished_at"]), check: checkOf(release) },
|
|
250
|
-
planWindows: store.handle.prepare(`SELECT provider, window, used_percent, window_minutes, resets_at, reached, plan, observed_at
|
|
255
|
+
planWindows: repos !== null ? [] : store.handle.prepare(`SELECT provider, window, used_percent, window_minutes, resets_at, reached, plan, observed_at
|
|
251
256
|
FROM provider_limit INDEXED BY sqlite_autoindex_provider_limit_1 ORDER BY provider, window`).all().map(row => ({
|
|
252
257
|
provider: String(row["provider"]),
|
|
253
258
|
plan: row["plan"] == null ? null : String(row["plan"]),
|
|
@@ -259,16 +264,17 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
259
264
|
observedAt: String(row["observed_at"]),
|
|
260
265
|
})),
|
|
261
266
|
signIn: pauses.map(one => ({ provider: one.provider, reason: signInReason(one), command: signInCommand(one), since: one.openedAt })),
|
|
262
|
-
tasks: workIndexPage(store, now, {
|
|
267
|
+
tasks: workIndexPage(store, now, { ...access, viewer }, { limit: 8 }).items
|
|
263
268
|
.map(one => ({ task: one.rootId, title: one.title, headline: one.status.label, sentence: one.status.detail })),
|
|
264
|
-
lead: (() => {
|
|
269
|
+
lead: repos !== null ? null : (() => {
|
|
265
270
|
// Only the asker's own lead: another person's lead is never "Your lead".
|
|
266
271
|
const activity = leadActivity(store, viewer);
|
|
267
272
|
return activity === null ? null : { owner: activity.owner, doing: activity.doing, at: activity.at, task: activity.taskId, line: leadActivityLine(activity, now) };
|
|
268
273
|
})(),
|
|
269
274
|
};
|
|
270
275
|
}
|
|
271
|
-
|
|
276
|
+
/** `limited`: a report over some projects (installationStatus's `repos`), which has no release check or plan windows to state. */
|
|
277
|
+
export function renderInstallationStatus(status, limited = false) {
|
|
272
278
|
const lines = [];
|
|
273
279
|
// A paused provider comes first: it is the one thing a person must do.
|
|
274
280
|
for (const one of status.signIn)
|
|
@@ -284,6 +290,9 @@ export function renderInstallationStatus(status) {
|
|
|
284
290
|
lines.push(status.running.count === 0 ? "Building: none" : `Building: ${status.running.count} — ${status.running.tasks.map(one => `${one.task} (#${one.run}, ${one.phase})`).join(", ")}${status.running.count > status.running.tasks.length ? ", …" : ""}`);
|
|
285
291
|
lines.push(status.queued.count === 0 ? "Queued: none" : `Queued: ${status.queued.count} — ${status.queued.tasks.map(one => `${one.task} (${one.reason})`).join(", ")}${status.queued.count > status.queued.tasks.length ? ", …" : ""}`);
|
|
286
292
|
lines.push(status.waitingForReview.count === 0 ? "Ready for review: none" : `Ready for review: ${status.waitingForReview.count} — ${status.waitingForReview.results.map(one => one.run === null ? one.task : `${one.task} (#${one.run})`).join(", ")}${status.waitingForReview.count > status.waitingForReview.results.length ? ", …" : ""}`);
|
|
293
|
+
// Neither the release check nor plan windows belong to a person's projects: a limited report leaves both out.
|
|
294
|
+
if (limited)
|
|
295
|
+
return lines;
|
|
287
296
|
if (status.releaseCheck === null) {
|
|
288
297
|
lines.push("Release check: none recorded");
|
|
289
298
|
}
|
package/dist/mcp-http.d.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
13
13
|
import type { Store } from "./store.js";
|
|
14
|
-
import { type RunOperateAs } from "./mcp-person.js";
|
|
14
|
+
import { type Person, type RunOperateAs } from "./mcp-person.js";
|
|
15
|
+
import { type Admission } from "./request-budget.js";
|
|
15
16
|
export type McpHttpOptions = {
|
|
16
17
|
store: Store;
|
|
17
18
|
clock: () => Date;
|
|
@@ -20,7 +21,11 @@ export type McpHttpOptions = {
|
|
|
20
21
|
evidenceRoot?: string;
|
|
21
22
|
/** The console's own sign-in for an `so_` bearer: the address's tries, expiry, revocation, a removed account. */
|
|
22
23
|
signedIn: (request: IncomingMessage) => boolean;
|
|
24
|
+
/** Where an MCP client finds how to sign in (mcp-oauth.ts), named on a 401; null when this address offers none. */
|
|
25
|
+
resourceMetadata?: (request: IncomingMessage) => string | null;
|
|
23
26
|
/** Runs a person's command on the server (operate.ts); resolved from operate.ts when not injected. */
|
|
24
27
|
runAs?: RunOperateAs;
|
|
28
|
+
/** A person token's request budget (request-budget.ts), charged once per request before its body is read. */
|
|
29
|
+
admit?: (person: Person) => Admission;
|
|
25
30
|
};
|
|
26
31
|
export declare function createMcpHttp(options: McpHttpOptions): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
|
package/dist/mcp-http.js
CHANGED
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
* Host check runs before this route. A revoked or expired token answers 401 on its next request.
|
|
11
11
|
*/
|
|
12
12
|
import { PACKAGE_VERSION } from "./version.js";
|
|
13
|
-
import { parseApiToken, secretMatches } from "./api-tokens.js";
|
|
13
|
+
import { parseApiToken, secretMatches, tokenLive, tokenProjects } from "./api-tokens.js";
|
|
14
|
+
import { oauthProjects, oauthTokenAllowed, RESOURCE_PATH } from "./mcp-oauth.js";
|
|
14
15
|
import { authenticateCoordinator } from "./coordinator.js";
|
|
15
16
|
import { LEGACY, MAX_REQUEST, META_CAPABILITIES, META_SERVER, META_VERSION, MODERN, TOOLS, UNSUPPORTED_VERSION, callCoordinatorTool, capabilitiesShape, readMessage, shapeResult, toolsPayload, } from "./mcp-core.js";
|
|
16
17
|
import { callPersonTool, personTools } from "./mcp-person.js";
|
|
18
|
+
import { limitWords } from "./request-budget.js";
|
|
17
19
|
/** The most decisions a coordinator credential is remembered to have read (propose_answer needs one). */
|
|
18
20
|
const READ_DECISIONS_KEPT = 200;
|
|
19
21
|
export function createMcpHttp(options) {
|
|
@@ -38,15 +40,20 @@ export function createMcpHttp(options) {
|
|
|
38
40
|
// The secret is checked here too, so this door never rests on how signedIn reads the request.
|
|
39
41
|
if (parsed === null || kept === null || !secretMatches(parsed.secret, kept.secretHash))
|
|
40
42
|
return null;
|
|
41
|
-
if (
|
|
43
|
+
if (!tokenLive(kept.row, options.clock().getTime()))
|
|
42
44
|
return null;
|
|
43
45
|
const account = store.accountOf(kept.row.account);
|
|
44
46
|
if (account === null || account.revokedAt !== null)
|
|
45
47
|
return null;
|
|
48
|
+
// An MCP sign-in's token: only while its access is fresh, and only in the projects the person chose (mcp-oauth.ts).
|
|
49
|
+
if (!oauthTokenAllowed(store, kept.row.id, RESOURCE_PATH, options.clock()))
|
|
50
|
+
return null;
|
|
51
|
+
const granted = oauthProjects(store, kept.row.id);
|
|
52
|
+
const projects = tokenProjects(tokenProjects(account.projects, kept.row.projects), granted);
|
|
46
53
|
return {
|
|
47
54
|
kind: "person",
|
|
48
55
|
person: {
|
|
49
|
-
principal: { kind: "person", account: kept.row.account, generation: account.generation, scope: kept.row.access, tokenId: kept.row.id, projects
|
|
56
|
+
principal: { kind: "person", account: kept.row.account, generation: account.generation, scope: kept.row.access, tokenId: kept.row.id, projects },
|
|
50
57
|
role: account.role,
|
|
51
58
|
tokenName: kept.row.name,
|
|
52
59
|
},
|
|
@@ -100,7 +107,18 @@ export function createMcpHttp(options) {
|
|
|
100
107
|
return refuse(response, 403, "browser requests are refused — connect an MCP client with your API token");
|
|
101
108
|
const caller = callerOf(request);
|
|
102
109
|
if (caller === null) {
|
|
103
|
-
|
|
110
|
+
const metadata = options.resourceMetadata?.(request) ?? null;
|
|
111
|
+
return refuse(response, 401, "sign in with your API token (Authorization: Bearer so_…) or a coordinator credential — passwords and cookies are not accepted here", { "www-authenticate": `Bearer realm="toolroll-mcp"${metadata === null ? "" : `, resource_metadata="${metadata}"`}` });
|
|
112
|
+
}
|
|
113
|
+
// A person's token is counted once per request, notifications included; a coordinator keeps its own proposal limit.
|
|
114
|
+
if (caller.kind === "person" && options.admit !== undefined) {
|
|
115
|
+
const admitted = options.admit(caller.person);
|
|
116
|
+
if (!admitted.ok && admitted.status === 503)
|
|
117
|
+
return refuse(response, 503, "request limits could not be checked; nothing ran — try again shortly");
|
|
118
|
+
if (!admitted.ok) {
|
|
119
|
+
request.resume();
|
|
120
|
+
return send(response, 429, { jsonrpc: "2.0", id: null, error: { code: -32000, message: limitWords(admitted.limit, admitted.retryAfter), data: { limit: admitted.limit, retryAfter: admitted.retryAfter } } }, { "retry-after": String(admitted.retryAfter) });
|
|
121
|
+
}
|
|
104
122
|
}
|
|
105
123
|
if (!/^application\/json\s*(;|$)/i.test(request.headers["content-type"] ?? ""))
|
|
106
124
|
return refuse(response, 415, "send the JSON-RPC message as application/json");
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP sign-in: OAuth 2.1 for /mcp, as the MCP authorization spec describes, so an engineer's MCP client connects
|
|
3
|
+
* without a pasted token. This console is both the authorization server and the protected resource.
|
|
4
|
+
*
|
|
5
|
+
* /mcp's 401 names /.well-known/oauth-protected-resource/mcp (RFC 9728), which names this console's
|
|
6
|
+
* /.well-known/oauth-authorization-server (RFC 8414). A client registers itself (RFC 7591, public clients only),
|
|
7
|
+
* sends the person to /oauth/authorize with a S256 PKCE challenge and resource=<origin>/mcp, and trades the code
|
|
8
|
+
* at /oauth/token for a short-lived access token and a refresh secret that rotates on every use.
|
|
9
|
+
*
|
|
10
|
+
* The person signs in with the console's own sign-in (password or identity provider, unchanged), then consents on
|
|
11
|
+
* one page: read or act, and which of their projects, confirmed with their password or a fresh provider check, the
|
|
12
|
+
* same step-up as making an API token. Nothing is granted without that form, its session CSRF value and a same-site
|
|
13
|
+
* post.
|
|
14
|
+
*
|
|
15
|
+
* A grant is an ordinary API token (api-tokens.ts): its access token is `so_<id>_<secret>`, it is listed and revoked
|
|
16
|
+
* with every other token (console Sessions & tokens, `toolroll tokens`), and /mcp runs it through the same person
|
|
17
|
+
* path. Its v113 binding (store.oauthGrant) adds what an ordinary token doesn't have: it works only at /mcp, its
|
|
18
|
+
* access secret lasts an hour, it is tied to the account generation that consented, and it reaches only the projects
|
|
19
|
+
* chosen (still intersected with the person's live access on every call). Only hashes are kept: of codes, access
|
|
20
|
+
* secrets and refresh secrets. Approvals, people and policy are never reachable: those have no tool and no scope.
|
|
21
|
+
*/
|
|
22
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
23
|
+
import type { Store } from "./store.js";
|
|
24
|
+
/** How long each piece lives. A code is spent at once; a grant ends after 30 days and the person consents again. */
|
|
25
|
+
export declare const OAUTH_TIMES: {
|
|
26
|
+
readonly requestMs: number;
|
|
27
|
+
readonly codeMs: 60000;
|
|
28
|
+
readonly accessSeconds: 3600;
|
|
29
|
+
readonly grantDays: 30;
|
|
30
|
+
};
|
|
31
|
+
/** The most sign-ins waiting at once, and registered clients kept. */
|
|
32
|
+
export declare const OAUTH_LIMITS: {
|
|
33
|
+
readonly requests: 200;
|
|
34
|
+
readonly requestsPerSource: 20;
|
|
35
|
+
readonly clients: 500;
|
|
36
|
+
readonly clientsPerSource: 20;
|
|
37
|
+
};
|
|
38
|
+
export declare const OAUTH_SCOPES: readonly ["read", "act"];
|
|
39
|
+
export declare const RESOURCE_PATH = "/mcp";
|
|
40
|
+
export declare const resourceMetadataUrl: (origin: string) => string;
|
|
41
|
+
/**
|
|
42
|
+
* Whether an API token may sign in to this request's path now. An ordinary token: yes (its own checks are elsewhere).
|
|
43
|
+
* An MCP sign-in's token: only at /mcp, only while its access secret is fresh and the account is at the generation
|
|
44
|
+
* that consented.
|
|
45
|
+
*/
|
|
46
|
+
export declare function oauthTokenAllowed(store: Store, tokenId: string, path: string, now: Date): boolean;
|
|
47
|
+
/** The projects an MCP sign-in's token was granted (the person's live access still applies), or null for an ordinary token. */
|
|
48
|
+
export declare const oauthProjects: (store: Store, tokenId: string) => string[] | null;
|
|
49
|
+
export type OAuthSession = {
|
|
50
|
+
name: string;
|
|
51
|
+
role: "approver" | "viewer";
|
|
52
|
+
csrf: string;
|
|
53
|
+
/** Signed in with an identity provider: its name, and whether it checked the person in the last ten minutes. */
|
|
54
|
+
sso: {
|
|
55
|
+
label: string;
|
|
56
|
+
fresh: boolean;
|
|
57
|
+
} | null;
|
|
58
|
+
};
|
|
59
|
+
export type OAuthHttpOptions = {
|
|
60
|
+
store: Store;
|
|
61
|
+
clock: () => Date;
|
|
62
|
+
/** This console's canonical origin for the request (the https public address, or this computer's loopback one); null: no sign-in here. */
|
|
63
|
+
originOf: (request: IncomingMessage) => string | null;
|
|
64
|
+
/** Trusted direct peer, or the last hop appended by the console's loopback proxy. */
|
|
65
|
+
requesterKey: (request: IncomingMessage) => string;
|
|
66
|
+
/** The browser session signed in by cookie, or null. A bearer never consents. */
|
|
67
|
+
session: (request: IncomingMessage) => OAuthSession | null;
|
|
68
|
+
/** A posted form came from a page of this console (its Origin, or Referer, names an allowed host). */
|
|
69
|
+
sameSite: (request: IncomingMessage) => boolean;
|
|
70
|
+
/** The step-up: the person's password, or (empty) a fresh identity-provider check. */
|
|
71
|
+
confirm: (session: OAuthSession, typed: string) => boolean;
|
|
72
|
+
/** The projects this person may choose: each a project the server knows that they can use now. */
|
|
73
|
+
projectsFor: (account: string) => string[];
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* A redirect a client may register: https anywhere, or http on 127.0.0.1 / [::1] (RFC 8252 §7.3), with no
|
|
77
|
+
* credentials or fragment.
|
|
78
|
+
*/
|
|
79
|
+
export declare function redirectAllowed(value: string): boolean;
|
|
80
|
+
/** A redirect matches a registered one exactly; on a loopback http address any port does (RFC 8252 §7.3). */
|
|
81
|
+
export declare function redirectMatches(presented: string, registered: readonly string[]): string | null;
|
|
82
|
+
export declare function createOAuthHttp(options: OAuthHttpOptions): (request: IncomingMessage, response: ServerResponse, url: URL) => Promise<boolean>;
|