toolroll 0.9.62 → 0.9.63

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 (100) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +51 -3
  3. package/dist/agent-fence.d.ts +155 -10
  4. package/dist/agent-fence.js +473 -38
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +21 -21
  7. package/dist/browser-workspace.d.ts +28 -0
  8. package/dist/chat-actions.js +2 -2
  9. package/dist/check-levels.js +3 -0
  10. package/dist/claim.js +1 -1
  11. package/dist/cli-help.d.ts +1 -1
  12. package/dist/cli-help.js +1 -0
  13. package/dist/cli.js +3 -0
  14. package/dist/contracts/cli.d.ts +8 -0
  15. package/dist/contracts/cli.js +3 -1
  16. package/dist/contracts/console-api.d.ts +19 -1
  17. package/dist/contracts/console-api.js +2 -1
  18. package/dist/contracts/integration-metadata.d.ts +2 -1
  19. package/dist/contracts/integration-metadata.js +2 -1
  20. package/dist/desktop-update.d.ts +1 -1
  21. package/dist/dispatch.js +7 -4
  22. package/dist/doctor.d.ts +53 -0
  23. package/dist/doctor.js +427 -0
  24. package/dist/evidence-pack.d.ts +1 -1
  25. package/dist/evidence-pack.js +2 -2
  26. package/dist/exec.d.ts +3 -0
  27. package/dist/exec.js +5 -4
  28. package/dist/flow-code.d.ts +2 -2
  29. package/dist/flow-code.js +4 -15
  30. package/dist/flow-engine.js +11 -6
  31. package/dist/flow-triggers.d.ts +8 -4
  32. package/dist/flow-triggers.js +45 -25
  33. package/dist/flow-unattended-words.d.ts +28 -0
  34. package/dist/flow-unattended-words.js +50 -0
  35. package/dist/flow-unattended.d.ts +227 -0
  36. package/dist/flow-unattended.js +776 -0
  37. package/dist/flows-cli.d.ts +28 -1
  38. package/dist/flows-cli.js +55 -10
  39. package/dist/flows-ui.d.ts +2 -1
  40. package/dist/flows-ui.js +4 -1
  41. package/dist/google-mail.d.ts +1 -0
  42. package/dist/google-mail.js +61 -18
  43. package/dist/integrations-ui.d.ts +2 -1
  44. package/dist/integrations-ui.js +6 -3
  45. package/dist/integrations.d.ts +17 -3
  46. package/dist/integrations.js +38 -18
  47. package/dist/invoke.js +300 -168
  48. package/dist/landed-cleanup.d.ts +3 -2
  49. package/dist/landed-cleanup.js +12 -5
  50. package/dist/lead-tools.js +11 -3
  51. package/dist/mcp-connect.d.ts +8 -7
  52. package/dist/mcp-connect.js +49 -51
  53. package/dist/oauth-run-token.d.ts +51 -0
  54. package/dist/oauth-run-token.js +127 -0
  55. package/dist/operate.js +7 -1
  56. package/dist/plan-auto.d.ts +11 -0
  57. package/dist/plan-auto.js +62 -11
  58. package/dist/process-recovery-eligibility.d.ts +1 -1
  59. package/dist/project-access.d.ts +3 -0
  60. package/dist/project-access.js +3 -0
  61. package/dist/project-delete.d.ts +3 -1
  62. package/dist/project-delete.js +10 -4
  63. package/dist/project-file.d.ts +3 -0
  64. package/dist/project-file.js +16 -0
  65. package/dist/project-tools.d.ts +99 -25
  66. package/dist/project-tools.js +394 -98
  67. package/dist/provider-home.d.ts +22 -0
  68. package/dist/provider-home.js +99 -0
  69. package/dist/provider.d.ts +17 -3
  70. package/dist/provider.js +34 -10
  71. package/dist/remote-main.d.ts +4 -0
  72. package/dist/remote-main.js +3 -3
  73. package/dist/scope.d.ts +1 -1
  74. package/dist/serve.js +11 -3
  75. package/dist/server/flows.js +27 -5
  76. package/dist/server/route-table.js +3 -0
  77. package/dist/server/runtime.d.ts +1 -1
  78. package/dist/server/settings.js +11 -3
  79. package/dist/storage-sweep-child.js +3 -1
  80. package/dist/storage-sweep-process.d.ts +12 -5
  81. package/dist/storage-sweep-process.js +23 -6
  82. package/dist/store.d.ts +38 -6
  83. package/dist/store.js +148 -15
  84. package/dist/subagent-tools.d.ts +1 -1
  85. package/dist/surface.js +6 -4
  86. package/dist/teardown-git.d.ts +14 -0
  87. package/dist/teardown-git.js +119 -0
  88. package/dist/tool-broker.d.ts +37 -0
  89. package/dist/tool-broker.js +369 -0
  90. package/dist/tool-launcher.js +5 -4
  91. package/dist/tool-secret-store.d.ts +170 -0
  92. package/dist/tool-secret-store.js +834 -0
  93. package/dist/toolroll-update.js +3 -2
  94. package/dist/tools-ui.d.ts +7 -1
  95. package/dist/tools-ui.js +17 -8
  96. package/dist/work-index.js +5 -3
  97. package/dist/worktree.d.ts +10 -4
  98. package/dist/worktree.js +70 -29
  99. package/package.json +3 -1
  100. package/scripts/provider-canary.mjs +210 -31
@@ -44,11 +44,12 @@ import { diagnoseTaskDispatch, withDispatchDiagnoses } from "./dispatch.js";
44
44
  import { agentChoicesFor, routeOfTask, INSTALLATION_SCOPE } from "./agentconfig.js";
