toolroll 0.9.63 → 0.9.67

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 (302) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +33 -5
  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 +117 -2
  16. package/dist/agent-fence.js +439 -12
  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 +39 -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/batch-checks.js +3 -2
  32. package/dist/browser/THIRD_PARTY_NOTICES.txt +94 -0
  33. package/dist/browser/terminal.css +37 -0
  34. package/dist/browser/terminal.js +228 -0
  35. package/dist/browser/workspace.css +2 -2
  36. package/dist/browser/workspace.js +42 -38
  37. package/dist/browser-shell.js +6 -3
  38. package/dist/browser-workspace.d.ts +235 -1
  39. package/dist/browser-workspace.js +60 -0
  40. package/dist/builder.d.ts +10 -1
  41. package/dist/builder.js +87 -24
  42. package/dist/chat-actions.d.ts +13 -1
  43. package/dist/chat-actions.js +197 -15
  44. package/dist/chat-ask.d.ts +4 -0
  45. package/dist/chat-ask.js +14 -0
  46. package/dist/chat-channel.js +20 -0
  47. package/dist/chat-continuity.js +5 -4
  48. package/dist/chat-decide.d.ts +0 -14
  49. package/dist/chat-decide.js +4 -1
  50. package/dist/chat-delivery.d.ts +9 -0
  51. package/dist/chat-delivery.js +45 -6
  52. package/dist/chat-messages.d.ts +8 -0
  53. package/dist/chat-messages.js +36 -4
  54. package/dist/check-levels-ui.js +2 -2
  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 +19 -0
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +4 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +21 -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/console-api.d.ts +13 -4
  71. package/dist/contracts/console-api.js +13 -4
  72. package/dist/contracts/flow-card.d.ts +4 -0
  73. package/dist/contracts/flow-card.js +3 -0
  74. package/dist/contracts/flow.d.ts +46 -0
  75. package/dist/contracts/flow.js +11 -2
  76. package/dist/contracts/lead-tools.d.ts +92 -0
  77. package/dist/contracts/lead-tools.js +13 -0
  78. package/dist/contracts/provider.d.ts +11 -1
  79. package/dist/contracts/provider.js +1 -1
  80. package/dist/contracts/route.d.ts +76 -1
  81. package/dist/contracts/route.js +1 -1
  82. package/dist/contracts/store-json.d.ts +2 -0
  83. package/dist/control-setup.d.ts +1 -1
  84. package/dist/control-ui.js +2 -2
  85. package/dist/converse.d.ts +16 -0
  86. package/dist/converse.js +32 -4
  87. package/dist/demo-chat.js +4 -4
  88. package/dist/demo.js +2 -2
  89. package/dist/desktop-update-gate.js +6 -0
  90. package/dist/desktop-update.d.ts +1 -1
  91. package/dist/discord-chat.js +4 -1
  92. package/dist/doctor.js +1 -1
  93. package/dist/egress-bridge.d.ts +17 -0
  94. package/dist/egress-bridge.js +111 -0
  95. package/dist/egress.d.ts +119 -0
  96. package/dist/egress.js +423 -0
  97. package/dist/exec.d.ts +78 -0
  98. package/dist/exec.js +367 -9
  99. package/dist/export-ui.js +2 -2
  100. package/dist/flow-engine.js +24 -5
  101. package/dist/flow-gallery-ui.js +2 -2
  102. package/dist/flow-gallery.js +27 -8
  103. package/dist/flow-share.js +16 -5
  104. package/dist/flow-triggers.d.ts +77 -8
  105. package/dist/flow-triggers.js +517 -70
  106. package/dist/flows-cli.js +8 -2
  107. package/dist/flows-ui.d.ts +0 -2
  108. package/dist/flows-ui.js +52 -4
  109. package/dist/flows.d.ts +4 -0
  110. package/dist/flows.js +9 -1
  111. package/dist/html.d.ts +9 -0
  112. package/dist/html.js +11 -0
  113. package/dist/invoke.d.ts +35 -0
  114. package/dist/invoke.js +347 -28
  115. package/dist/keys.d.ts +18 -14
  116. package/dist/keys.js +5 -0
  117. package/dist/lead-capabilities.js +3 -2
  118. package/dist/lead-contract.js +2 -0
  119. package/dist/lead-doors.d.ts +2 -0
  120. package/dist/lead-doors.js +7 -0
  121. package/dist/lead-follow.d.ts +2 -0
  122. package/dist/lead-follow.js +58 -2
  123. package/dist/lead-progress.js +1 -0
  124. package/dist/lead-queue-runtime.d.ts +46 -0
  125. package/dist/lead-queue-runtime.js +191 -0
  126. package/dist/lead-queue.d.ts +104 -0
  127. package/dist/lead-queue.js +187 -0
  128. package/dist/lead-sweep.d.ts +67 -0
  129. package/dist/lead-sweep.js +99 -0
  130. package/dist/lead-tools.d.ts +22 -0
  131. package/dist/lead-tools.js +152 -1
  132. package/dist/lead.d.ts +13 -5
  133. package/dist/lead.js +89 -29
  134. package/dist/limits-ui.d.ts +8 -1
  135. package/dist/limits-ui.js +26 -0
  136. package/dist/live-bus.d.ts +8 -0
  137. package/dist/live-bus.js +40 -4
  138. package/dist/live-detail.d.ts +27 -0
  139. package/dist/live-detail.js +57 -0
  140. package/dist/live-notes.d.ts +20 -0
  141. package/dist/live-notes.js +47 -0
  142. package/dist/live-records.d.ts +140 -0
  143. package/dist/live-records.js +935 -0
  144. package/dist/live-terminal.d.ts +269 -0
  145. package/dist/live-terminal.js +686 -0
  146. package/dist/live.d.ts +38 -27
  147. package/dist/live.js +142 -124
  148. package/dist/mcp-oauth.d.ts +6 -3
  149. package/dist/mcp-oauth.js +8 -4
  150. package/dist/memory-pass.js +2 -2
  151. package/dist/mobile-viewport.d.ts +5 -1
  152. package/dist/mobile-viewport.js +18 -2
  153. package/dist/model-catalog.js +4 -0
  154. package/dist/monitoring-ui.js +2 -2
  155. package/dist/needs-you.d.ts +1 -1
  156. package/dist/needs-you.js +11 -4
  157. package/dist/operate.d.ts +4 -0
  158. package/dist/operate.js +335 -19
  159. package/dist/passwordless.d.ts +23 -0
  160. package/dist/passwordless.js +26 -0
  161. package/dist/peek-cli.d.ts +12 -4
  162. package/dist/peek-cli.js +19 -11
  163. package/dist/phase-routing.js +10 -3
  164. package/dist/planner-source.d.ts +3 -0
  165. package/dist/planner-source.js +14 -0
  166. package/dist/planner.d.ts +2 -1
  167. package/dist/planner.js +7 -1
  168. package/dist/policy-ui.js +2 -2
  169. package/dist/policy.d.ts +1 -20
  170. package/dist/policy.js +29 -2
  171. package/dist/project-delete-ui.d.ts +18 -0
  172. package/dist/project-delete-ui.js +60 -9
  173. package/dist/project-delete.js +2 -2
  174. package/dist/project-memory.d.ts +3 -1
  175. package/dist/project-memory.js +57 -25
  176. package/dist/project-tools.d.ts +5 -3
  177. package/dist/project-tools.js +12 -9
  178. package/dist/provider-auth.d.ts +4 -3
  179. package/dist/provider-auth.js +5 -4
  180. package/dist/provider-connection.d.ts +2 -1
  181. package/dist/provider-connection.js +5 -0
  182. package/dist/provider.d.ts +25 -2
  183. package/dist/provider.js +63 -9
  184. package/dist/pull-request-ui.js +2 -2
  185. package/dist/push.js +1 -1
  186. package/dist/quick-start.d.ts +24 -0
  187. package/dist/quick-start.js +127 -0
  188. package/dist/release-adapters.d.ts +6 -0
  189. package/dist/release-adapters.js +70 -3
  190. package/dist/release.d.ts +10 -1
  191. package/dist/release.js +34 -15
  192. package/dist/request-budget-ui.js +2 -2
  193. package/dist/result-follow-ups.js +3 -2
  194. package/dist/result-review.d.ts +1 -1
  195. package/dist/result-review.js +9 -3
  196. package/dist/retention-ui.js +2 -2
  197. package/dist/retention.js +10 -1
  198. package/dist/run-efficiency.d.ts +73 -0
  199. package/dist/run-efficiency.js +228 -0
  200. package/dist/runner.d.ts +5 -1
  201. package/dist/runner.js +22 -1
  202. package/dist/scope.d.ts +183 -5
  203. package/dist/scope.js +256 -31
  204. package/dist/scout-net.d.ts +4 -1
  205. package/dist/scout-net.js +37 -12
  206. package/dist/scout.d.ts +20 -6
  207. package/dist/scout.js +27 -3
  208. package/dist/serve.js +248 -40
  209. package/dist/server/chat.d.ts +1 -1
  210. package/dist/server/chat.js +122 -31
  211. package/dist/server/chrome.d.ts +16 -2
  212. package/dist/server/chrome.js +63 -11
  213. package/dist/server/console.css +107 -42
  214. package/dist/server/flows.js +38 -8
  215. package/dist/server/guards.d.ts +1 -0
  216. package/dist/server/guards.js +23 -9
  217. package/dist/server/http.d.ts +7 -0
  218. package/dist/server/http.js +3 -2
  219. package/dist/server/live.d.ts +34 -1
  220. package/dist/server/live.js +39 -17
  221. package/dist/server/pages-assets.js +5 -0
  222. package/dist/server/pages.js +90 -13
  223. package/dist/server/people-tokens.d.ts +3 -0
  224. package/dist/server/people-tokens.js +55 -17
  225. package/dist/server/remote-hooks.js +12 -6
  226. package/dist/server/remote.d.ts +2 -1
  227. package/dist/server/remote.js +14 -5
  228. package/dist/server/render-chat.d.ts +12 -3
  229. package/dist/server/render-chat.js +47 -16
  230. package/dist/server/render-pages.d.ts +32 -4
  231. package/dist/server/render-pages.js +83 -13
  232. package/dist/server/render-people.d.ts +3 -2
  233. package/dist/server/render-people.js +3 -2
  234. package/dist/server/render-results.d.ts +18 -2
  235. package/dist/server/render-results.js +37 -28
  236. package/dist/server/render-settings.js +6 -5
  237. package/dist/server/render-tasks.d.ts +35 -10
  238. package/dist/server/render-tasks.js +111 -27
  239. package/dist/server/request-context.d.ts +2 -0
  240. package/dist/server/request-context.js +2 -1
  241. package/dist/server/route-table.d.ts +3 -0
  242. package/dist/server/route-table.js +47 -5
  243. package/dist/server/runtime.d.ts +33 -2
  244. package/dist/server/session.d.ts +25 -1
  245. package/dist/server/session.js +18 -4
  246. package/dist/server/settings.js +142 -10
  247. package/dist/server/sweeps.d.ts +4 -0
  248. package/dist/server/sweeps.js +58 -0
  249. package/dist/server/task-views.d.ts +2 -1
  250. package/dist/server/task-views.js +96 -6
  251. package/dist/server/tasks.js +327 -78
  252. package/dist/server/terminals.d.ts +31 -0
  253. package/dist/server/terminals.js +365 -0
  254. package/dist/setup-guide.js +3 -2
  255. package/dist/slack-chat.js +4 -1
  256. package/dist/spend-ui.js +3 -3
  257. package/dist/spend.js +2 -1
  258. package/dist/steer-capabilities.d.ts +32 -0
  259. package/dist/steer-capabilities.js +60 -0
  260. package/dist/steer-controls.d.ts +14 -0
  261. package/dist/steer-controls.js +36 -0
  262. package/dist/steer-status.js +2 -2
  263. package/dist/storage-ui.js +4 -4
  264. package/dist/store-baseline.d.ts +1 -1
  265. package/dist/store-baseline.js +1 -1
  266. package/dist/store.d.ts +440 -15
  267. package/dist/store.js +1072 -52
  268. package/dist/subagent-desk.js +1 -1
  269. package/dist/summary.js +3 -0
  270. package/dist/task-source.d.ts +58 -0
  271. package/dist/task-source.js +93 -0
  272. package/dist/team-leads.js +1 -1
  273. package/dist/teams-chat.js +4 -1
  274. package/dist/teardown-git.d.ts +22 -0
  275. package/dist/teardown-git.js +136 -2
  276. package/dist/telegram-lead.d.ts +3 -0
  277. package/dist/telegram-lead.js +44 -7
  278. package/dist/telegram.d.ts +11 -2
  279. package/dist/telegram.js +51 -17
  280. package/dist/terminal-catalog.d.ts +11 -0
  281. package/dist/terminal-catalog.js +129 -0
  282. package/dist/text-limits.d.ts +2 -0
  283. package/dist/text-limits.js +2 -0
  284. package/dist/tool-broker.d.ts +14 -0
  285. package/dist/tool-broker.js +67 -2
  286. package/dist/toolroll-update-ui.js +2 -2
  287. package/dist/toolroll-update.js +13 -3
  288. package/dist/tools-ui.js +2 -2
  289. package/dist/unfenced-test-support.d.ts +2 -0
  290. package/dist/unfenced-test-support.js +11 -0
  291. package/dist/webhook-limits.d.ts +23 -0
  292. package/dist/webhook-limits.js +30 -0
  293. package/dist/webhook-readers.d.ts +56 -0
  294. package/dist/webhook-readers.js +138 -0
  295. package/dist/webhook-signing.d.ts +27 -0
  296. package/dist/webhook-signing.js +119 -0
  297. package/dist/work-index.d.ts +29 -0
  298. package/dist/work-index.js +16 -2
  299. package/dist/workspace-revision.js +3 -1
  300. package/dist/writes-mode.d.ts +9 -0
  301. package/dist/writes-mode.js +8 -0
  302. package/package.json +15 -2
