toolroll 0.9.62 → 0.9.65

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 (345) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +66 -8
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +203 -10
  16. package/dist/agent-fence.js +623 -41
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +36 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/browser/THIRD_PARTY_NOTICES.txt +48 -0
  32. package/dist/browser/terminal.css +37 -0
  33. package/dist/browser/terminal.js +117 -0
  34. package/dist/browser/workspace.css +2 -2
  35. package/dist/browser/workspace.js +42 -38
  36. package/dist/browser-shell.js +6 -3
  37. package/dist/browser-workspace.d.ts +263 -1
  38. package/dist/browser-workspace.js +60 -0
  39. package/dist/builder.d.ts +6 -1
  40. package/dist/builder.js +34 -13
  41. package/dist/chat-actions.d.ts +13 -1
  42. package/dist/chat-actions.js +199 -17
  43. package/dist/chat-ask.d.ts +4 -0
  44. package/dist/chat-ask.js +14 -0
  45. package/dist/chat-channel.js +20 -0
  46. package/dist/chat-continuity.js +5 -4
  47. package/dist/chat-decide.d.ts +0 -14
  48. package/dist/chat-decide.js +4 -1
  49. package/dist/chat-delivery.d.ts +9 -0
  50. package/dist/chat-delivery.js +45 -6
  51. package/dist/chat-messages.d.ts +8 -0
  52. package/dist/chat-messages.js +36 -4
  53. package/dist/check-levels-ui.js +2 -2
  54. package/dist/check-levels.js +3 -0
  55. package/dist/checkout-proof.d.ts +28 -0
  56. package/dist/checkout-proof.js +62 -0
  57. package/dist/claim.d.ts +12 -0
  58. package/dist/claim.js +20 -1
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +5 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +24 -2
  63. package/dist/coding-ui.js +6 -4
  64. package/dist/console-instance.d.ts +124 -0
  65. package/dist/console-instance.js +337 -0
  66. package/dist/contracts/chat-actions.d.ts +16 -0
  67. package/dist/contracts/chat-actions.js +4 -0
  68. package/dist/contracts/chat-content.d.ts +19 -0
  69. package/dist/contracts/chat-content.js +6 -0
  70. package/dist/contracts/cli.d.ts +8 -0
  71. package/dist/contracts/cli.js +3 -1
  72. package/dist/contracts/console-api.d.ts +30 -4
  73. package/dist/contracts/console-api.js +13 -4
  74. package/dist/contracts/flow-card.d.ts +4 -0
  75. package/dist/contracts/flow-card.js +3 -0
  76. package/dist/contracts/flow.d.ts +46 -0
  77. package/dist/contracts/flow.js +11 -2
  78. package/dist/contracts/integration-metadata.d.ts +2 -1
  79. package/dist/contracts/integration-metadata.js +2 -1
  80. package/dist/contracts/lead-tools.d.ts +92 -0
  81. package/dist/contracts/lead-tools.js +13 -0
  82. package/dist/contracts/provider.d.ts +11 -1
  83. package/dist/contracts/provider.js +1 -1
  84. package/dist/contracts/route.d.ts +76 -1
  85. package/dist/contracts/route.js +1 -1
  86. package/dist/contracts/store-json.d.ts +2 -0
  87. package/dist/control-setup.d.ts +1 -1
  88. package/dist/control-ui.js +2 -2
  89. package/dist/converse.d.ts +16 -0
  90. package/dist/converse.js +32 -4
  91. package/dist/demo-chat.js +4 -4
  92. package/dist/demo.js +2 -2
  93. package/dist/desktop-update-gate.js +6 -0
  94. package/dist/discord-chat.js +4 -1
  95. package/dist/dispatch.js +7 -4
  96. package/dist/doctor.d.ts +53 -0
  97. package/dist/doctor.js +427 -0
  98. package/dist/egress-bridge.d.ts +17 -0
  99. package/dist/egress-bridge.js +111 -0
  100. package/dist/egress.d.ts +119 -0
  101. package/dist/egress.js +423 -0
  102. package/dist/evidence-pack.d.ts +1 -1
  103. package/dist/evidence-pack.js +2 -2
  104. package/dist/exec.d.ts +81 -0
  105. package/dist/exec.js +368 -9
  106. package/dist/export-ui.js +2 -2
  107. package/dist/flow-code.d.ts +2 -2
  108. package/dist/flow-code.js +4 -15
  109. package/dist/flow-engine.js +35 -11
  110. package/dist/flow-gallery-ui.js +2 -2
  111. package/dist/flow-gallery.js +27 -8
  112. package/dist/flow-share.js +16 -5
  113. package/dist/flow-triggers.d.ts +84 -11
  114. package/dist/flow-triggers.js +554 -87
  115. package/dist/flow-unattended-words.d.ts +28 -0
  116. package/dist/flow-unattended-words.js +50 -0
  117. package/dist/flow-unattended.d.ts +227 -0
  118. package/dist/flow-unattended.js +776 -0
  119. package/dist/flows-cli.d.ts +28 -1
  120. package/dist/flows-cli.js +63 -12
  121. package/dist/flows-ui.d.ts +2 -3
  122. package/dist/flows-ui.js +56 -5
  123. package/dist/flows.d.ts +4 -0
  124. package/dist/flows.js +9 -1
  125. package/dist/google-mail.d.ts +1 -0
  126. package/dist/google-mail.js +61 -18
  127. package/dist/html.d.ts +9 -0
  128. package/dist/html.js +11 -0
  129. package/dist/integrations-ui.d.ts +2 -1
  130. package/dist/integrations-ui.js +6 -3
  131. package/dist/integrations.d.ts +17 -3
  132. package/dist/integrations.js +38 -18
  133. package/dist/invoke.d.ts +17 -0
  134. package/dist/invoke.js +510 -182
  135. package/dist/keys.d.ts +18 -14
  136. package/dist/keys.js +5 -0
  137. package/dist/landed-cleanup.d.ts +3 -2
  138. package/dist/landed-cleanup.js +12 -5
  139. package/dist/lead-capabilities.js +3 -2
  140. package/dist/lead-contract.js +2 -0
  141. package/dist/lead-doors.d.ts +2 -0
  142. package/dist/lead-doors.js +7 -0
  143. package/dist/lead-follow.d.ts +2 -0
  144. package/dist/lead-follow.js +58 -2
  145. package/dist/lead-progress.js +1 -0
  146. package/dist/lead-queue-runtime.d.ts +46 -0
  147. package/dist/lead-queue-runtime.js +191 -0
  148. package/dist/lead-queue.d.ts +104 -0
  149. package/dist/lead-queue.js +187 -0
  150. package/dist/lead-sweep.d.ts +67 -0
  151. package/dist/lead-sweep.js +99 -0
  152. package/dist/lead-tools.d.ts +22 -0
  153. package/dist/lead-tools.js +163 -4
  154. package/dist/lead.d.ts +13 -5
  155. package/dist/lead.js +89 -29
  156. package/dist/limits-ui.d.ts +8 -1
  157. package/dist/limits-ui.js +26 -0
  158. package/dist/live-bus.d.ts +8 -0
  159. package/dist/live-bus.js +40 -4
  160. package/dist/live-detail.d.ts +27 -0
  161. package/dist/live-detail.js +57 -0
  162. package/dist/live-notes.d.ts +20 -0
  163. package/dist/live-notes.js +47 -0
  164. package/dist/live-records.d.ts +140 -0
  165. package/dist/live-records.js +935 -0
  166. package/dist/live-terminal.d.ts +258 -0
  167. package/dist/live-terminal.js +649 -0
  168. package/dist/live.d.ts +38 -27
  169. package/dist/live.js +142 -124
  170. package/dist/mcp-connect.d.ts +8 -7
  171. package/dist/mcp-connect.js +49 -51
  172. package/dist/mcp-oauth.d.ts +6 -3
  173. package/dist/mcp-oauth.js +8 -4
  174. package/dist/memory-pass.js +2 -2
  175. package/dist/mobile-viewport.d.ts +5 -1
  176. package/dist/mobile-viewport.js +18 -2
  177. package/dist/model-catalog.js +4 -0
  178. package/dist/monitoring-ui.js +2 -2
  179. package/dist/needs-you.d.ts +1 -1
  180. package/dist/needs-you.js +11 -4
  181. package/dist/oauth-run-token.d.ts +51 -0
  182. package/dist/oauth-run-token.js +127 -0
  183. package/dist/operate.d.ts +2 -0
  184. package/dist/operate.js +282 -18
  185. package/dist/passwordless.d.ts +23 -0
  186. package/dist/passwordless.js +26 -0
  187. package/dist/peek-cli.d.ts +12 -4
  188. package/dist/peek-cli.js +19 -11
  189. package/dist/phase-routing.js +10 -3
  190. package/dist/plan-auto.d.ts +11 -0
  191. package/dist/plan-auto.js +62 -11
  192. package/dist/planner-source.d.ts +3 -0
  193. package/dist/planner-source.js +14 -0
  194. package/dist/planner.d.ts +2 -1
  195. package/dist/planner.js +7 -1
  196. package/dist/policy-ui.js +2 -2
  197. package/dist/policy.d.ts +1 -20
  198. package/dist/policy.js +29 -2
  199. package/dist/process-recovery-eligibility.d.ts +1 -1
  200. package/dist/project-access.d.ts +3 -0
  201. package/dist/project-access.js +3 -0
  202. package/dist/project-delete-ui.d.ts +12 -0
  203. package/dist/project-delete-ui.js +41 -9
  204. package/dist/project-delete.d.ts +3 -1
  205. package/dist/project-delete.js +12 -6
  206. package/dist/project-file.d.ts +3 -0
  207. package/dist/project-file.js +16 -0
  208. package/dist/project-memory.d.ts +3 -1
  209. package/dist/project-memory.js +57 -25
  210. package/dist/project-tools.d.ts +103 -27
  211. package/dist/project-tools.js +399 -100
  212. package/dist/provider-auth.d.ts +4 -3
  213. package/dist/provider-auth.js +5 -4
  214. package/dist/provider-connection.d.ts +2 -1
  215. package/dist/provider-connection.js +5 -0
  216. package/dist/provider-home.d.ts +22 -0
  217. package/dist/provider-home.js +99 -0
  218. package/dist/provider.d.ts +42 -5
  219. package/dist/provider.js +97 -19
  220. package/dist/pull-request-ui.js +2 -2
  221. package/dist/push.js +1 -1
  222. package/dist/quick-start.d.ts +24 -0
  223. package/dist/quick-start.js +127 -0
  224. package/dist/release.d.ts +3 -0
  225. package/dist/release.js +14 -8
  226. package/dist/remote-main.d.ts +4 -0
  227. package/dist/remote-main.js +3 -3
  228. package/dist/request-budget-ui.js +2 -2
  229. package/dist/result-review.d.ts +1 -1
  230. package/dist/result-review.js +9 -3
  231. package/dist/retention-ui.js +2 -2
  232. package/dist/retention.js +10 -1
  233. package/dist/run-efficiency.d.ts +73 -0
  234. package/dist/run-efficiency.js +228 -0
  235. package/dist/runner.d.ts +5 -1
  236. package/dist/runner.js +22 -1
  237. package/dist/scope.d.ts +171 -6
  238. package/dist/scope.js +251 -31
  239. package/dist/scout-net.d.ts +4 -1
  240. package/dist/scout-net.js +37 -12
  241. package/dist/scout.d.ts +20 -6
  242. package/dist/scout.js +27 -3
  243. package/dist/serve.js +258 -43
  244. package/dist/server/chat.d.ts +1 -1
  245. package/dist/server/chat.js +122 -31
  246. package/dist/server/chrome.d.ts +6 -1
  247. package/dist/server/chrome.js +18 -7
  248. package/dist/server/console.css +52 -38
  249. package/dist/server/flows.js +65 -13
  250. package/dist/server/guards.d.ts +1 -0
  251. package/dist/server/guards.js +23 -9
  252. package/dist/server/http.d.ts +7 -0
  253. package/dist/server/http.js +3 -2
  254. package/dist/server/live.d.ts +34 -1
  255. package/dist/server/live.js +39 -17
  256. package/dist/server/pages-assets.js +5 -0
  257. package/dist/server/pages.js +90 -13
  258. package/dist/server/people-tokens.d.ts +3 -0
  259. package/dist/server/people-tokens.js +55 -17
  260. package/dist/server/remote-hooks.js +12 -6
  261. package/dist/server/remote.d.ts +2 -1
  262. package/dist/server/remote.js +14 -5
  263. package/dist/server/render-chat.d.ts +12 -3
  264. package/dist/server/render-chat.js +47 -16
  265. package/dist/server/render-pages.d.ts +4 -1
  266. package/dist/server/render-pages.js +5 -4
  267. package/dist/server/render-people.d.ts +3 -2
  268. package/dist/server/render-people.js +3 -2
  269. package/dist/server/render-results.d.ts +18 -2
  270. package/dist/server/render-results.js +37 -28
  271. package/dist/server/render-settings.js +6 -5
  272. package/dist/server/render-tasks.d.ts +35 -10
  273. package/dist/server/render-tasks.js +110 -27
  274. package/dist/server/request-context.d.ts +2 -0
  275. package/dist/server/request-context.js +2 -1
  276. package/dist/server/route-table.d.ts +3 -0
  277. package/dist/server/route-table.js +49 -5
  278. package/dist/server/runtime.d.ts +34 -3
  279. package/dist/server/session.d.ts +25 -1
  280. package/dist/server/session.js +18 -4
  281. package/dist/server/settings.js +132 -13
  282. package/dist/server/sweeps.d.ts +4 -0
  283. package/dist/server/sweeps.js +58 -0
  284. package/dist/server/task-views.d.ts +2 -1
  285. package/dist/server/task-views.js +96 -6
  286. package/dist/server/tasks.js +320 -74
  287. package/dist/server/terminals.d.ts +31 -0
  288. package/dist/server/terminals.js +365 -0
  289. package/dist/setup-guide.js +3 -2
  290. package/dist/slack-chat.js +4 -1
  291. package/dist/spend-ui.js +3 -3
  292. package/dist/spend.js +2 -1
  293. package/dist/steer-capabilities.d.ts +32 -0
  294. package/dist/steer-capabilities.js +60 -0
  295. package/dist/steer-controls.d.ts +14 -0
  296. package/dist/steer-controls.js +36 -0
  297. package/dist/steer-status.js +2 -2
  298. package/dist/storage-sweep-child.js +3 -1
  299. package/dist/storage-sweep-process.d.ts +12 -5
  300. package/dist/storage-sweep-process.js +23 -6
  301. package/dist/storage-ui.js +4 -4
  302. package/dist/store-baseline.d.ts +1 -1
  303. package/dist/store-baseline.js +1 -1
  304. package/dist/store.d.ts +466 -19
  305. package/dist/store.js +1185 -63
  306. package/dist/subagent-desk.js +1 -1
  307. package/dist/subagent-tools.d.ts +1 -1
  308. package/dist/summary.js +3 -0
  309. package/dist/surface.js +6 -4
  310. package/dist/task-source.d.ts +58 -0
  311. package/dist/task-source.js +93 -0
  312. package/dist/team-leads.js +1 -1
  313. package/dist/teams-chat.js +4 -1
  314. package/dist/teardown-git.d.ts +14 -0
  315. package/dist/teardown-git.js +119 -0
  316. package/dist/telegram-lead.d.ts +3 -0
  317. package/dist/telegram-lead.js +44 -7
  318. package/dist/telegram.d.ts +11 -2
  319. package/dist/telegram.js +51 -17
  320. package/dist/terminal-catalog.d.ts +11 -0
  321. package/dist/terminal-catalog.js +129 -0
  322. package/dist/text-limits.d.ts +2 -0
  323. package/dist/text-limits.js +2 -0
  324. package/dist/tool-broker.d.ts +39 -0
  325. package/dist/tool-broker.js +370 -0
  326. package/dist/tool-launcher.js +5 -4
  327. package/dist/tool-secret-store.d.ts +170 -0
  328. package/dist/tool-secret-store.js +834 -0
  329. package/dist/toolroll-update-ui.js +2 -2
  330. package/dist/toolroll-update.js +14 -3
  331. package/dist/tools-ui.d.ts +7 -1
  332. package/dist/tools-ui.js +19 -10
  333. package/dist/webhook-limits.d.ts +23 -0
  334. package/dist/webhook-limits.js +30 -0
  335. package/dist/webhook-readers.d.ts +56 -0
  336. package/dist/webhook-readers.js +138 -0
  337. package/dist/webhook-signing.d.ts +27 -0
  338. package/dist/webhook-signing.js +119 -0
  339. package/dist/work-index.d.ts +29 -0
  340. package/dist/work-index.js +21 -5
  341. package/dist/workspace-revision.js +3 -1
  342. package/dist/worktree.d.ts +10 -4
  343. package/dist/worktree.js +70 -29
  344. package/package.json +13 -2
  345. package/scripts/provider-canary.mjs +210 -31