45
45
  import { isNewModel, modelWords, priceWords, runtimeStates, seenModels } from "./model-catalog.js";
46
46
  import { agentsSummary, chosenWords, PHASES, postureWords, routeProblems, sameSpec, sizeSourceWords, sizeWords, specWords } from "./phase-routing.js";
47
- import { TOOL_CATALOG, discoverTools, projectToolsOf, secretsSetFor, toolCommandLine, toolStanding } from "./project-tools.js";
47
+ import { TOOL_CATALOG, credentialModeOf, discoverTools, projectToolsOf, secretsSetFor, toolCommandLine, toolSecretStorage, toolStanding } from "./project-tools.js";
48
48
  import { deciderOf, durationWords, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowFromSteps, stepsFor, withKeptSteps } from "./flows.js";
49
49
  import { parseContract, toModelSchema } from "./contracts/contract.js";
50
50
  import { LEAD_TOOL_INPUTS, LEAD_TOOL_READ_INPUTS, LEAD_TOOL_OPTIONS, LEAD_TOOL_OUTPUTS, reportToolOutput } from "./contracts/lead-tools.js";
51
51
  import { flowCardHref, flowDefinitionOf } from "./flow-engine.js";
52
+ import { activeUnattendedGrant } from "./flow-unattended.js";
52
53
  import { planInChat } from "./chat-decide.js";
53
54
  import { flowDecisionAt } from "./telegram-flow.js";
54
55
  import { flowPersonOf } from "./flow-send.js";
@@ -756,13 +757,14 @@ const LEAD_TOOL_HANDLERS = {
756
757
  rule: "Builds in this project get exactly these tools and nothing else configured on this computer. A tool reaches work approved after it was added; earlier approvals need approving again. Reviewers and this chat never get tools; Gemini builds get none.",
757
758
  tools: tools.map(tool => {
758
759
  const set = secretsSetFor(repo, tool.spec);
759
- return { name: tool.name, about: tool.spec.about, starts: toolCommandLine(tool.spec), standing: toolStanding(tool, set).words,
760
+ return { name: tool.name, about: tool.spec.about, starts: toolCommandLine(tool.spec), standing: toolStanding(tool, set).words, credentials: credentialModeOf(tool.spec, set)?.words ?? null,
760
761
  secrets: tool.spec.secrets.map(one => ({ name: one.name, optional: one.optional, set: set.includes(one.name) })),
761
762
  lastTest: tool.lastTest === null ? null : { ok: tool.lastTest.ok, at: tool.lastTest.at, tools: tool.lastTest.tools.slice(0, 40), problem: tool.lastTest.problem } };
762
763
  }),
763
764
  connectBySigningIn: connectionsOf(ctx.store, repo).map(one => ({ service: one.id, label: one.label, about: one.about, state: one.state, connect: `/settings/tools?repo=${encodeURIComponent(repo)}&connect=${one.id}#connect` })),
764
765
  commonTools: TOOL_CATALOG.filter(one => !names.has(one.name) && oneClickOf(one.name) === null).map(one => ({ catalog: one.name, label: one.label, about: one.about, needs: one.secrets.filter(secret => !secret.optional).map(secret => secret.name) })),
765
766
  foundOnThisComputer: found.filter(one => !names.has(one.spec.name)).map(one => ({ name: one.spec.name, source: one.source })),
767
+ secretsKept: toolSecretStorage().words,
766
768
  notice: "Never ask for, accept or repeat a secret's value in chat. Name the secret and open the Tools page (show_control tools with this repo).",
767
769
  } };
768
770
  },
@@ -837,7 +839,13 @@ const LEAD_TOOL_HANDLERS = {
837
839
  return { trigger: trigger.id, kind: trigger.kind, what: config === null ? "can't be read" : describeTrigger(config, ctx.store),
838
840
  startsIn: titleOf(config?.zone ?? definition.start), state: trigger.state, lastCheck: trigger.lastOutcome };
839
841
  }),
840
- rule: "Change it with propose_flow. Build and research steps file ordinary tasks under the usual approvals.",
842
+ // "Runs on its own" is signed with the owner's password on the flow page, never in chat; revoking is one click there.
843
+ runsOnItsOwn: (() => {
844
+ const own = activeUnattendedGrant(ctx.store, flow.id, ctx.now);
845
+ return own === null || own.terms.flowRevision !== flow.revision ? { on: false, change: `Turn it on (the owner's password) on the flow page: /flows/${flow.id}` }
846
+ : { on: true, until: own.absoluteExpiry, change: `Revoke it on the flow page: /flows/${flow.id}` };
847
+ })(),
848
+ rule: "Change it with propose_flow. Build and research steps file ordinary tasks under the usual approvals, unless runsOnItsOwn is on: then its schedule's cards are approved by the owner's signed grant. Signing or revoking that is on the flow page; send the person there.",
841
849
  } };
842
850
  }
843
851
  const repo = args["repo"] === undefined ? null : repoPathOf(ctx.who, args["repo"]);
@@ -1,5 +1,7 @@
1
1
  import { type ProjectTool, type ToolSpec } from "./project-tools.js";
2
2
  import type { Store } from "./store.js";
3
+ import { readScope, scopeList } from "./oauth-run-token.js";
4
+ export { readScope, scopeList };
3
5
  /**
4
6
  * A one-click service. `reads` names what a research step may read there:
5
7
  * an action is read-only only when it is named by a reading verb (READING)
@@ -63,10 +65,7 @@ export declare function connectionsOf(store: Store, repo: string, home?: string)
63
65
  * research read. An allow-list: a word nobody listed withholds the action.
64
66
  */