package/dist/operate.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { ACP_AGENTS, ACP_PROVIDER_IDS, isAcpProvider, isKeyedProvider } from "./acp-agents.js";
2
+ import { inspectAcpAgent } from "./acp-inspect.js";
3
+ import { runAcpChecks } from "./acp-check.js";
4
+ import { verifyLaunch } from "./acp-attest.js";
1
5
  import { notificationClaimHeld, claimNotifications, finalizeNotification, notificationDestination } from "./notification-delivery.js";
2
6
  import { adapterPolicy } from "./server/route-policy.js";
3
7
  import { UNSENT_REPLY_MS } from "./telegram-settings.js";
@@ -95,6 +99,7 @@ import { basename, dirname, join, resolve } from "node:path";
95
99
  import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
96
100
  import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, DEFAULT_ACTOR, parseCapabilityKey, verifiedAuthor, isDigestTime, RESULT_SCREENSHOTS, } from "./store.js";
97
101
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
102
+ import { claimInstance, clearInstance, openInstanceSocket } from "./console-instance.js";
98
103
  import { authorizePlanUnderMode } from "./plan-auto.js";
99
104
  import { ghDispatchAdapter, mirrorTaskId, syncPass } from "./sync.js";
100
105
  import { LIVE_FILE_BOUND, LIVE_RETAIN_MS, sweepLiveLogs } from "./live.js";
@@ -105,6 +110,8 @@ import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync,
105
110
  import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, taskBranches } from "./names.js";
106
111
  import { claimActor, currentActor, withActor } from "./actor.js";
107
112
  import { parseLeadToken } from "./api-tokens.js";
