toolroll 0.9.89 → 0.9.91

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 (138) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +16 -10
  3. package/THIRD_PARTY_NOTICES.md +37 -0
  4. package/dist/agent-board.d.ts +9 -0
  5. package/dist/agent-board.js +20 -0
  6. package/dist/agent-fence.js +20 -2
  7. package/dist/approval-authority.d.ts +7 -0
  8. package/dist/approval-authority.js +13 -2
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +692 -28
  10. package/dist/browser/terminal.js +42 -42
  11. package/dist/browser/workspace.css +1 -1
  12. package/dist/browser/workspace.js +32 -32
  13. package/dist/browser-workspace.d.ts +13 -0
  14. package/dist/builder.js +5 -2
  15. package/dist/chat-actions.d.ts +5 -0
  16. package/dist/chat-actions.js +7 -1
  17. package/dist/chat-delivery.js +2 -0
  18. package/dist/chat-task-actions.d.ts +1 -0
  19. package/dist/check-access.d.ts +3 -0
  20. package/dist/check-access.js +4 -1
  21. package/dist/cli-help.d.ts +1 -1
  22. package/dist/cli-help.js +6 -2
  23. package/dist/cli.d.ts +6 -0
  24. package/dist/cli.js +23 -0
  25. package/dist/coding-ui.js +4 -2
  26. package/dist/connect-cli.js +2 -1
  27. package/dist/console-instance.d.ts +34 -1
  28. package/dist/console-instance.js +124 -15
  29. package/dist/contracts/cli.d.ts +72 -0
  30. package/dist/contracts/cli.js +13 -0
  31. package/dist/contracts/console-api.d.ts +9 -2
  32. package/dist/contracts/console-api.js +9 -2
  33. package/dist/desktop-host.js +8 -3
  34. package/dist/desktop-update.js +2 -1
  35. package/dist/dispatch.d.ts +3 -0
  36. package/dist/dispatch.js +22 -2
  37. package/dist/easy-start-args.d.ts +13 -0
  38. package/dist/easy-start-args.js +24 -0
  39. package/dist/easy-start.d.ts +77 -0
  40. package/dist/easy-start.js +321 -0
  41. package/dist/first-run.d.ts +77 -0
  42. package/dist/first-run.js +45 -0
  43. package/dist/guides.js +1 -1
  44. package/dist/invoke.js +11 -6
  45. package/dist/laptop-background.d.ts +171 -0
  46. package/dist/laptop-background.js +550 -0
  47. package/dist/laptop-service.d.ts +111 -0
  48. package/dist/laptop-service.js +268 -0
  49. package/dist/lead-cli.js +2 -1
  50. package/dist/lead-doors.d.ts +6 -0
  51. package/dist/lead-doors.js +20 -2
  52. package/dist/live-records.d.ts +1 -1
  53. package/dist/live-records.js +3 -12
  54. package/dist/local-owner.d.ts +124 -0
  55. package/dist/local-owner.js +212 -0
  56. package/dist/login-secrets.d.ts +60 -0
  57. package/dist/login-secrets.js +195 -0
  58. package/dist/mcp-oauth.d.ts +4 -0
  59. package/dist/mcp-oauth.js +13 -7
  60. package/dist/model-choices.d.ts +43 -0
  61. package/dist/model-choices.js +80 -0
  62. package/dist/operate.d.ts +46 -1
  63. package/dist/operate.js +461 -40
  64. package/dist/person-proof.js +3 -0
  65. package/dist/project-knowledge.d.ts +7 -7
  66. package/dist/quick-start.d.ts +3 -1
  67. package/dist/quick-start.js +6 -1
  68. package/dist/release-adapters.d.ts +2 -0
  69. package/dist/release-adapters.js +30 -1
  70. package/dist/release-cli.js +2 -1
  71. package/dist/runner-process.d.ts +9 -0
  72. package/dist/runner-process.js +18 -0
  73. package/dist/sample-project.d.ts +31 -0
  74. package/dist/sample-project.js +141 -0
  75. package/dist/scope.d.ts +24 -2
  76. package/dist/scope.js +74 -3
  77. package/dist/serve.js +129 -22
  78. package/dist/server/chat.js +10 -3
  79. package/dist/server/chrome.d.ts +10 -2
  80. package/dist/server/chrome.js +7 -5
  81. package/dist/server/console.css +10 -0
  82. package/dist/server/guards.d.ts +3 -0
  83. package/dist/server/guards.js +22 -2
  84. package/dist/server/http.d.ts +22 -0
  85. package/dist/server/http.js +58 -0
  86. package/dist/server/live.js +4 -14
  87. package/dist/server/mutation-key.d.ts +70 -0
  88. package/dist/server/mutation-key.js +142 -0
  89. package/dist/server/pages.js +10 -7
  90. package/dist/server/people-tokens.js +240 -20
  91. package/dist/server/remote.d.ts +2 -0
  92. package/dist/server/remote.js +19 -4
  93. package/dist/server/render-new-task.js +5 -1
  94. package/dist/server/render-pages.d.ts +2 -1
  95. package/dist/server/render-pages.js +1 -1
  96. package/dist/server/render-people.d.ts +36 -0
  97. package/dist/server/render-people.js +65 -1
  98. package/dist/server/render-projects.js +4 -2
  99. package/dist/server/render-tasks.d.ts +12 -0
  100. package/dist/server/render-tasks.js +12 -4
  101. package/dist/server/request-context.d.ts +6 -0
  102. package/dist/server/route-table.d.ts +16 -0
  103. package/dist/server/route-table.js +121 -0
  104. package/dist/server/runtime.d.ts +6 -4
  105. package/dist/server/session.d.ts +31 -1
  106. package/dist/server/session.js +12 -2
  107. package/dist/server/settings.js +63 -8
  108. package/dist/server/task-views.js +10 -1
  109. package/dist/server/tasks.d.ts +10 -0
  110. package/dist/server/tasks.js +59 -9
  111. package/dist/server/terminals.js +15 -2
  112. package/dist/server/welcome.d.ts +22 -16
  113. package/dist/server/welcome.js +150 -52
  114. package/dist/settings-groups-ui.d.ts +7 -1
  115. package/dist/settings-groups-ui.js +23 -2
  116. package/dist/settings-proofs.js +1 -0
  117. package/dist/setup-guide.d.ts +2 -2
  118. package/dist/store.d.ts +124 -1
  119. package/dist/store.js +245 -12
  120. package/dist/surface.d.ts +10 -0
  121. package/dist/surface.js +40 -1
  122. package/dist/task-text.d.ts +6 -0
  123. package/dist/task-text.js +17 -0
  124. package/dist/telegram-lead.js +2 -0
  125. package/dist/terminal-text.d.ts +11 -0
  126. package/dist/terminal-text.js +26 -0
  127. package/dist/toolroll-update.d.ts +8 -1
  128. package/dist/toolroll-update.js +151 -4
  129. package/dist/tui-contract.d.ts +118 -0
  130. package/dist/tui-contract.js +9 -0
  131. package/dist/tui-source.d.ts +14 -0
  132. package/dist/tui-source.js +182 -0
  133. package/dist/tui.js +129 -0
  134. package/dist/up-processes.d.ts +13 -1
  135. package/dist/up-processes.js +13 -3
  136. package/dist/welcome-facts.d.ts +18 -0
  137. package/dist/welcome-facts.js +79 -0
  138. package/package.json +4 -2
package/dist/operate.js CHANGED
@@ -108,16 +108,17 @@ import { homedir, hostname } from "node:os";
108
108
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
109
109
  import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
110
110
  import { dur } from "./design/kit.js";
111
- import { openStore, openStoreNoMigrate, archiveTaskFamily, restoreTaskFamily, shipChecksForTask, elapsedSeconds, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
111
+ import { openStore, openStoreNoMigrate, initLocalMode, enrollOwnerPassword, installationCreatedBy, laptopPortOf, recordLaptopPort, localModeOf, localOwnerBinding, archiveTaskFamily, restoreTaskFamily, shipChecksForTask, elapsedSeconds, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
112
112
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
113
113
  import { claimInstance, clearInstance, openInstanceSocket } from "./console-instance.js";
114
+ import { backgroundOff, backgroundOn, backgroundShow, laptopStatus, laptopStop } from "./laptop-background.js";
114
115
  import { authorizePlanUnderMode } from "./plan-auto.js";
115
116
  import { ghDispatchAdapter, mirrorTaskId, syncPass } from "./sync.js";
116
117
  import { LIVE_FILE_BOUND, LIVE_RETAIN_MS, sweepLiveLogs } from "./live.js";
117
118
  import { addRepos, removeRepos, updateRepos, loadRepos, loadProjectRegistry, updateProjectRegistry, registryRemovals } from "./repos.js";
118
119
  import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
119
120
  import { pushPass } from "./push.js";
120
- import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync } from "node:fs";
121
+ import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync } from "node:fs";
121
122
  import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, taskBranches } from "./names.js";