65
67
  export declare function readsOnly(action: string, subjects: readonly string[]): boolean;
66
- export declare const readScope: (scope: string) => boolean;
67
68
  export declare const readOnlyGrant: (grant: readonly string[] | null) => boolean;
68
- /** An OAuth `scope` value as scope tokens (RFC 6749), sorted and deduplicated; null when absent, empty or malformed. */
69
- export declare function scopeList(value: unknown): string[] | null;
70
69
  /** The scope a connected service's sign-in was granted, as saved beside its token; null when it was never proved. */
71
70
  export declare function grantOf(repo: string, tool: string, home?: string): string[] | null;
72
71
  /**
@@ -98,6 +97,7 @@ type Server = {
98
97
  register: string;
99
98
  scopes: string[];
100
99
  resource: string | null;
100
+ revoke: string | null;
101
101
  };
102
102
  /** A sign-in on its way: what the callback needs to finish it (kept in the console's memory, 15 minutes). `kit` or `template` is the page it returns to. */
103
103
  export type ConnectVisit = {
@@ -115,6 +115,8 @@ export type ConnectVisit = {
115
115
  expires: number;
116
116
  /** The scope asked for, when one was: the grant when the token answer leaves `scope` out (RFC 6749 §5.1). */
117
117
  scope?: string | null;
118
+ /** Where the service revokes a token, when its metadata said: a run's own token is revoked there when the run ends. */
119
+ revoke?: string | null;
118
120
  };
119
121
  /** A server's sign-in: the resource metadata its 401 names (or the well-known one), then its authorization server's metadata. */
120
122
  export declare function discoverSignIn(mcpUrl: string, fetcher?: Fetch): Promise<Server | null>;
@@ -145,11 +147,11 @@ export declare function connectedSpec(serviceId: string): ToolSpec | null;
145
147
  /**
146
148
  * Finish connecting: trade the code for tokens, add the tool to the project
147
149
  * (or refresh its sign-in, when it's there already), keep the tokens in its
148
- * secrets file, and test it.
150
+ * secrets, and test it.
149
151
  */
150
152
  export declare function finishConnect(store: Store, visit: ConnectVisit, code: string, now: Date, options?: {
151
153
  fetcher?: Fetch;
152
- home?: string;
154
+ home?: string | undefined;
153
155
  test?: boolean;
154
156
  omitEnv?: readonly string[];
155
157
  }): Promise<{
@@ -162,9 +164,8 @@ export declare function finishConnect(store: Store, visit: ConnectVisit, code: s
162
164
  /** Keep connected services signed in: any token expiring within ten minutes is refreshed (the worker's pass, and before a subagent's call). */
163
165
  export declare function refreshConnections(store: Store, repos: readonly string[], now: Date, options?: {
164
166
  fetcher?: Fetch;
165
- home?: string;
167
+ home?: string | undefined;
166
168
  }): Promise<{
167
169
  refreshed: number;
168
170
  problems: string[];
169
171
  }>;
170
- export {};
@@ -7,16 +7,19 @@
7
7
  * authorization server at its origin), registers Toolroll as a client
8
8
  * with this console's callback, and sends the person to the service to sign
9
9
  * in. The callback trades the code for tokens, which live in the tool's own
10
- * secrets file (never the database, never a log), and the tool joins the
11
- * project like any other: tested, listed, and given to subagents under rules.
12
- * The worker refreshes tokens before they expire.
10
+ * secrets (the OS keychain, or its file where there is none; never the
11
+ * database, never a log), and the tool joins the project like any other:
12
+ * tested, listed, and given to subagents under rules. The worker refreshes
13
+ * tokens before they expire, and each run gets an access token of its own
14
+ * (oauth-run-token.ts).
13
15
  */
14
16
  import { createHash, randomBytes } from "node:crypto";
15
- import { homedir } from "node:os";
16
17
  import { basename } from "node:path";
17
- import { LOCAL_APPS, addToolTo, catalogTool, localAppOf, projectToolsOf, readToolSecrets, setToolSecrets, testToolOf } from "./project-tools.js";
18
+ import { LOCAL_APPS, addToolTo, catalogTool, localAppOf, projectToolsOf, readToolSecrets, renewingSignIn, setToolSecrets, testToolOf } from "./project-tools.js";
18
19
  import { envValue } from "./names.js";
19
- import { SCOPE_TOKEN, readAuthorizationServer, readProtectedResource, readRegistration, readTokenResponse } from "./contracts/integration-metadata.js";
20
+ import { readAuthorizationServer, readProtectedResource, readRegistration, readTokenResponse } from "./contracts/integration-metadata.js";
21
+ import { ACCESS, CLIENT, CLIENT_SECRET, EXPIRES, GRANTED, REFRESH, RESOURCE, REVOKE_URL, TOKEN_URL, readScope, scopeList } from "./oauth-run-token.js";
22
+ export { readScope, scopeList };
20
23
  /**
21
24
  * Services that connect this way: their servers speak streamable HTTP and
22
25
  * advertise registering clients on the spot. Advertising isn't accepting:
@@ -92,7 +95,7 @@ export const localConnectOf = (id) => {
92
95
  * `local` connects with no sign-in. `research` says, for a connected service research can't use because of the access
93
96
  * it was granted, why (shown on its Tools entry beside Reconnect read-only).
94
97
  */
95
- export function connectionsOf(store, repo, home = homedir()) {
98
+ export function connectionsOf(store, repo, home) {
96
99
  const tools = projectToolsOf(store, repo);
97
100
  const signIn = oneClickServices().map(service => {
98
101
  const had = tools.find(one => one.name === service.id);
@@ -126,29 +129,13 @@ export function readsOnly(action, subjects) {
126
129
  const subject = (word) => subjects.includes(word) || subjects.includes(word.replace(/s$/, ""));
127
130
  return words.some(word => READING.has(word)) && words.every(word => READING.has(word) || QUALIFIERS.has(word) || subject(word));
128
131
  }
129
- /**
130
- * Identity scopes a read-only grant may carry beside `<resource>:read` ones. An allow-list: any other scope (a
131
- * write, a wildcard, `introspection`, one nobody listed) makes the grant unsafe for research.
132
- */
133
- const IDENTITY_SCOPES = new Set(["openid", "profile", "email"]);
134
- const READ_SCOPE = /^[a-z][a-z0-9_]{0,63}:read$/;
135
- export const readScope = (scope) => IDENTITY_SCOPES.has(scope) || READ_SCOPE.test(scope);
136
132
  /** Whether a saved grant only reads: it names at least one scope and every one is a read or identity scope. Unknown (null) never is. */
137
133
  // A scope a server adds to every token without being asked, that grants no access to data. PostHog adds
138
134
  // `introspection` (its ALWAYS_ALLOWED_SCOPES): it lets the token be checked, and has no write form. Never requested.
139
135
  const GRANTED_ANYWAY = new Set(["introspection"]);
140
136
  export const readOnlyGrant = (grant) => grant !== null && grant.some(readScope) && grant.every(scope => readScope(scope) || GRANTED_ANYWAY.has(scope));
141
- /** An OAuth `scope` value as scope tokens (RFC 6749), sorted and deduplicated; null when absent, empty or malformed. */
142
- export function scopeList(value) {
143
- if (typeof value !== "string")
144
- return null;
145
- const tokens = value.split(" ").filter(one => one !== "");
146
- if (tokens.length === 0 || tokens.length > 512 || !tokens.every(one => SCOPE_TOKEN.test(one)))
147
- return null;
148
- return [...new Set(tokens)].sort();
149
- }
150
137
  /** The scope a connected service's sign-in was granted, as saved beside its token; null when it was never proved. */
151
- export function grantOf(repo, tool, home = homedir()) {
138
+ export function grantOf(repo, tool, home) {
152
139
  try {
153
140
  return scopeList(readToolSecrets(repo, tool, home)[GRANTED]);
154
141
  }
@@ -195,10 +182,6 @@ export function researchToolsOf(tools, launched = null, granted = () => null) {
195
182
  }
196
183
  return research;
197
184
  }
198
- /** The secrets a connected tool keeps: the bearer the MCP server takes, and what refreshing it needs. */
199
- const ACCESS = "OAUTH_ACCESS_TOKEN", REFRESH = "OAUTH_REFRESH_TOKEN", CLIENT = "OAUTH_CLIENT_ID", CLIENT_SECRET = "OAUTH_CLIENT_SECRET", TOKEN_URL = "OAUTH_TOKEN_URL", EXPIRES = "OAUTH_EXPIRES_AT", RESOURCE = "OAUTH_RESOURCE";
200
- /** The scope the sign-in granted, kept beside its token. Not one of the spec's secrets, so it never reaches a launch. */
201
- const GRANTED = "OAUTH_GRANTED_SCOPE";
202
185
  /** Where a service sends the person back after they sign in. */
203
186
  export const CONNECT_CALLBACK = "/settings/tools/connected";
204
187
  const VISIT_MS = 15 * 60_000, RENEW_RETRY_MS = 5 * 60_000;
@@ -269,7 +252,7 @@ export async function discoverSignIn(mcpUrl, fetcher = fetch) {
269
252
  }
270
253
  // A token is for the resource the server names (Stripe's has no trailing slash), when it names one.
271
254
  const named = resource.resource;
272
- return { authorize, token, register, scopes, resource: https(named) ? named : null };
255
+ return { authorize, token, register, scopes, resource: https(named) ? named : null, revoke: https(meta.revocation_endpoint) ? meta.revocation_endpoint : null };
273
256
  }
274
257
  /**
275
258
  * Start connecting a service: find its sign-in, register this console as a
@@ -323,7 +306,7 @@ export async function startConnect(input, fetcher = fetch, now = Date.now()) {
323
306
  const scope = scopes.length > 0 ? scopes.join(" ") : null;
324
307
  if (scope !== null)
325
308
  go.searchParams.set("scope", scope);
326
- return { ok: true, go: go.toString(), state, visit: { service: service.id, repo: input.repo, by: input.by, kit: input.kit ?? null, template: input.template ?? null, verifier, clientId, clientSecret, token: server.token, resource, redirect, expires: now + VISIT_MS, scope } };
309
+ return { ok: true, go: go.toString(), state, visit: { service: service.id, repo: input.repo, by: input.by, kit: input.kit ?? null, template: input.template ?? null, verifier, clientId, clientSecret, token: server.token, resource, redirect, expires: now + VISIT_MS, scope, revoke: server.revoke } };
327
310
  }
328
311
  /** The spec a connected service joins the project with: its MCP address, signed in with the token its sign-in gave. */
329
312
  export function connectedSpec(serviceId) {
@@ -341,11 +324,11 @@ const tokensFrom = (body, now) => ({
341
324
  /**
342
325
  * Finish connecting: trade the code for tokens, add the tool to the project
343
326
  * (or refresh its sign-in, when it's there already), keep the tokens in its
344
- * secrets file, and test it.
327
+ * secrets, and test it.
345
328
  */
346
329
  export async function finishConnect(store, visit, code, now, options = {}) {
347
330
  const service = oneClickOf(visit.service);
348
- const fetcher = options.fetcher ?? fetch, home = options.home ?? homedir();
331
+ const fetcher = options.fetcher ?? fetch, home = options.home;
349
332
  const spec = connectedSpec(service.id);
350
333
  const had = projectToolsOf(store, visit.repo).find(one => one.name === spec.name);
351
334
  if (had !== undefined && (had.spec.url !== spec.url || had.spec.bearer !== ACCESS))
@@ -375,7 +358,7 @@ export async function finishConnect(store, visit, code, now, options = {}) {
375
358
  }
376
359
  // The grant: what the answer says, or what was asked for when it says nothing; never one from an earlier sign-in.
377
360
  const granted = body.scopeSaid ? scopeList(body.scope) : scopeList(visit.scope ?? null);
378
- setToolSecrets(visit.repo, spec.name, { [ACCESS]: tokens.access, [CLIENT]: visit.clientId, [TOKEN_URL]: visit.token, [RESOURCE]: visit.resource, [GRANTED]: granted?.join(" ") ?? "",
361
+ setToolSecrets(visit.repo, spec.name, { [ACCESS]: tokens.access, [CLIENT]: visit.clientId, [TOKEN_URL]: visit.token, [RESOURCE]: visit.resource, [GRANTED]: granted?.join(" ") ?? "", [REVOKE_URL]: visit.revoke ?? "", [REFRESH]: "",
379
362
  ...(tokens.refresh === null ? {} : { [REFRESH]: tokens.refresh }), ...(tokens.expires === null ? {} : { [EXPIRES]: tokens.expires }), ...(visit.clientSecret === null ? {} : { [CLIENT_SECRET]: visit.clientSecret }) }, home);
380
363
  // The success line names the project it went to: the page and chat may be looking at another.
381
364
  const to = basename(visit.repo);
@@ -387,7 +370,7 @@ export async function finishConnect(store, visit, code, now, options = {}) {
387
370
  }
388
371
  /** Keep connected services signed in: any token expiring within ten minutes is refreshed (the worker's pass, and before a subagent's call). */
389
372
  export async function refreshConnections(store, repos, now, options = {}) {
390
- const fetcher = options.fetcher ?? fetch, home = options.home ?? homedir();
373
+ const fetcher = options.fetcher ?? fetch, home = options.home;
391
374
  const report = { refreshed: 0, problems: [] };
392
375
  for (const repo of repos)
393
376
  for (const tool of projectToolsOf(store, repo)) {
@@ -402,28 +385,43 @@ export async function refreshConnections(store, repos, now, options = {}) {
402
385
  if (now.getTime() - (renewTried.get(key) ?? 0) < RENEW_RETRY_MS)
403
386
  continue;
404
387
  renewTried.set(key, now.getTime());
405
- try {
406
- const answer = await fetcher(values[TOKEN_URL], { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, signal: AbortSignal.timeout(15_000),
407
- body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: values[REFRESH], client_id: values[CLIENT], ...(values[CLIENT_SECRET] === undefined ? {} : { client_secret: values[CLIENT_SECRET] }), ...(values[RESOURCE] === undefined ? {} : { resource: values[RESOURCE] }) }) });
408
- const body = readTokenResponse(await answer.json());
409
- if (body === null) {
410
- report.problems.push(`${tool.name}: couldn't reach it to renew its sign-in`);
411
- continue;
388
+ // Renewed one at a time with a run's own sign-in, across Toolroll's processes: a rotated refresh token is never used twice.
389
+ const problem = await renewingSignIn(repo, tool.name, home, async () => {
390
+ const latest = readToolSecrets(repo, tool.name, home);
391
+ if (!latest[REFRESH] || !latest[TOKEN_URL] || !latest[CLIENT])
392
+ return null;
393
+ try {
394
+ const answer = await fetcher(latest[TOKEN_URL], { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, signal: AbortSignal.timeout(15_000),
395
+ body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: latest[REFRESH], client_id: latest[CLIENT], ...(latest[CLIENT_SECRET] === undefined ? {} : { client_secret: latest[CLIENT_SECRET] }), ...(latest[RESOURCE] === undefined ? {} : { resource: latest[RESOURCE] }) }) });
396
+ const body = readTokenResponse(await answer.json());
397
+ if (body === null)
398
+ return `${tool.name}: couldn't reach it to renew its sign-in`;
399
+ const tokens = tokensFrom(body, now.getTime());
400
+ if (!answer.ok || tokens.access === null)
401
+ return `${tool.name}: its sign-in ran out; connect it again on the Tools page`;
402
+ // A refresh carries the same grant or a narrower one, and says so when it changes; said badly, it is unknown.
403
+ // Not saved (a locked keychain): the saved sign-in stays as it was, and this says so.
404
+ try {
405
+ setToolSecrets(repo, tool.name, { [ACCESS]: tokens.access, ...(tokens.refresh === null ? {} : { [REFRESH]: tokens.refresh }), ...(tokens.expires === null ? {} : { [EXPIRES]: tokens.expires }),
406
+ ...(body.scopeSaid ? { [GRANTED]: scopeList(body.scope)?.join(" ") ?? "" } : {}) }, home);
407
+ }
408
+ catch {
409
+ return `${tool.name}: its renewed sign-in couldn't be saved; check that the keychain is unlocked`;
410
+ }
411
+ return false;
412
412
  }
413
- const tokens = tokensFrom(body, now.getTime());
414
- if (!answer.ok || tokens.access === null) {
415
- report.problems.push(`${tool.name}: its sign-in ran out; connect it again on the Tools page`);
416
- continue;
413
+ catch {
414
+ return `${tool.name}: couldn't reach it to renew its sign-in`;
417
415
  }
418
- // A refresh carries the same grant or a narrower one, and says so when it changes; said badly, it is unknown.
419
- setToolSecrets(repo, tool.name, { [ACCESS]: tokens.access, ...(tokens.refresh === null ? {} : { [REFRESH]: tokens.refresh }), ...(tokens.expires === null ? {} : { [EXPIRES]: tokens.expires }),
420
- ...(body.scopeSaid ? { [GRANTED]: scopeList(body.scope)?.join(" ") ?? "" } : {}) }, home);
416
+ });
417
+ if (typeof problem === "string") {
418
+ report.problems.push(problem);
419
+ continue;
420
+ }
421
+ if (problem === false) {
421
422
  renewTried.delete(key);
422
423
  report.refreshed++;
423
424
  }
424
- catch {
425
- report.problems.push(`${tool.name}: couldn't reach it to renew its sign-in`);
426
- }
427
425
  }
428
426
  return report;
429
427
  }
@@ -0,0 +1,51 @@
1
+ import type { ToolSpec } from "./contracts/integration-metadata.js";
2
+ /** The secrets a connected tool keeps: the bearer the MCP server takes, and what renewing it needs. */
3
+ export declare const ACCESS = "OAUTH_ACCESS_TOKEN", REFRESH = "OAUTH_REFRESH_TOKEN", CLIENT = "OAUTH_CLIENT_ID", CLIENT_SECRET = "OAUTH_CLIENT_SECRET", TOKEN_URL = "OAUTH_TOKEN_URL", EXPIRES = "OAUTH_EXPIRES_AT", RESOURCE = "OAUTH_RESOURCE";
4
+ /** The scope the sign-in granted, kept beside its token. Not one of the spec's secrets, so it never reaches a launch. */
5
+ export declare const GRANTED = "OAUTH_GRANTED_SCOPE";
6
+ /** Where the service revokes a token (RFC 7009), when its sign-in said. Not one of the spec's secrets either. */
7
+ export declare const REVOKE_URL = "OAUTH_REVOKE_URL";
8
+ /** A tool connected by signing in: a web tool whose sign-in is the access token. */
9
+ export declare const signedIn: (spec: Pick<ToolSpec, "transport" | "bearer">) => boolean;
10
+ /** Whether runs of a signed-in tool get their own token: only when there is a refresh token to make one with. */
11
+ export declare const mintsRunTokens: (values: Readonly<Record<string, string>> | readonly string[]) => boolean;
12
+ /** A sign-in address Toolroll will send a token to: https, or a stand-in on this computer. */
13
+ export declare const signInAddress: (value: unknown) => value is string;
14
+ export declare const readScope: (scope: string) => boolean;
15
+ /** An OAuth `scope` value as scope tokens (RFC 6749), sorted and deduplicated; null when absent, empty or malformed. */
16
+ export declare function scopeList(value: unknown): string[] | null;
17
+ /**
18
+ * The scope a run asks for: none (the saved grant, never more) for a build; for a research step, only the grant's
19
+ * read scopes when it has some and others besides. A grant never said is left as it is.
20
+ */
21
+ export declare function runScope(granted: readonly string[] | null, readOnly: boolean): string[] | null;
22
+ /**
23
+ * One renewal of a sign-in at a time (per project and tool, in this process): a service that rotates its refresh
24
+ * token on every use would otherwise see the same one twice, and whichever answer was saved last would be the
25
+ * newest only by luck.
26
+ */
27
+ export declare function oneAtATime<T>(key: string, work: () => Promise<T>): Promise<T>;
28
+ type Fetch = typeof fetch;
29
+ /** A run's token: the access token alone, its expiry, a rotated refresh token to keep, and how to revoke it. */
30
+ export type RunToken = {
31
+ access: string;
32
+ expires: string | null;
33
+ rotated: string | null;
34
+ revoke: (() => Promise<void>) | null;
35
+ };
36
+ /**
37
+ * Trade the saved refresh token for an access token of this run's own. Plain reasons only: a service's answer is
38
+ * never repeated, so no token can ride an error into a log or the run's record.
39
+ */
40
+ export declare function mintRunToken(values: Readonly<Record<string, string>>, options: {
41
+ scope: readonly string[] | null;
42
+ now: Date;
43
+ fetcher?: Fetch;
44
+ }): Promise<{
45
+ ok: true;
46
+ token: RunToken;
47
+ } | {
48
+ ok: false;
49
+ reason: string;
50
+ }>;
51
+ export {};
@@ -0,0 +1,127 @@
1
+ /**
2
+ * A run's own sign-in for a service connected by signing in (mcp-connect.ts).
3
+ *
4
+ * The service's secrets are an access token and the refresh token that renews
5
+ * it. A run never gets the refresh token. When it launches, Toolroll — outside
6
+ * the fence — trades the refresh token for a fresh access token of the run's
7
+ * own, no broader than the saved grant (a research step asks for only its read
8
+ * scopes, where the service takes a narrower one), and only that access token
9
+ * enters the run's broker. When the run ends the token is revoked, where the
10
+ * service said how at sign-in, and otherwise runs out at its own expiry. A
11
+ * service that gave no refresh token has only its one access token: the broker
12
+ * holds it, as it holds an API key.
13
+ */
14
+ import { SCOPE_TOKEN, readTokenResponse } from "./contracts/integration-metadata.js";
15
+ /** The secrets a connected tool keeps: the bearer the MCP server takes, and what renewing it needs. */
16
+ export const ACCESS = "OAUTH_ACCESS_TOKEN", REFRESH = "OAUTH_REFRESH_TOKEN", CLIENT = "OAUTH_CLIENT_ID", CLIENT_SECRET = "OAUTH_CLIENT_SECRET", TOKEN_URL = "OAUTH_TOKEN_URL", EXPIRES = "OAUTH_EXPIRES_AT", RESOURCE = "OAUTH_RESOURCE";
17
+ /** The scope the sign-in granted, kept beside its token. Not one of the spec's secrets, so it never reaches a launch. */
18
+ export const GRANTED = "OAUTH_GRANTED_SCOPE";
19
+ /** Where the service revokes a token (RFC 7009), when its sign-in said. Not one of the spec's secrets either. */
20
+ export const REVOKE_URL = "OAUTH_REVOKE_URL";
21
+ /** A tool connected by signing in: a web tool whose sign-in is the access token. */
22
+ export const signedIn = (spec) => spec.transport === "http" && spec.bearer === ACCESS;
23
+ /** Whether runs of a signed-in tool get their own token: only when there is a refresh token to make one with. */
24
+ export const mintsRunTokens = (values) => {
25
+ const has = (name) => Array.isArray(values) ? values.includes(name) : (values[name] ?? "") !== "";
26
+ return has(REFRESH) && has(TOKEN_URL) && has(CLIENT);
27
+ };
28
+ const loopback = (url) => url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
29
+ /** A sign-in address Toolroll will send a token to: https, or a stand-in on this computer. */
30
+ export const signInAddress = (value) => {
31
+ if (typeof value !== "string")
32
+ return false;
33
+ try {
34
+ const url = new URL(value);
35
+ return url.protocol === "https:" || loopback(url);
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ };
41
+ /** Identity scopes a read-only grant may carry beside `<resource>:read` ones. */
42
+ const IDENTITY_SCOPES = new Set(["openid", "profile", "email"]);
43
+ const READ_SCOPE = /^[a-z][a-z0-9_]{0,63}:read$/;
44
+ export const readScope = (scope) => IDENTITY_SCOPES.has(scope) || READ_SCOPE.test(scope);
45
+ /** An OAuth `scope` value as scope tokens (RFC 6749), sorted and deduplicated; null when absent, empty or malformed. */
46
+ export function scopeList(value) {
47
+ if (typeof value !== "string")
48
+ return null;
49
+ const tokens = value.split(" ").filter(one => one !== "");
50
+ if (tokens.length === 0 || tokens.length > 512 || !tokens.every(one => SCOPE_TOKEN.test(one)))
51
+ return null;
52
+ return [...new Set(tokens)].sort();
53
+ }
54
+ /**
55
+ * The scope a run asks for: none (the saved grant, never more) for a build; for a research step, only the grant's
56
+ * read scopes when it has some and others besides. A grant never said is left as it is.
57
+ */
58
+ export function runScope(granted, readOnly) {
59
+ if (!readOnly || granted === null)
60
+ return null;
61
+ const reads = granted.filter(readScope);
62
+ return reads.length > 0 && reads.length < granted.length ? reads : null;
63
+ }
64
+ const busy = new Map();
65
+ /**
66
+ * One renewal of a sign-in at a time (per project and tool, in this process): a service that rotates its refresh
67
+ * token on every use would otherwise see the same one twice, and whichever answer was saved last would be the
68
+ * newest only by luck.
69
+ */
70
+ export function oneAtATime(key, work) {
71
+ const ran = (busy.get(key) ?? Promise.resolve()).then(work, work);
72
+ const settled = ran.then(() => undefined, () => undefined);
73
+ busy.set(key, settled);
74
+ void settled.then(() => { if (busy.get(key) === settled)
75
+ busy.delete(key); });
76
+ return ran;
77
+ }
78
+ /**
79
+ * Trade the saved refresh token for an access token of this run's own. Plain reasons only: a service's answer is
80
+ * never repeated, so no token can ride an error into a log or the run's record.
81
+ */
82
+ export async function mintRunToken(values, options) {
83
+ const fetcher = options.fetcher ?? fetch;
84
+ const refresh = values[REFRESH], tokenUrl = values[TOKEN_URL], client = values[CLIENT];
85
+ if (!refresh || !client || !signInAddress(tokenUrl))
86
+ return { ok: false, reason: "its sign-in can't be renewed for this run; connect it again on the Tools page" };
87
+ const ask = async (scope) => {
88
+ const answer = await fetcher(tokenUrl, { method: "POST", redirect: "error", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, signal: AbortSignal.timeout(15_000),
89
+ body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refresh, client_id: client,
90
+ ...(values[CLIENT_SECRET] ? { client_secret: values[CLIENT_SECRET] } : {}), ...(values[RESOURCE] ? { resource: values[RESOURCE] } : {}),
91
+ ...(scope === null ? {} : { scope: scope.join(" ") }) }) });
92
+ let body = null;
93
+ try {
94
+ body = readTokenResponse(await answer.json());
95
+ }
96
+ catch {
97
+ body = null;
98
+ }
99
+ return { ok: answer.ok, body };
100
+ };
101
+ try {
102
+ let answered = await ask(options.scope);
103
+ // A service that won't narrow a grant on renewal gets asked for the grant as saved; the broker still offers the
104
+ // step only its read-only actions.
105
+ if (!answered.ok && options.scope !== null && answered.body?.error === "invalid_scope")
106
+ answered = await ask(null);
107
+ const access = answered.body?.access_token;
108
+ if (!answered.ok || typeof access !== "string" || access === "")
109
+ return { ok: false, reason: "its sign-in ran out; connect it again on the Tools page" };
110
+ const body = answered.body;
111
+ const expires = body.expires_in === undefined ? null : new Date(options.now.getTime() + body.expires_in * 1000).toISOString();
112
+ const rotated = typeof body.refresh_token === "string" && body.refresh_token !== "" && body.refresh_token !== refresh ? body.refresh_token : null;
113
+ const revokeUrl = values[REVOKE_URL];
114
+ const revoke = signInAddress(revokeUrl) ? async () => {
115
+ try {
116
+ const answer = await fetcher(revokeUrl, { method: "POST", redirect: "error", headers: { "content-type": "application/x-www-form-urlencoded" }, signal: AbortSignal.timeout(10_000),
117
+ body: new URLSearchParams({ token: access, token_type_hint: "access_token", client_id: client, ...(values[CLIENT_SECRET] ? { client_secret: values[CLIENT_SECRET] } : {}) }) });
118
+ await answer.body?.cancel();
119
+ }
120
+ catch { /* it runs out at its own expiry */ }
121
+ } : null;
122
+ return { ok: true, token: { access, expires, rotated, revoke } };
123
+ }
124
+ catch {
125
+ return { ok: false, reason: "its service couldn't be reached to sign this run in" };
126
+ }
127
+ }
package/dist/operate.js CHANGED
@@ -52,6 +52,7 @@ import { readHooksBase, scheduleFromWords } from "./flow-triggers.js";
52
52
  import { sendSubagentSummaries } from "./subagent-admin.js";
53
53
  import { runRequestedUndos, sendSubagentWeeklies } from "./subagent-week.js";
54
54
  import { refreshConnections } from "./mcp-connect.js";
55
+ import { SECRETS_FIRST, startToolSecrets, toolSecretStorage } from "./project-tools.js";
55
56
  import { followDiscord } from "./discord.js";
56
57
  import { followTeams } from "./teams.js";
57
58
  import { followSlack } from "./slack.js";
@@ -815,6 +816,9 @@ async function runOperateLocal(command, argv, write, options = {}) {
815
816
  catch (error) {
816
817
  return databaseFailure(write, json, command, file, error);
817
818
  }
819
+ // Toolroll's folder for the secrets it holds, beside this database: made before any build can run.
820
+ if (file !== ":memory:")
821
+ startToolSecrets(store, file, () => new Date(), { eager: SECRETS_FIRST.has(command) });
818
822
  // The lead (a lead token, as --token or TOOLROLL_LEAD_TOKEN) acts for its owner in single commands; its work
819
823
  // pings nobody. Anyone else who signs in inside one of these commands is who acted. Services name nobody.
820
824
  const presentedFlag = text(flags, "token");
@@ -1940,6 +1944,7 @@ async function mcpCommand(file, flags, write, json) {
1940
1944
  if (!door.ok)
1941
1945
  return said(door.message);
1942
1946
  const store = door.store;
1947
+ startToolSecrets(store, file, () => new Date(), { eager: false });
1943
1948
  for (const suffix of ["", "-wal", "-shm"]) {
1944
1949
  try {
1945
1950
  chmodSync(`${file}${suffix}`, 0o600);
@@ -8480,7 +8485,8 @@ async function integrationsCommand(positional, flags, context) {
8480
8485
  const counts = { connected: 0, "not-set-up": 0, broken: 0 };
8481
8486
  for (const one of list)
8482
8487
  counts[one.state] += 1;
8483
- return succeed(context.write, context.json, command, { integrations: list, counts }, () => renderIntegrations(list));
8488
+ const secrets = toolSecretStorage();
8489
+ return succeed(context.write, context.json, command, { integrations: list, counts, secrets: { kind: secrets.kind, words: secrets.words, ...(secrets.problem === undefined ? {} : { problem: secrets.problem }) } }, () => renderIntegrations(list, secrets));
8484
8490
  }
8485
8491
  async function waitTask(positional, flags, context) {
8486
8492
  const command = "task wait";
@@ -1,4 +1,5 @@
1
1
  import type { Store } from "./store.js";
2
+ import { type UnattendedGrant } from "./flow-unattended.js";
2
3
  export declare const PLAN_AUTO_SCHEMA = "CREATE TABLE IF NOT EXISTS plan_authorization (\n task_id TEXT PRIMARY KEY REFERENCES task(id),\n source_digest TEXT NOT NULL,\n scope_digest TEXT NOT NULL,\n mode_digest TEXT NOT NULL,\n signed_by TEXT NOT NULL,\n created_at TEXT NOT NULL\n);";
3
4
  /** Join the console's authority-free proposal to the authenticated filing
4
5
  * road in one transaction. Planning defers the seal until verified ingestion. */
@@ -7,6 +8,16 @@ export declare function applyModeToNewFiling(store: Store, taskId: string, actor
7
8
  * code. This authorizes the exact filed contract, not arbitrary future
8
9
  * planner text. Rewrites, mode renewal, expiry and revocation fail closed. */
9
10
  export declare function authorizePlanUnderMode(store: Store, taskId: string, actor: string, now: Date): boolean;
11
+ /**
12
+ * A flow card's new filing under its flow's "runs on its own" grant (flow-unattended.ts), inside the engine's filing
13
+ * transaction, which has just proved the grant covers this card. A plain build that plans is pre-authorized for an
14
+ * unchanged plan only; anything else is sealed now, under a distinct `grant` basis. False (the filing then waits for a
15
+ * person, as it always did) when the filed terms aren't the safe, fixed ones the grant was signed for.
16
+ */
17
+ export declare function sealFlowFilingUnderGrant(store: Store, taskId: string, grant: UnattendedGrant, card: {
18
+ flow: number;
19
+ id: number;
20
+ }, now: Date): boolean;
10
21
  /** Read-side status only; finalization still proves the authority anew. */
11
22
  export declare function planAutoPending(store: Store, taskId: string, now: Date): boolean;
12
23
  /** Inside the verified planner finalization transaction, after the source,