@@ -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
  }
@@ -51,7 +51,9 @@ export type OAuthSession = {
51
51
  name: string;
52
52
  role: "approver" | "viewer";
53
53
  csrf: string;
54
- /** Signed in with an identity provider: its name, and whether it checked the person in the last ten minutes. */
54
+ /** The account's credential generation this session signed in under: a code is never issued under another. */
55
+ generation: number;
56
+ /** Signed in with an identity provider: its name, and whether an explicit re-check made on request awaits one step-up. */
55
57
  sso: {
56
58
  label: string;
57
59
  fresh: boolean;
@@ -68,8 +70,9 @@ export type OAuthHttpOptions = {
68
70
  session: (request: IncomingMessage) => OAuthSession | null;
69
71
  /** A posted form came from a page of this console (its Origin, or Referer, names an allowed host). */
70
72
  sameSite: (request: IncomingMessage) => boolean;
71
- /** The step-up: the person's password, or (empty) a fresh identity-provider check. */
72
- confirm: (session: OAuthSession, typed: string) => boolean;
73
+ /** The step-up, proved at the moment of consent on this request's live session: the person's password, or (empty)
74
+ * an explicit identity-provider re-check, spent by this consent. */
75
+ confirm: (request: IncomingMessage, session: OAuthSession, typed: string) => boolean;
73
76
  /** The projects this person may choose: each a project the server knows that they can use now. */
74
77
  projectsFor: (account: string) => string[];
75
78
  /** The token exchange's budget by source (request-budget.ts): charged once per request, before its body is read or any code or refresh secret is spent. */
package/dist/mcp-oauth.js CHANGED
@@ -308,8 +308,8 @@ export function createOAuthHttp(options) {
308
308
  return backToClient(response, origin, one.redirectUri, one.state, { error: "access_denied", error_description: "the person declined" }, "Cancelled. Returning to your MCP client…");
309
309
  }
310
310
  const account = store.accountOf(session.name);
311
- if (account === null || account.revokedAt !== null)
312
- return problemPage(response, 403, "Sign in first", "Your account can't sign in. Nothing was allowed.");
311
+ if (account === null || account.revokedAt !== null || account.generation !== session.generation)
312
+ return problemPage(response, 403, "Sign in first", "Your access changed. Sign in again; nothing was allowed.");
313
313
  const access = form.get("access") === "act" ? "act" : "read";
314
314
  const chosen = [...new Set(form.getAll("project"))];
315
315
  const again = (status, problem) => page(response, status, consentHtml(session, id, one, problem, { access, projects: chosen }));
@@ -323,11 +323,15 @@ export function createOAuthHttp(options) {
323
323
  return again(403, "Choose from your own projects.");
324
324
  if (!adapterPolicy({ caller: "cookie", capability: account.role === "approver" ? "act" : "read", viewer: account.role === "viewer" }, "step-up", source => source === "none" && chosen.every(path => mine.includes(path))).ok)
325
325
  return again(403, "Choose from your own projects.");
326
- if (!options.confirm(session, form.get("password") ?? ""))
326
+ if (!options.confirm(request, session, form.get("password") ?? ""))
327
327
  return again(403, "Enter your Toolroll password to allow access.");
328
+ // Re-proved after the step-up: the code carries the generation the person consented under, never a newer one.
329
+ const current = store.accountOf(session.name);
330
+ if (current === null || current.revokedAt !== null || current.generation !== session.generation)
331
+ return problemPage(response, 403, "Sign in first", "Your access changed. Sign in again; nothing was allowed.");
328
332
  waiting.delete(id);
329
333
  const code = randomBytes(32).toString("base64url");
330
- store.saveOAuthCode(sha256(code), { client: client.id, account: session.name, generation: account.generation, access, projects: chosen, redirectUri: one.redirectUri, resource: one.resource,
334
+ store.saveOAuthCode(sha256(code), { client: client.id, account: session.name, generation: session.generation, access, projects: chosen, redirectUri: one.redirectUri, resource: one.resource,
331
335
  challenge: one.challenge, expiresAt: new Date(now.getTime() + OAUTH_TIMES.codeMs).toISOString() });
332
336
  return backToClient(response, origin, one.redirectUri, one.state, { code }, "Allowed. Returning to your MCP client…");
333
337
  }
@@ -138,11 +138,11 @@ function fileSources(repo, kind, home) {
138
138
  /** The plane's own sessions: lead threads that touched the project, and finished crew runs with their handoff and check output. */
139
139
  function planeSources(store, repo, evidenceRoot) {
140
140
  const out = [];
141
- const threads = store.handle.prepare(`SELECT c.thread AS thread, c.id AS conversation, MAX(m.id) AS last, MAX(m.created_at) AS at FROM team_conversation c JOIN lead_message m ON m.thread=c.thread
141
+ const threads = store.handle.prepare(`SELECT c.thread AS thread, c.id AS conversation, MAX(m.id) AS last, MAX(m.created_at) AS at FROM team_conversation c JOIN lead_message m ON m.thread=c.thread AND m.pending=0
142
142
  WHERE EXISTS (SELECT 1 FROM json_each(c.projects_json) WHERE value=?) GROUP BY c.thread`).all(repo);
143
143
  for (const row of threads) {
144
144
  const thread = Number(row['thread']);
145
- out.push({ id: `lead:${thread}:${Number(row['last'])}`, repo, kind: 'lead', source: `conversation:${String(row['conversation'])}`, at: String(row['at']), trace: () => redact(store.handle.prepare('SELECT role,text FROM lead_message WHERE thread=? ORDER BY id LIMIT 400').all(thread).map(m => `${m['role']}: ${clip(String(m['text']), 4000)}`).join('\n')) });
145
+ out.push({ id: `lead:${thread}:${Number(row['last'])}`, repo, kind: 'lead', source: `conversation:${String(row['conversation'])}`, at: String(row['at']), trace: () => redact(store.handle.prepare('SELECT role,text FROM lead_message WHERE thread=? AND pending=0 ORDER BY id LIMIT 400').all(thread).map(m => `${m['role']}: ${clip(String(m['text']), 4000)}`).join('\n')) });
146
146
  }
147
147
  const runs = store.handle.prepare(`SELECT r.id, r.role, r.finished_at, r.handoff, t.external_id FROM run r JOIN task_ref t ON t.id=r.task_ref WHERE t.repo=? AND r.role IN ('builder','repair') AND r.outcome IS NOT NULL ORDER BY r.id DESC LIMIT 100`).all(repo);
148
148
  for (const row of runs) {
@@ -1,3 +1,7 @@
1
1
  /** Presentation only: no fields are read, no requests are sent, and no
2
- * keyboard events are captured. A focused input alone is NOT a keyboard. */
2
+ * keyboard events are captured. A focused input alone is NOT a keyboard.
3
+ *
4
+ * It publishes the visible area (--viewport-height, --viewport-top): a phone's
5
+ * keyboard shrinks only the visual viewport on iOS (100dvh never moves), so a
6
+ * full-height app shell sized to it is what keeps the composer above the keys. */
3
7
  export declare const MOBILE_VIEWPORT_SCRIPT: string;
@@ -1,10 +1,14 @@
1
1
  /** Presentation only: no fields are read, no requests are sent, and no
2
- * keyboard events are captured. A focused input alone is NOT a keyboard. */
2
+ * keyboard events are captured. A focused input alone is NOT a keyboard.
3
+ *
4
+ * It publishes the visible area (--viewport-height, --viewport-top): a phone's
5
+ * keyboard shrinks only the visual viewport on iOS (100dvh never moves), so a
6
+ * full-height app shell sized to it is what keeps the composer above the keys. */
3
7
  export const MOBILE_VIEWPORT_SCRIPT = String.raw `
4
8
  (function(){
5
9
  var root=document.documentElement,viewport=window.visualViewport;
6
10
  if(!viewport)return;
7
- var phone=window.matchMedia('(max-width: 760px)'),baseline=window.innerHeight,frame=0;
11
+ var phone=window.matchMedia('(max-width: 900px)'),baseline=window.innerHeight,frame=0;
8
12
  function update(){
9
13
  frame=0;
10
14
  var active=document.activeElement;
@@ -18,8 +22,20 @@ export const MOBILE_VIEWPORT_SCRIPT = String.raw `
18
22
  var opening=open&&!root.hasAttribute('data-mobile-keyboard');
19
23
  var atEnd=opening&&window.innerHeight+window.scrollY>=root.scrollHeight-120;
20
24
  root.toggleAttribute('data-mobile-keyboard',Boolean(open));
25
+ // A pinch zoom shrinks the visual viewport too, and then a keyboard can't be told from the zoom: zoomed, the shell
26
+ // is the whole screen (the reader pans to the field), so a keyboard closed while zoomed never leaves it short.
27
+ if(Math.abs(viewport.scale-1)<0.01){
28
+ root.style.setProperty('--viewport-height',Math.round(viewport.height)+'px');
29
+ root.style.setProperty('--viewport-top',Math.max(0,Math.round(viewport.offsetTop))+'px');
30
+ }else{
31
+ root.style.removeProperty('--viewport-height');
32
+ root.style.removeProperty('--viewport-top');
33
+ }
21
34
  root.style.setProperty('--keyboard-inset',open?Math.round(gap)+'px':'0px');
22
35
  if(atEnd)window.scrollTo({top:root.scrollHeight,behavior:'instant'});
36
+ // In the app shell the field sits in its own scrolling panel, which the shell's new size just cut short:
37
+ // bring it back into that panel's view once the smaller layout is in.
38
+ if(opening&&active.closest('.so-workspace'))requestAnimationFrame(function(){active.scrollIntoView({block:'nearest',behavior:'instant'});});
23
39
  }
24
40
  function schedule(){if(!frame)frame=requestAnimationFrame(update);}
25
41
  viewport.addEventListener('resize',schedule);
@@ -6,6 +6,7 @@
6
6
  * installed CLIs are current. Everything lands in orders.db so pickers,
7
7
  * spend pins and "new model" notices read one saved snapshot.
8
8
  */
9
+ import { isAcpProvider } from "./acp-agents.js";
9
10
  import { execFile } from "node:child_process";
10
11
  import { accessSync, constants, lstatSync, readFileSync, realpathSync } from "node:fs";
11
12
  import { homedir } from "node:os";
@@ -197,6 +198,9 @@ export function modelOptions(store, provider, now, home = homedir()) {
197
198
  }
198
199
  if (provider === "gemini")
199
200
  return seenModels(store, "gemini").slice(0, 20).map(option);
201
+ // An ACP agent offers only the models its check recorded, on the runner that checked it — read there, never guessed.
202
+ if (isAcpProvider(provider))
203
+ return [];
200
204
  return seenModels(store, "openrouter").filter(model => model.tools).sort((a, b) => a.name.localeCompare(b.name)).map(option);
201
205
  }
202
206
  /** Chat reserves worst-case spend: price per token in whole micro-dollars,
@@ -3,7 +3,7 @@
3
3
  * the company's tools. One form, the status of each destination beside it,
4
4
  * and how Prometheus reads `/metrics`. The signing secret is shown once.
5
5
  */
6
- import { html, postForm } from "./html.js";
6
+ import { html, postForm, reauth } from "./html.js";
7
7
  import { targetOf } from "./monitoring.js";
8
8
  import { whenUtc } from "./when-html.js";
9
9
  const when = (at) => whenUtc(at);
@@ -31,7 +31,7 @@ export function monitoringHtml(view, notice) {
31
31
  const s = view.settings;
32
32
  const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
33
33
  const metrics = view.origin === null ? "/metrics" : `${view.origin}/metrics`;
34
- return html `<section class="monitoring">${note}${postForm("/settings/monitoring", html `<fieldset><legend>Audit stream</legend><p class="meta">Every ledger entry with its seal, as it happens, to your log system.</p><label>Webhook<input type="url" name="webhook" value="${s.webhook?.url ?? ""}" placeholder="https://logs.example.com/standing-orders" spellcheck="false"></label>${statusLine(view, "webhook", s.webhook?.url ?? null)}${s.webhook === null ? "" : html `<label class="choice"><input type="checkbox" name="rotate" value="1"> Make a new signing secret</label>`}<label>Folder (JSON Lines, a file per day)<input type="text" name="folder" value="${s.folder?.path ?? ""}" placeholder="/var/log/standing-orders" spellcheck="false"></label>${statusLine(view, "folder", s.folder?.path ?? null)}</fieldset><fieldset><legend>Traces</legend><p class="meta">Each run as an OpenTelemetry span: timings, model, tokens and cost. Never a prompt or code.</p><label>Collector (OTLP over HTTP)<input type="url" name="traces" value="${s.traces?.endpoint ?? ""}" placeholder="https://otel.example.com:4318" spellcheck="false"></label>${statusLine(view, "traces", s.traces?.endpoint ?? null)}<label>Header name (optional)<input type="text" name="header-name" value="${s.traces?.header?.name ?? ""}" placeholder="x-honeycomb-team" spellcheck="false"></label><label>Header value<input type="password" name="header-value" autocomplete="off" placeholder="${s.traces?.header ? "Saved; leave blank to keep" : "API key"}"></label></fieldset><div class="step-up"><label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label></div><button type="submit">Save</button>`)}<fieldset><legend>Metrics</legend><p class="meta">Prometheus reads <code>${metrics}</code> with an instance operator's API token (<a href="/settings/sessions">Sessions &amp; tokens</a>).</p><details><summary>Example</summary><pre>curl -H "Authorization: Bearer so_…" ${metrics}</pre></details></fieldset></section>`;
34
+ return html `<section class="monitoring">${note}${postForm("/settings/monitoring", html `<fieldset><legend>Audit stream</legend><p class="meta">Every ledger entry with its seal, as it happens, to your log system.</p><label>Webhook<input type="url" name="webhook" value="${s.webhook?.url ?? ""}" placeholder="https://logs.example.com/standing-orders" spellcheck="false"></label>${statusLine(view, "webhook", s.webhook?.url ?? null)}${s.webhook === null ? "" : html `<label class="choice"><input type="checkbox" name="rotate" value="1"> Make a new signing secret</label>`}<label>Folder (JSON Lines, a file per day)<input type="text" name="folder" value="${s.folder?.path ?? ""}" placeholder="/var/log/standing-orders" spellcheck="false"></label>${statusLine(view, "folder", s.folder?.path ?? null)}</fieldset><fieldset><legend>Traces</legend><p class="meta">Each run as an OpenTelemetry span: timings, model, tokens and cost. Never a prompt or code.</p><label>Collector (OTLP over HTTP)<input type="url" name="traces" value="${s.traces?.endpoint ?? ""}" placeholder="https://otel.example.com:4318" spellcheck="false"></label>${statusLine(view, "traces", s.traces?.endpoint ?? null)}<label>Header name (optional)<input type="text" name="header-name" value="${s.traces?.header?.name ?? ""}" placeholder="x-honeycomb-team" spellcheck="false"></label><label>Header value<input type="password" name="header-value" autocomplete="off" placeholder="${s.traces?.header ? "Saved; leave blank to keep" : "API key"}"></label></fieldset><div class="step-up">${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`)}</div><button type="submit">Save</button>`)}<fieldset><legend>Metrics</legend><p class="meta">Prometheus reads <code>${metrics}</code> with an instance operator's API token (<a href="/settings/sessions">Sessions &amp; tokens</a>).</p><details><summary>Example</summary><pre>curl -H "Authorization: Bearer so_…" ${metrics}</pre></details></fieldset></section>`;
35
35
  }
36
36
  /** The webhook's signing secret, once: a focused page with no script. */
37
37
  export function signingSecretHtml(url, secret) {
@@ -6,7 +6,7 @@
6
6
  * The words never name the machine's internals; `plainSentence` keeps a
7
7
  * recorded reason only when it is free of them. */
8
8
  /** What a person is asked for. */
9
- export type NeedKey = "approval" | "card" | "answer" | "questions" | "sign-in" | "confirm-stopped" | "check-stopped" | "hold" | "choose-project" | "define-task" | "fix-request" | "choose-agent" | "fix-dependency" | "add-requirement" | "connect-builder" | "start-builder" | "vanished" | "review-result" | "rebuild" | "earlier-version" | "other";
9
+ export type NeedKey = "approval" | "card" | "answer" | "questions" | "sign-in" | "confirm-stopped" | "check-stopped" | "hold" | "choose-project" | "define-task" | "fix-request" | "choose-agent" | "fix-dependency" | "add-requirement" | "connect-builder" | "start-builder" | "vanished" | "review-result" | "rebuild" | "replan" | "earlier-version" | "other";
10
10
  /** What the work waits for when no person can act. */
11
11
  export type WaitKey = "build-stopping" | "build-finishing" | "other-computer" | "card-reply" | "card-time" | "card-ci";
12
12
  /** The action's code: the WorkAction that owns the act, or one of the acts a surface renders itself. */
package/dist/needs-you.js CHANGED
@@ -25,7 +25,11 @@ export const NEEDS = {
25
25
  useReason: reason => /the planner can carry at most/.test(reason) },
26
26
  "choose-agent": { sentence: () => "Choose an agent to build this.", action: { code: "select-agent", label: "Choose an agent" } },
27
27
  "fix-dependency": { sentence: () => "A task this one waits on didn't finish. Fix or remove it so this one can start.", action: { code: "repair-dependency", label: "Fix the required task" },
28
- useReason: reason => /didn't finish|did not finish|before it finished/i.test(reason), withReason: reason => `${reason.replace(/[.\s]*$/, ".")} Fix or remove it so this one can start.` },
28
+ useReason: reason => /didn't finish|did not finish|before it finished/i.test(reason), withReason: reason => {
29
+ // A reason that already says what to do is said once.
30
+ const said = reason.replace(/[.\s]*$/, ".");
31
+ return /Fix or remove it so this one can start\.$/.test(said) ? said : `${said} Fix or remove it so this one can start.`;
32
+ } },
29
33
  "add-requirement": { sentence: () => "This project is missing something the task needs. Add it so a builder can start.", action: { code: "repair-capability", label: "Add the requirement" } },
30
34
  "connect-builder": { sentence: () => "No builder works on this project yet. Connect one so the task can start.", action: { code: "start-worker", label: "Connect a builder" } },
31
35
  "start-builder": { sentence: () => "The builder for this project is offline. Start it and the task begins on its own.", action: { code: "start-worker", label: "Start the builder" } },
@@ -33,6 +37,8 @@ export const NEEDS = {
33
37
  "review-result": { sentence: () => "Check the result, then accept it or ask for changes.", action: { code: "open-result", label: "Open result" } },
34
38
  // Accepting it would not help: it was built to terms that are no longer the plan.
35
39
  rebuild: { sentence: context => `${Build(context)} was made to an earlier plan. Build it again to the current plan.`, action: { code: "retry-task", label: "Build again" } },
40
+ // The plan was rewritten after the build and not yet approved: the yes comes first, then Build again.
41
+ replan: { sentence: context => `The plan changed after ${build(context)}. Approve the new plan, then build it again.`, action: { code: "approve-scope", label: "Approve plan" } },
36
42
  "earlier-version": { sentence: () => "An earlier version of this task still needs you. Finish it first.", action: { code: "inspect-task", label: "Open earlier version" } },
37
43
  other: { sentence: () => "Open the task to see what it needs from you.", action: { code: "inspect-task", label: "Open the task" }, useReason: always },
38
44
  };
@@ -42,7 +48,7 @@ export const ASK_LABEL = { decide: "Decide", review: "Review", unblock: "Unblock
42
48
  export function askChipOf(read) {
43
49
  if (read.headline === "Failed")
44
50
  return "Failed";
45
- if (read.planChanged === true || read.need === "rebuild")
51
+ if (read.planChanged === true || read.need === "rebuild" || read.need === "replan")
46
52
  return "Plan changed";
47
53
  if (read.mismatch === true)
48
54
  return "Mismatch";
@@ -60,7 +66,8 @@ export function resultHoldUpSentence(facts) {
60
66
  if (facts.hold != null)
61
67
  return "A hold is on this result. Release it to accept the result.";
62
68
  if (facts.planChanged)
63
- return "The plan changed after this was built. Build it again to the current plan.";
69
+ return facts.unapproved ? "The plan changed after this was built. Approve the new plan, then build it again."
70
+ : "The plan changed after this was built. Build it again to the current plan.";
64
71
  if (facts.unapproved)
65
72
  return "The current plan isn't approved. Approve it, then review the result.";
66
73
  if (facts.question)
@@ -76,7 +83,7 @@ export function resultHoldUpSentence(facts) {
76
83
  export const NEED_ASK = {
77
84
  approval: "decide", card: "decide", answer: "decide", questions: "decide", "choose-project": "decide", "define-task": "decide",
78
85
  "fix-request": "decide", "choose-agent": "decide",
79
- "review-result": "review", rebuild: "review",
86
+ "review-result": "review", rebuild: "review", replan: "decide",
80
87
  "sign-in": "unblock", "confirm-stopped": "unblock", "check-stopped": "unblock", hold: "unblock", "fix-dependency": "unblock",
81
88
  "add-requirement": "unblock", "connect-builder": "unblock", "start-builder": "unblock", vanished: "unblock", "earlier-version": "unblock", other: "unblock",
82
89
  };
@@ -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.d.ts CHANGED
@@ -145,6 +145,8 @@ export declare function runOperateAs(argv: string[], opts: {
145
145
  * finding 27): exclusive, 0600, fsynced — file and directory both — so the
146
146
  * row only ever follows a durable secret. Throws on any failure. */
147
147
  export declare function writeLoginFileDurably(path: string, name: string, password: string): void;
148
+ /** The platform browser opener — detached, silent, never load-bearing. */
149
+ export declare function openBrowser(url: string): void;
148
150
  export declare function readUpConsoleUrl(dir: string): string | null;
149
151
  /**
150
152
  * The people allowed to agree to a scope.