122
123
  import { claimActor, currentActor, withActor } from "./actor.js";
123
124
  import { parseLeadToken } from "./api-tokens.js";
@@ -125,6 +126,8 @@ import { MAX_NETWORK_HOSTS, normalizeHost } from "./egress.js";
125
126
  import { isWritesMode, writesWords } from "./writes-mode.js";
126
127
  import { linuxFenceAvailable, macosFenceAvailable } from "./agent-fence.js";
127
128
  import { parseMachLookup, parseReadDeny, readDenyWords } from "./project-sandbox.js";
129
+ import { LOCAL_POLICY } from "./surface.js";
130
+ import { asLocalOwner, currentLocalOwner, LOCAL_MODE_CONFINES, localModeOn, localOwnerRefusal, localOwnerRequested } from "./local-owner.js";
128
131
  import { CHECK_ACCESS_WARNINGS, checkAccessRunsAs, checkAccessWords, isCheckAccess, signInHostsMissing, signInsUnavailableWords, widerThan } from "./check-access.js";
129
132
  import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
130
133
  import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
@@ -210,6 +213,7 @@ import { stopProcessTree } from "./process-tree.js";
210
213
  import { mintCoordinator, revokeCoordinator, listCoordinators } from "./coordinator.js";
211
214
  import { serveMcp } from "./mcp.js";
212
215
  import { createInterface } from "node:readline";
216
+ import { hashPassword } from "./scope.js";
213
217
  import { propose, approve, addApprover, authenticateAccount, authenticateApprover as passwordApprover, describeScope, approvalOf, hashToken as hashApproverToken, modeFilingCoverage, acceptanceLinesToInput, parseAcceptanceCriteria, splitAcceptanceRubric, rubricIsPlaceholder, isCommitSha, credentialProof, rememberedLogin, personApprovalOf } from "./scope.js";
214
218
  import { CHAT_APPROVE_ALL, presetTerms, modeTermsJson, modeDigestOf, modeTermsFromJson, modeWords, MODE_MAX_DAYS } from "./modes.js";
215
219
  import { WorktreePool } from "./worktree.js";