113
+ import { MAX_NETWORK_HOSTS, normalizeHost } from "./egress.js";
114
+ import { isWritesMode, writesWords } from "./writes-mode.js";
108
115
  import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
109
116
  import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
110
117
  import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
@@ -182,7 +189,7 @@ import { register, authenticate, heartbeat as heartbeatRunner, isAlive, recoverD
182
189
  import { mintCoordinator, revokeCoordinator, listCoordinators } from "./coordinator.js";
183
190
  import { serveMcp } from "./mcp.js";
184
191
  import { createInterface } from "node:readline";
185
- import { propose, approve, addApprover, authenticateAccount, authenticateApprover as passwordApprover, describeScope, approvalOf, hashToken as hashApproverToken, fileAndSealUnderMode, modeFilingCoverage, acceptanceLinesToInput, parseAcceptanceCriteria, splitAcceptanceRubric, rubricIsPlaceholder, isCommitSha } from "./scope.js";
192
+ import { propose, approve, addApprover, authenticateAccount, authenticateApprover as passwordApprover, describeScope, approvalOf, hashToken as hashApproverToken, fileAndSealUnderMode, modeFilingCoverage, acceptanceLinesToInput, parseAcceptanceCriteria, splitAcceptanceRubric, rubricIsPlaceholder, isCommitSha, credentialProof, rememberedLogin } from "./scope.js";
186
193
  import { CHAT_APPROVE_ALL, presetTerms, modeTermsJson, modeDigestOf, modeTermsFromJson, modeWords, MODE_MAX_DAYS } from "./modes.js";
187
194
  import { WorktreePool } from "./worktree.js";
188
195
  import { baseStandingWords, freshBaseFor, taskBaseOf } from "./task-base.js";
@@ -251,6 +258,15 @@ export const OPERATE_HELP = `toolroll — operating the queue
251
258
  toolroll project show show the current project and credential reference
252
259
  toolroll project rules --repo <p> a project's approval rules; an instance operator changes them with
253
260
  --not-requester on|off, --protect none|project, --protect-paths "a/**,b/**"
261
+ toolroll project network --repo <p> [--allow h,…] [--remove h,…] [--open|--allowlist] [--local any|none]
262
+ what the project's builds may reach on the network; an approver changes it
263
+ (new scopes seal it; approved ones keep theirs)
264
+ toolroll project writes --repo <p> [workspace|anywhere]
265
+ where the project's builds may write: only their checkout, caches and temp
266
+ (workspace, the default), or anywhere outside Toolroll's fenced folders;
267
+ an approver changes it (new scopes seal it; approved ones keep theirs)
268
+ toolroll config unconfined-agents [allow|refuse]
269
+ whether builds run where this machine can't confine them (instance operator)
254
270
  toolroll project concurrency [<n>] --repo <p>
255
271
  how many of a project's tasks build at once (default 2, never past the
256
272
  worker's capacity); an approver changes it with <n>
@@ -677,6 +693,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
677
693
  "no-open", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
678
694
  "self-heal", "plan-auto", "chat-approve", "full-access", "repair-auto", "review-retry-auto", "no-local",
679
695
  "html", "csv", "alerts-only",
696
+ // project network: what its builds may reach (--open | --allowlist).
697
+ "open", "allowlist",
680
698
  // v105: the full export.
681
699
  "zip",
682
700
  // Settings → Integrations: the last checks, without checking again.
@@ -698,6 +716,9 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
698
716
  // task steer: pause the running Claude build, then send the note.
699
717
  "now",
700
718
  ]);
719
+ /** `project network` reads --remove <hosts> and --local any|none as values, where every other verb's --remove and
720
+ * --local are switches. */
721
+ const PROJECT_NETWORK_VALUE_FLAGS = new Set(["remove", "local"]);
701
722
  export function parseOperateArgs(argv, ownValues = new Set()) {
702
723
  const positional = [];
703
724
  const flags = new Map();
@@ -752,10 +773,11 @@ export function parseOperateArgs(argv, ownValues = new Set()) {
752
773
  }
753
774
  /** Route a native command and return its exit code. Its password source cannot be selected by an HTTP request. */
754
775
  export async function runOperate(command, argv, write, options = {}) {
755
- return withPasswordSource(CLI_PASSWORD_SOURCE, () => runOperateLocal(command, argv, write, options));
776
+ // Each command gets its own remembered-login holder (Passwordless: "cli-login" is this command's, never the next one's).
777
+ return withPasswordSource(CLI_PASSWORD_SOURCE, () => rememberedLogin.run({ login: null }, () => runOperateLocal(command, argv, write, options)));
756
778
  }
757
779
  async function runOperateLocal(command, argv, write, options = {}) {
758
- const parsed = parseOperateArgs(argv, command === "flows" ? FLOWS_VALUE_FLAGS : undefined);
780
+ const parsed = parseOperateArgs(argv, ownValueFlags(command, argv));
759
781
  // A parse error precedes the flags map, so JSON mode is read from the raw
760
782
  // argv — the envelope contract holds even for the earliest refusal.
761
783
  if ("error" in parsed)
@@ -967,7 +989,7 @@ export async function runOperateAs(argv, opts) {
967
989
  const scope = REMOTE_SCOPES.get(row.invocation);
968
990
  if (row.remote !== "yes" || scope === undefined)
969
991
  return refuse("not-remote", REMOTE_MESSAGES.no);
970
- const parsed = parseOperateArgs(rest, root === "flows" ? FLOWS_VALUE_FLAGS : undefined);
992
+ const parsed = parseOperateArgs(rest, ownValueFlags(root, rest));
971
993
  if ("error" in parsed)
972
994
  return refuse("usage", parsed.error);
973
995
  // The parser's positionals must spell the same row (a value flag can't hide a subcommand).
@@ -1145,6 +1167,10 @@ async function dispatch(command, positional, flags, context) {
1145
1167
  return projectDeleteCommand(positional, flags, context);
1146
1168
  if (positional[0] === "concurrency")
1147
1169
  return projectConcurrencyCommand(positional, flags, context);
1170
+ if (positional[0] === "network")
1171
+ return projectNetworkCommand(positional, flags, context);
1172
+ if (positional[0] === "writes")
1173
+ return projectWritesCommand(positional, flags, context);
1148
1174
  if (positional[0] === "checks")
1149
1175
  return projectChecksCommand(positional, flags, context);
1150
1176
  if (positional[0] === "demo")
@@ -1223,6 +1249,8 @@ async function dispatch(command, positional, flags, context) {
1223
1249
  case "flows":
1224
1250
  return flowsCommand(positional, flags, context);
1225
1251
  case "config":
1252
+ if (positional[0] === "unconfined-agents")
1253
+ return unconfinedAgentsCommand(positional, flags, context);
1226
1254
  return configCommand(positional, flags, context);
1227
1255
  case "chat":
1228
1256
  return chatCommand(flags, context);
@@ -1243,6 +1271,8 @@ async function dispatch(command, positional, flags, context) {
1243
1271
  });
1244
1272
  case "keys":
1245
1273
  return keysCommand(positional, flags, context);
1274
+ case "agents":
1275
+ return agentsCommand(positional, flags, context);
1246
1276
  case "setup":
1247
1277
  return setupCommand(positional, flags, context);
1248
1278
  case "verify":
@@ -1488,6 +1518,9 @@ function claimCommand(positional, flags, context) {
1488
1518
  if (result.reason === "coordinator-filed") {
1489
1519
  return fail(write, json, "claim", "coordinator-filed", "a coordinator filed this — nothing runs until the operator signs its scope", EXIT.refused);
1490
1520
  }
1521
+ if (result.reason === "sweep-limit") {
1522
+ return fail(write, json, "claim", "sweep-limit", `its sweep already has ${result.limit} running at once — it starts when one finishes`, EXIT.refused, { sweep: result.sweep, limit: result.limit });
1523
+ }
1491
1524
  return fail(write, json, "claim", result.reason, `held by ${result.by} until ${result.until}`, EXIT.refused, { holder: result.by, until: result.until });
1492
1525
  }
1493
1526
  // Taking a task is what makes it running; leaving that to the caller would
@@ -1606,7 +1639,7 @@ async function runnerCommand(positional, flags, context) {
1606
1639
  if (acting === null) {
1607
1640
  return fail(write, json, "runner register", "usage", "`runner register <name> --repo <path> --as <you> --token <t>` — minting runner authority is an operator act", EXIT.usage);
1608
1641
  }
1609
- const authenticated = authenticateApprover(store, acting.name, acting.token);
1642
+ const authenticated = credentialApprover(store, acting.name, acting.token);
1610
1643
  if (!authenticated.ok) {
1611
1644
  return fail(write, json, "runner register", authenticated.reason, describeApproveFailure(authenticated.reason, name), EXIT.refused);
1612
1645
  }
@@ -1732,7 +1765,7 @@ async function runnerCommand(positional, flags, context) {
1732
1765
  if (acting === null) {
1733
1766
  return fail(write, json, "runner bind", "usage", "`runner bind <name> --repo <path> --as <you> --token <t>` — binding repositories is an operator act", EXIT.usage);
1734
1767
  }
1735
- const authenticated = authenticateApprover(store, acting.name, acting.token);
1768
+ const authenticated = credentialApprover(store, acting.name, acting.token);
1736
1769
  if (!authenticated.ok) {
1737
1770
  return fail(write, json, "runner bind", authenticated.reason, describeApproveFailure(authenticated.reason, name), EXIT.refused);
1738
1771
  }
@@ -1818,7 +1851,7 @@ async function coordinatorCommand(positional, flags, context) {
1818
1851
  if (acting === null) {
1819
1852
  return fail(write, json, "coordinator mint", "usage", "`coordinator mint <name> --repo <path> --as <you> --token <t>` — minting filing authority is an operator act", EXIT.usage);
1820
1853
  }
1821
- const authenticated = authenticateApprover(store, acting.name, acting.token);
1854
+ const authenticated = credentialApprover(store, acting.name, acting.token);
1822
1855
  if (!authenticated.ok) {
1823
1856
  return fail(write, json, "coordinator mint", authenticated.reason, describeApproveFailure(authenticated.reason, name), EXIT.refused);
1824
1857
  }
@@ -2292,6 +2325,17 @@ async function tickCommand(flags, context) {
2292
2325
  root: pool,
2293
2326
  ...(context.gitRunner === undefined ? {} : { runner: context.gitRunner }),
2294
2327
  });
2328
+ // ACP agent checks (acp-check.ts) run in a watch's own pass, under its lease, before anything is dispatched — one
2329
+ // at a time per runner. Only a watch's full pass runs them (never a build lane, never a bare tick).
2330
+ const watchOwner = text(flags, "incarnation");
2331
+ if (watchOwner !== undefined && !buildsOnly) {
2332
+ await runAcpChecks({
2333
+ store, runner, token, pool: worktrees, clock,
2334
+ watch: { repo: canonicalProject(repo) ?? resolve(repo), owner: watchOwner },
2335
+ progress: line => { if (!json)
2336
+ write(line); },
2337
+ });
2338
+ }
2295
2339
  // Where a first attempt's branch grows from, resolved once per pass. A
2296
2340
  // detached HEAD refuses the pass rather than guessing: an unattended commit
2297
2341
  // onto "wherever the operator happened to be" is not a default.
@@ -2616,6 +2660,20 @@ async function tickCommand(flags, context) {
2616
2660
  : [store.getScope(id)?.approvedProfile?.provider].filter((one) => one !== null && one !== undefined);
2617
2661
  let unattestedLane = null;
2618
2662
  for (const candidate of new Set(skipProviders)) {
2663
+ // An ACP agent is admitted by its own check on this runner (absence is denial), read here the way the gateway reads it.
2664
+ if (isAcpProvider(candidate)) {
2665
+ const checked = store.acpAttestationFor(candidate, runner);
2666
+ const verified = checked === null ? null : verifyLaunch(checked.launch);
2667
+ if (checked === null) {
2668
+ unattestedLane = `${ACP_AGENTS[candidate].name} isn't checked on ${runner} — run \`toolroll agents check ${candidate}\` there`;
2669
+ break;
2670
+ }
2671
+ if (verified !== null && !verified.ok) {
2672
+ unattestedLane = verified.problem;
2673
+ break;
2674
+ }
2675
+ continue;
2676
+ }
2619
2677
  if (attestationOf(candidate) === null)
