toolroll 0.7.0 → 0.8.0
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.
- package/CHANGELOG.md +413 -0
- package/README.md +15 -0
- package/dist/agent-onboard.d.ts +106 -0
- package/dist/agent-onboard.js +282 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +23 -23
- package/dist/browser-shell.js +5 -3
- package/dist/browser-workspace.d.ts +11 -0
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +8 -1
- package/dist/control-setup.d.ts +4 -4
- package/dist/daemon.d.ts +4 -0
- package/dist/daemon.js +28 -4
- package/dist/desktop-host.js +2 -2
- package/dist/desktop-update.d.ts +8 -2
- package/dist/desktop-update.js +39 -22
- package/dist/evidence.d.ts +24 -0
- package/dist/evidence.js +103 -5
- package/dist/first-run.d.ts +54 -0
- package/dist/first-run.js +101 -0
- package/dist/install-method.d.ts +1 -1
- package/dist/install-method.js +4 -0
- package/dist/operate.d.ts +9 -0
- package/dist/operate.js +68 -4
- package/dist/releases.d.ts +9 -0
- package/dist/releases.js +14 -1
- package/dist/serve.d.ts +16 -0
- package/dist/serve.js +151 -82
- package/dist/skills.d.ts +2 -0
- package/dist/skills.js +3 -1
- package/dist/store.d.ts +3 -3
- package/dist/store.js +9 -4
- package/dist/surface.js +2 -0
- package/dist/toolroll-update-cli.d.ts +15 -0
- package/dist/toolroll-update-cli.js +155 -0
- package/dist/toolroll-update-ui.d.ts +38 -0
- package/dist/toolroll-update-ui.js +89 -0
- package/dist/toolroll-update.d.ts +235 -0
- package/dist/toolroll-update.js +929 -0
- package/package.json +2 -1
package/dist/browser-shell.js
CHANGED
|
@@ -54,7 +54,9 @@ export function browserWorkspaceDocument(html, workspace, nonce, functionalScrip
|
|
|
54
54
|
const settleFades = `(function(){function s(e){var f=e.viewTransition;if(f)[f.finished,f.ready,f.updateCallbackDone].forEach(function(p){if(p)p.catch(function(){})})}addEventListener('pageswap',s);addEventListener('pagereveal',s)})();`;
|
|
55
55
|
return html
|
|
56
56
|
// A page showing a password keeps to its one script (the sensitivity contract), so it goes without.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
.replace('</
|
|
57
|
+
// Replacer functions, never strings: a `$\`` or `$'` in the page's data (an issue title, a TODO) would
|
|
58
|
+
// otherwise paste part of the page into the data block and close it early.
|
|
59
|
+
.replace('</head>', () => `<link rel="stylesheet" href="/assets/workspace.css">${workspace.sensitive ? "" : `<script nonce="${nonce}">${settleFades}</script>`}</head>`)
|
|
60
|
+
.replace('<body>', () => '<body><div id="standing-orders-workspace">')
|
|
61
|
+
.replace('</body>', () => `</div><script type="application/json" id="standing-orders-workspace-data" nonce="${nonce}">${serializeBrowserWorkspace(workspace)}</script><script nonce="${nonce}">${initialize}</script><script type="module" src="/assets/workspace.js" nonce="${nonce}"></script></body>`);
|
|
60
62
|
}
|
|
@@ -8,6 +8,7 @@ import type { Store } from './store.js';
|
|
|
8
8
|
import type { WorkSummaryAccess } from './work-summary.js';
|
|
9
9
|
import type { StatusTone } from './workspace-ui.js';
|
|
10
10
|
import type { AssignmentCard } from './assignment-ui.js';
|
|
11
|
+
import type { FirstRunStep, FirstTaskSuggestion } from './first-run.js';
|
|
11
12
|
export type BrowserProject = {
|
|
12
13
|
name: string;
|
|
13
14
|
path: string;
|
|
@@ -241,6 +242,8 @@ export type BrowserSettingsView = {
|
|
|
241
242
|
redirect: string | null;
|
|
242
243
|
};
|
|
243
244
|
} | null;
|
|
245
|
+
/** The installation's first Ready result, in words ("first result in 7 min"); null before it arrives. */
|
|
246
|
+
firstResult?: string | null;
|
|
244
247
|
/** Settings → Updates: this version, the latest known one and its notes, how to update, the daily-check switch, and each worker's version. */
|
|
245
248
|
updates?: BrowserUpdates | null;
|
|
246
249
|
};
|
|
@@ -783,6 +786,14 @@ export type BrowserWorkspace = {
|
|
|
783
786
|
signIn?: BrowserSignIn[];
|
|
784
787
|
/** A newer Toolroll exists: a quiet notice for an operator, until they dismiss this version. */
|
|
785
788
|
update?: BrowserUpdateNotice;
|
|
789
|
+
/** Chat's first run, until the first Ready result: the three steps and first tasks to try. */
|
|
790
|
+
firstRun?: BrowserFirstRun;
|
|
791
|
+
};
|
|
792
|
+
/** `sandbox`: the demo command, offered beside the sign-in command while no agent is signed in. */
|
|
793
|
+
export type BrowserFirstRun = {
|
|
794
|
+
steps: FirstRunStep[];
|
|
795
|
+
suggestions: FirstTaskSuggestion[];
|
|
796
|
+
sandbox: string | null;
|
|
786
797
|
};
|
|
787
798
|
/** The console's update notice: neutral, never the accent — an update does not need a person. */
|
|
788
799
|
export type BrowserUpdateNotice = {
|
package/dist/cli.d.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { previewGithubRepo, cloneGithubRepo } from "./onboard.js";
|
|
14
14
|
import { type SessionCliOptions } from "./session-cli.js";
|
|
15
15
|
import { type TeamCliOptions } from "./team-cli.js";
|
|
16
|
+
import { type UpdateCliDeps } from "./toolroll-update-cli.js";
|
|
16
17
|
import { type OperateOptions } from "./operate.js";
|
|
17
18
|
export type CliOptions = {
|
|
18
19
|
roots: string[];
|
|
@@ -38,7 +39,7 @@ export type ParseResult = {
|
|
|
38
39
|
error: string;
|
|
39
40
|
};
|
|
40
41
|
type Write = (line: string) => void;
|
|
41
|
-
export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> connect one (no path: the repo you are in)\n toolroll repos remove <path>\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll status running, queued, ready results, release check and plan windows\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
|
|
42
|
+
export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> connect one (no path: the repo you are in)\n toolroll repos remove <path>\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
|
|
42
43
|
export declare function parseArgs(argv: readonly string[]): ParseResult;
|
|
43
44
|
/** The commands that operate the queue rather than report on the world. */
|
|
44
45
|
/** Every verb the CLI routes OUTSIDE runOperate. Exported (with
|
|
@@ -60,6 +61,7 @@ export type MainOptions = {
|
|
|
60
61
|
preview?: typeof previewGithubRepo;
|
|
61
62
|
clone?: typeof cloneGithubRepo;
|
|
62
63
|
};
|
|
64
|
+
update?: UpdateCliDeps;
|
|
63
65
|
};
|
|
64
66
|
/**
|
|
65
67
|
* Strip `-o <file>` / `--output <file>` before dispatch. The flag belongs to
|
package/dist/cli.js
CHANGED
|
@@ -28,6 +28,7 @@ import { readPulls } from "./pulls.js";
|
|
|
28
28
|
import { detectGraphs, chooseBackend, setupOptions, LABELS as GRAPH_LABELS, } from "./graph.js";
|
|
29
29
|
import { openStore, openStoreReadOnly, databasePath } from "./store.js";
|
|
30
30
|
import { PACKAGE_VERSION } from "./version.js";
|
|
31
|
+
import { runUpdateCommand } from "./toolroll-update-cli.js";
|
|
31
32
|
import { runOperate, OPERATE_HELP } from "./operate.js";
|
|
32
33
|
import { renderReport, renderPulls, renderGraph } from "./render.js";
|
|
33
34
|
import { readRemote } from "./remote.js";
|
|
@@ -47,6 +48,8 @@ Usage
|
|
|
47
48
|
preview, then clone and connect (--yes)
|
|
48
49
|
toolroll link put \`toolroll\` on your PATH
|
|
49
50
|
toolroll unlink take it off again
|
|
51
|
+
toolroll update update to the latest release: verified, drained, undoable
|
|
52
|
+
(preview first; --yes, --now, --at HH:MM, --rollback)
|
|
50
53
|
toolroll contract the machine contract: envelope version + capabilities
|
|
51
54
|
(--commands dumps the declared command guide)
|
|
52
55
|
toolroll skills install --claude-code [--dir <path>]
|
|
@@ -56,6 +59,7 @@ Usage
|
|
|
56
59
|
toolroll skills get <name> print one guide (version-matched, never stale)
|
|
57
60
|
toolroll demo a seeded throwaway sandbox — see it working in 90 seconds
|
|
58
61
|
toolroll up app + builder for every saved project — the normal start
|
|
62
|
+
toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes)
|
|
59
63
|
toolroll status running, queued, ready results, release check and plan windows
|
|
60
64
|
toolroll session native coding sessions through the running service
|
|
61
65
|
toolroll connect save a private connection to your central service
|
|
@@ -179,12 +183,13 @@ export function parseArgs(argv) {
|
|
|
179
183
|
* which answers as `scan`. */
|
|
180
184
|
export const TOP_LEVEL_COMMANDS = [
|
|
181
185
|
"", "pulls", "graph", "repos", "repos add", "repos remove", "repos add-from-github",
|
|
182
|
-
"link", "unlink", "contract", "skills list", "skills get", "skills install", "demo",
|
|
186
|
+
"link", "unlink", "update", "contract", "skills list", "skills get", "skills install", "demo",
|
|
183
187
|
...SESSION_CLI_ACTIONS.map(action => `session ${action}`),
|
|
184
188
|
...TEAM_CLI_ACTIONS,
|
|
185
189
|
];
|
|
186
190
|
export const OPERATE_COMMANDS = new Set([
|
|
187
191
|
"up",
|
|
192
|
+
"onboard",
|
|
188
193
|
"status",
|
|
189
194
|
"ready",
|
|
190
195
|
"task",
|
|
@@ -368,6 +373,8 @@ async function dispatch(argv, write, mainOptions) {
|
|
|
368
373
|
if (first === "link" || first === "unlink") {
|
|
369
374
|
return runLinkCommand(first, rest, write, mainOptions.binSource);
|
|
370
375
|
}
|
|
376
|
+
if (first === "update")
|
|
377
|
+
return runUpdateCommand(rest, write, mainOptions.update);
|
|
371
378
|
if (first === "contract")
|
|
372
379
|
return runContractCommand(rest, write);
|
|
373
380
|
if (first === "session")
|
package/dist/control-setup.d.ts
CHANGED
|
@@ -6,13 +6,13 @@ export type SetupInputs = {
|
|
|
6
6
|
seconds: string;
|
|
7
7
|
};
|
|
8
8
|
export declare function previewSetup(store: Store, repo: string, inputs: SetupInputs): {
|
|
9
|
-
provider?: never;
|
|
10
|
-
model?: never;
|
|
11
9
|
ok: false;
|
|
12
10
|
message: string;
|
|
13
11
|
fingerprint?: never;
|
|
14
12
|
command?: never;
|
|
15
13
|
seconds?: never;
|
|
14
|
+
provider?: never;
|
|
15
|
+
model?: never;
|
|
16
16
|
} | {
|
|
17
17
|
message?: never;
|
|
18
18
|
ok: true;
|
|
@@ -23,13 +23,13 @@ export declare function previewSetup(store: Store, repo: string, inputs: SetupIn
|
|
|
23
23
|
model: string;
|
|
24
24
|
};
|
|
25
25
|
export declare function approveSetup(store: Store, repo: string, inputs: SetupInputs, fingerprint: string, by: string, token: string, now: Date): {
|
|
26
|
-
provider?: never;
|
|
27
|
-
model?: never;
|
|
28
26
|
ok: false;
|
|
29
27
|
message: string;
|
|
30
28
|
fingerprint?: never;
|
|
31
29
|
command?: never;
|
|
32
30
|
seconds?: never;
|
|
31
|
+
provider?: never;
|
|
32
|
+
model?: never;
|
|
33
33
|
} | {
|
|
34
34
|
ok: true;
|
|
35
35
|
};
|
package/dist/daemon.d.ts
CHANGED
|
@@ -94,6 +94,7 @@ export declare function launchdPlist(args: {
|
|
|
94
94
|
associatedBundleId?: string;
|
|
95
95
|
keepAlive?: boolean;
|
|
96
96
|
startInterval?: number;
|
|
97
|
+
runAtLoad?: boolean;
|
|
97
98
|
}): string;
|
|
98
99
|
/**
|
|
99
100
|
* Everything `install` would do, computed without doing it — the unit text,
|
|
@@ -144,6 +145,9 @@ export type ServiceStart = {
|
|
|
144
145
|
* healthy service to prove it can.
|
|
145
146
|
*/
|
|
146
147
|
export declare function installLaunchdService(definition: ServiceDefinition, run: SupervisorRunner): Promise<ServiceStart>;
|
|
148
|
+
/** Replace a file whole: a flushed temporary beside it, renamed over it, and the directory flushed, so a crash
|
|
149
|
+
* leaves the old text or the new, never a torn one. */
|
|
150
|
+
export declare function writeFileDurably(file: string, content: string | Uint8Array, mode?: number): void;
|
|
147
151
|
/** Explicit stop: unload AND disable, so nothing relaunches it until a person installs again. */
|
|
148
152
|
export declare function stopLaunchdService(definition: ServiceDefinition, run: SupervisorRunner): Promise<{
|
|
149
153
|
ok: true;
|
package/dist/daemon.js
CHANGED
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
* injectable — the tests read the generated unit and script `launchctl`,
|
|
36
36
|
* and never touch the machine's real supervision.
|
|
37
37
|
*/
|
|
38
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
39
|
-
import { createHash } from "node:crypto";
|
|
38
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
39
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
40
40
|
import { homedir } from "node:os";
|
|
41
41
|
import { fileURLToPath } from "node:url";
|
|
42
42
|
import { dirname, join } from "node:path";
|
|
@@ -157,7 +157,7 @@ ${escaped}
|
|
|
157
157
|
${environment}
|
|
158
158
|
</dict>
|
|
159
159
|
<key>RunAtLoad</key>
|
|
160
|
-
|
|
160
|
+
<${args.runAtLoad === false ? "false" : "true"}/>
|
|
161
161
|
<key>KeepAlive</key>
|
|
162
162
|
<${args.keepAlive === false ? "false" : "true"}/>${args.startInterval === undefined ? "" : `\n <key>StartInterval</key>\n <integer>${args.startInterval}</integer>`}
|
|
163
163
|
<key>ThrottleInterval</key>
|
|
@@ -343,7 +343,7 @@ export async function installLaunchdService(definition, run) {
|
|
|
343
343
|
let changed = before !== definition.unitContent;
|
|
344
344
|
mkdirSync(join(definition.logPath, ".."), { recursive: true });
|
|
345
345
|
mkdirSync(join(definition.unitPath, ".."), { recursive: true });
|
|
346
|
-
|
|
346
|
+
writeFileDurably(definition.unitPath, definition.unitContent, 0o644);
|
|
347
347
|
const status = await run("launchctl", ["print", service]);
|
|
348
348
|
const loaded = status.code === 0;
|
|
349
349
|
if (loaded)
|
|
@@ -382,6 +382,30 @@ export async function installLaunchdService(definition, run) {
|
|
|
382
382
|
return { ok: true, changed, action: loaded ? "reloaded" : "bootstrapped" };
|
|
383
383
|
return { ok: false, message: `launchctl refused the unit: ${firstLine(modern.stderr) || firstLine(legacy.stderr) || `exit ${legacy.code}`}` };
|
|
384
384
|
}
|
|
385
|
+
/** Replace a file whole: a flushed temporary beside it, renamed over it, and the directory flushed, so a crash
|
|
386
|
+
* leaves the old text or the new, never a torn one. */
|
|
387
|
+
export function writeFileDurably(file, content, mode = 0o600) {
|
|
388
|
+
const temp = join(dirname(file), `.${file.slice(dirname(file).length + 1)}.${randomUUID().slice(0, 8)}.tmp`);
|
|
389
|
+
const fd = openSync(temp, "wx", mode);
|
|
390
|
+
try {
|
|
391
|
+
writeFileSync(fd, content);
|
|
392
|
+
fsyncSync(fd);
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
closeSync(fd);
|
|
396
|
+
}
|
|
397
|
+
chmodSync(temp, mode);
|
|
398
|
+
renameSync(temp, file);
|
|
399
|
+
if (process.platform !== "win32") {
|
|
400
|
+
const directory = openSync(dirname(file), "r");
|
|
401
|
+
try {
|
|
402
|
+
fsyncSync(directory);
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
closeSync(directory);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
385
409
|
/** Explicit stop: unload AND disable, so nothing relaunches it until a person installs again. */
|
|
386
410
|
export async function stopLaunchdService(definition, run) {
|
|
387
411
|
const service = `gui/${launchdUid()}/${definition.label}`;
|
package/dist/desktop-host.js
CHANGED
|
@@ -6,7 +6,7 @@ import { homedir, hostname } from "node:os";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { createHmac } from "node:crypto";
|
|
8
8
|
import { openStore, openStoreNoMigrate, databasePath, readSchemaVersion, SCHEMA_VERSION } from "./store.js";
|
|
9
|
-
import {
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
10
|
import { authenticateApprover, hashPassword } from "./scope.js";
|
|
11
11
|
import { runOperate, writeLoginFileDurably } from "./operate.js";
|
|
12
12
|
import { daemonStatus, installLaunchdService, planDesktopService, stopLaunchdService } from "./daemon.js";
|
|
@@ -117,7 +117,7 @@ export function desktopDatabaseStatus(file) {
|
|
|
117
117
|
const result = (ready, message) => ({ ready, message, expectedSchema: SCHEMA_VERSION });
|
|
118
118
|
try {
|
|
119
119
|
// Read-only open: a missing/unreadable file must never become a new queue.
|
|
120
|
-
db = new DatabaseSync(file, { readOnly: true });
|
|
120
|
+
db = new createRequire(import.meta.url)("node:sqlite").DatabaseSync(file, { readOnly: true });
|
|
121
121
|
db.exec("PRAGMA busy_timeout = 1000");
|
|
122
122
|
const schema = readSchemaVersion(db);
|
|
123
123
|
if (!schema.ok)
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DatabaseSync } from "node:sqlite";
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import { type DesktopBundle } from "./desktop-bundle.js";
|
|
3
3
|
type Phase = "prepared" | "draining" | "backing-up" | "stopping" | "installing" | "verifying" | "rolling-back" | "releasing" | "complete" | "restored" | "cancelled" | "needs-attention";
|
|
4
4
|
export type UpdateJournal = {
|
|
@@ -48,6 +48,7 @@ export declare const updateTerminal: (phase: string) => boolean;
|
|
|
48
48
|
/** Durable receipts are private and independent of the app being replaced. */
|
|
49
49
|
export declare function durableJson(file: string, value: unknown): void;
|
|
50
50
|
export declare function readUpdateJournal(stateDir: string, retainedReceipt?: string): UpdateJournal | null;
|
|
51
|
+
export declare function lingeringWork(db: DatabaseSync): string | null;
|
|
51
52
|
export declare function previewDesktopUpdate(stateDir: string, installed: string, candidate: string, label: string, hooks?: UpdateHooks): Promise<{
|
|
52
53
|
old: DesktopBundle;
|
|
53
54
|
next: DesktopBundle;
|
|
@@ -65,7 +66,6 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
65
66
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
66
67
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
67
68
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
68
|
-
version?: never;
|
|
69
69
|
active: boolean;
|
|
70
70
|
phase: string;
|
|
71
71
|
detail: string;
|
|
@@ -79,6 +79,7 @@ export declare function desktopUpdateStatus(stateDir: string): {
|
|
|
79
79
|
wasRunning?: never;
|
|
80
80
|
backupPath?: never;
|
|
81
81
|
updateId?: never;
|
|
82
|
+
version?: never;
|
|
82
83
|
buildId?: never;
|
|
83
84
|
workDir?: never;
|
|
84
85
|
} | {
|
|
@@ -104,6 +105,11 @@ export declare function requestUpdateStop(stateDir: string): void;
|
|
|
104
105
|
export declare function requestUpdateRestore(stateDir: string): void;
|
|
105
106
|
export declare function launchDesktopUpdate(stateDir: string, retry?: boolean): Promise<void>;
|
|
106
107
|
export declare function pauseUpdateRecovery(stateDir: string, id: string, detail: string): void;
|
|
108
|
+
/** A private, verified copy of the task database, made under a write
|
|
109
|
+
* reservation so no writer slips in between the snapshot and the copy. The
|
|
110
|
+
* copy drops the update's own admission pause. `inside` runs before the
|
|
111
|
+
* reservation is released, `after` once it is; returns the copy's hash. */
|
|
112
|
+
export declare function verifiedDatabaseBackup(databaseFile: string, backupPath: string, gateId: string, inside?: () => Promise<void>, after?: () => void): Promise<string>;
|
|
107
113
|
/** A SQLite OS lock releases on updater death. Each irreversible step has a
|
|
108
114
|
* preceding durable intent; recovery determines the actual side by hashes. */
|
|
109
115
|
export declare function runDesktopUpdate(stateDir: string, hooks?: UpdateHooks): Promise<void>;
|
package/dist/desktop-update.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { closeSync, chmodSync, copyFileSync, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
5
|
import { bundleHash, readDesktopBundle, verifyDesktopUpdateBundles } from "./desktop-bundle.js";
|
|
6
6
|
import { readDesktopConfig, desktopServiceCommand, desktopServiceDefinition, verifyDesktopLiveness } from "./desktop-host.js";
|
|
7
7
|
import { daemonStatus } from "./daemon.js";
|
|
@@ -10,6 +10,11 @@ import { readSchemaVersion, SCHEMA_VERSION, Store } from "./store.js";
|
|
|
10
10
|
import { activeUpdateWork, freezeUpdateGate, installUpdateGate, removeUpdateGate, updateAdmissionPaused } from "./desktop-update-gate.js";
|
|
11
11
|
import { currentDesktopAccess } from "./desktop-access.js";
|
|
12
12
|
import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists } from "./coding-update.js";
|
|
13
|
+
/** Loaded on first use (as backup.ts and store.ts do), so modules that only import this one (the console,
|
|
14
|
+
* and tests that load it in a browser-like environment) never need `node:sqlite` itself. */
|
|
15
|
+
function sqlite() {
|
|
16
|
+
return createRequire(import.meta.url)("node:sqlite");
|
|
17
|
+
}
|
|
13
18
|
export const updateTerminal = (phase) => ["complete", "restored", "cancelled"].includes(phase);
|
|
14
19
|
const terminal = updateTerminal;
|
|
15
20
|
const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
@@ -82,7 +87,7 @@ export function readUpdateJournal(stateDir, retainedReceipt) {
|
|
|
82
87
|
function connect(file, readOnly = false) {
|
|
83
88
|
if (!existsSync(file) || lstatSync(file).isSymbolicLink())
|
|
84
89
|
throw Error("The task database is missing or linked. It was not recreated.");
|
|
85
|
-
const db = new DatabaseSync(file, { readOnly });
|
|
90
|
+
const db = new (sqlite().DatabaseSync)(file, { readOnly });
|
|
86
91
|
db.exec("PRAGMA busy_timeout=1000");
|
|
87
92
|
const schema = readSchemaVersion(db);
|
|
88
93
|
if (!schema.ok || schema.version !== SCHEMA_VERSION) {
|
|
@@ -134,7 +139,7 @@ async function otherControllers(app, stateDir) {
|
|
|
134
139
|
const command = `${join(resources(app), "runtime", "node")} ${join(resources(app), "dist", "desktop-host.js")} serve --state `;
|
|
135
140
|
return result.stdout.split("\n").some(line => line.trim().startsWith(command) && line.trim() !== command + resolve(stateDir));
|
|
136
141
|
}
|
|
137
|
-
function lingeringWork(db) {
|
|
142
|
+
export function lingeringWork(db) {
|
|
138
143
|
const store = new Store(db);
|
|
139
144
|
for (const row of db.prepare("SELECT DISTINCT run FROM run_process WHERE exited_at IS NULL").all()) {
|
|
140
145
|
const problem = store.stopQuiescenceProblem(Number(row.run));
|
|
@@ -221,7 +226,7 @@ export async function prepareDesktopUpdate(stateDir, installed, candidate, label
|
|
|
221
226
|
export function sqliteLock(file, busyTimeoutMs = 0) {
|
|
222
227
|
if (existsSync(file))
|
|
223
228
|
privateFile(file);
|
|
224
|
-
const db = new DatabaseSync(file);
|
|
229
|
+
const db = new (sqlite().DatabaseSync)(file);
|
|
225
230
|
chmodSync(file, 0o600);
|
|
226
231
|
try {
|
|
227
232
|
db.exec(`PRAGMA busy_timeout=${busyTimeoutMs}; BEGIN EXCLUSIVE`);
|
|
@@ -335,16 +340,39 @@ async function verifiedBackup(j) {
|
|
|
335
340
|
}
|
|
336
341
|
// A crashed partial attempt gets a fresh filename; no backup is overwritten.
|
|
337
342
|
const backupPath = existsSync(file) ? join(j.workDir, `orders.backup.${randomUUID()}.db`) : file;
|
|
338
|
-
const
|
|
343
|
+
const backupHash = await verifiedDatabaseBackup(j.databaseFile, backupPath, j.id, async () => {
|
|
344
|
+
await verifiedCodingBackup(j);
|
|
345
|
+
}, () => {
|
|
346
|
+
for (const [source, name] of [[join(j.stateDir, "desktop.json"), "desktop.json"], [join(dirname(j.databaseFile), "repos.json"), "repos.json"], [join(dirname(j.databaseFile), "up-login.txt"), "up-login.txt"]]) {
|
|
347
|
+
if (source && name && existsSync(source)) {
|
|
348
|
+
if (!lstatSync(source).isFile() || lstatSync(source).isSymbolicLink())
|
|
349
|
+
throw Error("A saved configuration file is linked or not a regular file. Nothing was replaced.");
|
|
350
|
+
if (name === "up-login.txt")
|
|
351
|
+
privateFile(source);
|
|
352
|
+
copyFileSync(source, join(j.workDir, name));
|
|
353
|
+
chmodSync(join(j.workDir, name), 0o600);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
j.backupPath = backupPath;
|
|
358
|
+
j.backupHash = backupHash;
|
|
359
|
+
save(j, "backing-up", "Private database backups verified; saved tasks, evidence and coding sessions match.");
|
|
360
|
+
}
|
|
361
|
+
/** A private, verified copy of the task database, made under a write
|
|
362
|
+
* reservation so no writer slips in between the snapshot and the copy. The
|
|
363
|
+
* copy drops the update's own admission pause. `inside` runs before the
|
|
364
|
+
* reservation is released, `after` once it is; returns the copy's hash. */
|
|
365
|
+
export async function verifiedDatabaseBackup(databaseFile, backupPath, gateId, inside = async () => { }, after = () => { }) {
|
|
366
|
+
const db = connect(databaseFile);
|
|
339
367
|
try {
|
|
340
368
|
db.exec("BEGIN IMMEDIATE");
|
|
341
369
|
// The write reservation prevents a concurrent writer; a separate read
|
|
342
370
|
// connection is required because SQLite cannot back up a write transaction.
|
|
343
|
-
const source = connect(
|
|
371
|
+
const source = connect(databaseFile, true);
|
|
344
372
|
let before;
|
|
345
373
|
try {
|
|
346
374
|
before = snapshot(source);
|
|
347
|
-
await backup(source, backupPath);
|
|
375
|
+
await sqlite().backup(source, backupPath);
|
|
348
376
|
}
|
|
349
377
|
finally {
|
|
350
378
|
source.close();
|
|
@@ -354,23 +382,14 @@ async function verifiedBackup(j) {
|
|
|
354
382
|
try {
|
|
355
383
|
if (copied.prepare("PRAGMA integrity_check").get()?.integrity_check !== "ok" || copied.prepare("PRAGMA foreign_key_check").all().length !== 0 || snapshot(copied) !== before)
|
|
356
384
|
throw Error("Backup verification failed. The installed app is unchanged.");
|
|
357
|
-
removeUpdateGate(copied,
|
|
385
|
+
removeUpdateGate(copied, gateId);
|
|
358
386
|
}
|
|
359
387
|
finally {
|
|
360
388
|
copied.close();
|
|
361
389
|
}
|
|
362
|
-
await
|
|
390
|
+
await inside();
|
|
363
391
|
db.exec("COMMIT");
|
|
364
|
-
|
|
365
|
-
if (source && name && existsSync(source)) {
|
|
366
|
-
if (!lstatSync(source).isFile() || lstatSync(source).isSymbolicLink())
|
|
367
|
-
throw Error("A saved configuration file is linked or not a regular file. Nothing was replaced.");
|
|
368
|
-
if (name === "up-login.txt")
|
|
369
|
-
privateFile(source);
|
|
370
|
-
copyFileSync(source, join(j.workDir, name));
|
|
371
|
-
chmodSync(join(j.workDir, name), 0o600);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
392
|
+
after();
|
|
374
393
|
const fd = openSync(backupPath, "r");
|
|
375
394
|
try {
|
|
376
395
|
fsyncSync(fd);
|
|
@@ -378,9 +397,7 @@ async function verifiedBackup(j) {
|
|
|
378
397
|
finally {
|
|
379
398
|
closeSync(fd);
|
|
380
399
|
}
|
|
381
|
-
|
|
382
|
-
j.backupHash = fileHash(backupPath);
|
|
383
|
-
save(j, "backing-up", "Private database backups verified; saved tasks, evidence and coding sessions match.");
|
|
400
|
+
return fileHash(backupPath);
|
|
384
401
|
}
|
|
385
402
|
finally {
|
|
386
403
|
db.close();
|
package/dist/evidence.d.ts
CHANGED
|
@@ -300,6 +300,30 @@ export type SecretHit = {
|
|
|
300
300
|
};
|
|
301
301
|
/** Scan text line by line; every hit names its pattern and line. */
|
|
302
302
|
export declare function scanForSecrets(text: string): SecretHit[];
|
|
303
|
+
/**
|
|
304
|
+
* Whether one matched secret shape is a documented or reserved placeholder:
|
|
305
|
+
* anything spelling EXAMPLE (AWS's own AKIAIOSFODNN7EXAMPLE), a password on
|
|
306
|
+
* a reserved example host, or a value that is mostly one repeated character.
|
|
307
|
+
*/
|
|
308
|
+
export declare function isPlaceholderSecret(name: string, line: string, match: RegExpExecArray): boolean;
|
|
309
|
+
/** A secret shape the diff ADDS, with the patch line (for redaction) and
|
|
310
|
+
* the file and line it lands on at the new head (for the alert). */
|
|
311
|
+
export type PatchSecretHit = SecretHit & {
|
|
312
|
+
file: string;
|
|
313
|
+
fileLine: number;
|
|
314
|
+
};
|
|
315
|
+
/**
|
|
316
|
+
* Scan a unified diff for secrets it commits: only added lines count —
|
|
317
|
+
* removing a line cannot commit a secret, and context lines were already
|
|
318
|
+
* there — and documented or reserved placeholders are not secrets.
|
|
319
|
+
*/
|
|
320
|
+
export declare function scanPatchForSecrets(patch: string): PatchSecretHit[];
|
|
321
|
+
/** The alert for a candidate head with a flagged added line: where each
|
|
322
|
+
* hit is and what to do, without repeating the value. */
|
|
323
|
+
export declare function secretAlert(hits: readonly PatchSecretHit[], runId: number): {
|
|
324
|
+
subject: string;
|
|
325
|
+
body: string;
|
|
326
|
+
};
|
|
303
327
|
/** Replace every hit line with a marker naming what was found — the display copy holds no secret. */
|
|
304
328
|
export declare function redactSecretLines(text: string, hits: readonly SecretHit[]): string;
|
|
305
329
|
/**
|
package/dist/evidence.js
CHANGED
|
@@ -638,6 +638,104 @@ export function scanForSecrets(text) {
|
|
|
638
638
|
}
|
|
639
639
|
return hits;
|
|
640
640
|
}
|
|
641
|
+
/** Hosts the DNS standards reserve for examples and loopback (RFC 2606,
|
|
642
|
+
* RFC 6761): a URL with a password on one of these reaches nothing real. */
|
|
643
|
+
function reservedHost(host) {
|
|
644
|
+
const name = host.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
645
|
+
if (name === "127.0.0.1" || name === "::1")
|
|
646
|
+
return true;
|
|
647
|
+
if (/(?:^|\.)example\.(?:com|net|org)$/.test(name))
|
|
648
|
+
return true;
|
|
649
|
+
return /(?:^|\.)(?:example|test|invalid|localhost)$/.test(name);
|
|
650
|
+
}
|
|
651
|
+
/** Mostly one character, e.g. "sk-" and forty a's: a fixture, not a key. */
|
|
652
|
+
function repeatedFiller(value) {
|
|
653
|
+
const longest = value.split(/[^A-Za-z0-9]+/).reduce((best, part) => (part.length > best.length ? part : best), "");
|
|
654
|
+
if (longest.length < 8)
|
|
655
|
+
return false;
|
|
656
|
+
const counts = new Map();
|
|
657
|
+
for (const char of longest)
|
|
658
|
+
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
659
|
+
return Math.max(...counts.values()) * 2 > longest.length;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Whether one matched secret shape is a documented or reserved placeholder:
|
|
663
|
+
* anything spelling EXAMPLE (AWS's own AKIAIOSFODNN7EXAMPLE), a password on
|
|
664
|
+
* a reserved example host, or a value that is mostly one repeated character.
|
|
665
|
+
*/
|
|
666
|
+
export function isPlaceholderSecret(name, line, match) {
|
|
667
|
+
if (name === "private-key")
|
|
668
|
+
return false;
|
|
669
|
+
if (/example/i.test(match[0]))
|
|
670
|
+
return true;
|
|
671
|
+
if (name !== "password-in-url")
|
|
672
|
+
return repeatedFiller(match[0]);
|
|
673
|
+
const host = /^(\[[^\]\s]*\]|[^\s/:?#'"`]+)/.exec(line.slice(match.index + match[0].length - 1))?.[1] ?? "";
|
|
674
|
+
const password = /:([^\s:@/]+)@[^@]*$/.exec(match[0])?.[1] ?? "";
|
|
675
|
+
return reservedHost(host) || repeatedFiller(password);
|
|
676
|
+
}
|
|
677
|
+
/** The first secret shape on a line that is not a placeholder, if any. */
|
|
678
|
+
function realSecretShape(line) {
|
|
679
|
+
for (const { name, pattern } of SECRET_PATTERNS) {
|
|
680
|
+
for (const match of line.matchAll(new RegExp(pattern.source, `${pattern.flags}g`))) {
|
|
681
|
+
if (!isPlaceholderSecret(name, line, match))
|
|
682
|
+
return name;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Scan a unified diff for secrets it commits: only added lines count —
|
|
689
|
+
* removing a line cannot commit a secret, and context lines were already
|
|
690
|
+
* there — and documented or reserved placeholders are not secrets.
|
|
691
|
+
*/
|
|
692
|
+
export function scanPatchForSecrets(patch) {
|
|
693
|
+
const hits = [];
|
|
694
|
+
const lines = patch.split("\n");
|
|
695
|
+
let file = "";
|
|
696
|
+
let inHunk = false;
|
|
697
|
+
let next = 0;
|
|
698
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
699
|
+
const line = lines[index];
|
|
700
|
+
if (line.startsWith("diff --git ")) {
|
|
701
|
+
inHunk = false;
|
|
702
|
+
file = /^diff --git a\/.* b\/(.*)$/.exec(line)?.[1] ?? "";
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
706
|
+
if (hunk !== null) {
|
|
707
|
+
inHunk = true;
|
|
708
|
+
next = Number(hunk[1]);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
if (!inHunk) {
|
|
712
|
+
if (line.startsWith("+++ "))
|
|
713
|
+
file = line.slice(4).replace(/^b\//, "");
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
if (line.startsWith("+")) {
|
|
717
|
+
const name = realSecretShape(line.slice(1));
|
|
718
|
+
if (name !== null)
|
|
719
|
+
hits.push({ name, line: index + 1, file, fileLine: next });
|
|
720
|
+
next += 1;
|
|
721
|
+
}
|
|
722
|
+
else if (line.startsWith(" "))
|
|
723
|
+
next += 1;
|
|
724
|
+
}
|
|
725
|
+
return hits;
|
|
726
|
+
}
|
|
727
|
+
/** The alert for a candidate head with a flagged added line: where each
|
|
728
|
+
* hit is and what to do, without repeating the value. */
|
|
729
|
+
export function secretAlert(hits, runId) {
|
|
730
|
+
const shown = hits.slice(0, 5).map(hit => `${hit.file}:${hit.fileLine} (${hit.name})`);
|
|
731
|
+
const more = hits.length > shown.length ? `, and ${hits.length - shown.length} more` : "";
|
|
732
|
+
return {
|
|
733
|
+
subject: `Run #${runId} adds what looks like a key`,
|
|
734
|
+
body: `${hits.length === 1 ? "An added line looks" : `${hits.length} added lines look`} like a real key: ${shown.join(", ")}${more}. ` +
|
|
735
|
+
`This run won't be published and the saved diff hides ${hits.length === 1 ? "that line" : "those lines"}. ` +
|
|
736
|
+
`If this is a real key, rotate it and rewrite the branch. If it's a test value, assemble it at run time.`,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
641
739
|
/** Replace every hit line with a marker naming what was found — the display copy holds no secret. */
|
|
642
740
|
export function redactSecretLines(text, hits) {
|
|
643
741
|
const flagged = new Map(hits.map(hit => [hit.line, hit.name]));
|
|
@@ -689,8 +787,10 @@ export async function captureTerminalDiff(store, git, worktree, base, head, root
|
|
|
689
787
|
// SECOND durable, console-served copy. Hit lines are redacted in the
|
|
690
788
|
// stored artifact, the row says redacted, a page names the branch — and
|
|
691
789
|
// the publication gate refuses to push a redacted run's branch anywhere.
|
|
790
|
+
// Only lines the diff adds count, and the alert is one per candidate head:
|
|
791
|
+
// every gate over the same head would otherwise repeat it.
|
|
692
792
|
const rawPatch = patch.code === 0 ? patch.stdout : patch.stderr;
|
|
693
|
-
const hits = patch.code === 0 ?
|
|
793
|
+
const hits = patch.code === 0 ? scanPatchForSecrets(rawPatch) : [];
|
|
694
794
|
const patchContent = Buffer.from(hits.length > 0 ? redactSecretLines(rawPatch, hits) : rawPatch, "utf8");
|
|
695
795
|
const diffId = storeEvidence(store, root, runId, "terminal-diff", "terminal-diff.patch", patchContent, patchCommand, now, {
|
|
696
796
|
redacted: hits.length > 0,
|
|
@@ -699,13 +799,11 @@ export async function captureTerminalDiff(store, git, worktree, base, head, root
|
|
|
699
799
|
if (hits.length > 0) {
|
|
700
800
|
store.enqueueNotification({
|
|
701
801
|
source: { run: runId },
|
|
702
|
-
dedupeKey: `secret:${
|
|
802
|
+
dedupeKey: `secret:${head}`,
|
|
703
803
|
kind: "secret-detected",
|
|
704
804
|
pushClass: "attention",
|
|
705
805
|
link: `/r/${runId}`,
|
|
706
|
-
|
|
707
|
-
body: `${hits.length} high-confidence secret shape(s) in the accepted diff (${[...new Set(hits.map(one => one.name))].join(", ")}). ` +
|
|
708
|
-
`The branch holds the real bytes — rewrite it before anything merges. Publication of this run is blocked.`,
|
|
806
|
+
...secretAlert(hits, runId),
|
|
709
807
|
}, now);
|
|
710
808
|
}
|
|
711
809
|
const statArgs = ["--no-optional-locks", "diff", "--numstat", "-z", base, head];
|