@@ -258,6 +262,7 @@ import { latestRelease, newerRelease, notifySecurityRelease, notifyVersionLive,
258
262
  import { installMethod } from "./install-method.js";
259
263
  import { PACKAGE_VERSION } from "./version.js";
260
264
  import { machineActing } from "./scope.js";
265
+ import { loginFileOf, migrateLegacyLogin, savedLoginFile, writableLoginFile } from "./login-secrets.js";
261
266
  /**
262
267
  * 0 done · 1 broke · 2 bad usage · 3 ran fine, the answer is no.
263
268
  *
@@ -450,16 +455,24 @@ External trackers — build what a tracker nominates, under local approvals
450
455
  [--max <n>] tasks (default 1),
451
456
  [--base <ref>] for first attempts.
452
457
  Never pushes.
458
+ toolroll background on|off|show keep this computer's Toolroll running when you log in (on), stop
459
+ that (off, only Toolroll's own login item), or how it stands
460
+ toolroll stop stop this computer's Toolroll once its running work is saved
453
461
  toolroll up [--project-root <dir>] one command to a working cockpit:
454
462
  app + builder + browser. Mints
455
463
  your login on first run (saved to
456
- up-login.txt beside the database),
464
+ .toolroll-secrets/up-login.txt
465
+ beside the database),
457
466
  remembers the projects folder, and
458
467
  reconnects every saved repository.
459
468
  Add local or GitHub projects in the
460
469
  app; they start without a restart.
461
470
  --repo still adds an exact path;
462
471
  --no-open skips the browser;
472
+ --laptop: this computer only, with
473
+ ~/Projects when no folder is
474
+ chosen (a new install signs in
475
+ here without a password);
463
476
  the app and the builder run as two
464
477
  processes (--single-process: one);
465
478
  --host 0.0.0.0 --allow-host name:port
@@ -742,7 +755,7 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
742
755
  "no-carry",
743
756
  "json", "yes", "all", "brief", "local", "history", "latest-watch", "dry-run", "file",
744
757
  "clear", "follow", "ready", "all-tasks", "inbound-only", "help", "undo", "anyone", "allow-dispatch", "allow-merge", "merge-delete-branch",
745
- "no-open", "single-process", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
758
+ "no-open", "single-process", "laptop", "web", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
746
759
  "self-heal", "plan-auto", "chat-approve", "full-access", "repair-auto", "review-retry-auto", "no-local",
747
760
  "html", "csv", "alerts-only",
748
761
  // project network: what its builds may reach (--open | --allowlist).
@@ -851,6 +864,11 @@ async function runOperateLocal(command, argv, write, options = {}) {
851
864
  }
852
865
  return EXIT.ok;
853
866
  }
867
+ // Easy-start C3: the terminal UI's local owner runs only commands marked for it — decided here, before any command
868
+ // that answers without the database (mcp, a public-address check, restore) could run. Bound after the database opens.
869
+ if (localOwnerRequested() && localOwnerRowAct(command, positional, flags) === null) {
870
+ return fail(write, json, command, "refused", "Set a password, or sign in with your password, to do this.", EXIT.refused);
871
+ }
854
872
  const file = text(flags, "db") ?? options.databaseFile ?? databasePath(process.env, homedir());
855
873
  const now = options.now ?? new Date();
856
874
  try {
@@ -887,7 +905,9 @@ async function runOperateLocal(command, argv, write, options = {}) {
887
905
  }
888
906
  let store;
889
907
  try {
890
- store = (options.openDatabase ?? openStore)(file);
908
+ // v147: `up --laptop` records who made a NEW database (installation_fact created-by), the fact local mode starts from.
909
+ const laptop = command === "up" && flags.has("laptop") && options.upRole === undefined;
910
+ store = options.openDatabase !== undefined ? options.openDatabase(file) : openStore(file, laptop ? { createdBy: "laptop" } : {});
891
911
  }
892
912
  catch (error) {
893
913
  return databaseFailure(write, json, command, file, error);
@@ -895,6 +915,21 @@ async function runOperateLocal(command, argv, write, options = {}) {
895
915
  // Toolroll's folder for the secrets it holds, beside this database: made before any build can run.
896
916
  if (file !== ":memory:")
897
917
  startToolSecrets(store, file, () => new Date(), { eager: SECRETS_FIRST.has(command) });
918
+ // Easy-start C3: the terminal UI's commands run as the local owner (runAsLocalOwner; never argv or the environment):
919
+ // bound here from local mode as it reads now, and only a command marked for the local owner (surface.ts LOCAL_ALLOW).
920
+ if (localOwnerRequested()) {
921
+ const admitted = localOwnerCommand(store, command, positional, flags);
922
+ if (!admitted.ok) {
923
+ store.close();
924
+ return fail(write, json, command, "refused", admitted.message, EXIT.refused);
925
+ }
926
+ try {
927
+ return await dispatchOn(command, positional, flags, parsed.repoList, { store, write, json, file, now }, options, run => asLocalOwner(admitted.principal, run));
928
+ }
929
+ finally {
930
+ store.close();
931
+ }
932
+ }
898
933
  // The lead (a lead token, as --token or TOOLROLL_LEAD_TOKEN) acts for its owner in single commands; its work
899
934
  // pings nobody. Anyone else who signs in inside one of these commands is who acted. Services name nobody.
900
935
  const presentedFlag = text(flags, "token");
@@ -981,6 +1016,8 @@ async function dispatchOn(command, positional, flags, repoList, base, options, e
981
1016
  ...(options.installBin === undefined ? {} : { installBin: options.installBin }),
982
1017
  ...(options.onboardSeams === undefined ? {} : { onboardSeams: options.onboardSeams }),
983
1018
  ...(options.upSeams === undefined ? {} : { upSeams: options.upSeams }),
1019
+ ...(options.laptopSeams === undefined ? {} : { laptopSeams: options.laptopSeams }),
1020
+ ...(options.easySeams === undefined ? {} : { easySeams: options.easySeams }),
984
1021
  ...(options.upRole === undefined ? {} : { upRole: options.upRole }),
985
1022
  ...(options.folderLook === undefined ? {} : { folderLook: options.folderLook }),
986
1023
  ...(upNeedsOneProcess(options) ? { upOneProcess: true } : {}),
@@ -1331,7 +1368,7 @@ async function dispatch(command, positional, flags, context) {
1331
1368
  return runTokensCommand(positional, flags, {
1332
1369
  store: context.store, write: context.write, json: context.json, clock: context.clock,
1333
1370
  caller: activeRemote() !== null ? "remote" : currentActor()?.lead === true || context.leadToken !== undefined ? "lead" : "local",
1334
- rememberedName: readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE))?.name ?? null,
1371
+ rememberedName: readLoginFile(savedLoginFile(dirname(context.databaseFile)))?.name ?? null,
1335
1372
  interactive, ask, askHidden, readStdin: readBoundedStdin,
1336
1373
  });
1337
1374
  case "keys":
@@ -1364,6 +1401,16 @@ async function dispatch(command, positional, flags, context) {
1364
1401
  return onboardCommand(flags, context);
1365
1402
  case "daemon":
1366
1403
  return daemonCommand(positional, flags, context);
1404
+ case "background":
1405
+ return backgroundCommand(positional, flags, context);
1406
+ case "stop":
1407
+ return stopCommand(positional, flags, context);
1408
+ case "open":
1409
+ return openCommand(positional, flags, context);
1410
+ case "welcome":
1411
+ return welcomeCommand(positional, flags, context);
1412
+ case "password":
1413
+ return passwordCommand(positional, flags, context);
1367
1414
  case "bridge":
1368
1415
  return bridgeCommand(positional, flags, context);
1369
1416
  case "publish":
@@ -4006,6 +4053,9 @@ async function startConsole(options) {
4006
4053
  ...(options.editorLinks === undefined ? {} : { editorLinks: options.editorLinks }),
4007
4054
  ...(options.instanceSecret === undefined ? {} : { instanceSecret: options.instanceSecret }),
4008
4055
  ...(options.draining === undefined ? {} : { draining: options.draining }),
4056
+ ...(options.keyEpoch === undefined ? {} : { keyEpoch: options.keyEpoch }),
4057
+ // Easy-start C2: on a protected loopback install, every browser mutation also presents the session's key.
4058
+ browserKey: true,
4009
4059
  });
4010
4060
  await new Promise((resolve, reject) => {
4011
4061
  server.once("error", reject);
@@ -6844,9 +6894,23 @@ function readLoginFile(path) {
6844
6894
  /** The approver decision tree (arc 2 findings 8/21/29/32), exhaustively. */
6845
6895
  async function resolveUpApprover(context, flags, canPrompt) {
6846
6896
  const { store, clock } = context;
6847
- const loginFile = join(dirname(context.databaseFile), UP_LOGIN_FILE);
6848
- const asFlag = text(flags, "as");
6897
+ const stateDir = dirname(context.databaseFile);
6849
6898
  const notes = [];
6899
+ // Easy-start C4: local mode first, before any login file is read or moved and before any prompt. Its owner, active,
6900
+ // starts this `up` with local authority: the console can mint only a local-owner link from it, never a full one.
6901
+ const local = localOwnerBinding(store);
6902
+ if (local !== null)
6903
+ return { approver: local.account, approvers: [local.account], verified: false, passwordFile: null, mintedPassword: null, notes, startAuthority: "local" };
6904
+ // An older version's login beside the database moves into the reserved folder every fence denies (login-secrets.ts).
6905
+ const migration = migrateLegacyLogin(stateDir);
6906
+ if (migration.kind === "moved")
6907
+ notes.push(`moved your saved login into ${migration.to} (owner-only, kept from agents)`);
6908
+ else if (migration.kind === "both")
6909
+ notes.push(`${migration.from} was left in place: ${migration.to} already holds a different login, and that one is used`);
6910
+ else if (migration.kind === "refused" || migration.kind === "failed")
6911
+ notes.push(`your saved login stays at ${migration.from} for now: ${migration.reason}`);
6912
+ let loginFile = savedLoginFile(stateDir);
6913
+ const asFlag = text(flags, "as");
6850
6914
  let names = store.listApprovers().map(one => one.name);
6851
6915
  if (names.length === 0) {
6852
6916
  // Adoption first (finding 27/32): an orphan file from a crashed
@@ -6856,7 +6920,7 @@ async function resolveUpApprover(context, flags, canPrompt) {
6856
6920
  const adopted = store.bootstrapApproverIfNone(orphan.name, hashApproverToken(orphan.password), clock());
6857
6921
  if (adopted.ok) {
6858
6922
  notes.push(`adopted the login from ${UP_LOGIN_FILE} — a previous start was interrupted before it finished`);
6859
- return { approver: orphan.name, approvers: [orphan.name], verified: true, passwordFile: loginFile, mintedPassword: null, notes };
6923
+ return { approver: orphan.name, approvers: [orphan.name], verified: true, passwordFile: loginFile, mintedPassword: null, notes, startAuthority: "full" };
6860
6924
  }
6861
6925
  names = store.listApprovers().map(one => one.name); // a winner appeared; fall through
6862
6926
  }
@@ -6867,21 +6931,22 @@ async function resolveUpApprover(context, flags, canPrompt) {
6867
6931
  const name = asFlag ?? process.env["USER"] ?? process.env["USERNAME"] ?? "operator";
6868
6932
  const password = mintSecret(12);
6869
6933
  try {
6934
+ loginFile = writableLoginFile(stateDir);
6870
6935
  writeLoginFileDurably(loginFile, name, password);
6871
6936
  }
6872
6937
  catch (error) {
6873
6938
  return {
6874
- refusal: `could not create ${UP_LOGIN_FILE} beside the database (${describe(error)}) — if a file is already there, another \`up\` may be starting; wait a moment, your login will be in it`,
6939
+ refusal: `could not create ${loginFileOf(stateDir)} (${describe(error)}) — if a file is already there, another \`up\` may be starting; wait a moment, your login will be in it`,
6875
6940
  };
6876
6941
  }
6877
6942
  const made = store.bootstrapApproverIfNone(name, hashApproverToken(password), clock());
6878
6943
  if (made.ok) {
6879
- return { approver: name, approvers: [name], verified: true, passwordFile: loginFile, mintedPassword: password, notes };
6944
+ return { approver: name, approvers: [name], verified: true, passwordFile: loginFile, mintedPassword: password, notes, startAuthority: "full" };
6880
6945
  }
6881
6946
  // Lost the race. Keep the file if the winner IS our identity (an
6882
6947
  // adopter beat us to our own file); otherwise it is ours to remove.
6883
6948
  if (authenticateApprover(store, name, password).ok) {
6884
- return { approver: name, approvers: store.listApprovers().map(one => one.name), verified: true, passwordFile: loginFile, mintedPassword: null, notes };
6949
+ return { approver: name, approvers: store.listApprovers().map(one => one.name), verified: true, passwordFile: loginFile, mintedPassword: null, notes, startAuthority: "full" };
6885
6950
  }
6886
6951
  try {
6887
6952
  unlinkSync(loginFile);
@@ -6936,19 +7001,20 @@ async function resolveUpApprover(context, flags, canPrompt) {
6936
7001
  // nothing typed. An account that predates `up` joins the same road.
6937
7002
  if (verified && remembered !== null && readLoginFile(loginFile) === null) {
6938
7003
  try {
7004
+ loginFile = writableLoginFile(stateDir);
6939
7005
  writeLoginFileDurably(loginFile, selected, remembered);
6940
7006
  passwordFile = loginFile;
6941
- notes.push(`remembered your login on this machine in ${UP_LOGIN_FILE} (owner-only) — the next \`up\` asks for nothing`);
7007
+ notes.push(`remembered your login on this machine in ${loginFile} (owner-only) — the next \`up\` asks for nothing`);
6942
7008
  }
6943
7009
  catch {
6944
- notes.push(`could not save your login beside the database — the next \`up\` will ask again`);
7010
+ notes.push(`could not save your login in ${loginFileOf(stateDir)} — the next \`up\` will ask again`);
6945
7011
  }
6946
7012
  }
6947
7013
  if (!verified) {
6948
7014
  notes.push(`could not verify ${selected}'s password — the console will still ask at login. If it is lost: another approver can add you (\`approver add\`), or restore the database/${UP_LOGIN_FILE}.`);
6949
7015
  }
6950
7016
  }
6951
- return { approver: selected, approvers: names, verified, passwordFile, mintedPassword: null, notes };
7017
+ return { approver: selected, approvers: names, verified, passwordFile, mintedPassword: null, notes, startAuthority: verified ? "full" : "none" };
6952
7018
  }
6953
7019
  /** The platform browser opener — detached, silent, never load-bearing. */
6954
7020
  export function openBrowser(url) {
@@ -7004,7 +7070,7 @@ async function onboardCommand(flags, context) {
7004
7070
  const served = portGiven === undefined ? readUpConsoleUrl(dirname(context.databaseFile)) : null;
7005
7071
  const home = seams.home ?? homedir();
7006
7072
  const env = seams.env ?? process.env;
7007
- const loginFile = join(dirname(context.databaseFile), UP_LOGIN_FILE);
7073
+ const loginFile = savedLoginFile(dirname(context.databaseFile));
7008
7074
  const gitRun = context.gitRunner ?? ((file, args, opts) => run(file, [...args], { ...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }), timeoutMs: 10_000 }));
7009
7075
  const registryPath = registryPathOf(context);
7010
7076
  const checker = createConnectionChecker({ home, env });
@@ -7096,7 +7162,7 @@ async function upCommand(flags, context) {
7096
7162
  return demoFence;
7097
7163
  // 1. Validation, before anything at all.
7098
7164
  const portGiven = text(flags, "port");
7099
- const port = Number(portGiven ?? 4180);
7165
+ let port = Number(portGiven ?? 4180);
7100
7166
  if (portGiven !== undefined && (!Number.isInteger(port) || port < 1 || port >= 65536))
7101
7167
  return fail(write, json, "up", "usage", "--port is a whole number under 65536", EXIT.usage);
7102
7168
  const editorGiven = text(flags, "editor");
@@ -7120,6 +7186,11 @@ async function upCommand(flags, context) {
7120
7186
  const allowFlag = text(flags, "allow-host");
7121
7187
  // --public-url: `up` serves a real domain exactly as `serve` does (one process is the whole team server).
7122
7188
  const publicUrlFlag = text(flags, "public-url");
7189
+ // --laptop (easy-start A): this computer only. A laptop start never listens beyond loopback or names another address.
7190
+ const laptop = flags.has("laptop");
7191
+ if (laptop && (!["127.0.0.1", "localhost", "::1"].includes(hostFlag) || allowFlag !== undefined || publicUrlFlag !== undefined)) {
7192
+ return fail(write, json, "up", "usage", "--laptop serves this computer only: leave out --host, --allow-host and --public-url", EXIT.usage);
7193
+ }
7123
7194
  const progress = (line) => {
7124
7195
  // v99: with TOOLROLL_LOG_FORMAT=json, each line is one JSON event on stderr for a log shipper.
7125
7196
  if (envValue(process.env, "LOG_FORMAT") === "json")
@@ -7205,8 +7276,13 @@ async function upCommand(flags, context) {
7205
7276
  }
7206
7277
  // Saved projects that are only away for now still start the service: it serves an empty, restrictive set until they
7207
7278
  // answer (never the unscoped mode), and connects each as it comes back.
7208
- if (repos.length === 0 && projectRoots.length === 0 && deferredRepos.length === 0)
7209
- return fail(write, json, "up", "no-projects", "choose where your projects live once: `toolroll up --project-root <dir>`", EXIT.refused);
7279
+ if (repos.length === 0 && projectRoots.length === 0 && deferredRepos.length === 0) {
7280
+ // A laptop start with nowhere chosen yet uses ~/Projects (made if it isn't there) instead of refusing.
7281
+ const fallback = laptop ? upLaptopProjectsRoot(context.upSeams?.home ?? homedir()) : null;
7282
+ if (fallback === null)
7283
+ return fail(write, json, "up", "no-projects", "choose where your projects live once: `toolroll up --project-root <dir>`", EXIT.refused);
7284
+ projectRoots.push(fallback);
7285
+ }
7210
7286
  // A person at a terminal reads three lines (onboarding); the folders are for --verbose, a service log, or an agent.
7211
7287
  const quietStart = !json && !flags.has("verbose") && (context.upSeams?.terminal ?? process.stdout.isTTY === true) && !underAgent(context.upSeams?.env ?? process.env);
7212
7288
  if (!quietStart) {
@@ -7217,11 +7293,29 @@ async function upCommand(flags, context) {
7217
7293
  }
7218
7294
  // 3. Reserve the port BEFORE any identity or enrollment mutation
7219
7295
  // (finding 7/19): a busy port must refuse while the world is untouched.
7220
- const probe = createNetServer();
7221
- const reserved = await new Promise(resolveProbe => {
7222
- probe.once("error", () => resolveProbe(false));
7223
- probe.listen(port, "127.0.0.1", () => resolveProbe(true));
7224
- });
7296
+ // A laptop start with no --port: the port this install saved, else (a NEW laptop install only) the first free one from
7297
+ // `up`'s default on (upLaptopPortCandidates), saved once it is reserved. Every other install keeps its port.
7298
+ const candidates = laptop && portGiven === undefined ? upLaptopPortCandidates(store, context.upSeams?.laptopPorts) : [port];
7299
+ let probe = createNetServer();
7300
+ let reserved = false;
7301
+ for (const candidate of candidates) {
7302
+ // A port this database's own console has held is never stepped past: that is this install, restarting or running.
7303
+ if (candidate !== candidates[0] && lifecycleOf(store, `console:${candidate}`) !== null)
7304
+ break;
7305
+ probe = createNetServer();
7306
+ port = candidate;
7307
+ reserved = await new Promise(resolveProbe => {
7308
+ probe.once("error", () => resolveProbe(false));
7309
+ probe.listen(candidate, "127.0.0.1", () => resolveProbe(true));
7310
+ });
7311
+ if (reserved || lifecycleOf(store, `console:${candidate}`) !== null)
7312
+ break;
7313
+ }
7314
+ if (reserved && laptop && portGiven === undefined && candidates.length > 1) {
7315
+ recordLaptopPort(store, port, new Date());
7316
+ if (port !== candidates[0])
7317
+ progress(`port ${candidates[0]} is in use by another program, so Toolroll uses ${port} on this computer`);
7318
+ }
7225
7319
  if (!reserved) {
7226
7320
  // The console that holds it said it is stopping: a restart is under way, so say that instead of "another one runs".
7227
7321
  const holder = lifecycleOf(store, `console:${port}`);
@@ -7237,7 +7331,13 @@ async function upCommand(flags, context) {
7237
7331
  let runnerToken = "";
7238
7332
  let approver = null;
7239
7333
  try {
7240
- // 4. The approver (findings 2/8/17/21/27/29/32).
7334
+ // 4. The approver (findings 2/8/17/21/27/29/32). A laptop start first turns local mode on for a database it just
7335
+ // made with no account yet, when this machine has a fence (easy-start plan F); anywhere else this changes nothing.
7336
+ if (flags.has("laptop")) {
7337
+ const made = upLaptopLocalMode(context, text(flags, "as"));
7338
+ if (made.ok)
7339
+ progress("local mode is on: this computer's browser signs in without a password");
7340
+ }
7241
7341
  const approverPlan = await resolveUpApprover(context, flags, canPrompt);
7242
7342
  if ("refusal" in approverPlan)
7243
7343
  return fail(write, json, "up", "login", approverPlan.refusal, EXIT.refused);
@@ -7248,7 +7348,8 @@ async function upCommand(flags, context) {
7248
7348
  // recovering runner authority on an UNVERIFIED operator identity was a
7249
7349
  // registration road around the ceremony. No verified password, no
7250
7350
  // runner — the login file or another approver restores the road.
7251
- if (!approverPlan.verified)
7351
+ // Easy-start C4: local mode's owner is the other start authority (no password exists to verify); "none" refuses.
7352
+ if (approverPlan.startAuthority === "none")
7252
7353
  return fail(write, json, "up", "login", `${approverPlan.approver}'s password could not be verified — a runner only registers under a proven operator. Type it correctly, restore ${UP_LOGIN_FILE}, or have another approver re-add you.`, EXIT.refused);
7253
7354
  // 5. The runner, through the atomic door (findings 1/16/26/31), with
7254
7355
  // the suffix budget (finding 24) for generated names only.
@@ -7413,6 +7514,7 @@ async function upCommand(flags, context) {
7413
7514
  const consoleConfig = {
7414
7515
  host: hostFlag, port, allowedHosts: allowFlag === undefined ? null : allowFlag.split(",").map(one => one.trim()).filter(one => one !== ""),
7415
7516
  publicUrl: publicUrlFlag ?? null, localRunner: runnerName, poolRoot: pool, repos: [...repos], projectRoots, registryPath, editor: editorGiven !== undefined,
7517
+ startAuthority: approver?.startAuthority ?? "none",
7416
7518
  };
7417
7519
  const pendingLinks = new Map();
7418
7520
  let linkRequests = 0;
@@ -7556,12 +7658,16 @@ async function upCommand(flags, context) {
7556
7658
  // one-time sign-in link (single use, ten minutes, this computer only); it is never printed. The password stays
7557
7659
  // saved beside the database, for later and for other devices, and is never printed either.
7558
7660
  const account = approverPlan2.approver;
7559
- // The console mints it (its sign-in secrets stay there); it comes back only to be opened, never printed.
7560
- const link = account === null || flags.has("no-open") ? null : await new Promise(resolveLink => {
7661
+ const localStart = approverPlan2.startAuthority === "local";
7662
+ // The console mints it (its sign-in secrets stay there); it comes back only to be opened, never printed. Its kind
7663
+ // is this start's authority: a local start asks for a local owner's link (it names no account), a full one for the
7664
+ // account's; the console mints only the kind it was started with.
7665
+ const request = upLinkRequest(approverPlan2.startAuthority, account);
7666
+ const link = request === null || flags.has("no-open") ? null : await new Promise(resolveLink => {
7561
7667
  const id = ++linkRequests;
7562
7668
  const timer = setTimeout(() => { pendingLinks.delete(id); resolveLink(null); }, 10_000);
7563
7669
  pendingLinks.set(id, value => { clearTimeout(timer); resolveLink(value); });
7564
- consoleRole?.send({ type: "sign-in-link", id, account });
7670
+ consoleRole?.send({ type: "sign-in-link", id, ...request });
7565
7671
  });
7566
7672
  // Every start says where the saved password is and the exact command that shows it, browser or not (Settings
7567
7673
  // revamp S4, D2). Only the path is printed, never what the file holds: absolute (a relative --db names this
@@ -7573,7 +7679,17 @@ async function upCommand(flags, context) {
7573
7679
  : showCommand === null ? `sign in as ${account} with your password` : `sign in as ${account}`;
7574
7680
  write("");
7575
7681
  write(`Toolroll is on ${url}`);
7576
- if (link !== null) {
7682
+ if (localStart) {
7683
+ // Local mode (easy-start C4): there is no password to point at; a new start opens a new one-time link.
7684
+ if (link !== null) {
7685
+ write("Opening it in your browser now, already signed in.");
7686
+ write("If it doesn't open, stop it with Ctrl-C and run the same command again.");
7687
+ (context.upSeams?.openBrowser ?? openBrowser)(new URL(link, url).toString());
7688
+ }
7689
+ else
7690
+ write("To sign in from this computer's browser, run the same command again without --no-open.");
7691
+ }
7692
+ else if (link !== null) {
7577
7693
  write("Opening it in your browser now, already signed in.");
7578
7694
  write(`If it doesn't open, go to that address and ${signInWords}.`);
7579
7695
  (context.upSeams?.openBrowser ?? openBrowser)(new URL(link, url).toString());
@@ -8410,7 +8526,7 @@ async function statusCommand(positional, flags, context) {
8410
8526
  if (positional.length > 0)
8411
8527
  return fail(context.write, context.json, command, "usage", "Use `toolroll status [--json]`.", EXIT.usage);
8412
8528
  // Whose lead the status line follows: the lead's own person, else the remembered login; nobody shows no lead.
8413
- const viewer = currentActor()?.account ?? readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE))?.name ?? null;
8529
+ const viewer = currentActor()?.account ?? readLoginFile(savedLoginFile(dirname(context.databaseFile)))?.name ?? null;
8414
8530
  // A remote person limited to some projects sees only those (remoteLensOf): their tasks and counts, and nothing
8415
8531
  // that belongs to the installation or other projects (updates, integrations, builds by project, unsent replies).
8416
8532
  const lens = context.principal?.lens ?? null;
@@ -8442,7 +8558,10 @@ async function statusCommand(positional, flags, context) {
8442
8558
  const unsent = context.store.unsentTelegramReplies(null, null, new Date(context.clock().getTime() - UNSENT_REPLY_MS));
8443
8559
  const unsentLine = unsentRepliesLine(unsent);
8444
8560
  const unsentReplies = unsent.length === 0 ? {} : { unsentReplies: unsent.map(one => ({ ...one, retry: "toolroll bridge telegram retry" })) };
8445
- return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting, ...unsentReplies, ...(legacyWebhookWarning === null ? {} : { legacyWebhookWarning }) }, () => [...(waiting === null ? [] : [waiting.words]), ...(unsentLine === null ? [] : [unsentLine]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine]), ...(legacyWebhookWarning === null ? [] : [legacyWebhookWarning])]);
8561
+ // This installation's console and background service (easy-start D), beside its updates and integrations: a person
8562
+ // limited to some projects returned above without it; an all-project caller reads what a local one does.
8563
+ const laptop = { laptop: await laptopStatus(context.store, context.databaseFile, context.laptopSeams) };
8564
+ return succeed(context.write, context.json, command, { ...status, projects, ...laptop, ...update, ...integrations, ...updateWaiting, ...unsentReplies, ...(legacyWebhookWarning === null ? {} : { legacyWebhookWarning }) }, () => [...(waiting === null ? [] : [waiting.words]), ...(unsentLine === null ? [] : [unsentLine]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine]), ...(legacyWebhookWarning === null ? [] : [legacyWebhookWarning])]);
8446
8565
  }
8447
8566
  function unsentRepliesLine(unsent) {
8448
8567
  if (unsent.length === 0)
@@ -9405,7 +9524,7 @@ function noteActingPerson(flags, context) {
9405
9524
  authenticateAccount(context.store, name, token);
9406
9525
  return;
9407
9526
  }
9408
- const remembered = readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE));
9527
+ const remembered = readLoginFile(savedLoginFile(dirname(context.databaseFile)));
9409
9528
  if (remembered !== null && (name === undefined || name === remembered.name))
9410
9529
  claimActor(remembered.name);
9411
9530
  }
@@ -10281,7 +10400,7 @@ async function proposalsCommand(positional, flags, context) {
10281
10400
  for (const line of answerContextLines(store, row.payload))
10282
10401
  write(line);
10283
10402
  }
10284
- const outcome = confirmCoordinatorProposal(store, verified.who, id, now, { confirm: flags.has("yes"), via: "cli" });
10403
+ const outcome = confirmCoordinatorProposal(store, verified.who, id, now, { authority: currentLocalOwner() === null ? "full" : "local-owner", confirm: flags.has("yes"), via: "cli" });
10285
10404
  if (!outcome.ok)
10286
10405
  return fail(write, json, "proposals confirm", outcome.reason, outcome.reason === "needs-confirm" ? `${outcome.said} — read the consequences above, then pass --yes` : outcome.said, EXIT.refused);
10287
10406
  return succeed(write, json, "proposals confirm", { proposal: id, kind: outcome.kind, said: outcome.said, ...(outcome.taskId === null ? {} : { task: outcome.taskId }) }, () => [
@@ -10344,6 +10463,10 @@ function credentialsFrom(flags, context) {
10344
10463
  const remote = activeRemote();
10345
10464
  if (remote !== null)
10346
10465
  return { name: remote.account, token: remote.secret };
10466
+ // Easy-start C3: the terminal UI's local owner has no password; the prover proves its allowlisted act instead.
10467
+ const local = currentLocalOwner();
10468
+ if (local !== null)
10469
+ return { name: local.account, token: "" };
10347
10470
  // The lead signs in as its owner with its own token, never the owner's password.
10348
10471
  const lead = currentActor();
10349
10472
  if (context.leadToken !== undefined && lead?.lead === true)
@@ -10351,7 +10474,7 @@ function credentialsFrom(flags, context) {
10351
10474
  let name = text(flags, "as");
10352
10475
  let token = text(flags, "token");
10353
10476
  if (token === undefined) {
10354
- const remembered = readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE));
10477
+ const remembered = readLoginFile(savedLoginFile(dirname(context.databaseFile)));
10355
10478
  if (remembered !== null && (name === undefined || name === remembered.name)) {
10356
10479
  name = remembered.name;
10357
10480
  token = remembered.password;
@@ -10365,6 +10488,10 @@ function credentialsFrom(flags, context) {
10365
10488
  }
10366
10489
  async function askCredentials(flags, context) {
10367
10490
  let { name, token } = credentialsFrom(flags, context);
10491
+ // Easy-start C3: the local owner is never asked for a password (the prover decides its act, refusing all others).
10492
+ const local = currentLocalOwner();
10493
+ if (local !== null)
10494
+ return { name: local.account, token: "" };
10368
10495
  if ((name === undefined || token === undefined) && interactive() && !context.json) {
10369
10496
  name ??= await ask("username: ");
10370
10497
  token ??= await askHidden("password: ");
@@ -10810,6 +10937,9 @@ async function unconfinedAgentsCommand(positional, flags, context) {
10810
10937
  : "Builds this machine can't confine at the OS don't start."];
10811
10938
  if (wanted === undefined)
10812
10939
  return succeed(write, json, command, { allowed: store.unfencedAgentsAllowed() }, () => words(store.unfencedAgentsAllowed()));
10940
+ // Easy-start C5: while local mode is on, this installation always confines; said before any password is asked for.
10941
+ if (wanted === "allow" && localModeOn(store))
10942
+ return fail(write, json, command, "refused", LOCAL_MODE_CONFINES, EXIT.refused);
10813
10943
  const acting = await askCredentials(flags, context);
10814
10944
  const verified = acting === null ? null : credentialApprover(store, acting.name, acting.token);
10815
10945
  if (acting === null || !verified?.ok || !store.isInstanceOperator(acting.name))
@@ -11732,7 +11862,7 @@ async function projectCheckAccessCommand(positional, flags, context) {
11732
11862
  // How it actually runs here: with no OS fence and unconfined running allowed, without a sandbox whatever is saved; on
11733
11863
  // a saved sign-ins level (off for now) runs sandboxed. Said, never hidden.
11734
11864
  const fenced = context.fenceAvailable?.() ?? (macosFenceAvailable() || linuxFenceAvailable());
11735
- const runsAs = (one) => checkAccessRunsAs(one, { platform, fenced, unconfinedAllowed: context.store.unfencedAgentsAllowed() });
11865
+ const runsAs = (one) => checkAccessRunsAs(one, { platform, fenced, unconfinedAllowed: context.store.unfencedAgentsAllowed(), localMode: localModeOn(context.store) });
11736
11866
  const here = runsAs(current);
11737
11867
  const words = (one) => {
11738
11868
  if (runsAs(one) === "unsandboxed" && one !== "unsandboxed")
@@ -11751,6 +11881,9 @@ async function projectCheckAccessCommand(positional, flags, context) {
11751
11881
  if (unavailable !== null)
11752
11882
  return fail(context.write, context.json, command, "refused", unavailable, EXIT.refused);
11753
11883
  const widening = widerThan(wanted, current);
11884
+ // Easy-start C5: while local mode is on, this installation always confines; said before any password is asked for.
11885
+ if (wanted === "unsandboxed" && localModeOn(context.store))
11886
+ return fail(context.write, context.json, command, "refused", LOCAL_MODE_CONFINES, EXIT.refused);
11754
11887
  const acting = await askCredentials(flags, context);
11755
11888
  const proved = acting === null ? null : authenticateApprover(context.store, acting.name, acting.token, repo);
11756
11889
  if (widening) {
@@ -12090,9 +12223,13 @@ async function runUpConsoleRole(context, config, port, build) {
12090
12223
  // v137: this console's lifecycle (service_lifecycle "console:<port>"): started, a short heartbeat, draining once
12091
12224
  // its `up` stops, stopped at the end. While draining, /healthz and every page say Toolroll is restarting.
12092
12225
  let draining = false;
12226
+ // v147 (easy-start C2): this start's key epoch, minted once and recorded with the instance claim below. A browser's
12227
+ // mutation key from any other start is stale.
12228
+ const keyEpoch = randomBytes(16).toString("hex");
12093
12229
  let console_;
12094
12230
  try {
12095
12231
  console_ = await startConsole({
12232
+ keyEpoch,
12096
12233
  instanceSecret: () => instanceSecret,
12097
12234
  draining: () => draining,
12098
12235
  context,
@@ -12153,7 +12290,7 @@ async function runUpConsoleRole(context, config, port, build) {
12153
12290
  else if (message.type === "sign-in-link") {
12154
12291
  let link = null;
12155
12292
  try {
12156
- link = console_.server.mintSignInLink(message.account);
12293
+ link = consoleSignInLink(console_.server, config.startAuthority, message);
12157
12294
  }
12158
12295
  catch {
12159
12296
  link = null;
@@ -12166,9 +12303,11 @@ async function runUpConsoleRole(context, config, port, build) {
12166
12303
  // isn't offered; the console runs either way. The socket is taken FIRST: only the console that owns it publishes a
12167
12304
  // record. A second console on the same database (another port) never replaces the first one's record.
12168
12305
  let mintedSecret = null;
12169
- const instanceSocket = await openInstanceSocket(context.databaseFile, { pid: process.pid, build: packageVersion(), database: context.databaseFile, port: console_.port }, () => mintedSecret);
12306
+ const instanceSocket = await openInstanceSocket(context.databaseFile, { pid: process.pid, build: packageVersion(), database: context.databaseFile, port: console_.port }, () => mintedSecret,
12307
+ // Easy start D: `toolroll open`'s link verb. mintLocalLink names no account and mints only while local mode is on.
12308
+ () => console_.server.mintLocalLink());
12170
12309
  if (instanceSocket.ok) {
12171
- mintedSecret = await claimInstance(store, context.databaseFile, { pid: process.pid, port: console_.port, build: packageVersion(), socket: instanceSocket.name }, clock());
12310
+ mintedSecret = await claimInstance(store, context.databaseFile, { pid: process.pid, port: console_.port, build: packageVersion(), socket: instanceSocket.name, keyEpoch: console_.server.keyEpoch }, clock());
12172
12311
  instanceSecret = mintedSecret;
12173
12312
  if (mintedSecret === null)
12174
12313
  progress("attach: another console is running on this database — `toolroll` in a repository opens that one");
@@ -12906,7 +13045,7 @@ async function tidyTasksCommand(positional, flags, context) {
12906
13045
  // the apply reads the same person's projects, so an unchanged list keeps its digest.
12907
13046
  // Whose lists: `--as <you>`, else this machine's saved login (`toolroll up`), else everything (an unrestricted
12908
13047
  // person's list; a person limited to some projects previews with --as).
12909
- const previewAs = text(flags, "as") ?? readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE))?.name;
13048
+ const previewAs = text(flags, "as") ?? readLoginFile(savedLoginFile(dirname(context.databaseFile)))?.name;
12910
13049
  const daysArg = `${days === TIDY_DEFAULT_DAYS ? "" : ` --days ${days}`}${previewAs === undefined ? "" : ` --as ${previewAs}`}`;
12911
13050
  // `--as` only ever narrows: the named person's projects within the caller's own lens, never beyond it.
12912
13051
  const lens = context.principal?.lens ?? null;
@@ -12997,3 +13136,285 @@ function taskShipCommand(positional, context) {
12997
13136
  ...checks.map(one => ` ${(one.workflow === null ? one.name : `${one.workflow} / ${one.name}`).padEnd(32)} ${(SHIP_CHECK_WORDS[one.state] ?? one.state).padEnd(9)} ${one.seconds === null ? "–" : dur(one.seconds)}`),
12998
13137
  ]);
12999
13138
  }
13139
+ // ---- easy-start wave 1a: the laptop start and its start authority (plan A, C4, F) ---------------------------------
13140
+ /** `up --laptop` with no projects folder chosen yet: ~/Projects, made owner-only if it isn't there, as its real path.
13141
+ * Null when it can't be made or isn't a folder (the ordinary refusal then applies). */
13142
+ function upLaptopProjectsRoot(home) {
13143
+ const folder = join(home, "Projects");
13144
+ try {
13145
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
13146
+ }
13147
+ catch {
13148
+ return null;
13149
+ }
13150
+ return canonicalProject(folder);
13151
+ }
13152
+ /**
13153
+ * Local mode for a laptop start (plan F): only for a database this start's `--laptop` made (created-by laptop) with no
13154
+ * account yet, and only where this machine has a fence (diagnostic: on Linux without bubblewrap the ordinary password
13155
+ * flow runs). The owner is named like today's minted login: --as, else the user's name. Idempotent: a later start, a
13156
+ * racing start or an install with accounts changes nothing.
13157
+ */
13158
+ function upLaptopLocalMode(context, asFlag) {
13159
+ const fence = context.upSeams?.fenceAvailable ?? (() => process.platform === "darwin" ? macosFenceAvailable() : linuxFenceAvailable());
13160
+ const owner = asFlag ?? process.env["USER"] ?? process.env["USERNAME"] ?? "operator";
13161
+ // Cheap reads first: the fence probe runs only for a database that could still be initialized.
13162
+ if (installationCreatedBy(context.store) !== "laptop" || localModeOf(context.store) !== null || context.store.listApprovers().length > 0)
13163
+ return { ok: false, reason: "already" };
13164
+ return initLocalMode(context.store, { owner, fenceAvailable: fence(), now: context.clock() });
13165
+ }
13166
+ /** The sign-in link `up` asks its console for, by this start's authority: none for "none". */
13167
+ export function upLinkRequest(authority, account) {
13168
+ if (authority === "local")
13169
+ return { kind: "local" };
13170
+ if (authority === "full" && account !== null)
13171
+ return { kind: "full", account };
13172
+ return null;
13173
+ }
13174
+ /**
13175
+ * The console's answer to a link request (plan C4): only the kind its own start was authorised for, whatever the
13176
+ * request says. A local start mints only a local owner's link (mintLocalLink names no account); a full start only the
13177
+ * named account's; "none", or a request of the other kind, mints nothing.
13178
+ */
13179
+ export function consoleSignInLink(server, startAuthority, message) {
13180
+ if (message.kind === "local")
13181
+ return startAuthority === "local" ? server.mintLocalLink() : null;
13182
+ return startAuthority === "full" ? server.mintSignInLink(message.account) : null;
13183
+ }
13184
+ /**
13185
+ * Easy-start C3: whether the terminal UI's local owner may run this command, and as whom. The row is read from the
13186
+ * parsed words (like a remote run's): a command with no row, or a row not marked for the local owner (surface.ts
13187
+ * LOCAL_POLICY), is refused before any command code runs. So are credentials named on the command line (a lead token,
13188
+ * a password): the local owner's authority is its own. Bound to local mode as it reads now; every write it then makes
13189
+ * re-proves that (Store.transact).
13190
+ */
13191
+ function localOwnerCommand(store, command, positional, flags) {
13192
+ const binding = localOwnerBinding(store);
13193
+ if (binding === null)
13194
+ return { ok: false, message: "Local mode is off. Sign in with your password in the browser." };
13195
+ const act = localOwnerRowAct(command, positional, flags);
13196
+ if (act === null)
13197
+ return { ok: false, message: localOwnerRefusal(store.accountOf(binding.account)?.passwordSet === true, "/").message };
13198
+ return { ok: true, principal: { account: binding.account, localGeneration: binding.localGeneration, accountGeneration: binding.accountGeneration, transport: "terminal", act, sessionHash: null, request: null } };
13199
+ }
13200
+ /** The act a command is for the local owner (surface.ts LOCAL_POLICY, its row read from the parsed words), or null:
13201
+ * no row, a row not marked for it, or credentials named on the command line. */
13202
+ function localOwnerRowAct(command, positional, flags) {
13203
+ if (["as", "token", "token-file", "token-env"].some(name => flags.has(name)))
13204
+ return null;
13205
+ const row = remoteRowOf(command, positional);
13206
+ // A project's trusted remote (whose main may complete landed work) is set with full authority only.
13207
+ if (row?.invocation === "repos add" && flags.has("remote"))
13208
+ return null;
13209
+ const mark = row === null ? undefined : LOCAL_POLICY.get(row.invocation);
13210
+ return mark === undefined || mark.local !== "allow" ? null : mark.act;
13211
+ }
13212
+ // ---- easy-start wave 2c: the background service and `stop` (plan D) ---------------------------------------------------
13213
+ /** The handoff's seams: the test's own, else this process's terminal (a person may confirm only at one) and prompt. */
13214
+ function laptopSeamsOf(context) {
13215
+ const terminal = !context.json && process.stdin.isTTY === true && process.stdout.isTTY === true && !underAgent(process.env);
13216
+ return { terminal, confirm: promptConfirm, ...context.laptopSeams };
13217
+ }
13218
+ /**
13219
+ * `toolroll background on|off|show`: keep this computer's Toolroll running in the background (a login item), stop
13220
+ * doing that, or say how it stands. This computer's own service: never for a remote caller, never on a demo database.
13221
+ */
13222
+ async function backgroundCommand(positional, flags, context) {
13223
+ const { write, json } = context;
13224
+ const [action = "show", ...rest] = positional;
13225
+ const command = `background ${action}`;
13226
+ if (!["on", "off", "show"].includes(action) || rest.length > 0)
13227
+ return fail(write, json, "background", "usage", "Use `toolroll background on|off|show [--port <n>]`.", EXIT.usage);
13228
+ for (const name of flags.keys())
13229
+ if (!["db", "json", "port"].includes(name))
13230
+ return fail(write, json, command, "usage", `--${name} is not a background option.`, EXIT.usage);
13231
+ if (context.principal !== undefined)
13232
+ return fail(write, json, command, "not-remote", "The background service is this computer's own; run it there.", EXIT.refused);
13233
+ const portGiven = text(flags, "port");
13234
+ const port = portGiven === undefined ? null : Number(portGiven);
13235
+ if (port !== null && (!Number.isInteger(port) || port < 1 || port >= 65536))
13236
+ return fail(write, json, command, "usage", "--port is a whole number under 65536", EXIT.usage);
13237
+ const seams = laptopSeamsOf(context);
13238
+ if (action === "show") {
13239
+ const shown = await backgroundShow(context.store, context.databaseFile, port, seams);
13240
+ if (!shown.ok)
13241
+ return fail(write, json, command, shown.reason, shown.message, EXIT.refused);
13242
+ const show = shown.show;
13243
+ const state = show.installed === "absent" ? "off" : show.loaded ? "on" : "installed, not running";
13244
+ return succeed(write, json, command, { ...show, background: show.installed === "absent" ? "off" : "on" }, () => [
13245
+ `Background: ${state}${show.installed === "different" ? " (set up for a different version or port; `toolroll background on` updates it)" : ""}`,
13246
+ show.running === null ? "Toolroll isn't running." : `Toolroll is running at ${show.running.url}`,
13247
+ ...show.conflicts.map(one => `Also here: ${one.reason}`),
13248
+ ]);
13249
+ }
13250
+ const demo = refuseDemo(context, command);
13251
+ if (demo !== null)
13252
+ return demo;
13253
+ if (action === "off") {
13254
+ const off = await backgroundOff(context.store, context.databaseFile, seams);
13255
+ if (!off.ok)
13256
+ return fail(write, json, command, off.reason, off.message, off.reason === "supervisor" || off.reason === "still-running" ? EXIT.failed : EXIT.refused);
13257
+ return succeed(write, json, command, off, () => [off.action === "already-off" ? "Toolroll wasn't set to run in the background." : "Toolroll no longer runs in the background. Start it with `toolroll` when you need it."]);
13258
+ }
13259
+ const on = await backgroundOn(context.store, context.databaseFile, port, seams);
13260
+ if (!on.ok) {
13261
+ const code = ["agent", "conflict", "declined", "running-elsewhere", "unsupported", "console-unproven", "invalid-unit", "unreadable"].includes(on.reason) ? EXIT.refused : EXIT.failed;
13262
+ if (json) {
13263
+ write(envelopeJson({ ok: false, command, reason: on.reason, message: on.message, ...(on.rolledBack === undefined ? {} : { rolledBack: on.rolledBack }) }));
13264
+ return code;
13265
+ }
13266
+ write(`✗ ${on.message}.`);
13267
+ for (const line of on.rolledBack ?? [])
13268
+ write(` ${line}`);
13269
+ return code;
13270
+ }
13271
+ return succeed(write, json, command, on, () => [on.action === "unchanged" ? `Toolroll already runs in the background at ${on.url}.` : `Toolroll now runs in the background at ${on.url}. It starts when you log in.`]);
13272
+ }
13273
+ /**
13274
+ * `toolroll stop`: the background service stopped through its supervisor (it comes back at login), or else the
13275
+ * foreground `up` that runs this database's console, stopped by SIGTERM to its controller (found and proved through
13276
+ * its signed process record), waited for until every process it named has exited. Never the console child itself.
13277
+ */
13278
+ async function stopCommand(positional, flags, context) {
13279
+ const { write, json } = context;
13280
+ const command = "stop";
13281
+ if (positional.length > 0)
13282
+ return fail(write, json, command, "usage", "Use `toolroll stop`.", EXIT.usage);
13283
+ for (const name of flags.keys())
13284
+ if (!["db", "json"].includes(name))
13285
+ return fail(write, json, command, "usage", `--${name} is not a stop option.`, EXIT.usage);
13286
+ if (context.principal !== undefined)
13287
+ return fail(write, json, command, "not-remote", "Stopping Toolroll is done on its own computer.", EXIT.refused);
13288
+ const stopped = await laptopStop(context.store, context.databaseFile, laptopSeamsOf(context));
13289
+ if (!stopped.ok)
13290
+ return fail(write, json, command, stopped.reason, stopped.message, stopped.reason === "still-running" || stopped.reason === "supervisor" ? EXIT.failed : EXIT.refused);
13291
+ return succeed(write, json, command, stopped, () => [
13292
+ stopped.action === "not-running" ? "Toolroll isn't running."
13293
+ : stopped.action === "service-stopped" ? "Toolroll stopped. It starts again when you log in — run `toolroll background off` to stop that."
13294
+ : "Toolroll stopped.",
13295
+ ]);
13296
+ }
13297
+ /** A person at this terminal: stdin and stdout a TTY, not --json, not under a coding agent (a UX guard, plan F1). */
13298
+ function personAtTerminal(context) {
13299
+ return !context.json && (context.easySeams?.terminal ?? (process.stdin.isTTY === true && process.stdout.isTTY === true && !underAgent(process.env)));
13300
+ }
13301
+ /**
13302
+ * `toolroll open [--web]`: make sure this computer's Toolroll runs (the background service if installed, else `up
13303
+ * --laptop` as a child of this process), then open the terminal UI on it as the local owner — or, with --web, this
13304
+ * computer's browser, signed in through the console's socket `link` verb. Never prints a sign-in link.
13305
+ */
13306
+ async function openCommand(positional, flags, context) {
13307
+ const { write, json } = context;
13308
+ const command = "open";
13309
+ if (positional.length > 0)
13310
+ return fail(write, json, command, "usage", "Use `toolroll open [--web]`.", EXIT.usage);
13311
+ for (const name of flags.keys())
13312
+ if (!["db", "json", "web"].includes(name))
13313
+ return fail(write, json, command, "usage", `--${name} is not an open option.`, EXIT.usage);
13314
+ if (context.principal !== undefined)
13315
+ return fail(write, json, command, "not-remote", "Toolroll opens on its own computer.", EXIT.refused);
13316
+ if (!personAtTerminal(context))
13317
+ return fail(write, json, command, "not-a-terminal", "Run `toolroll open` yourself at a terminal: it opens Toolroll here, signed in.", EXIT.refused);
13318
+ const easy = await import("./easy-start.js");
13319
+ const seams = easy.defaultEasyStartSeams({ databaseFile: context.databaseFile, write, build: packageVersion(), admit: async () => EXIT.ok });
13320
+ return easy.runEasyStart("open", { web: flags.has("web"), projectRoot: null }, { ...seams, ...context.easySeams?.seams });
13321
+ }
13322
+ /**
13323
+ * `toolroll welcome`: the three steps to a first result (Agent · Project · First task), from the same model the
13324
+ * console's /welcome shows. `--json` prints the model; at a terminal it walks the steps one at a time, checking each
13325
+ * again when the person says it's done. Reads only.
13326
+ */
13327
+ async function welcomeCommand(positional, flags, context) {
13328
+ const { write, json } = context;
13329
+ const command = "welcome";
13330
+ if (positional.length > 0)
13331
+ return fail(write, json, command, "usage", "Use `toolroll welcome [--json]`.", EXIT.usage);
13332
+ for (const name of flags.keys())
13333
+ if (!["db", "json"].includes(name))
13334
+ return fail(write, json, command, "usage", `--${name} is not a welcome option.`, EXIT.usage);
13335
+ const { welcomeHere, welcomeLines, welcomeStepLines, WELCOME_STEPS } = await import("./welcome-facts.js");
13336
+ const read = async () => (await welcomeHere(context.store, context.databaseFile, context.clock(), context.easySeams?.check === undefined ? {} : { check: context.easySeams.check })).model;
13337
+ let model = await read();
13338
+ if (json || !personAtTerminal(context) || localOwnerRequested())
13339
+ return succeed(write, json, command, { ...model }, () => welcomeLines(model));
13340
+ const askNow = context.easySeams?.ask ?? ask;
13341
+ write(model.left === 0 ? "Setup is done." : `Welcome to Toolroll. ${model.left} ${model.left === 1 ? "step" : "steps"} to your first result.`);
13342
+ for (const key of WELCOME_STEPS) {
13343
+ for (;;) {
13344
+ write("");
13345
+ for (const line of welcomeStepLines(model, key))
13346
+ write(line);
13347
+ if (model[key].done)
13348
+ break;
13349
+ const said = (await askNow("Press Enter once it's done, s to skip, q to stop: ")).trim().toLowerCase();
13350
+ if (said === "q")
13351
+ return EXIT.ok;
13352
+ if (said === "s")
13353
+ break;
13354
+ model = await read();
13355
+ }
13356
+ }
13357
+ write("");
13358
+ write(model.left === 0 ? "You're set. Run `toolroll` to open Toolroll." : "Run `toolroll` to open Toolroll; the steps left are there too.");
13359
+ return EXIT.ok;
13360
+ }
13361
+ /**
13362
+ * `toolroll password set`: the local owner's first password (plan C4), typed twice, hidden, at a person's own
13363
+ * terminal — never from a flag, a file or the environment, never under a coding agent. Only while local mode is on and
13364
+ * its owner has none: one transaction (store.ts enrollOwnerPassword) checks that again, writes the hash, moves the
13365
+ * account's generation and ends every session. Local mode stays on. With a password already set, it is refused: sign
13366
+ * in with it in the browser.
13367
+ */
13368
+ async function passwordCommand(positional, flags, context) {
13369
+ const { write, json, store } = context;
13370
+ const [action, ...rest] = positional;
13371
+ const command = action === undefined ? "password" : `password ${action}`;
13372
+ if (action !== "set" || rest.length > 0)
13373
+ return fail(write, json, command, "usage", "Use `toolroll password set`.", EXIT.usage);
13374
+ for (const name of flags.keys())
13375
+ if (!["db", "json"].includes(name))
13376
+ return fail(write, json, command, "usage", `--${name} is not a password set option: the password is only ever typed.`, EXIT.usage);
13377
+ if (context.principal !== undefined)
13378
+ return fail(write, json, command, "not-remote", "A password is set on Toolroll's own computer.", EXIT.refused);
13379
+ if (localOwnerRequested() || !personAtTerminal(context))
13380
+ return fail(write, json, command, "not-a-terminal", "Set your password yourself at a terminal: run `toolroll password set`.", EXIT.refused);
13381
+ const binding = localOwnerBinding(store);
13382
+ if (binding === null)
13383
+ return fail(write, json, command, "not-local", "This Toolroll already signs in with a password. Sign in with it in the browser.", EXIT.refused);
13384
+ if (store.accountOf(binding.account)?.passwordSet === true)
13385
+ return fail(write, json, command, "already-set", "A password is already set. Sign in with it in the browser.", EXIT.refused);
13386
+ const typeHidden = context.easySeams?.askHidden ?? askHidden;
13387
+ write(`Choose a password for ${binding.account}. It signs you in from other devices and approves what this computer's browser can't.`);
13388
+ const chosen = await typeHidden("New password: ");
13389
+ if (chosen.length < 8)
13390
+ return fail(write, json, command, "weak-password", "A password needs at least 8 characters. Nothing changed.", EXIT.refused);
13391
+ const again = await typeHidden("Type it again: ");
13392
+ if (again !== chosen)
13393
+ return fail(write, json, command, "mismatch", "The two passwords didn't match. Nothing changed.", EXIT.refused);
13394
+ const enrolled = enrollOwnerPassword(store, binding.account, hashPassword(chosen), binding.localGeneration, context.clock());
13395
+ if (!enrolled.ok) {
13396
+ const words = enrolled.reason === "already-set" ? "A password was just set. Sign in with it in the browser."
13397
+ : enrolled.reason === "local-mode-changed" ? "Local mode changed while you were typing. Nothing changed."
13398
+ : "Only this computer's owner sets the first password. Nothing changed.";
13399
+ return fail(write, json, command, enrolled.reason, words, EXIT.refused);
13400
+ }
13401
+ return succeed(write, json, command, { account: binding.account, generation: enrolled.generation }, () => [
13402
+ "Password set.",
13403
+ "Open browser tabs and the terminal UI sign in again: run `toolroll open`, or sign in with your password.",
13404
+ ]);
13405
+ }
13406
+ /**
13407
+ * Easy start: the ports `up --laptop` (no --port) tries, in order. The port this install saved, alone; else, for a NEW
13408
+ * laptop install (made by `up --laptop`, nothing saved yet), `up`'s default and the next nineteen, so a console that
13409
+ * isn't this database's (another Toolroll, the desktop app's) never stops a first start; else (an install made any other
13410
+ * way) `up`'s default alone, as before. `fromSeam` replaces the default range in tests.
13411
+ */
13412
+ export function upLaptopPortCandidates(store, fromSeam) {
13413
+ const saved = laptopPortOf(store);
13414
+ if (saved !== null)
13415
+ return [saved];
13416
+ const range = fromSeam ?? Array.from({ length: 20 }, (_, index) => 4180 + index);
13417
+ if (installationCreatedBy(store) !== "laptop")
13418
+ return [range[0] ?? 4180];
13419
+ return [...range];
13420
+ }