2620
2678
  continue;
2621
2679
  let verdict = attestedThisPass.get(candidate);
@@ -2646,7 +2704,9 @@ async function tickCommand(flags, context) {
2646
2704
  // budget is raised (the 100% alert went out; the task page and the spend page say why).
2647
2705
  // Budgets are dollars: work that runs only on subscriptions passes (see Store.budgetGate).
2648
2706
  // Each provider as it bills now.
2649
- const budgeted = budgetHold(ref.id, store.agentsFor([spec.provider, ...skipProviders]));
2707
+ // An ACP agent bills as its declaration for the exact model it would run on this runner.
2708
+ const sealedModel = store.getScope(id)?.approvedProfile?.model ?? null;
2709
+ const budgeted = budgetHold(ref.id, [...new Set([spec.provider, ...skipProviders])].map(provider => isAcpProvider(provider) ? store.agentFor(provider, provider === spec.provider ? spec.model ?? sealedModel : sealedModel, runner) : store.agentsFor([provider])[0]));
2650
2710
  if (budgeted.over !== null) {
2651
2711
  dispatched.push({ id, outcome: "skipped", reason: "budget", detail: budgetWords(budgeted) });
2652
2712
  continue;
@@ -3900,6 +3960,7 @@ async function startConsole(options) {
3900
3960
  ...(options.registryPath === undefined ? {} : { registryPath: options.registryPath }),
3901
3961
  ...(options.upConsole === undefined ? {} : { upConsole: options.upConsole }),
3902
3962
  ...(options.editorLinks === undefined ? {} : { editorLinks: options.editorLinks }),
3963
+ ...(options.instanceSecret === undefined ? {} : { instanceSecret: options.instanceSecret }),
3903
3964
  });
3904
3965
  await new Promise((resolve, reject) => {
3905
3966
  server.once("error", reject);
@@ -4035,6 +4096,8 @@ async function requeueTask(positional, flags, context) {
4035
4096
  claimed: `a runner holds ${id} right now — wait for the attempt to end, or stop it`,
4036
4097
  "accepted-result": `${id}'s last result was accepted; change it through a revision, not a rerun`,
4037
4098
  published: `${id}'s last result is published; revise it through its pull request`,
4099
+ // Only an Approve & build again asks for an exact build; the CLI never does.
4100
+ moved: `${id} changed since its page was read; read it again`,
4038
4101
  };
4039
4102
  return fail(write, json, "task requeue", result.reason, why[result.reason], EXIT.refused);
4040
4103
  }
@@ -4317,6 +4380,13 @@ async function providersCommand(flags, context) {
4317
4380
  // that serializes four of them reads as a hang (Phase 3).
4318
4381
  const probed = await Promise.all(PROVIDER_IDS.map(async (id) => {
4319
4382
  const facts = inspectionOf(id);
4383
+ // An ACP agent is looked at, never run (acp-inspect.ts): its version is its package's, read; whether it may
4384
+ // run is its conformance check.
4385
+ if (isAcpProvider(id)) {
4386
+ const seen = inspectAcpAgent(id);
4387
+ const version = { code: seen.found ? 0 : 127, stdout: seen.found ? (seen.package?.version ?? "") : "", stderr: "", notFound: !seen.found, timedOut: false };
4388
+ return { id, facts, version, installed: seen.found, identity: seen.found ? `not checked on this computer — run \`toolroll agents check ${id}\`` : null };
4389
+ }
4320
4390
  // A version probe carries no key (Codex gemini verify, finding 2).
4321
4391
  const version = await probe(facts.binary, ["--version"], { timeoutMs: 5_000, omitEnv: ALL_CREDENTIAL_ENV });
4322
4392
  const installed = version.code === 0 && !version.notFound;
@@ -4373,7 +4443,7 @@ async function providersCommand(flags, context) {
4373
4443
  if (runnerName === undefined || runnerToken === undefined) {
4374
4444
  return fail(write, json, "providers", "usage", "`toolroll providers --report --runner <name> --token <t>` records readiness under that runner", EXIT.usage);
4375
4445
  }
4376
- const observed = await observeProviderReadiness((file, args, options) => probe(file, args, { timeoutMs: options?.timeoutMs ?? 5_000, ...(options?.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) }));
4446
+ const observed = await observeProviderReadiness((file, args, options) => probe(file, args, { timeoutMs: options?.timeoutMs ?? 5_000, ...(options?.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) }), process.env, PROVIDER_IDS, provider => store.acpAttestationFor(provider, runnerName));
4377
4447
  const reported = reportProviderReadinessAuthed(store, { name: runnerName, token: runnerToken, observations: observed }, context.clock());
4378
4448
  if (!reported.ok) {
4379
4449
  return fail(write, json, "providers", reported.reason, describeAuth(reported.reason, runnerName), EXIT.refused);
@@ -4444,6 +4514,83 @@ async function providersCommand(flags, context) {
4444
4514
  * own — these are 0600 files under the operator's home, and whoever can
4445
4515
  * write them already owns the machine's spend.
4446
4516
  */
4517
+ /**
4518
+ * `toolroll agents …` — the coding agents Toolroll runs over ACP (acp-agents.ts): what is installed here, which are
4519
+ * checked on which runner, and how each checked model is billed. An agent runs only once its conformance check passed
4520
+ * on that runner (acp-check.ts); asking for one, or declaring a model billed by the agent's own plan, takes a password.
4521
+ *
4522
+ * agents each agent: installed, checked, billing
4523
+ * agents check <agent> --model <m> [--runner r] ask the runner's watch to check it (password)
4524
+ * agents revoke <agent> [--runner r] the agent stops running there
4525
+ * agents billing <agent> <model> plan|metered [--runner r]
4526
+ */
4527
+ async function agentsCommand(positional, flags, context) {
4528
+ const { store, write, json, clock } = context;
4529
+ const [action = "list", provider, ...rest] = positional;
4530
+ const runners = store.listRunners().filter(one => one.retiredAt === null).map(one => one.name);
4531
+ const runnerGiven = text(flags, "runner");
4532
+ const runner = runnerGiven ?? (runners.length === 1 ? runners[0] : undefined);
4533
+ if (action === "list") {
4534
+ const rows = ACP_PROVIDER_IDS.map(id => {
4535
+ const seen = inspectAcpAgent(id);
4536
+ const checked = runners.flatMap(name => { const one = store.acpAttestationFor(id, name); return one === null ? [] : [{ runner: name, agent: one.agent, models: one.models, at: one.recordedAt }]; });
4537
+ const latest = store.acpChecksFor(id, 1)[0] ?? null;
4538
+ return { provider: id, name: ACP_AGENTS[id].name, installed: seen.found ? seen.realpath : null, checked, latestCheck: latest === null ? null : { id: latest.id, state: latest.state, model: latest.model, result: latest.result } };
4539
+ });
4540
+ if (json) {
4541
+ write(envelopeJson({ ok: true, command: "agents", agents: rows }));
4542
+ return EXIT.ok;
4543
+ }
4544
+ for (const row of rows) {
4545
+ write(` ${row.name.padEnd(10)} ${row.installed === null ? "not installed" : row.installed}`);
4546
+ for (const one of row.checked)
4547
+ write(` checked on ${one.runner}: ${one.agent.name} ${one.agent.version}, ${one.models.length} models (${one.at.slice(0, 10)})`);
4548
+ if (row.latestCheck !== null && row.latestCheck.state !== "passed")
4549
+ write(` check #${row.latestCheck.id} (${row.latestCheck.model}): ${row.latestCheck.state}${row.latestCheck.result === null ? "" : ` — ${row.latestCheck.result}`}`);
4550
+ }
4551
+ write(" → `agents check <agent> --model <id>` asks this runner's watch to check one; it runs on the watch's next pass");
4552
+ return EXIT.ok;
4553
+ }
4554
+ if (!["check", "revoke", "billing"].includes(action))
4555
+ return fail(write, json, "agents", "usage", "`toolroll agents [check|revoke|billing]`", EXIT.usage);
4556
+ if (provider === undefined || !isAcpProvider(provider))
4557
+ return fail(write, json, `agents ${action}`, "usage", `which agent? ${ACP_PROVIDER_IDS.join(", ")}`, EXIT.usage);
4558
+ if (runner === undefined || !runners.includes(runner))
4559
+ return fail(write, json, `agents ${action}`, "usage", `which runner? --runner ${runners.join(" | ") || "<name>"}`, EXIT.usage);
4560
+ const acting = await askCredentials(flags, context);
4561
+ if (acting === null)
4562
+ return fail(write, json, `agents ${action}`, "usage", "this takes your name and password — `--as <you>` and the hidden prompt", EXIT.usage);
4563
+ const verified = verifyApproverByPassword(store, acting.name, acting.token, store.knownRepos().filter(repo => store.accountCanAccess(acting.name, repo)));
4564
+ if (!verified.ok)
4565
+ return fail(write, json, `agents ${action}`, "unauthenticated", "that is not an approver, or the password does not match", EXIT.refused);
4566
+ // Which agents a runner admits, and how they bill, hold for every project the runner serves: an operator's call.
4567
+ if (!store.isInstanceOperator(verified.who.name))
4568
+ return fail(write, json, `agents ${action}`, "forbidden", "only an installation operator can check, revoke or set billing for a runner's agents", EXIT.refused);
4569
+ const name = ACP_AGENTS[provider].name;
4570
+ const now = clock();
4571
+ if (action === "revoke") {
4572
+ const revoked = store.revokeAcpAttestation(provider, runner, verified.who.name, now);
4573
+ return succeed(write, json, "agents revoke", { provider, runner, revoked }, () => [revoked ? `${name} no longer runs on ${runner}; check it again to bring it back.` : `${name} wasn't checked on ${runner}.`]);
4574
+ }
4575
+ if (action === "billing") {
4576
+ const [model, kind] = rest;
4577
+ if (model === undefined || !validModelId(model) || (kind !== "plan" && kind !== "metered"))
4578
+ return fail(write, json, "agents billing", "usage", "`agents billing <agent> <model> plan|metered`", EXIT.usage);
4579
+ const revision = store.declareAcpBilling({ provider, runner, model, kind, by: verified.who.name }, now);
4580
+ return succeed(write, json, "agents billing", { provider, runner, model, kind, revision }, () => [kind === "plan"
4581
+ ? `${name} on ${model} (${runner}) is billed by ${name}'s own plan: its runs count as $0, unmeasured, and monthly budgets don't hold them. Runs already started keep the terms they started with.`
4582
+ : `${name} on ${model} (${runner}) is metered: its cost is unknown, so capped work and hard budgets hold it.`]);
4583
+ }
4584
+ // check
4585
+ const model = text(flags, "model");
4586
+ if (model === undefined || !validModelId(model))
4587
+ return fail(write, json, "agents check", "usage", "`agents check <agent> --model <id>` — a model the agent offers (its own list shows after the check)", EXIT.usage);
4588
+ const id = store.requestAcpCheck({ provider, runner, model, by: verified.who.name }, now);
4589
+ return succeed(write, json, "agents check", { provider, runner, model, request: id }, () => [
4590
+ `Check #${id} asked for: ${name} on ${model}, on ${runner}. Its watch runs it on its next pass (two short runs, in the fence; the model's own spend).`,
4591
+ "→ `toolroll agents` shows how it went.",
4592
+ ]);
4593
+ }
4447
4594
  async function keysCommand(positional, flags, context) {
4448
4595
  const { write, json } = context;
4449
4596
  const [action, provider, ...rest] = positional;
@@ -4451,7 +4598,8 @@ async function keysCommand(positional, flags, context) {
4451
4598
  return fail(write, json, "keys", "usage", `unknown \`keys ${action ?? ""}\` — try ${KEYS_ACTIONS.join(", ")}`, EXIT.usage);
4452
4599
  }
4453
4600
  if (action === "status") {
4454
- const rows = PROVIDER_IDS.map(one => ({
4601
+ // ACP agents sign in their own way: Toolroll keeps no key for them, so they aren't listed here.
4602
+ const rows = PROVIDER_IDS.filter(isKeyedProvider).map(one => ({
4455
4603
  provider: one,
4456
4604
  envName: PROVIDER_KEY_ENV[one],
4457
4605
  ...keyStatus(one),
@@ -4475,7 +4623,10 @@ async function keysCommand(positional, flags, context) {
4475
4623
  return EXIT.ok;
4476
4624
  }
4477
4625
  if (provider === undefined || !isProviderId(provider)) {
4478
- return fail(write, json, `keys ${action}`, "usage", `which provider? ${PROVIDER_IDS.join(", ")}`, EXIT.usage);
4626
+ return fail(write, json, `keys ${action}`, "usage", `which provider? ${PROVIDER_IDS.filter(isKeyedProvider).join(", ")}`, EXIT.usage);
4627
+ }
4628
+ if (!isKeyedProvider(provider)) {
4629
+ return fail(write, json, `keys ${action}`, "refused", `${ACP_AGENTS[provider].name} signs in its own way (\`${ACP_AGENTS[provider].signIn}\`) — Toolroll keeps no key for it`, EXIT.refused);
4479
4630
  }
4480
4631
  if (action === "clear") {
4481
4632
  const cleared = clearProviderKey(provider);
@@ -4580,7 +4731,8 @@ async function peopleCommand(positional, flags, context) {
4580
4731
  if (acting === null) {
4581
4732
  return fail(write, json, `people ${action}`, "usage", `\`toolroll people ${action} … --as <you> --token <t>\``, EXIT.usage);
4582
4733
  }
4583
- const authenticated = authenticateApprover(store, acting.name, acting.token);
4734
+ // Inviting and widening access are credential acts; revoking only narrows.
4735
+ const authenticated = (action === "revoke" ? authenticateApprover(store, acting.name, acting.token) : credentialApprover(store, acting.name, acting.token));
4584
4736
  if (!authenticated.ok) {
4585
4737
  return fail(write, json, `people ${action}`, authenticated.reason, describeApproveFailure(authenticated.reason, acting.name), EXIT.refused);
4586
4738
  }
@@ -4647,7 +4799,8 @@ async function chatApprovalCommand(positional, flags, context) {
4647
4799
  const acting = await askCredentials(flags, context);
4648
4800
  if (acting === null)
4649
4801
  return fail(write, json, command, "unauthenticated", "Approving from chat is your own setting: pass --as <you> --token <your password> (or sign in once with toolroll up).", EXIT.refused);
4650
- const authed = authenticateApprover(store, acting.name, acting.token);
4802
+ // Turning approving from chat on (or changing its limits) is a credential act; showing or turning it off is not.
4803
+ const authed = (action === "on" ? credentialApprover(store, acting.name, acting.token) : authenticateApprover(store, acting.name, acting.token));
4651
4804
  if (!authed.ok)
4652
4805
  return fail(write, json, command, "unauthenticated", "That name and password aren't an approver's. Nothing was changed.", EXIT.refused);
4653
4806
  const given = text(flags, "repo");
@@ -4710,7 +4863,8 @@ async function modeCommand(positional, flags, context) {
4710
4863
  if (acting === null) {
4711
4864
  return fail(write, json, "mode", "usage", "signing or revoking a mode takes `--as <you> --token <t>`", EXIT.usage);
4712
4865
  }
4713
- const authed = authenticateApprover(store, acting.name, acting.token);
4866
+ // Signing a mode that lets a chat app approve is a credential act; any other mode signing or revoking is ordinary.
4867
+ const authed = (flag(flags, "chat-approve") ? credentialApprover(store, acting.name, acting.token) : authenticateApprover(store, acting.name, acting.token));
4714
4868
  if (!authed.ok) {
4715
4869
  return fail(write, json, "mode", authed.reason, "that is not an active approver, or the token does not match", EXIT.refused);
4716
4870
  }
@@ -6816,7 +6970,7 @@ async function resolveUpApprover(context, flags, canPrompt) {
6816
6970
  return { approver: selected, approvers: names, verified, passwordFile, mintedPassword: null, notes };
6817
6971
  }
6818
6972
  /** The platform browser opener — detached, silent, never load-bearing. */
6819
- function openBrowser(url) {
6973
+ export function openBrowser(url) {
6820
6974
  // TOOLROLL_BROWSER_COMMAND names another program to open it with (it gets the address as its one argument).
6821
6975
  const chosen = envValue(process.env, "BROWSER_COMMAND");
6822
6976
  const command = chosen !== undefined && chosen !== "" ? [chosen, url]
@@ -7182,9 +7336,12 @@ async function upCommand(flags, context) {
7182
7336
  // proved set on each request, so its project rail and unified chat move
7183
7337
  // with the live builder instead of freezing at process start.
7184
7338
  const activeRepos = new Set(repos);
7339
+ // Cockpit B1: this start's instance secret, minted once it is listening; the console answers its challenges with it.
7340
+ let instanceSecret = null;
7185
7341
  let console_;
7186
7342
  try {
7187
7343
  console_ = await startConsole({
7344
+ instanceSecret: () => instanceSecret,
7188
7345
  context,
7189
7346
  host: hostFlag,
7190
7347
  port,
@@ -7442,7 +7599,7 @@ async function upCommand(flags, context) {
7442
7599
  const observeReadiness = () => {
7443
7600
  if (context.agentRunner !== undefined)
7444
7601
  return;
7445
- void observeProviderReadiness((file, args, options) => run(file, args, { timeoutMs: options?.timeoutMs ?? 5_000, ...(options?.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) }))
7602
+ void observeProviderReadiness((file, args, options) => run(file, args, { timeoutMs: options?.timeoutMs ?? 5_000, ...(options?.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) }), process.env, PROVIDER_IDS, provider => store.acpAttestationFor(provider, runnerName))
7446
7603
  .then(observed => {
7447
7604
  if (!stopping)
7448
7605
  reportProviderReadinessAuthed(store, { name: runnerName, token: runnerToken, observations: observed }, clock());
@@ -7474,6 +7631,21 @@ async function upCommand(flags, context) {
7474
7631
  login: { account: approverPlan2.passwordFile === null ? approverPlan2.approver : loginAccount(approverPlan2.passwordFile) ?? approverPlan2.approver, file: approverPlan2.passwordFile },
7475
7632
  });
7476
7633
  recordUpConsoleUrl(dirname(context.databaseFile), url, clock());
7634
+ // Cockpit B1: who this console is, for bare `toolroll` to prove before it attaches: a fresh secret in the database
7635
+ // (behind the agent fence), and a discovery-only socket beside it. A socket that can't open only means attaching
7636
+ // isn't offered; the console runs either way.
7637
+ // The socket is taken FIRST: only the console that owns it publishes a record. A second console on the same database
7638
+ // (another port) never replaces the first one's record; it runs, but isn't attached to.
7639
+ let mintedSecret = null;
7640
+ const instanceSocket = await openInstanceSocket(context.databaseFile, { pid: process.pid, build: packageVersion(), database: context.databaseFile, port: console_.port }, () => mintedSecret);
7641
+ if (instanceSocket.ok) {
7642
+ mintedSecret = await claimInstance(store, context.databaseFile, { pid: process.pid, port: console_.port, build: packageVersion(), socket: instanceSocket.name }, clock());
7643
+ instanceSecret = mintedSecret;
7644
+ if (mintedSecret === null)
7645
+ progress("attach: another console is running on this database — `toolroll` in a repository opens that one");
7646
+ }
7647
+ else
7648
+ progress(`attach: ${instanceSocket.problem} — \`toolroll\` in a repository won't find this console`);
7477
7649
  if (fatal === null) {
7478
7650
  if (json) {
7479
7651
  write(envelopeJson({
@@ -7541,7 +7713,15 @@ async function upCommand(flags, context) {
7541
7713
  process.removeListener("SIGINT", stop);
7542
7714
  process.removeListener("SIGTERM", stop);
7543
7715
  retireRunnerIfCurrent(store, runnerName, runnerToken, clock());
7544
- await new Promise(done => console_.server.close(() => done()));
7716
+ if (instanceSocket.ok)
7717
+ instanceSocket.close();
7718
+ if (mintedSecret !== null)
7719
+ clearInstance(store, mintedSecret);
7720
+ let closeProblem = null;
7721
+ await new Promise(done => console_.server.close(error => { closeProblem = error ?? null; done(); }));
7722
+ // A live terminal that couldn't be proved stopped (or any other close failure) is said, never "stopped".
7723
+ if (closeProblem !== null && fatal === null)
7724
+ fatal = closeProblem.message;
7545
7725
  if (fatal !== null) {
7546
7726
  // The startup envelope (when json) already went out; the exit code is
7547
7727
  // the health signal (finding 11/20) — never a second envelope.
@@ -7671,7 +7851,7 @@ async function bridgeCommand(positional, flags, context) {
7671
7851
  return fail(write, json, `bridge ${action}`, "usage", "`--as <you> --token <your password>` — pairing hands your authority to a chat, so it takes your credential", EXIT.usage);
7672
7852
  }
7673
7853
  const { name: asWho, token } = acting;
7674
- const authenticated = authenticateApprover(store, asWho, token);
7854
+ const authenticated = (action === "pair" ? credentialApprover(store, asWho, token) : authenticateApprover(store, asWho, token));
7675
7855
  if (!authenticated.ok) {
7676
7856
  return fail(write, json, `bridge ${action}`, authenticated.reason, describeApproveFailure(authenticated.reason, asWho), EXIT.refused);
7677
7857
  }
@@ -9451,7 +9631,7 @@ async function leadCommand(positional, flags, context) {
9451
9631
  if (currentActor()?.lead === true)
9452
9632
  return fail(write, json, command, "refused", "A lead cannot mint lead tokens. The person it acts for runs this with their password.", EXIT.refused);
9453
9633
  const acting = await askCredentials(flags, context);
9454
- const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
9634
+ const verified = acting === null ? null : (flags.has("revoke") ? authenticateApprover(store, acting.name, acting.token) : credentialApprover(store, acting.name, acting.token));
9455
9635
  if (acting === null || verified === null || !verified.ok) {
9456
9636
  return fail(write, json, command, "unauthenticated", "A lead token is minted behind your password: pass --as and --token (or use the remembered login).", EXIT.refused);
9457
9637
  }
@@ -10365,6 +10545,10 @@ function credentialsFrom(flags, context) {
10365
10545
  if (remembered !== null && (name === undefined || name === remembered.name)) {
10366
10546
  name = remembered.name;
10367
10547
  token = remembered.password;
10548
+ // This command proves with the remembered login: under Passwordless that reads as "cli-login", never as typed.
10549
+ const holder = rememberedLogin.getStore();
10550
+ if (holder !== undefined)
10551
+ holder.login = { name, token };
10368
10552
  }
10369
10553
  }
10370
10554
  return { name, token };
@@ -10385,6 +10569,12 @@ async function askCredentials(flags, context) {
10385
10569
  * which scope.ts's authenticateAccount takes as that person (a viewer for a read token) and never checks as a
10386
10570
  * password; these add the token's own project limit.
10387
10571
  */
10572
+ /** A credential act from the CLI (docs/plans/passwordless.md §5): a password typed for this command; with Passwordless on,
10573
+ * never the remembered login. */
10574
+ function credentialApprover(store, by, token, repo) {
10575
+ const proved = authenticateApprover(store, by, token, repo);
10576
+ return !proved.ok || credentialProof(proved.method) ? proved : { ok: false, reason: "not-an-approver" };
10577
+ }
10388
10578
  function authenticateApprover(store, by, token, repo) {
10389
10579
  const remote = activeRemote();
10390
10580
  if (remote !== null && repo !== undefined && !remote.allows(repo))
@@ -10734,6 +10924,123 @@ async function projectRulesCommand(positional, flags, context) {
10734
10924
  context.store.setApprovalRules(repo, next, acting.name, context.clock());
10735
10925
  return succeed(context.write, context.json, command, { repo, rules: next }, () => [`${repo}: ${rulesSummary(next)}`]);
10736
10926
  }
10927
+ /**
10928
+ * `project network --repo <p> [--allow h,…] [--remove h,…] [--open|--allowlist] [--local any|none]`: what the project's
10929
+ * builds may reach (egress.ts). New scopes are written with it and their approvals seal it; scopes already approved
10930
+ * keep the network they were approved with. Widening or narrowing it is an approver's act with a password typed now,
10931
+ * kept in the ledger as before → after.
10932
+ */
10933
+ async function projectNetworkCommand(positional, flags, context) {
10934
+ const command = "project network";
10935
+ const allowed = new Set(["repo", "allow", "remove", "open", "allowlist", "local", "as", "token", "token-file", "token-env", "db", "json"]);
10936
+ for (const name of flags.keys())
10937
+ if (!allowed.has(name))
10938
+ return fail(context.write, context.json, command, "usage", `--${name} is not a project network option.`, EXIT.usage);
10939
+ const repoFlag = text(flags, "repo");
10940
+ const local = flags.get("local");
10941
+ if (positional.length > 1 || repoFlag === undefined || (flags.has("open") && flags.has("allowlist")) || (local !== undefined && local !== "any" && local !== "none")) {
10942
+ return fail(context.write, context.json, command, "usage", "Use project network --repo <project path> [--allow host,…] [--remove host,…] [--open | --allowlist] [--local any|none].", EXIT.usage);
10943
+ }
10944
+ const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
10945
+ const known = [...new Set([...context.store.knownRepos(), ...context.store.listProjects().map(one => one.path), ...("error" in registered ? [] : registered.repos)])];
10946
+ const repo = known.find(one => one === repoFlag || one === resolve(repoFlag) || one === canonicalProject(repoFlag));
10947
+ if (repo === undefined)
10948
+ return fail(context.write, context.json, command, "not-found", "That isn't a project Toolroll knows.", EXIT.refused);
10949
+ const current = context.store.projectNetwork(repo);
10950
+ const words = (one) => [
10951
+ `${repo}: ${one.mode === "open" ? "builds may reach any host" : `builds reach their agent's provider, the package registries the project uses${one.hosts.length === 0 ? "" : `, and ${one.hosts.join(", ")}`}`}.`,
10952
+ one.loopback === "any" ? "On a Mac, builds may reach this Mac's local services (never Toolroll's own)." : "On a Mac, builds reach no local services but their own proxy and tools.",
10953
+ ];
10954
+ const listOf = (name) => {
10955
+ const given = text(flags, name);
10956
+ if (given === undefined)
10957
+ return [];
10958
+ const hosts = given.split(",").map(one => one.trim()).filter(Boolean).map(normalizeHost);
10959
+ return hosts.some(one => one === null) ? null : hosts;
10960
+ };
10961
+ const add = listOf("allow"), remove = listOf("remove");
10962
+ if (add === null || remove === null)
10963
+ return fail(context.write, context.json, command, "usage", "A host is a name like api.stripe.com or *.stripe.com: no address, port or path.", EXIT.usage);
10964
+ const changing = add.length > 0 || remove.length > 0 || flags.has("open") || flags.has("allowlist") || local !== undefined;
10965
+ if (!changing)
10966
+ return succeed(context.write, context.json, command, { repo, network: current }, () => words(current));
10967
+ const next = {
10968
+ mode: flags.has("open") ? "open" : flags.has("allowlist") ? "allowlist" : current.mode,
10969
+ hosts: [...new Set([...current.hosts.filter(one => !remove.includes(one)), ...add])].sort(),
10970
+ loopback: local === "any" ? "any" : local === "none" ? "proxy-only" : current.loopback,
10971
+ };
10972
+ if (next.hosts.length > MAX_NETWORK_HOSTS)
10973
+ return fail(context.write, context.json, command, "usage", `A project lists at most ${MAX_NETWORK_HOSTS} hosts.`, EXIT.usage);
10974
+ const acting = await askCredentials(flags, context);
10975
+ const verified = acting === null ? null : credentialApprover(context.store, acting.name, acting.token, repo);
10976
+ if (acting === null || verified === null || !verified.ok || !context.store.accountCanAccess(acting.name, repo)) {
10977
+ return fail(context.write, context.json, command, "refused", "An approver for this project changes what its builds may reach, with their password: pass --as and --token.", EXIT.refused);
10978
+ }
10979
+ const changed = context.store.setProjectNetwork(repo, next, acting.name, context.clock());
10980
+ return succeed(context.write, context.json, command, { repo, before: changed.before, network: changed.after }, () => [
10981
+ ...words(changed.after),
10982
+ "Scopes written from now on use this; scopes already approved keep the network they were approved with.",
10983
+ ]);
10984
+ }
10985
+ /**
10986
+ * `project writes --repo <p> [workspace|anywhere]`: where the project's builds may write (writes-mode.ts). `workspace`:
10987
+ * only their checkout and its Git data, their caches, their temp folder and the provider state their CLI needs;
10988
+ * `anywhere`: anywhere outside Toolroll's fenced folders. New scopes seal it; approved ones keep theirs. Changing it is
10989
+ * an approver's act with a password typed now, kept in the ledger as before → after.
10990
+ */
10991
+ async function projectWritesCommand(positional, flags, context) {
10992
+ const command = "project writes";
10993
+ const allowed = new Set(["repo", "as", "token", "token-file", "token-env", "db", "json"]);
10994
+ for (const name of flags.keys())
10995
+ if (!allowed.has(name))
10996
+ return fail(context.write, context.json, command, "usage", `--${name} is not a project writes option.`, EXIT.usage);
10997
+ const repoFlag = text(flags, "repo");
10998
+ const wanted = positional[1];
10999
+ if (positional.length > 2 || repoFlag === undefined || (wanted !== undefined && !isWritesMode(wanted))) {
11000
+ return fail(context.write, context.json, command, "usage", "Use project writes --repo <project path> [workspace | anywhere].", EXIT.usage);
11001
+ }
11002
+ const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
11003
+ const known = [...new Set([...context.store.knownRepos(), ...context.store.listProjects().map(one => one.path), ...("error" in registered ? [] : registered.repos)])];
11004
+ const repo = known.find(one => one === repoFlag || one === resolve(repoFlag) || one === canonicalProject(repoFlag));
11005
+ if (repo === undefined)
11006
+ return fail(context.write, context.json, command, "not-found", "That isn't a project Toolroll knows.", EXIT.refused);
11007
+ const words = (one) => [`${repo}: builds write ${writesWords(one)}.`];
11008
+ const current = context.store.projectWrites(repo);
11009
+ if (wanted === undefined)
11010
+ return succeed(context.write, context.json, command, { repo, writes: current }, () => words(current));
11011
+ const acting = await askCredentials(flags, context);
11012
+ const verified = acting === null ? null : credentialApprover(context.store, acting.name, acting.token, repo);
11013
+ if (acting === null || verified === null || !verified.ok || !context.store.accountCanAccess(acting.name, repo)) {
11014
+ return fail(context.write, context.json, command, "refused", "An approver for this project changes where its builds may write, with their password: pass --as and --token.", EXIT.refused);
11015
+ }
11016
+ const changed = context.store.setProjectWrites(repo, wanted, acting.name, context.clock());
11017
+ return succeed(context.write, context.json, command, { repo, before: changed.before, writes: changed.after }, () => [
11018
+ ...words(changed.after),
11019
+ "Scopes written from now on use this; scopes already approved keep what they were approved with.",
11020
+ ]);
11021
+ }
11022
+ /** `config unconfined-agents [allow|refuse]`: whether a build may run on a machine that can't confine it at the OS
11023
+ * (no macOS sandbox, no working bubblewrap). Refused unless an instance operator allows it, with a password. */
11024
+ async function unconfinedAgentsCommand(positional, flags, context) {
11025
+ const command = "config unconfined-agents";
11026
+ const { store, write, json } = context;
11027
+ const wanted = positional[1];
11028
+ if (positional.length > 2 || (wanted !== undefined && wanted !== "allow" && wanted !== "refuse")) {
11029
+ return fail(write, json, command, "usage", "Use config unconfined-agents [allow|refuse].", EXIT.usage);
11030
+ }
11031
+ const words = (allowed) => [allowed
11032
+ ? "Builds run even where this machine can't confine them (each such run says \"Not confined\")."
11033
+ : "Builds this machine can't confine at the OS don't start."];
11034
+ if (wanted === undefined)
11035
+ return succeed(write, json, command, { allowed: store.unfencedAgentsAllowed() }, () => words(store.unfencedAgentsAllowed()));
11036
+ const acting = await askCredentials(flags, context);
11037
+ const verified = acting === null ? null : credentialApprover(store, acting.name, acting.token);
11038
+ if (acting === null || verified === null || !verified.ok || !store.isInstanceOperator(acting.name)) {
11039
+ return fail(write, json, command, "refused", "An instance operator changes this, with their password: pass --as and --token.", EXIT.refused);
11040
+ }
11041
+ store.setUnfencedAgentsAllowed(wanted === "allow", acting.name, context.clock());
11042
+ return succeed(write, json, command, { allowed: wanted === "allow" }, () => words(wanted === "allow"));
11043
+ }
10737
11044
  /** `project concurrency [<n>] --repo <p>`: how many of a project's tasks build at once. Changing it is an approver's act. */
10738
11045
  async function projectConcurrencyCommand(positional, flags, context) {
10739
11046
  const command = "project concurrency";
@@ -11618,3 +11925,12 @@ function databaseFailure(write, json, command, file, error) {
11618
11925
  return fail(write, json, command, "database", describe(error), EXIT.failed);
11619
11926
  return fail(write, json, command, "database-busy", `The database ${file} stayed busy for ${CONCURRENT_WRITER_WAIT_MS / 1_000} seconds. Wait for the other Toolroll process to finish, then try again.`, EXIT.failed);
11620
11927
  }
11928
+ // Last in the file, so the password tripwire's function positions (password-freshness.test.ts) stay as reviewed.
11929
+ /** The value flags a verb reads under a global switch's name, for parseOperateArgs. */
11930
+ export function ownValueFlags(command, argv) {
11931
+ if (command === "flows")
11932
+ return FLOWS_VALUE_FLAGS;
11933
+ if (command === "project" && argv.find(arg => !arg.startsWith("-")) === "network")
11934
+ return PROJECT_NETWORK_VALUE_FLAGS;
11935
+ return undefined;
11936
+ }