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
@@ -3,6 +3,9 @@
3
3
  const METHOD_IS_PERSON = {
4
4
  "password-now": true, "sso-now": true, "fresh-password": true, "fresh-sso": true, "session": true, "cli-login": true,
5
5
  "remote-token": false, "lead-token": false,
6
+ // Easy-start C1: the laptop's own owner in an allowlisted act — a person, standing only while local mode stays on at
7
+ // the generation it was given under (approval-authority.ts localSealStandsSql).
8
+ "local-owner": true,
6
9
  };
7
10
  /** Proofs that aren't credential methods: the paired-chat tap, and the phone plan's passkey ceremony. */
8
11
  const OTHER_PERSON_PROOFS = new Set(["chat", "passkey"]);
@@ -22,19 +22,18 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
22
22
  export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
23
23
  /** Chat reads only an admitted project. Keep the full source in the project UI. */
24
24
  export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
25
- history?: never;
25
+ content?: never;
26
26
  revision: number;
27
27
  stale: boolean;
28
28
  notice: string;
29
29
  decision?: never;
30
30
  title?: never;
31
- content?: never;
31
+ history?: never;
32
32
  instructions?: never;
33
33
  references?: never;
34
34
  decisions?: never;
35
35
  } | {
36
- revision?: never;
37
- history?: never;
36
+ content?: never;
38
37
  stale?: never;
39
38
  decision: {
40
39
  repo: string;
@@ -52,26 +51,27 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
52
51
  };
53
52
  notice: string;
54
53
  title?: never;
55
- content?: never;
54
+ revision?: never;
55
+ history?: never;
56
56
  instructions?: never;
57
57
  references?: never;
58
58
  decisions?: never;
59
59
  } | {
60
- history?: never;
61
60
  stale?: never;
62
61
  decision?: never;
63
62
  revision: number;
64
63
  title: string;
65
64
  content: string;
66
65
  notice: string;
66
+ history?: never;
67
67
  instructions?: never;
68
68
  references?: never;
69
69
  decisions?: never;
70
70
  } | {
71
+ content?: never;
71
72
  stale?: never;
72
73
  decision?: never;
73
74
  title?: never;
74
- content?: never;
75
75
  revision: number;
76
76
  history: {
77
77
  revision: number;
@@ -1,4 +1,4 @@
1
- import { type AttachSeams } from "./console-instance.js";
1
+ import { type AttachProof, type AttachSeams } from "./console-instance.js";
2
2
  export type QuickStartSeams = {
3
3
  databaseFile: string;
4
4
  cwd: string;
@@ -20,5 +20,7 @@ export type QuickStartSeams = {
20
20
  };
21
21
  /** Whether argv is a bare `toolroll` (nothing, or only --json). */
22
22
  export declare const isBareInvocation: (argv: readonly string[]) => boolean;
23
+ /** This database's console, proved live without creating, migrating or changing anything (also easy-start.ts's). */
24
+ export declare function proveConsoleReadOnly(databaseFile: string, build: string, attach?: AttachSeams): Promise<AttachProof>;
23
25
  /** null: not this command (outside a repository) — the caller scans as before. */
24
26
  export declare function runQuickStart(argv: readonly string[], write: (line: string) => void, seams: QuickStartSeams): Promise<number | null>;
@@ -45,7 +45,12 @@ function databasePresence(file) {
45
45
  return error.code === "ENOENT" ? "absent" : "unknown";
46
46
  }
47
47
  }
48
- async function prove(seams) {
48
+ function prove(seams) {
49
+ return proveConsoleReadOnly(seams.databaseFile, seams.build, seams.attach ?? {});
50
+ }
51
+ /** This database's console, proved live without creating, migrating or changing anything (also easy-start.ts's). */
52
+ export async function proveConsoleReadOnly(databaseFile, build, attach = {}) {
53
+ const seams = { databaseFile, build, attach };
49
54
  // Read only, never created or migrated, never cleaned up: proving changes nothing. "None" only when nothing of this
50
55
  // build can be running on it (no database yet, or one older than this build, which `up` brings up to date); a
51
56
  // database that can't be read, is mid-upgrade or is newer than this build can't be checked, so it is refused.
@@ -62,6 +62,8 @@ export type AdapterOptions = {
62
62
  formula?: string;
63
63
  /** How long the deployment itself may run, in minutes. */
64
64
  deployMinutes?: number;
65
+ /** The Toolroll CLI the release calls, as [program, ...leading args]; by default this runtime's own bin.js. */
66
+ cli?: readonly string[];
65
67
  run?: Exec;
66
68
  fetch?: typeof fetch;
67
69
  say?: (line: string) => void;
@@ -10,6 +10,7 @@ import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, realpath
10
10
  import { homedir, tmpdir } from "node:os";
11
11
  import { basename, delimiter, dirname, join } from "node:path";
12
12
  import { createRequire } from "node:module";
13
+ import { fileURLToPath } from "node:url";
13
14
  import { parse as parseYaml } from "yaml";
14
15
  import { sleep } from "./sleep.js";
15
16
  import { callLabel, readOnlyCall, retryCall, retryingExec, writeWithRecheck } from "./release-net.js";
@@ -50,6 +51,13 @@ export const exec = (command, args, options = {}) => new Promise(done => {
50
51
  child.stdin?.end();
51
52
  });
52
53
  /** Without NODE_OPTIONS, which a test runner or debugger may have left for this process. */
54
+ /** This runtime's own CLI (dist/bin.js beside this module) when it has one; from source, `toolroll` as installed. */
55
+ const ownCli = () => {
56
+ const bin = join(dirname(fileURLToPath(import.meta.url)), "bin.js");
57
+ return existsSync(bin) ? [process.execPath, bin] : ["toolroll"];
58
+ };
59
+ /** Journal phases the deployment's finish accepts (scripts/deploy-phases.mjs finish): the new service runs, unproven. */
60
+ const FINISHES_FORWARD = Object.freeze(["started", "healthy"]);
53
61
  const cleanEnv = (extra = {}) => {
54
62
  const env = { ...process.env, ...extra };
55
63
  delete env["NODE_OPTIONS"];
@@ -244,8 +252,11 @@ export function releaseAdapters(options) {
244
252
  /** A write by gh, never retried by itself. */
245
253
  const ghWrite = (remainingMs, ...args) => run(tools.gh, args, { cwd: "/", env: gitEnv, timeoutMs: Math.min(120_000, remainingMs) });
246
254
  const ghJson = async (what, ...args) => JSON.parse(await must(gh(...args), what));
255
+ // The build running this release, never a PATH lookup: until the link step, `toolroll` on PATH is still the previous
256
+ // build, which refuses a database the deployment migrated, so a release resumed after its deploy couldn't read its task.
257
+ const cli = options.cli ?? ownCli();
247
258
  const toolroll = async (...args) => {
248
- const result = await run("toolroll", [...args, "--json"], { env: cleanEnv(), timeoutMs: 120_000 });
259
+ const result = await run(cli[0], [...cli.slice(1), ...args, "--json"], { env: cleanEnv(), timeoutMs: 120_000 });
249
260
  try {
250
261
  return object(JSON.parse(result.stdout));
251
262
  }
@@ -562,6 +573,24 @@ export function releaseAdapters(options) {
562
573
  const running = (options.processes ?? processes)().filter(one => one.command.includes("deploy-browser.mjs") && one.command.includes(stage));
563
574
  if (running.length > 0)
564
575
  return { ok: false, message: `the deployment is still running (process ${running.map(one => one.pid).join(", ")}); let it finish, then rerun` };
576
+ // The new service started (deploy-rescue.ts FORWARD_ONLY): recovery refuses to roll it back, so the release
577
+ // resumes it forward with the deployment's own finish (health, both consoles, leases, then the pause lifted).
578
+ // Finishing deploys, so the candidate is proven first, as for a deploy.
579
+ const phase = (() => { try {
580
+ return object(JSON.parse(readFileSync(join(stage, "deployment.json"), "utf8")))["phase"];
581
+ }
582
+ catch {
583
+ return undefined;
584
+ } })();
585
+ if (FINISHES_FORWARD.includes(String(phase))) {
586
+ const proven = proveCandidate(runId);
587
+ if (!proven.ok)
588
+ return stopped(stage, proven.message);
589
+ const finished = await deployScript(worktree, stage, runId, proven, ["--yes", "--phase", "finish"]);
590
+ if (finished.code === 0)
591
+ return { ok: true };
592
+ return stopped(stage, `${tail(finished)} (the deployment journal is at ${String(phase)}; finishing it forward stopped; ${join(stage, "deployment.json")})`);
593
+ }
565
594
  // Recovery needs no approval, but it runs only the recorded commit's own script, extracted by this runtime.
566
595
  const recorded = recordedCommit(runId);
567
596
  if (recorded === null)
@@ -297,7 +297,8 @@ function installedGateWorld() {
297
297
  db.exec("PRAGMA busy_timeout=5000");
298
298
  return db;
299
299
  },
300
- servicePlists: () => ["com.toolroll.browser", "com.standing-orders.browser"].map(label => join(agents, `${label}.plist`))
300
+ // The laptop service too: two services on one database is a conflict the gate names, never one it skips.
301
+ servicePlists: () => ["com.toolroll.browser", "com.standing-orders.browser", "com.toolroll.up"].map(label => join(agents, `${label}.plist`))
301
302
  .filter(path => existsSync(path)).map(path => ({ path, text: readFileSync(path, "utf8") })),
302
303
  };
303
304
  }
@@ -65,3 +65,12 @@ export declare function recordedHolderProvedGone(file: string, holder: {
65
65
  gone: false;
66
66
  why: string;
67
67
  };
68
+ /**
69
+ * Whether a record is authentic for exactly this registration on this host (the checks holderProvedGone makes before
70
+ * it looks at any process): sealed with the stored credential hash, for this runner, host, registration and
71
+ * credential. Null when it stands; otherwise why not. It says nothing about which processes still run.
72
+ */
73
+ export declare function recordStandsFor(record: RunnerProcessRecord | null, holder: {
74
+ runner: Runner;
75
+ credentialHash: string;
76
+ }, host: string): string | null;
@@ -217,3 +217,21 @@ export function recordedHolderProvedGone(file, holder, host, probe = realProcess
217
217
  return { gone: false, why: "its process record changed during the exit proof" };
218
218
  return proof;
219
219
  }
220
+ /**
221
+ * Whether a record is authentic for exactly this registration on this host (the checks holderProvedGone makes before
222
+ * it looks at any process): sealed with the stored credential hash, for this runner, host, registration and
223
+ * credential. Null when it stands; otherwise why not. It says nothing about which processes still run.
224
+ */
225
+ export function recordStandsFor(record, holder, host) {
226
+ if (record === null)
227
+ return "no readable record of its processes";
228
+ const expected = sealRunnerProcessRecord(record, holder.credentialHash).signature;
229
+ if (typeof record.signature !== "string" || !/^[a-f0-9]{64}$/.test(record.signature)
230
+ || !timingSafeEqual(Buffer.from(record.signature, "hex"), Buffer.from(expected, "hex")))
231
+ return "its process record is not authenticated";
232
+ if (record.runner !== holder.runner.name || record.host !== holder.runner.host || record.host !== host)
233
+ return "its record is for another worker or machine";
234
+ if (record.registeredAt !== holder.runner.registeredAt || record.credential !== credentialDigest(holder.credentialHash))
235
+ return "its record is from an earlier start";
236
+ return null;
237
+ }
@@ -0,0 +1,31 @@
1
+ import { type ExecResult, type RunOptions } from "./exec.js";
2
+ import type { Store } from "./store.js";
3
+ export type GitRun = (file: string, args: readonly string[], options: RunOptions) => Promise<ExecResult>;
4
+ export type SampleOutcome = {
5
+ ok: true;
6
+ path: string;
7
+ created: boolean;
8
+ } | {
9
+ ok: false;
10
+ reason: "no-root" | "exists" | "failed";
11
+ message: string;
12
+ };
13
+ /** Make (or find again) the sample project in `root`, the folder projects go in. */
14
+ export declare function createSampleProject(root: string | null, git?: GitRun): Promise<SampleOutcome>;
15
+ /** The sample's own check: its one test file, run by Node's test runner. Fixed here; never anyone's input. */
16
+ export declare const SAMPLE_CHECK_COMMAND = "node --test";
17
+ /** Who the sample's check is recorded as approved by: Toolroll itself, never the person who clicked (no step-up). */
18
+ export declare const SAMPLE_CHECK_ACTOR = "toolroll";
19
+ /**
20
+ * The sample project's check, set as part of making it, so its first result reads checked: the fixed command above,
21
+ * recorded as Toolroll's (SAMPLE_CHECK_ACTOR), with the person who added the sample named in the ledger line. Only
22
+ * for a sample this request just made (the caller passes createSampleProject's `created: true`; a folder found again
23
+ * is never armed), only for the folder this made (our marker in its .git), and only while the project has never had
24
+ * a check at all (no verify_command row, live or revoked, for it or its quick check): a check someone cleared stays
25
+ * cleared, and one someone approved is never replaced. It takes no command: this is not a way to set a project's
26
+ * check. True when it set one now.
27
+ */
28
+ export declare function registerSampleCheck(store: Pick<Store, "transact" | "handle" | "setVerifyCommand" | "recordAction">, made: {
29
+ path: string;
30
+ created: boolean;
31
+ }, repo: string, addedBy: string, now: Date): boolean;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The sample project the welcome offers ("Try a sample project"): a small real git repository — a README, one function
3
+ * and its test — made in the project folder, so a first task has something true to change. It is registered like any
4
+ * other project by its caller (project-admission.ts).
5
+ *
6
+ * The folder is claimed with an exclusive mkdir; a folder already there is used again only when it is ours (a real
7
+ * directory, not a link, holding our marker inside its .git), and refused otherwise: nothing in someone else's folder
8
+ * is touched. Git runs hardened (no system or global config, no hooks or hook templates, no fsmonitor, no signing, no
9
+ * prompts, an allowlisted environment), and a folder this call made is removed again when making it fails.
10
+ */
11
+ import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { isAbsolute, join } from "node:path";
13
+ import { run } from "./exec.js";
14
+ import { SAMPLE_PROJECT_NAME } from "./first-run.js";
15
+ import { ENV, ENV_ALLOWLIST } from "./remote-main.js";
16
+ import { TEARDOWN_FLAGS } from "./teardown-git.js";
17
+ /** Inside the sample's .git: says the folder is the one this made (never tracked, so the work tree stays clean). */
18
+ const MARKER = "toolroll-sample";
19
+ const MARKER_TEXT = "Made by Toolroll's welcome as the sample project.\n";
20
+ const FILES = {
21
+ "README.md": `# Toolroll sample
22
+
23
+ A tiny project to try Toolroll on. \`src/price.js\` has one function, and \`test/price.test.js\` checks it.
24
+
25
+ Run the tests with \`npm test\` (Node.js 22 or newer, nothing to install).
26
+
27
+ Things to ask for:
28
+
29
+ - Round prices to whole cents and add a test for it.
30
+ - Refuse a negative discount with a clear error.
31
+ - Add a short usage example to this README.
32
+ `,
33
+ "package.json": `${JSON.stringify({ name: "toolroll-sample", version: "1.0.0", private: true, type: "module", scripts: { test: "node --test" } }, null, 2)}\n`,
34
+ "src/price.js": `/** The price after a percentage discount: priceWithDiscount(80, 25) is 60. */
35
+ export function priceWithDiscount(price, percent) {
36
+ return price - (price * percent) / 100;
37
+ }
38
+ `,
39
+ "test/price.test.js": `import assert from "node:assert/strict";
40
+ import { test } from "node:test";
41
+ import { priceWithDiscount } from "../src/price.js";
42
+
43
+ test("takes the discount off the price", () => {
44
+ assert.equal(priceWithDiscount(80, 25), 60);
45
+ });
46
+ `,
47
+ ".gitignore": "node_modules/\n",
48
+ };
49
+ /** Git for the sample: fixed settings first, an allowlisted environment with no system or global config. */
50
+ const SAMPLE_FLAGS = [
51
+ ...TEARDOWN_FLAGS, "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", "-c", "init.templateDir=",
52
+ "-c", "user.name=Toolroll", "-c", "user.email=sample@toolroll.invalid", "-c", "core.autocrlf=false",
53
+ ];
54
+ function kindOf(path) {
55
+ try {
56
+ const seen = lstatSync(path);
57
+ return seen.isDirectory() && !seen.isSymbolicLink() ? "dir" : "other";
58
+ }
59
+ catch (error) {
60
+ return error.code === "ENOENT" ? "missing" : "other";
61
+ }
62
+ }
63
+ /** Ours: a real folder whose .git is a real folder holding our marker, a regular file with our words. */
64
+ function isOurs(target) {
65
+ if (kindOf(target) !== "dir" || kindOf(join(target, ".git")) !== "dir")
66
+ return false;
67
+ const marker = join(target, ".git", MARKER);
68
+ try {
69
+ const seen = lstatSync(marker);
70
+ return seen.isFile() && seen.size === Buffer.byteLength(MARKER_TEXT) && readFileSync(marker, "utf8") === MARKER_TEXT;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ /** Make (or find again) the sample project in `root`, the folder projects go in. */
77
+ export async function createSampleProject(root, git = run) {
78
+ if (root === null || root === "" || !isAbsolute(root) || kindOf(root) !== "dir") {
79
+ return { ok: false, reason: "no-root", message: "There's no project folder to put the sample in." };
80
+ }
81
+ const target = join(root, SAMPLE_PROJECT_NAME);
82
+ if (isOurs(target))
83
+ return { ok: true, path: target, created: false };
84
+ try {
85
+ mkdirSync(target); // the claim: exclusive, and never through a link
86
+ }
87
+ catch {
88
+ return { ok: false, reason: "exists", message: `A folder named ${SAMPLE_PROJECT_NAME} is already there, so nothing was changed.` };
89
+ }
90
+ const fail = () => {
91
+ try {
92
+ rmSync(target, { recursive: true, force: true });
93
+ }
94
+ catch { /* left for a person to remove */ }
95
+ return { ok: false, reason: "failed", message: "The sample project couldn't be made. Nothing was added." };
96
+ };
97
+ try {
98
+ for (const [file, body] of Object.entries(FILES)) {
99
+ const path = join(target, file);
100
+ mkdirSync(join(path, ".."), { recursive: true });
101
+ writeFileSync(path, body, { flag: "wx" });
102
+ }
103
+ const options = { cwd: target, env: ENV, envAllowlist: ENV_ALLOWLIST, timeoutMs: 30_000 };
104
+ for (const args of [["init", "-q", "--template=", "-b", "main"], ["add", "--", "."], ["commit", "-q", "--no-verify", "-m", "Sample project"]]) {
105
+ const ran = await git("git", [...SAMPLE_FLAGS, ...args], options);
106
+ if (ran.code !== 0)
107
+ return fail();
108
+ }
109
+ writeFileSync(join(target, ".git", MARKER), MARKER_TEXT, { flag: "wx", mode: 0o600 });
110
+ }
111
+ catch {
112
+ return fail();
113
+ }
114
+ return { ok: true, path: target, created: true };
115
+ }
116
+ /** The sample's own check: its one test file, run by Node's test runner. Fixed here; never anyone's input. */
117
+ export const SAMPLE_CHECK_COMMAND = "node --test";
118
+ const SAMPLE_CHECK_TIMEOUT_MS = 60_000;
119
+ /** Who the sample's check is recorded as approved by: Toolroll itself, never the person who clicked (no step-up). */
120
+ export const SAMPLE_CHECK_ACTOR = "toolroll";
121
+ /**
122
+ * The sample project's check, set as part of making it, so its first result reads checked: the fixed command above,
123
+ * recorded as Toolroll's (SAMPLE_CHECK_ACTOR), with the person who added the sample named in the ledger line. Only
124
+ * for a sample this request just made (the caller passes createSampleProject's `created: true`; a folder found again
125
+ * is never armed), only for the folder this made (our marker in its .git), and only while the project has never had
126
+ * a check at all (no verify_command row, live or revoked, for it or its quick check): a check someone cleared stays
127
+ * cleared, and one someone approved is never replaced. It takes no command: this is not a way to set a project's
128
+ * check. True when it set one now.
129
+ */
130
+ export function registerSampleCheck(store, made, repo, addedBy, now) {
131
+ if (!made.created || !isAbsolute(repo) || !isOurs(repo))
132
+ return false;
133
+ return store.transact(() => {
134
+ if (store.handle.prepare("SELECT 1 FROM verify_command WHERE repo IN (?, ?) LIMIT 1").get(repo, `quick:${repo}`) !== undefined)
135
+ return false;
136
+ store.setVerifyCommand({ repo, command: SAMPLE_CHECK_COMMAND, timeoutMs: SAMPLE_CHECK_TIMEOUT_MS, approvedBy: SAMPLE_CHECK_ACTOR }, now);
137
+ store.recordAction({ at: now.toISOString(), actor: SAMPLE_CHECK_ACTOR, repo, taskId: null, runId: null, action: "check approved", outcome: "approved", source: "policy",
138
+ detail: `the sample project's own test (${SAMPLE_CHECK_COMMAND}), set by Toolroll when ${addedBy} added the sample` });
139
+ return true;
140
+ });
141
+ }
package/dist/scope.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { ACCEPTANCE_LIMITS, EVIDENCE_KINDS, type EvidenceKind } from "./contracts/acceptance-terms.js";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { type LocalTransport } from "./local-owner.js";
3
4
  import { isAcpProfile, type AcpProviderId } from "./acp-agents.js";
4
5
  export { isAcpProfile };
5
6
  import { type NetworkProfile } from "./egress.js";
@@ -651,7 +652,9 @@ export type FreshOp = "approve-standard" | "chat-send" | "reply-question";
651
652
  /** "session": Passwordless is on and this is the person's own live browser session (docs/plans/passwordless.md). */
652
653
  /** "remote-token" / "lead-token": a machine credential stood in for the person (a remote run's API token, a lead's
653
654
  * token) — fine for ordinary acts it is allowed, never for a credential act (it would mint a way in with no password). */
654
- export type AuthMethod = "password-now" | "sso-now" | "fresh-password" | "fresh-sso" | "session" | "cli-login" | "remote-token" | "lead-token";
655
+ /** "local-owner" (easy-start C1): the local owner of a laptop install, in an allowlisted act (local-owner.ts) — a person,
656
+ * never a credential act, and its approvals stand only while local mode stays on at their generation. */
657
+ export type AuthMethod = "password-now" | "sso-now" | "fresh-password" | "fresh-sso" | "session" | "cli-login" | "remote-token" | "lead-token" | "local-owner";
655
658
  /**
656
659
  * The local CLI's remembered login, marked for the one command that read it (operate.ts credentialsFrom): with
657
660
  * Passwordless on, a proof made with it records "cli-login" — never a typed password — and no credential act accepts it.
@@ -794,7 +797,10 @@ export declare function approve(store: Store, taskId: string, by: string, now: D
794
797
  /** The ledger act that records how an approval's person was proved (password-now, sso-now, fresh-password, fresh-sso). */
795
798
  export declare const APPROVAL_PROOF = "approval proved";
796
799
  /** The proof row's detail: when the person was proved, the generation, and the exact digest they said yes to. */
797
- export declare const approvalProofDetail: (at: number, generation: number, digest: string) => string;
800
+ export declare const approvalProofDetail: (at: number, generation: number, digest: string, local?: {
801
+ generation: number;
802
+ transport: LocalTransport;
803
+ }) => string;
798
804
  /** The proof of THIS approval: the approver's own row for these exact bytes (never another approval's words). */
799
805
  export declare function approvalProofOf(store: Store, taskId: string, by: string, digest: string): {
800
806
  method: string;
@@ -1007,3 +1013,19 @@ export declare function releaseApprovalOf(store: Store, taskId: string, _now?: D
1007
1013
  /** When a password typed at `at` (ISO) stops standing in, for a page's words (ISO); null when `at` isn't a time. Words
1008
1014
  * only: freshnessFor decides whether it stands in, at the moment of use. */
1009
1015
  export declare function freshUntil(at: string): string | null;
1016
+ export type LocalApprovalRefusal = "local-mode-off" | "not-owner" | "no-scope" | "release" | "protected" | "escalated" | "coordinator" | "unsandboxed";
1017
+ /**
1018
+ * Whether the local owner may approve this task without a password (easy-start plan C5). ONE rule, read by the
1019
+ * preview (the task page's approve form and the chat review render no password field when it is ok), the chat
1020
+ * precheck (an empty field passes) and the seal itself (approve(), inside its transaction). Refused: local mode off or
1021
+ * another person; no plan; release or deploy terms; protected or two-person work; an escalated profile; work a
1022
+ * coordinator filed; a project whose checks are stored as unsandboxed (or a plan that sealed that). Plans a lead or
1023
+ * a scout proposed are allowed.
1024
+ */
1025
+ export declare function localApprovalEligible(store: Store, by: string, taskId: string): {
1026
+ ok: true;
1027
+ } | {
1028
+ ok: false;
1029
+ reason: LocalApprovalRefusal;
1030
+ message: string;
1031
+ };
package/dist/scope.js CHANGED
@@ -6,6 +6,7 @@ import { currentPasswordSource, passwordGuardOf, provenPasswordAccount } from ".
6
6
  import { validateScopeText } from "./task-text.js";
7
7
  import { contractChangesOf } from "./contract-changes.js";
8
8
  import { AsyncLocalStorage } from "node:async_hooks";
9
+ import { currentLocalOwner, localModeOn, localOwnerInFlight, localOwnerRefusal, localOwnerStandsNow } from "./local-owner.js";
9
10
  import { projectAuthority } from "./project-access.js";
10
11
  import { activeRemote } from "./remote-run.js";
11
12
  import { claimActor, currentActor } from "./actor.js";
@@ -824,7 +825,9 @@ export function authenticateAccount(store, by, secret) {
824
825
  const hash = account?.credentialHash ?? dummy;
825
826
  if (!hash.startsWith("scrypt$"))
826
827
  verifyCredential(dummy, secret);
827
- const matches = verifyCredential(hash, secret);
828
+ // v147: an account with no password set (a local-mode owner) has a random hash; the KDF still runs (same timing), and
829
+ // nothing it is given ever matches.
830
+ const matches = verifyCredential(hash, secret) && account !== null && account.passwordSet;
828
831
  if (account === null || !matches) {
829
832
  guard.failed(by, now, source, account !== null);
830
833
  return { ok: false, reason: "unknown" };
@@ -902,6 +905,16 @@ export function ssoNowAvailable(by, now = Date.now()) {
902
905
  */
903
906
  export function proveAccountNow(store, by, token, options = {}) {
904
907
  const now = Date.now();
908
+ // Easy-start C1: a local owner is decided FIRST, before Passwordless and freshness, whatever was typed. An allowlisted
909
+ // act of its own, still standing (re-read here, inside the caller's transaction), is "local-owner"; anything else —
910
+ // a credential act, a read, another person, local mode moved — proves nothing. It never upgrades to a typed password.
911
+ // A lead's token or a remote API token in that chain is never the local owner (it stays a machine's proof below).
912
+ const local = activeRemote() === null && currentActor()?.lead !== true ? currentLocalOwner() : null;
913
+ if (local !== null) {
914
+ if (options.credential === true || local.account !== by || local.act === "read" || !localOwnerInFlight(local) || !localOwnerStandsNow(store, local))
915
+ return { ok: false, reason: "not-an-approver" };
916
+ return { ok: true, method: "local-owner", at: now };
917
+ }
905
918
  const facts = freshFacts.getStore();
906
919
  if (facts != null && facts.actor === by && !facts.live())
907
920
  return { ok: false, reason: "not-an-approver" };
@@ -1072,6 +1085,16 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
1072
1085
  // credential, not a person at a ceremony. Refused before any vote or proof row is written.
1073
1086
  if (!personProof(authenticated.method))
1074
1087
  return { ok: false, reason: "machine-proof" };
1088
+ // Easy-start C5: a local owner approves only as the approve-plan act, and only eligible work (one shared rule:
1089
+ // localApprovalEligible, read again here inside the seal's transaction).
1090
+ const local = authenticated.method === "local-owner" ? currentLocalOwner() : null;
1091
+ if (authenticated.method === "local-owner") {
1092
+ if (local === null || local.act !== "approve-plan")
1093
+ return { ok: false, reason: "policy", message: localOwnerRefusal(store.accountOf(by)?.passwordSet === true, "/").message };
1094
+ const eligible = localApprovalEligible(store, by, taskId);
1095
+ if (!eligible.ok)
1096
+ return { ok: false, reason: "policy", message: eligible.message };
1097
+ }
1075
1098
  const scope = store.getScope(taskId);
1076
1099
  if (scope === null)
1077
1100
  return { ok: false, reason: "no-scope" };
@@ -1121,7 +1144,7 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
1121
1144
  // How the person was proved, beside every yes they give (a vote kept for protected work, or the seal): a password
1122
1145
  // typed now, a re-check now, or a remembered one — with when, the credential generation and the exact digest.
1123
1146
  const proved = () => store.recordAction({ at: now.toISOString(), actor: by, repo: store.lookupRef(taskId)?.repo ?? null, taskId, runId: null, action: APPROVAL_PROOF,
1124
- outcome: authenticated.method, source: "work", detail: approvalProofDetail(authenticated.at, store.accountOf(by)?.generation ?? 0, scope.digest) });
1147
+ outcome: authenticated.method, source: "work", detail: approvalProofDetail(authenticated.at, store.accountOf(by)?.generation ?? 0, scope.digest, local === null ? undefined : { generation: local.localGeneration, transport: local.transport }) });
1125
1148
  if (gate.verdict === "vote") {
1126
1149
  // The same person again adds nothing: no new vote, no proof row (and the approval roads refund a re-check).
1127
1150
  if (gate.already)
@@ -1161,7 +1184,7 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
1161
1184
  /** The ledger act that records how an approval's person was proved (password-now, sso-now, fresh-password, fresh-sso). */
1162
1185
  export const APPROVAL_PROOF = "approval proved";
1163
1186
  /** The proof row's detail: when the person was proved, the generation, and the exact digest they said yes to. */
1164
- export const approvalProofDetail = (at, generation, digest) => `${new Date(at).toISOString()} · generation ${generation} · digest ${digest}`;
1187
+ export const approvalProofDetail = (at, generation, digest, local) => `${new Date(at).toISOString()} · generation ${generation}${local === undefined ? "" : ` · local ${local.generation} ${local.transport}`} · digest ${digest}`;
1165
1188
  /** The proof of THIS approval: the approver's own row for these exact bytes (never another approval's words). */
1166
1189
  export function approvalProofOf(store, taskId, by, digest) {
1167
1190
  const rows = store.handle.prepare("SELECT outcome, detail FROM action_ledger WHERE task_id = ? AND action = ? AND actor = ? ORDER BY id DESC LIMIT 50").all(taskId, APPROVAL_PROOF, by);
@@ -1398,6 +1421,10 @@ export function proofDetailOf(detail) {
1398
1421
  }
1399
1422
  }
1400
1423
  const parts = detail.split(" · ");
1424
+ // Easy-start C5: a local owner's proof names its local generation and transport between the two (approvalProofDetail).
1425
+ const local = parts.length === 4 ? parts.splice(2, 1)[0] : null;
1426
+ if (local !== null && !/^local \d+ (browser|terminal)$/.test(local))
1427
+ return null;
1401
1428
  if (parts.length !== 3 || !parts[2].startsWith("digest ") || !/^generation \d+$/.test(parts[1]) || Number.isNaN(Date.parse(parts[0])))
1402
1429
  return null;
1403
1430
  return { at: parts[0], digest: parts[2].slice("digest ".length) };
@@ -1616,3 +1643,47 @@ export function freshUntil(at) {
1616
1643
  const ms = Date.parse(at);
1617
1644
  return Number.isFinite(ms) ? new Date(ms + FRESH_WINDOW_MS).toISOString() : null;
1618
1645
  }
1646
+ const LOCAL_APPROVAL_WORDS = {
1647
+ "local-mode-off": "Local mode is off: approve with your password.",
1648
+ "not-owner": "Only this computer's owner approves here without a password.",
1649
+ "no-scope": "This task has no plan to approve yet.",
1650
+ release: "Releases and deploys need your password.",
1651
+ protected: "Protected work needs your password and a second approver.",
1652
+ escalated: "Work with wider permissions needs your password.",
1653
+ coordinator: "Work a coordinator filed needs your password.",
1654
+ unsandboxed: "This project's checks run without a sandbox, so approving needs your password.",
1655
+ };
1656
+ /**
1657
+ * Whether the local owner may approve this task without a password (easy-start plan C5). ONE rule, read by the
1658
+ * preview (the task page's approve form and the chat review render no password field when it is ok), the chat
1659
+ * precheck (an empty field passes) and the seal itself (approve(), inside its transaction). Refused: local mode off or
1660
+ * another person; no plan; release or deploy terms; protected or two-person work; an escalated profile; work a
1661
+ * coordinator filed; a project whose checks are stored as unsandboxed (or a plan that sealed that). Plans a lead or
1662
+ * a scout proposed are allowed.
1663
+ */
1664
+ export function localApprovalEligible(store, by, taskId) {
1665
+ const no = (reason) => ({ ok: false, reason, message: LOCAL_APPROVAL_WORDS[reason] });
1666
+ if (!localModeOn(store))
1667
+ return no("local-mode-off");
1668
+ const owner = store.handle.prepare("SELECT owner FROM local_mode WHERE id = 1").get()?.["owner"];
1669
+ if (owner !== by)
1670
+ return no("not-owner");
1671
+ const scope = store.getScope(taskId);
1672
+ if (scope === null)
1673
+ return no("no-scope");
1674
+ if (deployGateScope(scope) || releaseCheckScope(scope))
1675
+ return no("release");
1676
+ const gate = store.approvalGate(taskId, by, "person");
1677
+ if (gate.verdict !== "seal" || gate.protectedWork)
1678
+ return no("protected");
1679
+ if (scope.profile == null || levelOfProfile(scope.profile) === "escalated")
1680
+ return no("escalated");
1681
+ const ref = store.handle.prepare("SELECT repo, coordinator_cid FROM task_ref WHERE backend = 'built-in' AND external_id = ?").get(taskId);
1682
+ if (ref === undefined || ref["coordinator_cid"] != null)
1683
+ return no("coordinator");
1684
+ // The stored level, never the launch cap: a project saved as unsandboxed is refused even though local mode confines it.
1685
+ const stored = ref["repo"] == null ? undefined : store.handle.prepare("SELECT level FROM project_check_access WHERE repo = ?").get(ref["repo"])?.["level"];
1686
+ if (stored === "unsandboxed" || scope.profile.checkAccess === "unsandboxed")
1687
+ return no("unsandboxed");
1688
+ return { ok: true };
1689
+ }