toolroll 0.9.74 → 0.9.75
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 +31 -0
- package/dist/assignment.d.ts +3 -0
- package/dist/assignment.js +27 -8
- package/dist/browser/workspace.js +27 -27
- package/dist/browser-workspace.d.ts +1 -1
- package/dist/chat-acceptance.d.ts +4 -1
- package/dist/chat-acceptance.js +13 -3
- package/dist/cli.js +1 -1
- package/dist/coding-handoff.js +4 -3
- package/dist/coding-ui.js +2 -2
- package/dist/coding-workspace.js +16 -4
- package/dist/contracts/console-api.d.ts +5 -1
- package/dist/contracts/console-api.js +10 -0
- package/dist/control-ui.d.ts +3 -2
- package/dist/control-ui.js +7 -2
- package/dist/demo.js +5 -3
- package/dist/desktop-update.d.ts +3 -0
- package/dist/desktop-update.js +43 -9
- package/dist/first-run.d.ts +8 -5
- package/dist/first-run.js +39 -6
- package/dist/flow-card-task.js +3 -2
- package/dist/flow-unattended.d.ts +2 -0
- package/dist/flow-unattended.js +12 -1
- package/dist/integrations.d.ts +4 -3
- package/dist/integrations.js +5 -6
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +2 -2
- package/dist/lead-follow.js +3 -1
- package/dist/openrouter-models.d.ts +5 -2
- package/dist/openrouter-models.js +7 -4
- package/dist/operate.js +2 -0
- package/dist/project-learning.js +44 -6
- package/dist/project-tools.js +19 -1
- package/dist/pull-request-ui.d.ts +1 -1
- package/dist/pull-request-ui.js +5 -1
- package/dist/push.d.ts +2 -0
- package/dist/push.js +24 -19
- package/dist/read-only-request.d.ts +4 -0
- package/dist/read-only-request.js +15 -0
- package/dist/repository-context.js +6 -1
- package/dist/result-follow-ups.js +20 -1
- package/dist/result-review.js +5 -1
- package/dist/serve.js +162 -6
- package/dist/server/agents-live-filed.d.ts +17 -0
- package/dist/server/agents-live-filed.js +97 -0
- package/dist/server/agents-live-room.js +4 -1
- package/dist/server/chat.js +31 -23
- package/dist/server/http.d.ts +5 -0
- package/dist/server/last-checks.d.ts +17 -0
- package/dist/server/last-checks.js +22 -0
- package/dist/server/pages.js +18 -4
- package/dist/server/people-tokens.js +7 -4
- package/dist/server/render-pages.d.ts +2 -1
- package/dist/server/render-pages.js +28 -24
- package/dist/server/render-results.d.ts +11 -2
- package/dist/server/render-results.js +54 -12
- package/dist/server/render-settings.js +3 -3
- package/dist/server/render-tasks.js +7 -4
- package/dist/server/route-table.js +14 -1
- package/dist/server/runtime.d.ts +16 -1
- package/dist/server/settings.js +82 -29
- package/dist/server/task-views.js +0 -1
- package/dist/server/tasks.js +11 -2
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +1 -1
- package/dist/storage.js +2 -1
- package/dist/store.d.ts +3 -0
- package/dist/store.js +33 -9
- package/dist/subagent-work.js +3 -0
- package/dist/task-status.d.ts +28 -1
- package/dist/task-status.js +93 -11
- package/dist/teardown-git.d.ts +27 -1
- package/dist/teardown-git.js +185 -39
- package/dist/telegram-progress.js +4 -4
- package/dist/telegram-status.js +8 -3
- package/dist/tool-secret-store.js +8 -0
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +4 -3
- package/dist/toolroll-update.js +10 -9
- package/dist/work-index.js +29 -12
- package/dist/workspace-ui.d.ts +5 -0
- package/dist/workspace-ui.js +38 -10
- package/package.json +1 -1
package/dist/task-status.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* technical reason stays one tap away in `why`. */
|
|
13
13
|
import { DEFAULT_LEAD_NAME } from "./lead-identity.js";
|
|
14
14
|
import { html } from "./html.js";
|
|
15
|
+
import { FAILED_CHECK } from "./result-acts.js";
|
|
15
16
|
import { CODE_NEED, NEEDS, needSentence, waitSentence } from "./needs-you.js";
|
|
16
17
|
/** A cancelled task with a successor reads "Replaced by <id>" everywhere, never "Cancelled". */
|
|
17
18
|
export const replacedWords = (successor) => `Replaced by ${successor}`;
|
|
@@ -38,6 +39,67 @@ export function releaseStateOf(checks) {
|
|
|
38
39
|
export function checkBackingOf(checks) {
|
|
39
40
|
return checks?.status === "passed" ? "covered" : checksUnderway(checks) ? "running" : releaseStateOf(checks);
|
|
40
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Did the approved check pass for this result? The ONE answer the status headline and sentence, the Project checks
|
|
44
|
+
* row, the road's Checks step and the result page all read, so none of them can say "passed" while another says the
|
|
45
|
+
* check couldn't be read. Two records speak to it: the machine verdict sealed when the build finished, and the saved
|
|
46
|
+
* check receipt read now (with any follow-up check on the same commit). Fail closed: the check passed only when the
|
|
47
|
+
* receipt read now says so. A verdict that recorded a pass whose receipt is missing, says nothing ran, or can't be
|
|
48
|
+
* read is unreadable; a verdict that recorded a failure stays failed unless a later check on the same commit passed.
|
|
49
|
+
* `checks` undefined or null: nothing was read. Returns the check as every surface shows it (null: nothing to show).
|
|
50
|
+
*/
|
|
51
|
+
export function approvedCheckOf(verdict, reasons, checks) {
|
|
52
|
+
let failedExit = null;
|
|
53
|
+
for (const reason of reasons) {
|
|
54
|
+
const found = FAILED_CHECK.exec(reason);
|
|
55
|
+
if (found !== null) {
|
|
56
|
+
failedExit = Number(found[1]);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const recordedFail = verdict === "refuted" && failedExit !== null;
|
|
61
|
+
const recordedPass = verdict === "verified";
|
|
62
|
+
const read = checks ?? null;
|
|
63
|
+
if (read === null) {
|
|
64
|
+
if (recordedFail)
|
|
65
|
+
return { status: "failed", exitCode: failedExit, head: null };
|
|
66
|
+
return recordedPass ? { status: "unavailable", exitCode: null, head: null } : null;
|
|
67
|
+
}
|
|
68
|
+
if (read.status === "passed" || read.status === "failed" || read.status === "running")
|
|
69
|
+
return read;
|
|
70
|
+
// Checks Off covered by a passing release check read as passed (the row says on which commit); nothing to correct.
|
|
71
|
+
if (read.level === "off" && read.release != null && !recordedFail)
|
|
72
|
+
return read;
|
|
73
|
+
if (recordedFail)
|
|
74
|
+
return { ...read, status: "failed", exitCode: failedExit };
|
|
75
|
+
// The verdict says the check passed, the receipt read now can't show it: the two disagree, so it doesn't count.
|
|
76
|
+
if (recordedPass)
|
|
77
|
+
return { ...read, status: "unavailable", exitCode: null };
|
|
78
|
+
return read;
|
|
79
|
+
}
|
|
80
|
+
export function approvedCheckStateOf(checks) {
|
|
81
|
+
if (checks == null)
|
|
82
|
+
return null;
|
|
83
|
+
// A failure stands while a new check runs: only its result can change the answer. A check that couldn't be read (a
|
|
84
|
+
// result waiting for its batch check has none yet) reads running while one is underway, and never passed.
|
|
85
|
+
if (checks.status === "failed")
|
|
86
|
+
return "failed";
|
|
87
|
+
if (checksUnderway(checks) || checks.status === "running")
|
|
88
|
+
return "running";
|
|
89
|
+
if (checks.status === "unavailable")
|
|
90
|
+
return "unreadable";
|
|
91
|
+
if (checks.status === "passed")
|
|
92
|
+
return "passed";
|
|
93
|
+
if (checks.level === "off")
|
|
94
|
+
return checks.release != null ? "passed" : "off";
|
|
95
|
+
return "not-run";
|
|
96
|
+
}
|
|
97
|
+
/** A saved check that can't be read, in the words every surface uses. */
|
|
98
|
+
export const CHECK_UNREADABLE = "The check result couldn't be read.";
|
|
99
|
+
export const CHECK_UNREADABLE_ASK = `${CHECK_UNREADABLE} Run checks on it, or open the result to see why.`;
|
|
100
|
+
/** A saved check whose files are intact but whose command was approved again since it ran. */
|
|
101
|
+
export const CHECK_COMMAND_CHANGED = "The check command changed since this check; run checks again.";
|
|
102
|
+
const unreadableAsk = (checks) => checks?.cause === "command-changed" ? CHECK_COMMAND_CHANGED : CHECK_UNREADABLE_ASK;
|
|
41
103
|
/** "T2", "T2 and T3", "T2, T3 and 2 more": short enough for one line. The full list is on the result. */
|
|
42
104
|
export function peerWords(peers, shown = 2) {
|
|
43
105
|
if (peers.length <= shown)
|
|
@@ -65,10 +127,11 @@ function resultAsk(facts) {
|
|
|
65
127
|
if (blocker !== undefined)
|
|
66
128
|
return blocker.message;
|
|
67
129
|
const req = facts.requirements;
|
|
68
|
-
if (req
|
|
69
|
-
return null;
|
|
70
|
-
if ((req.yours > 0 || (req.unshown ?? 0) > 0) && req.ask != null)
|
|
130
|
+
if (req != null && facts.accepted !== true && req.unverified !== true && (req.yours > 0 || (req.unshown ?? 0) > 0) && req.ask != null)
|
|
71
131
|
return req.ask;
|
|
132
|
+
// A check that can't be read is never a pass: a person runs it again or looks at why, unless they already accepted.
|
|
133
|
+
if (facts.accepted !== true && approvedCheckStateOf(facts.checks) === "unreadable")
|
|
134
|
+
return unreadableAsk(facts.checks);
|
|
72
135
|
return null;
|
|
73
136
|
}
|
|
74
137
|
export function headlineOf(facts) {
|
|
@@ -122,6 +185,8 @@ function sentenceOf(headline, facts) {
|
|
|
122
185
|
}
|
|
123
186
|
if (facts.checks?.batch?.state === "waiting")
|
|
124
187
|
return "Its full check runs with other results within 10 minutes. Review the change, then mark it complete.";
|
|
188
|
+
if (facts.checks?.status === "unavailable")
|
|
189
|
+
return `${facts.checks.cause === "command-changed" ? CHECK_COMMAND_CHANGED : CHECK_UNREADABLE} Review the change, then mark it complete.`;
|
|
125
190
|
// Never claim a check that isn't known to have passed.
|
|
126
191
|
if (facts.checks == null)
|
|
127
192
|
return "Review the change, then mark it complete.";
|
|
@@ -155,7 +220,11 @@ function detailsOf(headline, facts) {
|
|
|
155
220
|
const batch = checks.batch ?? null;
|
|
156
221
|
const together = batch === null ? null : checkedTogetherWords(batch);
|
|
157
222
|
const batchWhy = batch === null ? null : BATCH_WHY[batch.state] ?? null;
|
|
158
|
-
|
|
223
|
+
// A failure or an unreadable check keeps its words while a new check runs, so the row never reads better than the answer.
|
|
224
|
+
const again = checksUnderway(checks) && checks.status === "failed" ? " · running again" : "";
|
|
225
|
+
if (again !== "")
|
|
226
|
+
row("checks", CHECKS_LABEL, `${quick ? "Quick checks failed" : "Failed"}${checks.exitCode === null ? "" : ` (exit ${checks.exitCode})`}${again}`, problem(headline), headline === "Failed" ? { href: checksHref } : { action: { label: "See what failed", href: checksHref } });
|
|
227
|
+
else if (batch?.state === "waiting")
|
|
159
228
|
row("checks", CHECKS_LABEL, "Waiting to check with other results", "running", { href: checksHref });
|
|
160
229
|
else if (checks.running != null)
|
|
161
230
|
row("checks", CHECKS_LABEL, `${checks.running === "quick" ? "Quick" : "Full"} checks running`, "running", { href: checksHref });
|
|
@@ -178,6 +247,8 @@ function detailsOf(headline, facts) {
|
|
|
178
247
|
else if (checks.status === "not-run")
|
|
179
248
|
row("checks", CHECKS_LABEL, "Didn't run", "none", runChecks === null ? {} : { action: runChecks });
|
|
180
249
|
// A saved check that can't be read is run again where that is possible.
|
|
250
|
+
else if (checks.cause === "command-changed")
|
|
251
|
+
row("checks", CHECKS_LABEL, "Check command changed since", "note", { action: runChecks ?? { label: OPEN_RESULT, href: facts.links?.result ?? null }, why: CHECK_COMMAND_CHANGED });
|
|
181
252
|
else
|
|
182
253
|
row("checks", CHECKS_LABEL, "Couldn't be read", "note", { action: runChecks ?? { label: OPEN_RESULT, href: facts.links?.result ?? null } });
|
|
183
254
|
}
|
|
@@ -255,7 +326,7 @@ export function statusOf(facts) {
|
|
|
255
326
|
const primaryAction = need === null || ownWords ? facts.action ?? null : { label: need.action.label, href: facts.action?.href ?? null };
|
|
256
327
|
const sentence = lead === "on-it" ? leadSentence(headline, facts) : lead === "lapsed" ? `${leadLapsed(facts.leadName)} ${sentenceOf(headline, facts)}` : sentenceOf(headline, facts);
|
|
257
328
|
return { headline, tone: HEADLINE_TONE[headline], sentence, details: detailsOf(headline, facts),
|
|
258
|
-
primaryAction, why: [...new Set(facts.why ?? [])].filter(one => one.trim() !== ""), need };
|
|
329
|
+
primaryAction, why: [...new Set(facts.why ?? [])].filter(one => one.trim() !== ""), need, check: facts.report ? null : approvedCheckStateOf(facts.checks) };
|
|
259
330
|
}
|
|
260
331
|
/** The name most surfaces already import. */
|
|
261
332
|
export const taskStatusOf = statusOf;
|
|
@@ -267,7 +338,7 @@ const CODE_STAGE = {
|
|
|
267
338
|
"waiting-dependency": ["queued"], "scouting-ready": ["queued"], "planning-ready": ["queued"], "provider-quota": ["queued"],
|
|
268
339
|
// A request the planner cannot be given (too long to hand back, say) waits on a person to shorten it.
|
|
269
340
|
"planner-source": ["needs-you", "fix-request"],
|
|
270
|
-
running: ["building"], reviewing: ["reviewing"], "review-pending": ["building"],
|
|
341
|
+
running: ["building"], reviewing: ["reviewing"], "review-pending": ["building"], "checks-running": ["checking"],
|
|
271
342
|
"signed-out": ["needs-you", "sign-in"],
|
|
272
343
|
"needs-approval": ["needs-you", "approval"],
|
|
273
344
|
"waiting-decision": ["needs-you", "answer"], "decision-queue": ["needs-you", "answer"],
|
|
@@ -432,7 +503,8 @@ export function completionBlockersOf(f) {
|
|
|
432
503
|
}
|
|
433
504
|
else if (!f.report && f.checkRequired && f.checks?.status !== "passed" && release === undefined) {
|
|
434
505
|
blockers.push({ key: "check-missing", message: checksUnderway(f.checks) ? "Its project check hasn't finished yet. Mark it complete once it passes."
|
|
435
|
-
: "
|
|
506
|
+
: f.checks?.status === "unavailable" ? f.checks.cause === "command-changed" ? `${CHECK_COMMAND_CHANGED.replace(/; run checks again\.$/, ".")} Run checks again, then mark it complete.` : `${CHECK_UNREADABLE} Run checks on it, then mark it complete.`
|
|
507
|
+
: "No passing project check is recorded for this result. Run checks on it, then mark it complete." });
|
|
436
508
|
}
|
|
437
509
|
if (!f.accepted && f.checks?.status !== "failed") {
|
|
438
510
|
const refuted = f.verdict === "refuted";
|
|
@@ -457,11 +529,24 @@ export function unverifiedWhenRefuted(requirements, verdict) {
|
|
|
457
529
|
export function evidenceOf(receipt, problems = receipt?.caveats ?? []) {
|
|
458
530
|
if (receipt === null)
|
|
459
531
|
return null;
|
|
532
|
+
// The check's own reading is the Project checks row's to say (a changed command is not a missing file).
|
|
533
|
+
problems = problems.filter(one => one !== receipt.checks.detail);
|
|
460
534
|
const shortened = receipt.artifacts.filter(one => !one.complete).length + problems.filter(one => /shortened|cut short/.test(one)).length;
|
|
461
535
|
const damaged = problems.filter(one => /unavailable or changed|no longer verif|cannot be read|could not be read|failed capture|altered|damaged/i.test(one)).length;
|
|
462
536
|
const missing = problems.filter(one => /missing|ambiguous|no longer exists/i.test(one) && !/unavailable or changed/.test(one)).length;
|
|
463
537
|
return { shortened, missing, damaged };
|
|
464
538
|
}
|
|
539
|
+
/** A saved check reading as the shared fact (head, level, a follow-up running, a batch, release coverage). */
|
|
540
|
+
export function checksFactOf(checks, head) {
|
|
541
|
+
return { status: checks.status, exitCode: checks.exitCode, head,
|
|
542
|
+
...(checks.level == null ? {} : { level: checks.level }), ...(checks.running == null ? {} : { running: checks.running }),
|
|
543
|
+
...(checks.batch == null ? {} : { batch: checks.batch }), ...(checks.release == null ? {} : { release: checks.release }),
|
|
544
|
+
...(checks.cause == null ? {} : { cause: checks.cause }) };
|
|
545
|
+
}
|
|
546
|
+
/** A receipt's approved check, as every surface reads it (`approvedCheckOf` over its verdict and its saved check). */
|
|
547
|
+
export function receiptChecksOf(receipt) {
|
|
548
|
+
return approvedCheckOf(receipt.proof?.verdict ?? null, receipt.proof?.reasons ?? [], checksFactOf(receipt.checks, receipt.head));
|
|
549
|
+
}
|
|
465
550
|
/** The status facts of one assignment, as every surface reads them. */
|
|
466
551
|
export function assignmentStatusFacts(assignment, options = {}) {
|
|
467
552
|
const { stage, need, wait } = assignmentStageOf(assignment, options.work ?? null, options.planning ?? false);
|
|
@@ -471,10 +556,7 @@ export function assignmentStatusFacts(assignment, options = {}) {
|
|
|
471
556
|
// A saved result's rows show whenever it is the reason a person is needed, too.
|
|
472
557
|
const withResult = finished || stage === "failed" || ((stage === "needs-you" || stage === "waiting") && receipt !== null);
|
|
473
558
|
const report = receipt?.completionKind === "research-report";
|
|
474
|
-
const checks = receipt === null || !withResult ? null
|
|
475
|
-
: { status: receipt.checks.status, exitCode: receipt.checks.exitCode, head: receipt.head,
|
|
476
|
-
...(receipt.checks.level == null ? {} : { level: receipt.checks.level }), ...(receipt.checks.running == null ? {} : { running: receipt.checks.running }),
|
|
477
|
-
...(receipt.checks.batch == null ? {} : { batch: receipt.checks.batch }), ...(receipt.checks.release == null ? {} : { release: receipt.checks.release }) };
|
|
559
|
+
const checks = receipt === null || !withResult ? null : receiptChecksOf(receipt);
|
|
478
560
|
const publication = assignment.publication;
|
|
479
561
|
const pullRequest = options.pullRequest !== undefined ? options.pullRequest
|
|
480
562
|
: publication === null ? (withResult && !report ? { state: "none", number: null, url: null, ci: null, error: null } : null)
|
package/dist/teardown-git.d.ts
CHANGED
|
@@ -33,4 +33,30 @@ export declare function checkoutIncludes(checkout: string): boolean;
|
|
|
33
33
|
* program (anything but INERT_LFS). A file that
|
|
34
34
|
* isn't a plain one, or doesn't parse, counts as naming one. */
|
|
35
35
|
export declare function lfsExtensionIn(checkout: string): boolean;
|
|
36
|
-
|
|
36
|
+
/** `mode` "read" is for reading a checkout an agent may still be writing (the code page): every command also takes
|
|
37
|
+
* readFlags() and runs against the checkout's readView, so neither its config nor its info/attributes can change
|
|
38
|
+
* between the inspection and the run. */
|
|
39
|
+
export declare function checkoutGit(inner?: Runner, mode?: "build" | "read"): Runner;
|
|
40
|
+
/**
|
|
41
|
+
* Flags for reading a checkout an agent writes while a page waits (docs/plans/get-side-effects.md). Command-line
|
|
42
|
+
* settings win over anything the checkout's config says, even one changed after it was inspected: no fsmonitor, hooks,
|
|
43
|
+
* pager or credential helper; no network (lazy fetch off, every protocol refused); and attributes read from the empty
|
|
44
|
+
* tree, so no filter or textconv a .gitattributes names can run. A diff also takes --no-ext-diff --no-textconv.
|
|
45
|
+
*/
|
|
46
|
+
export declare const PAGE_READ_FLAGS: readonly string[];
|
|
47
|
+
/** The read flags, for every read of an agent-written checkout, whatever the HTTP method (security review of
|
|
48
|
+
* 01bf3114): the worktree's attributes come from the empty tree. Git still reads the repository's info/attributes and
|
|
49
|
+
* its config as it starts, so the flags alone don't stop a filter planted in both after the inspection: every read
|
|
50
|
+
* also runs against the checkout's readView (checkoutGitSync, checkoutGit "read"), whose config and info are the plane's. The cost is cosmetic: a checkout's line-ending or encoding rules are not applied, so
|
|
51
|
+
* a file they would normalize can read as changed (fail closed: shipping then asks for a clean commit). */
|
|
52
|
+
export declare function readFlags(): readonly string[];
|
|
53
|
+
/**
|
|
54
|
+
* checkoutGit, synchronously, for reads a page shows from a checkout an agent writes (docs/plans/get-side-effects.md):
|
|
55
|
+
* the same refusals (an include, an .lfsconfig extension, a config key that can run a command), no system or global
|
|
56
|
+
* config, the operator's own values carried, PAGE_READ_FLAGS and the checkout's readView (which hold even if its config
|
|
57
|
+
* or info/attributes change after they were read), submodules skipped, bounded. A refusal throws.
|
|
58
|
+
*/
|
|
59
|
+
export declare function checkoutGitSync(cwd: string, args: readonly string[], options?: {
|
|
60
|
+
timeoutMs?: number;
|
|
61
|
+
maxBuffer?: number;
|
|
62
|
+
}): string;
|
package/dist/teardown-git.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* nothing is removed.
|
|
12
12
|
*/
|
|
13
13
|
import { spawnSync } from "node:child_process";
|
|
14
|
-
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
15
16
|
import { join, resolve } from "node:path";
|
|
16
17
|
import { run } from "./exec.js";
|
|
17
18
|
import { ENV, ENV_ALLOWLIST } from "./remote-main.js";
|
|
@@ -166,47 +167,112 @@ export function teardownGit(runner = run) {
|
|
|
166
167
|
* that can't be read so counts as including.
|
|
167
168
|
*/
|
|
168
169
|
export function checkoutIncludes(checkout) {
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
170
|
+
const dirs = gitDirsOf(checkout);
|
|
171
|
+
if (dirs === null)
|
|
172
|
+
return true;
|
|
173
|
+
for (const path of [join(dirs.common, "config"), join(dirs.gitdir, "config.worktree")]) {
|
|
174
|
+
if (!existsSync(path) && path.endsWith("config.worktree"))
|
|
175
|
+
continue;
|
|
176
|
+
if (configIncludes(path))
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
/** One config file, read only as the regular file it is (bounded), or null. */
|
|
182
|
+
function regularFile(path) {
|
|
183
|
+
try {
|
|
184
|
+
const held = lstatSync(path);
|
|
185
|
+
if (!held.isFile() || held.size > 1024 * 1024)
|
|
177
186
|
return null;
|
|
178
|
-
|
|
179
|
-
}
|
|
187
|
+
return readFileSync(path, "utf8");
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** A checkout's git directory and common directory, as git finds them from its `.git` (a folder, or a one-line
|
|
194
|
+
* `gitdir: ` file); null when the pointer isn't one git would read the same way. */
|
|
195
|
+
function gitDirsOf(checkout) {
|
|
180
196
|
let gitdir = join(checkout, ".git");
|
|
181
197
|
try {
|
|
182
198
|
if (!lstatSync(gitdir).isDirectory()) {
|
|
183
199
|
// As git reads a .git file: "gitdir: " then the path, one line; anything else (more lines, stray space) refused.
|
|
184
|
-
const text = (
|
|
200
|
+
const text = (regularFile(gitdir) ?? "").replace(/\r?\n$/, "");
|
|
185
201
|
if (!text.startsWith("gitdir: ") || /[\r\n]/.test(text))
|
|
186
|
-
return
|
|
202
|
+
return null;
|
|
187
203
|
const pointer = text.slice("gitdir: ".length);
|
|
188
204
|
if (pointer === "" || pointer !== pointer.trim())
|
|
189
|
-
return
|
|
205
|
+
return null;
|
|
190
206
|
gitdir = resolve(checkout, pointer);
|
|
191
207
|
}
|
|
192
208
|
}
|
|
193
209
|
catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const named = regularFile(join(gitdir, "commondir"))?.trim();
|
|
213
|
+
return { gitdir, common: named === undefined || named === "" ? gitdir : resolve(gitdir, named) };
|
|
214
|
+
}
|
|
215
|
+
/** Whether one config file includes another (or can't be read as a plain file): by a plain reading, and as git itself
|
|
216
|
+
* parses it, includes not followed (only this one regular file, from a neutral folder, with no other config read). */
|
|
217
|
+
function configIncludes(path) {
|
|
218
|
+
const text = regularFile(path);
|
|
219
|
+
if (text === null || /^\s*\[\s*include(if)?\b/im.test(text))
|
|
194
220
|
return true;
|
|
221
|
+
const parsed = spawnSync("git", ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
|
|
222
|
+
return parsed.status !== 0 || parsed.stdout.split("\0").some(name => /^(include\.path|includeif\..*\.path)$/i.test(name));
|
|
223
|
+
}
|
|
224
|
+
function readView(checkout) {
|
|
225
|
+
const dirs = gitDirsOf(checkout);
|
|
226
|
+
if (dirs === null)
|
|
227
|
+
throw new Error("the checkout's .git couldn't be read as git reads it");
|
|
228
|
+
const view = realpathSync(mkdtempSync(join(tmpdir(), "toolroll-git-read-")));
|
|
229
|
+
const dispose = () => rmSync(view, { recursive: true, force: true });
|
|
230
|
+
try {
|
|
231
|
+
let config = regularFile(join(dirs.common, "config"));
|
|
232
|
+
if (config === null)
|
|
233
|
+
throw new Error("the checkout's git config couldn't be read as a plain file");
|
|
234
|
+
const configPath = join(view, "config");
|
|
235
|
+
writeFileSync(configPath, config, { mode: 0o600 });
|
|
236
|
+
const plain = { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } };
|
|
237
|
+
if (/worktreeconfig/i.test(config)) {
|
|
238
|
+
const flag = spawnSync("git", ["config", "--file", configPath, "--no-includes", "--type=bool", "--get", "extensions.worktreeConfig"], plain);
|
|
239
|
+
if (flag.status !== 0 && flag.status !== 1)
|
|
240
|
+
throw new Error("the checkout's git config couldn't be read");
|
|
241
|
+
if (flag.stdout.trim() === "true") {
|
|
242
|
+
// Git would also read the worktree's config.worktree, which the agent can still change: folded in as it is now.
|
|
243
|
+
const worktree = join(dirs.gitdir, "config.worktree");
|
|
244
|
+
const own = existsSync(worktree) ? regularFile(worktree) : "";
|
|
245
|
+
if (own === null)
|
|
246
|
+
throw new Error("the checkout's config.worktree couldn't be read as a plain file");
|
|
247
|
+
config = `${config}\n${own}`;
|
|
248
|
+
writeFileSync(configPath, config, { mode: 0o600 });
|
|
249
|
+
}
|
|
250
|
+
const unset = spawnSync("git", ["config", "--file", configPath, "--unset-all", "extensions.worktreeConfig"], plain);
|
|
251
|
+
if (unset.status !== 0 && unset.status !== 5)
|
|
252
|
+
throw new Error("the checkout's git config couldn't be read");
|
|
253
|
+
}
|
|
254
|
+
if (configIncludes(configPath))
|
|
255
|
+
throw new Error("the checkout's git config includes another file");
|
|
256
|
+
mkdirSync(join(view, "info"));
|
|
257
|
+
const exclude = join(dirs.common, "info", "exclude");
|
|
258
|
+
if (existsSync(exclude)) {
|
|
259
|
+
const text = regularFile(exclude);
|
|
260
|
+
if (text !== null)
|
|
261
|
+
writeFileSync(join(view, "info", "exclude"), text, { mode: 0o600 });
|
|
262
|
+
}
|
|
263
|
+
for (const name of readdirSync(dirs.common)) {
|
|
264
|
+
if (name === "config" || name === "info" || name === "hooks")
|
|
265
|
+
continue;
|
|
266
|
+
symlinkSync(join(dirs.common, name), join(view, name));
|
|
267
|
+
}
|
|
195
268
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
continue;
|
|
200
|
-
const text = regular(path);
|
|
201
|
-
if (text === null || /^\s*\[\s*include(if)?\b/im.test(text))
|
|
202
|
-
return true;
|
|
203
|
-
// And as git itself parses it, includes not followed (only this one regular file, from a neutral folder, with no
|
|
204
|
-
// other config read): an include git sees by any spelling is refused.
|
|
205
|
-
const parsed = spawnSync("git", ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
|
|
206
|
-
if (parsed.status !== 0 || parsed.stdout.split("\0").some(name => /^(include\.path|includeif\..*\.path)$/i.test(name)))
|
|
207
|
-
return true;
|
|
269
|
+
catch (error) {
|
|
270
|
+
dispose();
|
|
271
|
+
throw error;
|
|
208
272
|
}
|
|
209
|
-
|
|
273
|
+
// Where git names the view (rev-parse --git-common-dir), the caller is told the repository's own common directory.
|
|
274
|
+
const restore = (output) => output.split("\n").map(line => line === view || line.startsWith(`${view}/`) ? dirs.common + line.slice(view.length) : line).join("\n");
|
|
275
|
+
return { env: { GIT_DIR: dirs.gitdir, GIT_COMMON_DIR: view, GIT_WORK_TREE: resolve(checkout) }, restore, dispose };
|
|
210
276
|
}
|
|
211
277
|
/** Whether the checkout's .lfsconfig (read as git parses it, that file only) sets any git-lfs key that could name a
|
|
212
278
|
* program (anything but INERT_LFS). A file that
|
|
@@ -223,7 +289,10 @@ export function lfsExtensionIn(checkout) {
|
|
|
223
289
|
const parsed = spawnSync("git", ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
|
|
224
290
|
return parsed.status !== 0 || parsed.stdout.split("\0").filter(Boolean).some(name => lfsCommand(name.toLowerCase()) || /^include(if)?\./i.test(name));
|
|
225
291
|
}
|
|
226
|
-
|
|
292
|
+
/** `mode` "read" is for reading a checkout an agent may still be writing (the code page): every command also takes
|
|
293
|
+
* readFlags() and runs against the checkout's readView, so neither its config nor its info/attributes can change
|
|
294
|
+
* between the inspection and the run. */
|
|
295
|
+
export function checkoutGit(inner = run, mode = "build") {
|
|
227
296
|
return async (file, args, options) => {
|
|
228
297
|
if (file !== "git")
|
|
229
298
|
return inner(file, args, options);
|
|
@@ -233,21 +302,98 @@ export function checkoutGit(inner = run) {
|
|
|
233
302
|
// git-lfs also reads .lfsconfig from the checkout: one that names an extension (a command it would run) is refused.
|
|
234
303
|
if (lfsExtensionIn(cwd))
|
|
235
304
|
return refused("the checkout's .lfsconfig sets a git-lfs key that can run a command");
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
305
|
+
let view = null;
|
|
306
|
+
if (mode === "read") {
|
|
307
|
+
try {
|
|
308
|
+
view = readView(cwd);
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
return refused(error instanceof Error ? error.message : "the checkout couldn't be read");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
const viewed = { ...options, env: { ...(options?.env ?? {}), ...(view?.env ?? {}) } };
|
|
316
|
+
// The checkout's own config may name no command.
|
|
317
|
+
const own = await inner("git", ["--no-optional-locks", "config", "--list", "--show-scope", "-z"], view === null ? options : viewed);
|
|
318
|
+
if (own.code !== 0)
|
|
319
|
+
return refused("its config couldn't be read");
|
|
320
|
+
const key = commandSetting(own.stdout);
|
|
321
|
+
if (key !== null)
|
|
322
|
+
return refused(`the checkout's git config sets ${key}, which can run a command`);
|
|
323
|
+
// The operator's own config contributes values only (carriedSettings); git then reads no global or system config.
|
|
324
|
+
// A read carries no filter: it reads attributes from the empty tree anyway.
|
|
325
|
+
const carried = view === null ? carriedSettings(own.stdout) : withoutFilters(carriedSettings(own.stdout));
|
|
326
|
+
const env = { ...viewed.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" };
|
|
327
|
+
// Never into a submodule or nested repository, whose own config this never read: status and diff skip them.
|
|
328
|
+
const safeArgs = [...args];
|
|
329
|
+
const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
|
|
330
|
+
if (command >= 0 && !safeArgs.includes("--ignore-submodules=all"))
|
|
331
|
+
safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
|
|
332
|
+
const flags = view === null ? [] : readFlags();
|
|
333
|
+
const ran = await inner("git", [...TEARDOWN_FLAGS, ...flags, "-c", "diff.ignoreSubmodules=all", "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", ...carried, ...safeArgs], { ...options, env });
|
|
334
|
+
return view !== null && args.includes("rev-parse") ? { ...ran, stdout: view.restore(ran.stdout) } : ran;
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
view?.dispose();
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const withoutFilters = (carried) => carried.filter((one, at, all) => !(one.startsWith("filter.") || (one === "-c" && all[at + 1]?.startsWith("filter."))));
|
|
342
|
+
/**
|
|
343
|
+
* Flags for reading a checkout an agent writes while a page waits (docs/plans/get-side-effects.md). Command-line
|
|
344
|
+
* settings win over anything the checkout's config says, even one changed after it was inspected: no fsmonitor, hooks,
|
|
345
|
+
* pager or credential helper; no network (lazy fetch off, every protocol refused); and attributes read from the empty
|
|
346
|
+
* tree, so no filter or textconv a .gitattributes names can run. A diff also takes --no-ext-diff --no-textconv.
|
|
347
|
+
*/
|
|
348
|
+
export const PAGE_READ_FLAGS = [
|
|
349
|
+
"--no-lazy-fetch", "--attr-source=4b825dc642cb6eb9a060e54bf8d69288fbee4904",
|
|
350
|
+
"-c", "protocol.allow=never", "-c", "core.pager=cat", "-c", "core.alternateRefsCommand=", "-c", "core.attributesFile=/dev/null",
|
|
351
|
+
];
|
|
352
|
+
/** The read flags, for every read of an agent-written checkout, whatever the HTTP method (security review of
|
|
353
|
+
* 01bf3114): the worktree's attributes come from the empty tree. Git still reads the repository's info/attributes and
|
|
354
|
+
* its config as it starts, so the flags alone don't stop a filter planted in both after the inspection: every read
|
|
355
|
+
* also runs against the checkout's readView (checkoutGitSync, checkoutGit "read"), whose config and info are the plane's. The cost is cosmetic: a checkout's line-ending or encoding rules are not applied, so
|
|
356
|
+
* a file they would normalize can read as changed (fail closed: shipping then asks for a clean commit). */
|
|
357
|
+
export function readFlags() {
|
|
358
|
+
return PAGE_READ_FLAGS;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* checkoutGit, synchronously, for reads a page shows from a checkout an agent writes (docs/plans/get-side-effects.md):
|
|
362
|
+
* the same refusals (an include, an .lfsconfig extension, a config key that can run a command), no system or global
|
|
363
|
+
* config, the operator's own values carried, PAGE_READ_FLAGS and the checkout's readView (which hold even if its config
|
|
364
|
+
* or info/attributes change after they were read), submodules skipped, bounded. A refusal throws.
|
|
365
|
+
*/
|
|
366
|
+
export function checkoutGitSync(cwd, args, options = {}) {
|
|
367
|
+
if (checkoutIncludes(cwd))
|
|
368
|
+
throw new Error("the checkout's git config includes another file, or couldn't be read as a plain file");
|
|
369
|
+
if (lfsExtensionIn(cwd))
|
|
370
|
+
throw new Error("the checkout's .lfsconfig sets a git-lfs key that can run a command");
|
|
371
|
+
// Its config and info/attributes as read once, in a folder of the plane's own: neither can change before git runs.
|
|
372
|
+
const view = readView(cwd);
|
|
373
|
+
try {
|
|
374
|
+
const env = { PATH: process.env["PATH"] ?? "", HOME: process.env["HOME"] ?? "", LANG: "C", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", ...view.env };
|
|
375
|
+
const bounded = { cwd, env, encoding: "utf8", timeout: options.timeoutMs ?? 60_000, maxBuffer: options.maxBuffer ?? 1_048_576, stdio: ["ignore", "pipe", "pipe"] };
|
|
376
|
+
const own = spawnSync("git", ["--no-optional-locks", "config", "--list", "--show-scope", "-z"], bounded);
|
|
377
|
+
if (own.status !== 0)
|
|
378
|
+
throw new Error("its config couldn't be read");
|
|
240
379
|
const key = commandSetting(own.stdout);
|
|
241
380
|
if (key !== null)
|
|
242
|
-
|
|
243
|
-
// The operator's own config contributes values only (carriedSettings); git then reads no global or system config.
|
|
244
|
-
const carried = carriedSettings(own.stdout);
|
|
245
|
-
const env = { ...(options?.env ?? {}), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" };
|
|
246
|
-
// Never into a submodule or nested repository, whose own config this never read: status and diff skip them.
|
|
381
|
+
throw new Error(`the checkout's git config sets ${key}, which can run a command`);
|
|
247
382
|
const safeArgs = [...args];
|
|
248
383
|
const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
|
|
249
384
|
if (command >= 0 && !safeArgs.includes("--ignore-submodules=all"))
|
|
250
385
|
safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
|
|
251
|
-
|
|
252
|
-
|
|
386
|
+
// An external diff program is named by config, which may change after it was read: never used here.
|
|
387
|
+
if (command >= 0 && safeArgs[command] === "diff")
|
|
388
|
+
safeArgs.splice(command + 1, 0, "--no-ext-diff", "--no-textconv");
|
|
389
|
+
// The operator's own values, but no filter: PAGE_READ_FLAGS reads attributes from the empty tree anyway.
|
|
390
|
+
const carried = withoutFilters(carriedSettings(own.stdout));
|
|
391
|
+
const ran = spawnSync("git", [...TEARDOWN_FLAGS, ...readFlags(), "-c", "diff.ignoreSubmodules=all", ...carried, ...safeArgs], bounded);
|
|
392
|
+
if (ran.status !== 0)
|
|
393
|
+
throw new Error(`git ${args[0] ?? ""} failed`);
|
|
394
|
+
return args.includes("rev-parse") ? view.restore(ran.stdout) : ran.stdout;
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
view.dispose();
|
|
398
|
+
}
|
|
253
399
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { assignmentOf } from "./assignment.js";
|
|
8
8
|
import { chatResultHref, chatControlHref } from "./chat-controls.js";
|
|
9
9
|
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
10
|
-
import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, unverifiedWhenRefuted, taskStatusOf } from "./task-status.js";
|
|
10
|
+
import { approvedCheckOf, assignmentStatusFacts, checksFactOf, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, unverifiedWhenRefuted, taskStatusOf } from "./task-status.js";
|
|
11
11
|
import { leadClaimOf } from "./lead-voice.js";
|
|
12
12
|
import { leadNameOf } from "./lead-identity.js";
|
|
13
13
|
import { chatTitle } from "./chat-voice.js";
|
|
@@ -46,10 +46,10 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
|
|
|
46
46
|
const proof = store.proofVerdictFor(run.id);
|
|
47
47
|
const machine = proof?.machineVerdict ?? proof?.verdict ?? null;
|
|
48
48
|
const exit = failedCheckExit(proof?.reasons ?? []);
|
|
49
|
-
|
|
49
|
+
// The one answer (task-status.ts approvedCheckOf) when a saved check was read; a verdict alone claims no pass.
|
|
50
|
+
const checks = recorded !== null ? approvedCheckOf(machine, proof?.reasons ?? [], checksFactOf(recorded, run.headRevision))
|
|
50
51
|
: exit !== null ? { status: "failed", exitCode: exit, head: run.headRevision }
|
|
51
|
-
: machine === "
|
|
52
|
-
: machine === "attested" ? { status: "not-run", exitCode: null, head: null } : null;
|
|
52
|
+
: machine === "attested" ? { status: "not-run", exitCode: null, head: null } : null;
|
|
53
53
|
facts = { stage: "finished", report: run.role === "scout", checks, requirements: unverifiedWhenRefuted(requirementsOf(proof?.matrix), proof?.verdict),
|
|
54
54
|
...(pullRequest === undefined ? {} : { pullRequest }) };
|
|
55
55
|
}
|
package/dist/telegram-status.js
CHANGED
|
@@ -4,8 +4,8 @@ import { manualReviewOnly } from "./proof.js";
|
|
|
4
4
|
* No provider calls, repository access, new workflow state, or inferred success. */
|
|
5
5
|
import { diagnoseTaskDispatch, withDispatchDiagnoses } from "./dispatch.js";
|
|
6
6
|
import { familyCompleted } from "./result-completion.js";
|
|
7
|
-
import { assignmentStatusFacts, plainReasonOf, stageOfDispatch, taskStatusOf } from "./task-status.js";
|
|
8
|
-
import { assignmentOf } from "./assignment.js";
|
|
7
|
+
import { approvedCheckOf, approvedCheckStateOf, assignmentStatusFacts, checksFactOf, plainReasonOf, stageOfDispatch, taskStatusOf } from "./task-status.js";
|
|
8
|
+
import { assignmentChecksForRun, assignmentOf } from "./assignment.js";
|
|
9
9
|
import { CHAT_CONTROLS, chatControlHref, chatResultHref } from "./chat-controls.js";
|
|
10
10
|
export function phoneCommand(text) {
|
|
11
11
|
// Slash forms everywhere; the bare words too, because Slack and Discord
|
|
@@ -257,7 +257,12 @@ export function phoneTaskView(store, repos, id, now, root) {
|
|
|
257
257
|
const proof = store.proofVerdictFor(result.id);
|
|
258
258
|
link = taskLinkFor(id, d, { run: result.id, verdict: proof !== null });
|
|
259
259
|
const accepted = store.proofAcceptance(result.id) !== null;
|
|
260
|
-
|
|
260
|
+
// Did the approved check pass (task-status.ts approvedCheckOf): read with the saved files, else no claim at all.
|
|
261
|
+
const checkNow = root === undefined || proof?.verdict !== "verified" ? null
|
|
262
|
+
: approvedCheckStateOf(approvedCheckOf(proof.verdict, proof.reasons, checksFactOf(assignmentChecksForRun(store, root, result.id, now), result.headRevision)));
|
|
263
|
+
const verifiedWords = root === undefined ? "Open the result to see its checks" : checkNow === "passed" ? "Checks passed at completion"
|
|
264
|
+
: checkNow === "failed" ? "Checks failed" : checkNow === "running" ? "Checks running" : "The check result couldn't be read";
|
|
265
|
+
const proofWords = { verified: verifiedWords, attested: "Checks reported by the agent, not run by Toolroll", short: "Required saved material is missing", refuted: "The saved result conflicts with the approved scope" };
|
|
261
266
|
lines.push(`Checks: ${proof === null ? "No completion record saved" : manualReviewOnly(proof) ? accepted ? "Accepted by a person; the recorded checks are unchanged" : "A person must inspect this result; no recorded check failed" : proofWords[proof.verdict]}.`);
|
|
262
267
|
if (manualReviewOnly(proof) && !accepted)
|
|
263
268
|
lines.push('Reply “Send the result summary” for the checks, notes and screenshots.');
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* database records which key Toolroll made: a folder, key, link or lock that
|
|
28
28
|
* something else put there first is refused, never used.
|
|
29
29
|
*/
|
|
30
|
+
import { inReadOnlyRequest } from "./read-only-request.js";
|
|
30
31
|
import { spawnSync } from "node:child_process";
|
|
31
32
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
32
33
|
import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
@@ -541,6 +542,10 @@ export function secretFolder(state) {
|
|
|
541
542
|
const known = folders.get(root);
|
|
542
543
|
if (known !== undefined && unchanged(known))
|
|
543
544
|
return known;
|
|
545
|
+
// A page visit prepares nothing (docs/plans/get-side-effects.md): the console prepares its folder as it starts, and
|
|
546
|
+
// an action checks a changed one again. Until then the page says secrets can't be read right now.
|
|
547
|
+
if (inReadOnlyRequest())
|
|
548
|
+
throw new SecretStoreError(NOT_READY);
|
|
544
549
|
// Changed since it was checked (or never seen): checked against the same record (the database's, for Toolroll's
|
|
545
550
|
// own folder; this process's, for a test's), and refused if it doesn't match.
|
|
546
551
|
const named = pending.get(root);
|
|
@@ -797,6 +802,9 @@ export function readHeldSecret(state, name, file) {
|
|
|
797
802
|
const backend = secretBackend();
|
|
798
803
|
if (backend === null)
|
|
799
804
|
return readOwnFile(path);
|
|
805
|
+
// A page visit moves nothing and takes no lock (docs/plans/get-side-effects.md): the keychain, else the old file.
|
|
806
|
+
if (inReadOnlyRequest())
|
|
807
|
+
return readSealed(backend, folder, heldItem(folder, name)) ?? readOwnFile(path);
|
|
800
808
|
return withSecretLock(folder, `held.${name}`, () => {
|
|
801
809
|
moveHeldIn(backend, folder, name, path);
|
|
802
810
|
return readSealed(backend, folder, heldItem(folder, name));
|
|
@@ -65,9 +65,10 @@ export function updatesHtml(view, notice) {
|
|
|
65
65
|
: view.method.kind === "source" ? html `<h2>Toolroll ${view.current}</h2><p class="meta">This runs from a source checkout. Update it with git.</p>`
|
|
66
66
|
: newer && view.method.kind === "desktop" ? html `<h2>Toolroll ${latest} is available</h2><p class="meta">You have ${view.current}. Update it from the Toolroll app.</p>`
|
|
67
67
|
: newer ? html `<h2>Toolroll ${latest} is available</h2><p class="meta">You have ${view.current}. Running work finishes first, and your current version is kept so you can go back.</p>`
|
|
68
|
-
: "off" in view.latest ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Update checks are off.</p
|
|
69
|
-
:
|
|
70
|
-
: html `<h2>Toolroll ${view.current}
|
|
68
|
+
: "off" in view.latest ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Update checks are off.</p>${postForm("/settings/updates/check", html `<button type="submit">Check now</button>`, { attrs: { class: "update-actions" } })}`
|
|
69
|
+
: "pending" in view.latest ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Not checked for a newer release yet.</p>${postForm("/settings/updates/check", html `<button type="submit">Check now</button>`, { attrs: { class: "update-actions" } })}`
|
|
70
|
+
: latest === null ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Couldn’t check for a newer release: ${"problem" in view.latest ? view.latest.problem : ""}</p>`
|
|
71
|
+
: html `<h2>Toolroll ${view.current} is up to date</h2>`;
|
|
71
72
|
const form = newer && !["npx", "source", "desktop"].includes(view.method.kind)
|
|
72
73
|
? postForm("/settings/updates", html `${reauth(html `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}<div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button><button type="submit" name="when" value="when-idle">When idle</button><button type="submit" name="when" value="tonight">Tonight (03:00)</button></div>`, { hidden: { version: latest } })
|
|
73
74
|
: "";
|