toolroll 0.9.76 → 0.9.77
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 +192 -0
- package/README.md +11 -4
- package/THIRD_PARTY_NOTICES.md +5 -5
- package/dist/agent-fence.js +28 -8
- package/dist/assignment.js +3 -0
- package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -31
- package/dist/builder.d.ts +14 -0
- package/dist/builder.js +140 -41
- package/dist/chat-decide.js +4 -0
- package/dist/check-failure.d.ts +92 -0
- package/dist/check-failure.js +267 -0
- package/dist/check-progress.d.ts +28 -1
- package/dist/check-progress.js +105 -6
- package/dist/check-report.d.ts +52 -6
- package/dist/check-report.js +193 -8
- package/dist/checkout-cleanup.js +2 -2
- package/dist/cli.js +8 -5
- package/dist/coding-handoff.d.ts +1 -0
- package/dist/coding-handoff.js +20 -11
- package/dist/commit-extract.d.ts +6 -0
- package/dist/commit-extract.js +63 -8
- package/dist/console-chrome.d.ts +41 -0
- package/dist/console-chrome.js +166 -0
- package/dist/contracts/release-check.d.ts +12 -0
- package/dist/contracts/release-check.js +17 -0
- package/dist/contracts/verification-receipt.d.ts +20 -2
- package/dist/contracts/verification-receipt.js +15 -3
- package/dist/contracts/verification-view.d.ts +8 -0
- package/dist/controller-supervisor.d.ts +8 -0
- package/dist/controller-supervisor.js +33 -1
- package/dist/demo.js +3 -2
- package/dist/deploy-gate.js +5 -0
- package/dist/dispose.d.ts +3 -0
- package/dist/dispose.js +6 -3
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +10 -2
- package/dist/envelope.js +2 -1
- package/dist/evidence.js +4 -1
- package/dist/exec.d.ts +7 -0
- package/dist/exec.js +7 -1
- package/dist/git-floor.d.ts +16 -0
- package/dist/git-floor.js +36 -0
- package/dist/grant.js +5 -1
- package/dist/guides.js +4 -3
- package/dist/operate.d.ts +13 -0
- package/dist/operate.js +773 -319
- package/dist/prepared-evidence.d.ts +2 -1
- package/dist/prepared-evidence.js +8 -3
- package/dist/project-knowledge.js +6 -2
- package/dist/project-learning.js +2 -2
- package/dist/proof.js +6 -0
- package/dist/read-executor-worker.js +8 -4
- package/dist/read-executor.d.ts +18 -1
- package/dist/read-executor.js +8 -0
- package/dist/release-adapters.d.ts +48 -1
- package/dist/release-adapters.js +343 -10
- package/dist/release-authority.js +6 -2
- package/dist/release-auto.d.ts +38 -7
- package/dist/release-auto.js +131 -15
- package/dist/release-card.d.ts +15 -3
- package/dist/release-card.js +25 -9
- package/dist/release-ci-hold.d.ts +41 -0
- package/dist/release-ci-hold.js +53 -0
- package/dist/release-cli.js +21 -12
- package/dist/release-coverage.js +5 -2
- package/dist/release-recheck.d.ts +1 -1
- package/dist/release-recheck.js +6 -1
- package/dist/release.d.ts +108 -11
- package/dist/release.js +294 -39
- package/dist/review-context.js +8 -6
- package/dist/runner.js +2 -1
- package/dist/sandbox-runtime.d.ts +8 -0
- package/dist/sandbox-runtime.js +14 -2
- package/dist/scope.js +7 -1
- package/dist/secret-mint.d.ts +1 -0
- package/dist/secret-mint.js +14 -0
- package/dist/serve.js +101 -51
- package/dist/server/chat.js +5 -5
- package/dist/server/render-tasks.d.ts +2 -0
- package/dist/server/render-tasks.js +2 -1
- package/dist/server/request-context.d.ts +6 -0
- package/dist/server/runtime.d.ts +2 -0
- package/dist/server/task-views.js +7 -2
- package/dist/setup-guide.d.ts +2 -2
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +28 -1
- package/dist/store.d.ts +10 -0
- package/dist/store.js +25 -1
- package/dist/task-base.d.ts +4 -0
- package/dist/task-base.js +22 -2
- package/dist/teardown-git.d.ts +52 -3
- package/dist/teardown-git.js +143 -31
- package/dist/test-infra.js +1 -1
- package/dist/up-processes.d.ts +137 -0
- package/dist/up-processes.js +165 -0
- package/dist/up-role-child.d.ts +1 -0
- package/dist/up-role-child.js +77 -0
- package/dist/verification-evidence.d.ts +1 -0
- package/dist/verification-evidence.js +13 -1
- package/package.json +2 -1
package/dist/store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mintSecret } from "./secret-mint.js";
|
|
1
2
|
import { WRITES_DEFAULT } from "./writes-mode.js";
|
|
2
3
|
import { checkAccessOf, widerThan } from "./check-access.js";
|
|
3
4
|
import { PROJECT_SANDBOX_DEFAULT, sandboxOf } from "./project-sandbox.js";
|
|
@@ -72,6 +73,7 @@ import { hasDisguisedText, hasForbiddenControls, validateNote } from "./decision
|
|
|
72
73
|
import { parseReviewContext, reviewContextCustodyProblem } from "./review-context.js";
|
|
73
74
|
import { foldReview, manualReviewOnly } from "./proof.js";
|
|
74
75
|
import { liveReleaseOf } from "./scope.js";
|
|
76
|
+
import { releaseCiHoldOf, releaseCiRunningOf } from "./release-ci-hold.js";
|
|
75
77
|
import { APPROVAL_BASES, approvalOf, machineActing, personProof, proofDetailOf, quarantinedAuthor, sealOriginOf, digestOf, canonicalProfileJson, chainFromJson, profileDigestOf, profileFromJson, scopeAuthorityOf, routeParityProblem, parseAcceptanceCriteria, exactAcceptance, exactStringList, exactSafeIntegerOrNull } from "./scope.js";
|
|
76
78
|
import { resolveScopeProfile, resolveRouteCandidates, exactPinOf, routeOfTask, agentChoicesFor } from "./agentconfig.js";
|
|
77
79
|
import { canonicalOverridesJson, canonicalRouteJson, isRiskLevel, isTaskSize, headroomFrom, makesNoPlan, legOf, overridesFromJson, phaseOfRole, recommendRoute, routeDigestOf, routeFromJson, routeProblems, routeStampProblem, ROUTE_ERA, PROVIDER_ID_LIST, } from "./phase-routing.js";
|
|
@@ -6932,6 +6934,10 @@ export class Store {
|
|
|
6932
6934
|
// road it came by.
|
|
6933
6935
|
if (basis === undefined && machineActing())
|
|
6934
6936
|
return false;
|
|
6937
|
+
// A RELEASE WAITING FOR CI (Codex review round 1, finding 4): filed while its pull request's checks still ran,
|
|
6938
|
+
// only the automatic road (which proves CI itself) seals it until the plane read exact-head CI passing.
|
|
6939
|
+
if (basis?.kind !== "auto-check" && basis?.kind !== "auto-green" && releaseCiHoldOf(this, taskId) !== null)
|
|
6940
|
+
return false;
|
|
6935
6941
|
// `carried` is written by the carry transaction alone (carryReleaseApproval), never through a seal.
|
|
6936
6942
|
if (basis?.kind === "carried")
|
|
6937
6943
|
return false;
|
|
@@ -8018,7 +8024,7 @@ export class Store {
|
|
|
8018
8024
|
/** A single-use door into the instance (U2): 128-bit token, sha256
|
|
8019
8025
|
* stored, role pinned at mint, 72 hours. Only the caller's own
|
|
8020
8026
|
* authentication gates this — an approver mints; a viewer cannot. */
|
|
8021
|
-
mintInvite(role, mintedBy, now, token = () =>
|
|
8027
|
+
mintInvite(role, mintedBy, now, token = () => mintSecret(16), projects = null) {
|
|
8022
8028
|
const access = normalizeProjectAccess(projects);
|
|
8023
8029
|
return this.transact(() => {
|
|
8024
8030
|
if (!this.isInstanceOperator(mintedBy))
|
|
@@ -20507,6 +20513,9 @@ export function reserveRelease(store, input) {
|
|
|
20507
20513
|
const a = releaseAuthority(store, input.authority);
|
|
20508
20514
|
if (a === null)
|
|
20509
20515
|
return { ok: false, why: `No release ${input.authority}.` };
|
|
20516
|
+
// Early approval authorizes the check, never an irreversible operation before the plane confirms exact-head CI.
|
|
20517
|
+
if (releaseCiRunningOf(store, input.taskId) !== null)
|
|
20518
|
+
return { ok: false, why: "This release still waits for GitHub CI. Resume it with `toolroll release`." };
|
|
20510
20519
|
const at = input.now.toISOString();
|
|
20511
20520
|
// Expiry stops every live state; an operation already reserved settles only through reconciliation.
|
|
20512
20521
|
if (a.expiresAt <= at)
|
|
@@ -20548,6 +20557,8 @@ export function runReleaseOperation(store, id, epoch, now, issuer = currentRelea
|
|
|
20548
20557
|
const a = op === null ? null : releaseAuthority(store, op.authority);
|
|
20549
20558
|
if (op === null || a === null || op.state !== "reserved" || op.epoch !== epoch || a.leaseEpoch !== epoch)
|
|
20550
20559
|
return false;
|
|
20560
|
+
if (releaseCiRunningOf(store, op.taskId) !== null)
|
|
20561
|
+
return false;
|
|
20551
20562
|
// Past the expiry nothing new starts; the reserved operation is settled by reconciliation instead.
|
|
20552
20563
|
if (a.expiresAt <= now.toISOString())
|
|
20553
20564
|
return false;
|
|
@@ -20858,3 +20869,16 @@ export function agentsLiveRuns(store, endedSince) {
|
|
|
20858
20869
|
repo: row["live_task_repo"] === null || row["live_task_repo"] === undefined ? null : String(row["live_task_repo"]),
|
|
20859
20870
|
}));
|
|
20860
20871
|
}
|
|
20872
|
+
/**
|
|
20873
|
+
* This runner's watches a predecessor left needing recovery (Codex review round 1, finding 2): a watch lease still
|
|
20874
|
+
* live, or one whose holder left a claim open (or a run open under a claim a reaper already released). A replacement
|
|
20875
|
+
* worker takes each over — recovering the holder's incarnation in the same transaction — before it admits new work,
|
|
20876
|
+
* whichever projects its start names (a project being removed when the predecessor died is in none of them).
|
|
20877
|
+
*/
|
|
20878
|
+
export function predecessorWatches(store, runner, now) {
|
|
20879
|
+
return store.handle.prepare(`SELECT w.repo AS repo, w.owner AS owner FROM watch_lease w
|
|
20880
|
+
WHERE w.runner = ? AND (w.expires_at > ? OR EXISTS (
|
|
20881
|
+
SELECT 1 FROM claim c WHERE c.runner = w.runner AND c.incarnation = w.owner
|
|
20882
|
+
AND (c.released_at IS NULL OR EXISTS (SELECT 1 FROM run r WHERE r.lease_id = c.lease_id AND r.outcome IS NULL))))
|
|
20883
|
+
ORDER BY w.repo`).all(runner, now.toISOString()).map(row => ({ repo: String(row["repo"]), owner: String(row["owner"]) }));
|
|
20884
|
+
}
|
package/dist/task-base.d.ts
CHANGED
|
@@ -33,3 +33,7 @@ export declare function freshBaseFor(store: Store, taskId: string, branch: strin
|
|
|
33
33
|
};
|
|
34
34
|
} | Record<string, never>;
|
|
35
35
|
export declare function taskBaseOf(store: Store, taskId: string, git?: GitRead): TaskBaseView | null;
|
|
36
|
+
/** `git`'s answers kept for `ttlMs` (at most `entries` of them), for a page that shows the base line on every refresh:
|
|
37
|
+
* each read still goes through the same frozen view when it runs; only its answer is reused for a moment. Display only —
|
|
38
|
+
* never for a decision. */
|
|
39
|
+
export declare function recentGitReads(git?: GitRead, ttlMs?: number, entries?: number, now?: () => number): GitRead;
|
package/dist/task-base.js
CHANGED
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
* branch's pinned origin, or the newest plan's base — and the behind count is
|
|
5
5
|
* read from Git now. Reads only.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { viewedGitSync } from "./teardown-git.js";
|
|
8
8
|
export const readGit = (cwd, args) => {
|
|
9
9
|
try {
|
|
10
|
-
|
|
10
|
+
// Every git version through a frozen read view, bounded (eval-harness review 1, finding 7): a partial clone, an
|
|
11
|
+
// include or a command-naming key is refused from the config git then runs with.
|
|
12
|
+
const ran = viewedGitSync(cwd, args, { timeoutMs: 5_000, maxBuffer: 65_536 });
|
|
13
|
+
return ran.status === 0 ? ran.stdout.trim() : null;
|
|
11
14
|
}
|
|
12
15
|
catch {
|
|
13
16
|
return null;
|
|
@@ -49,3 +52,20 @@ export function taskBaseOf(store, taskId, git = readGit) {
|
|
|
49
52
|
const view = { base, branch, main: behind === null ? null : main, behind };
|
|
50
53
|
return { ...view, words: `Base ${baseStandingWords(view)}` };
|
|
51
54
|
}
|
|
55
|
+
/** `git`'s answers kept for `ttlMs` (at most `entries` of them), for a page that shows the base line on every refresh:
|
|
56
|
+
* each read still goes through the same frozen view when it runs; only its answer is reused for a moment. Display only —
|
|
57
|
+
* never for a decision. */
|
|
58
|
+
export function recentGitReads(git = readGit, ttlMs = 15_000, entries = 64, now = Date.now) {
|
|
59
|
+
const kept = new Map();
|
|
60
|
+
return (cwd, args) => {
|
|
61
|
+
const key = JSON.stringify([cwd, args]), at = now(), hit = kept.get(key);
|
|
62
|
+
if (hit !== undefined && at - hit.at < ttlMs && at >= hit.at)
|
|
63
|
+
return hit.value;
|
|
64
|
+
const value = git(cwd, args);
|
|
65
|
+
kept.delete(key);
|
|
66
|
+
if (kept.size >= entries)
|
|
67
|
+
kept.delete(kept.keys().next().value);
|
|
68
|
+
kept.set(key, { at, value });
|
|
69
|
+
return value;
|
|
70
|
+
};
|
|
71
|
+
}
|
package/dist/teardown-git.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export declare function teardownGit(runner?: Runner, binding?: GitBinding): Git;
|
|
|
38
38
|
* git at any file, a pipe that never ends among them. Each file is read only as the regular file it is, bounded; one
|
|
39
39
|
* that can't be read so counts as including.
|
|
40
40
|
*/
|
|
41
|
-
export declare function checkoutIncludes(checkout: string): boolean;
|
|
41
|
+
export declare function checkoutIncludes(checkout: string, gitFile?: string): boolean;
|
|
42
42
|
export type GitBinding = {
|
|
43
43
|
repo?: string | null;
|
|
44
44
|
};
|
|
@@ -50,7 +50,7 @@ export declare const WRITE_VIEW_FLAGS: readonly string[];
|
|
|
50
50
|
/** Whether the checkout's .lfsconfig (read as git parses it, that file only) sets any git-lfs key that could name a
|
|
51
51
|
* program (anything but INERT_LFS). A file that
|
|
52
52
|
* isn't a plain one, or doesn't parse, counts as naming one. */
|
|
53
|
-
export declare function lfsExtensionIn(checkout: string): boolean;
|
|
53
|
+
export declare function lfsExtensionIn(checkout: string, gitFile?: string): boolean;
|
|
54
54
|
/** Every command runs against a view of the checkout's repository (gitView) whose config and info are copies the
|
|
55
55
|
* plane made, inspected there, so neither can change between the inspection and the run. `mode` "read" is for reading
|
|
56
56
|
* a checkout an agent may still be writing (the code page): every command also takes readFlags(), and the view holds
|
|
@@ -69,7 +69,7 @@ export declare const PAGE_READ_FLAGS: readonly string[];
|
|
|
69
69
|
* its config as it starts, so the flags alone don't stop a filter planted in both after the inspection: every read
|
|
70
70
|
* also runs against a view of the checkout's repository (gitView; 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
|
|
71
71
|
* a file they would normalize can read as changed (fail closed: shipping then asks for a clean commit). */
|
|
72
|
-
export declare function readFlags(): readonly string[];
|
|
72
|
+
export declare function readFlags(gitFile?: string): readonly string[];
|
|
73
73
|
/**
|
|
74
74
|
* checkoutGit, synchronously, for reads a page shows from a checkout an agent writes (docs/plans/get-side-effects.md):
|
|
75
75
|
* the same refusals (an include, an .lfsconfig extension, a config key that can run a command), no system or global
|
|
@@ -81,6 +81,30 @@ export declare function checkoutGitSync(cwd: string, args: readonly string[], op
|
|
|
81
81
|
maxBuffer?: number;
|
|
82
82
|
binding?: GitBinding;
|
|
83
83
|
}): string;
|
|
84
|
+
/**
|
|
85
|
+
* checkoutGitSync's run, answering git's exit status instead of throwing on a non-zero one (a refusal, or a repository
|
|
86
|
+
* that changed while git ran, still throws): for reads whose "no" is an exit code (`merge-base --is-ancestor`), and,
|
|
87
|
+
* with `buffer`, for blobs read as bytes (their output is never rewritten).
|
|
88
|
+
*/
|
|
89
|
+
export declare function viewedGitSync(cwd: string, args: readonly string[], options: {
|
|
90
|
+
timeoutMs?: number;
|
|
91
|
+
maxBuffer?: number;
|
|
92
|
+
binding?: GitBinding;
|
|
93
|
+
}): {
|
|
94
|
+
status: number | null;
|
|
95
|
+
stdout: string;
|
|
96
|
+
};
|
|
97
|
+
export declare function viewedGitSync(cwd: string, args: readonly string[], options: {
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
maxBuffer?: number;
|
|
100
|
+
binding?: GitBinding;
|
|
101
|
+
buffer: true;
|
|
102
|
+
input?: Buffer;
|
|
103
|
+
git?: string;
|
|
104
|
+
}): {
|
|
105
|
+
status: number | null;
|
|
106
|
+
stdout: Buffer;
|
|
107
|
+
};
|
|
84
108
|
/**
|
|
85
109
|
* checkoutGit ("build": the repository written through its view) for callers that name the repository with a leading
|
|
86
110
|
* `git -C <dir>` instead of a cwd: worktree add and remove in a project's repository, a merge in a checkout of it.
|
|
@@ -88,3 +112,28 @@ export declare function checkoutGitSync(cwd: string, args: readonly string[], op
|
|
|
88
112
|
* what they name. Anything but git goes to `inner` as given.
|
|
89
113
|
*/
|
|
90
114
|
export declare function hardenedGit(inner?: Runner, mode?: "build" | "read", binding?: GitBinding): Runner;
|
|
115
|
+
/** Asked once per git executable (eval-harness review 2, follow-up): a fixed git given to viewedGitSync is probed
|
|
116
|
+
* itself, never answered for by the one PATH names. */
|
|
117
|
+
export declare function lazyFetchSupported(gitFile?: string): boolean;
|
|
118
|
+
/** For tests: forget the probes' answers, or set one for every executable (null forgets). */
|
|
119
|
+
export declare function setLazyFetchSupportedForTest(value: boolean | null): void;
|
|
120
|
+
/** The words for a partial clone refused on a git without `--no-lazy-fetch`. */
|
|
121
|
+
export declare const PARTIAL_CLONE_REFUSAL = "this project is a partial clone; protected reads need git 2.45 or newer";
|
|
122
|
+
/** `--no-lazy-fetch` where the installed git has it, else nothing (and lazyFetchSetting refuses partial clones). */
|
|
123
|
+
export declare function noLazyFetch(): readonly string[];
|
|
124
|
+
/**
|
|
125
|
+
* The repository setting (local or worktree scope of a `git config --list --show-scope -z` listing) that makes lazy
|
|
126
|
+
* fetching possible, or null: `extensions.partialClone` set at all, any `remote.<name>.promisor` not plainly false, or
|
|
127
|
+
* any `remote.<name>.partialCloneFilter` set (git makes that remote a promisor). Fails closed: a listing that can't be
|
|
128
|
+
* read names a setting. `remote.<name>.vcs` (a foreign-VCS helper) is named too, though the classifier already refuses
|
|
129
|
+
* it (commandSetting: not in the remote allowlist), as it does every `protocol.*` key and every include.
|
|
130
|
+
*/
|
|
131
|
+
export declare function lazyFetchSetting(listing: string): string | null;
|
|
132
|
+
/**
|
|
133
|
+
* For the reads that run git directly on a repository rather than through a view: with `--no-lazy-fetch` they run as
|
|
134
|
+
* they always have (the flag stops any fetch). Without it, they must not check a repository's config and then run git
|
|
135
|
+
* on it (the config could change in between, or an include could set what the check never saw): `inner`, wrapped so
|
|
136
|
+
* every git command runs through a frozen read view (checkoutGit "read"), whose own config listing (includes refused,
|
|
137
|
+
* command and protocol settings refused, a partial clone refused) is the config git then runs with.
|
|
138
|
+
*/
|
|
139
|
+
export declare function lazyFetchSafeGit(inner?: Runner): Runner;
|
package/dist/teardown-git.js
CHANGED
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
* nothing is removed.
|
|
12
12
|
*/
|
|
13
13
|
import { spawnSync } from "node:child_process";
|
|
14
|
-
import { closeSync, constants as fsConstants, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
14
|
+
import { accessSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
15
15
|
import { randomBytes } from "node:crypto";
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { ensureGitViewsRoot, withCheckoutLock } from "./git-views.js";
|
|
18
18
|
import { gitIncident } from "./git-incident.js";
|
|
19
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
19
|
+
import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
20
20
|
import { run } from "./exec.js";
|
|
21
21
|
import { ENV, ENV_ALLOWLIST } from "./remote-main.js";
|
|
22
22
|
import { WORKTREE_TIMEOUT_MS } from "./worktree.js";
|
|
@@ -304,29 +304,37 @@ export function teardownGit(runner = run, binding = {}) {
|
|
|
304
304
|
* git at any file, a pipe that never ends among them. Each file is read only as the regular file it is, bounded; one
|
|
305
305
|
* that can't be read so counts as including.
|
|
306
306
|
*/
|
|
307
|
-
export function checkoutIncludes(checkout) {
|
|
307
|
+
export function checkoutIncludes(checkout, gitFile = "git") {
|
|
308
308
|
const dirs = gitDirsOf(checkout);
|
|
309
309
|
if (dirs === null)
|
|
310
310
|
return true;
|
|
311
311
|
for (const path of [join(dirs.common, "config"), join(dirs.gitdir, "config.worktree")]) {
|
|
312
312
|
if (!existsSync(path) && path.endsWith("config.worktree"))
|
|
313
313
|
continue;
|
|
314
|
-
if (
|
|
314
|
+
if (includeIn(path, gitFile) !== null)
|
|
315
315
|
return true;
|
|
316
316
|
}
|
|
317
317
|
return false;
|
|
318
318
|
}
|
|
319
319
|
/** One config file, read only as the regular file it is (bounded), or null. */
|
|
320
320
|
function regularFile(path) {
|
|
321
|
+
// Opened without following a link and without blocking, then judged by what was opened: a FIFO swapped in after a
|
|
322
|
+
// look never hangs the read (eval-harness review 1, finding 7).
|
|
323
|
+
let fd = null;
|
|
321
324
|
try {
|
|
322
|
-
|
|
325
|
+
fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
326
|
+
const held = fstatSync(fd);
|
|
323
327
|
if (!held.isFile() || held.size > 1024 * 1024)
|
|
324
328
|
return null;
|
|
325
|
-
return readFileSync(
|
|
329
|
+
return readFileSync(fd, "utf8");
|
|
326
330
|
}
|
|
327
331
|
catch {
|
|
328
332
|
return null;
|
|
329
333
|
}
|
|
334
|
+
finally {
|
|
335
|
+
if (fd !== null)
|
|
336
|
+
closeSync(fd);
|
|
337
|
+
}
|
|
330
338
|
}
|
|
331
339
|
/** A checkout's git directory and common directory, as git finds them from its `.git` (a folder, or a one-line
|
|
332
340
|
* `gitdir: ` file); null when the pointer isn't one git would read the same way. */
|
|
@@ -350,18 +358,13 @@ function gitDirsOf(checkout) {
|
|
|
350
358
|
const named = regularFile(join(gitdir, "commondir"))?.trim();
|
|
351
359
|
return { gitdir, common: named === undefined || named === "" ? gitdir : resolve(gitdir, named) };
|
|
352
360
|
}
|
|
353
|
-
/** Whether one config file includes another (or can't be read as a plain file): by a plain reading, and as git itself
|
|
354
|
-
* parses it, includes not followed (only this one regular file, from a neutral folder, with no other config read). */
|
|
355
|
-
function configIncludes(path) {
|
|
356
|
-
return includeIn(path) !== null;
|
|
357
|
-
}
|
|
358
361
|
/** The include key one config file sets (lowercased, as git names it), "include" when only a plain reading finds one,
|
|
359
362
|
* or "an unreadable file" when it can't be read as a plain file or parsed; null when it includes nothing. */
|
|
360
|
-
function includeIn(path) {
|
|
363
|
+
function includeIn(path, gitFile = "git") {
|
|
361
364
|
const text = regularFile(path);
|
|
362
365
|
if (text === null)
|
|
363
366
|
return "an unreadable file";
|
|
364
|
-
const parsed = spawnSync(
|
|
367
|
+
const parsed = spawnSync(gitFile, ["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" } });
|
|
365
368
|
if (parsed.status !== 0)
|
|
366
369
|
return "an unreadable file";
|
|
367
370
|
const named = parsed.stdout.split("\0").find(name => /^(include\.path|includeif\..*\.path)$/i.test(name));
|
|
@@ -471,7 +474,7 @@ function boundDirs(checkout, binding = {}) {
|
|
|
471
474
|
const ADMIN_LINKED = ["index", "logs", "refs", "reftable"];
|
|
472
475
|
/** What git keeps in a common directory (its path.c common_list, and reftable), linked whether or not it exists yet. */
|
|
473
476
|
const COMMON_NAMES = ["branches", "gc.pid", "logs", "lost-found", "modules", "objects", "packed-refs", "reftable", "refs", "remotes", "rr-cache", "shallow", "svn", "worktrees"];
|
|
474
|
-
function gitView(checkout, kind, binding = {}) {
|
|
477
|
+
function gitView(checkout, kind, binding = {}, gitFile = "git") {
|
|
475
478
|
const dirs = boundDirs(checkout, binding);
|
|
476
479
|
const root = ensureGitViewsRoot(realpathSync(homedir()));
|
|
477
480
|
const view = realpathSync(mkdtempSync(join(root, `${kind}-`)));
|
|
@@ -487,7 +490,7 @@ function gitView(checkout, kind, binding = {}) {
|
|
|
487
490
|
writeFileSync(configPath, config, { mode: 0o600 });
|
|
488
491
|
const plain = { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } };
|
|
489
492
|
if (/worktreeconfig/i.test(config)) {
|
|
490
|
-
const flag = spawnSync(
|
|
493
|
+
const flag = spawnSync(gitFile, ["config", "--file", configPath, "--no-includes", "--type=bool", "--get", "extensions.worktreeConfig"], plain);
|
|
491
494
|
if (flag.status !== 0 && flag.status !== 1)
|
|
492
495
|
throw new Error("the checkout's git config couldn't be read");
|
|
493
496
|
if (flag.stdout.trim() === "true") {
|
|
@@ -499,11 +502,11 @@ function gitView(checkout, kind, binding = {}) {
|
|
|
499
502
|
config = `${config}\n${own}`;
|
|
500
503
|
writeFileSync(configPath, config, { mode: 0o600 });
|
|
501
504
|
}
|
|
502
|
-
const unset = spawnSync(
|
|
505
|
+
const unset = spawnSync(gitFile, ["config", "--file", configPath, "--unset-all", "extensions.worktreeConfig"], plain);
|
|
503
506
|
if (unset.status !== 0 && unset.status !== 5)
|
|
504
507
|
throw new Error("the checkout's git config couldn't be read");
|
|
505
508
|
}
|
|
506
|
-
const include = includeIn(configPath);
|
|
509
|
+
const include = includeIn(configPath, gitFile);
|
|
507
510
|
if (include !== null)
|
|
508
511
|
throw new Error(`the checkout's git config includes another file (${include})`);
|
|
509
512
|
mkdirSync(join(view, "info"));
|
|
@@ -1026,7 +1029,7 @@ export const WRITE_VIEW_FLAGS = ["-c", "worktree.useRelativePaths=false", "-c",
|
|
|
1026
1029
|
/** Whether the checkout's .lfsconfig (read as git parses it, that file only) sets any git-lfs key that could name a
|
|
1027
1030
|
* program (anything but INERT_LFS). A file that
|
|
1028
1031
|
* isn't a plain one, or doesn't parse, counts as naming one. */
|
|
1029
|
-
export function lfsExtensionIn(checkout) {
|
|
1032
|
+
export function lfsExtensionIn(checkout, gitFile = "git") {
|
|
1030
1033
|
const path = join(checkout, ".lfsconfig");
|
|
1031
1034
|
try {
|
|
1032
1035
|
if (!lstatSync(path).isFile())
|
|
@@ -1035,7 +1038,7 @@ export function lfsExtensionIn(checkout) {
|
|
|
1035
1038
|
catch {
|
|
1036
1039
|
return false;
|
|
1037
1040
|
}
|
|
1038
|
-
const parsed = spawnSync(
|
|
1041
|
+
const parsed = spawnSync(gitFile, ["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" } });
|
|
1039
1042
|
return parsed.status !== 0 || parsed.stdout.split("\0").filter(Boolean).some(name => lfsCommand(name) || ["include", "includeif"].includes(keyParts(name).section));
|
|
1040
1043
|
}
|
|
1041
1044
|
/** Every command runs against a view of the checkout's repository (gitView) whose config and info are copies the
|
|
@@ -1070,12 +1073,15 @@ export function checkoutGit(inner = run, mode = "build", binding = {}) {
|
|
|
1070
1073
|
const key = commandSetting(own.stdout, { lfsChecked: true });
|
|
1071
1074
|
if (key !== null)
|
|
1072
1075
|
return refused(`the checkout's git config sets ${key}, which can run a command`);
|
|
1076
|
+
// A git without --no-lazy-fetch: a repository that could fetch lazily is refused, from the view's frozen config.
|
|
1077
|
+
if (!lazyFetchSupported() && lazyFetchSetting(own.stdout) !== null)
|
|
1078
|
+
return refused(PARTIAL_CLONE_REFUSAL);
|
|
1073
1079
|
// The operator's own config contributes values only (carriedSettings); git then reads no global or system config.
|
|
1074
1080
|
// A read carries no filter: it reads attributes from the empty tree anyway.
|
|
1075
1081
|
const carried = mode === "read" ? withoutFilters(carriedSettings(own.stdout)) : carriedSettings(own.stdout);
|
|
1076
1082
|
const env = { ...viewed.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" };
|
|
1077
1083
|
// Never into a submodule or nested repository, whose own config this never read: status and diff skip them.
|
|
1078
|
-
const safeArgs =
|
|
1084
|
+
const safeArgs = withoutUnsupportedFlags(args);
|
|
1079
1085
|
const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
|
|
1080
1086
|
if (command >= 0 && !safeArgs.includes("--ignore-submodules=all"))
|
|
1081
1087
|
safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
|
|
@@ -1117,8 +1123,8 @@ export const PAGE_READ_FLAGS = [
|
|
|
1117
1123
|
* its config as it starts, so the flags alone don't stop a filter planted in both after the inspection: every read
|
|
1118
1124
|
* also runs against a view of the checkout's repository (gitView; 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
|
|
1119
1125
|
* a file they would normalize can read as changed (fail closed: shipping then asks for a clean commit). */
|
|
1120
|
-
export function readFlags() {
|
|
1121
|
-
return PAGE_READ_FLAGS;
|
|
1126
|
+
export function readFlags(gitFile = "git") {
|
|
1127
|
+
return lazyFetchSupported(gitFile) ? PAGE_READ_FLAGS : PAGE_READ_FLAGS.filter(flag => flag !== "--no-lazy-fetch");
|
|
1122
1128
|
}
|
|
1123
1129
|
/**
|
|
1124
1130
|
* checkoutGit, synchronously, for reads a page shows from a checkout an agent writes (docs/plans/get-side-effects.md):
|
|
@@ -1127,22 +1133,32 @@ export function readFlags() {
|
|
|
1127
1133
|
* or info/attributes change after they were read), submodules skipped, bounded. A refusal throws.
|
|
1128
1134
|
*/
|
|
1129
1135
|
export function checkoutGitSync(cwd, args, options = {}) {
|
|
1130
|
-
|
|
1136
|
+
const ran = viewedGitSync(cwd, args, options);
|
|
1137
|
+
if (ran.status !== 0)
|
|
1138
|
+
throw new Error(`git ${args[0] ?? ""} failed`);
|
|
1139
|
+
return ran.stdout;
|
|
1140
|
+
}
|
|
1141
|
+
export function viewedGitSync(cwd, args, options = {}) {
|
|
1142
|
+
const gitFile = options.git ?? "git";
|
|
1143
|
+
if (checkoutIncludes(cwd, gitFile))
|
|
1131
1144
|
throw new Error("the checkout's git config includes another file, or couldn't be read as a plain file");
|
|
1132
|
-
if (lfsExtensionIn(cwd))
|
|
1145
|
+
if (lfsExtensionIn(cwd, gitFile))
|
|
1133
1146
|
throw new Error("the checkout's .lfsconfig sets a git-lfs key that can run a command");
|
|
1134
1147
|
// Its config and info/attributes as read once, in a folder of the plane's own: neither can change before git runs.
|
|
1135
|
-
const view = gitView(cwd, "read", options.binding);
|
|
1148
|
+
const view = gitView(cwd, "read", options.binding, gitFile);
|
|
1136
1149
|
try {
|
|
1137
1150
|
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 };
|
|
1138
1151
|
const bounded = { cwd, env, encoding: "utf8", timeout: options.timeoutMs ?? 60_000, maxBuffer: options.maxBuffer ?? 1_048_576, stdio: ["ignore", "pipe", "pipe"] };
|
|
1139
|
-
const own = spawnSync(
|
|
1152
|
+
const own = spawnSync(gitFile, ["--no-optional-locks", "config", "--list", "--show-scope", "-z"], bounded);
|
|
1140
1153
|
if (own.status !== 0)
|
|
1141
1154
|
throw new Error("its config couldn't be read");
|
|
1142
1155
|
const key = commandSetting(own.stdout, { lfsChecked: true });
|
|
1143
1156
|
if (key !== null)
|
|
1144
1157
|
throw new Error(`the checkout's git config sets ${key}, which can run a command`);
|
|
1145
|
-
|
|
1158
|
+
// A git without --no-lazy-fetch: a repository that could fetch lazily is refused, from the view's frozen config.
|
|
1159
|
+
if (!lazyFetchSupported(gitFile) && lazyFetchSetting(own.stdout) !== null)
|
|
1160
|
+
throw new Error(PARTIAL_CLONE_REFUSAL);
|
|
1161
|
+
const safeArgs = withoutUnsupportedFlags(args, gitFile);
|
|
1146
1162
|
const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
|
|
1147
1163
|
if (command >= 0 && !safeArgs.includes("--ignore-submodules=all"))
|
|
1148
1164
|
safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
|
|
@@ -1151,13 +1167,20 @@ export function checkoutGitSync(cwd, args, options = {}) {
|
|
|
1151
1167
|
safeArgs.splice(command + 1, 0, "--no-ext-diff", "--no-textconv");
|
|
1152
1168
|
// The operator's own values, but no filter: PAGE_READ_FLAGS reads attributes from the empty tree anyway.
|
|
1153
1169
|
const carried = withoutFilters(carriedSettings(own.stdout));
|
|
1154
|
-
const
|
|
1155
|
-
if (
|
|
1156
|
-
|
|
1170
|
+
const argv = [...TEARDOWN_FLAGS, ...readFlags(gitFile), "-c", "diff.ignoreSubmodules=all", ...carried, ...safeArgs];
|
|
1171
|
+
if (options.buffer === true) {
|
|
1172
|
+
// stdin is the caller's own bytes when given (cat-file --batch), else closed.
|
|
1173
|
+
const ran = spawnSync(gitFile, argv, { ...bounded, encoding: "buffer", ...(options.input === undefined ? {} : { input: options.input, stdio: ["pipe", "pipe", "pipe"] }) });
|
|
1174
|
+
const why = view.settle();
|
|
1175
|
+
if (why !== null)
|
|
1176
|
+
throw new Error(why);
|
|
1177
|
+
return { status: ran.error === undefined ? ran.status : null, stdout: ran.stdout };
|
|
1178
|
+
}
|
|
1179
|
+
const ran = spawnSync(gitFile, argv, bounded);
|
|
1157
1180
|
const why = view.settle();
|
|
1158
1181
|
if (why !== null)
|
|
1159
1182
|
throw new Error(why);
|
|
1160
|
-
return view.restore(ran.stdout, args);
|
|
1183
|
+
return { status: ran.error === undefined ? ran.status : null, stdout: view.restore(ran.stdout ?? "", args) };
|
|
1161
1184
|
}
|
|
1162
1185
|
finally {
|
|
1163
1186
|
view.dispose();
|
|
@@ -1183,3 +1206,92 @@ export function hardenedGit(inner = run, mode = "build", binding = {}) {
|
|
|
1183
1206
|
return hardened("git", rest, { ...options, ...(cwd === undefined ? {} : { cwd }) });
|
|
1184
1207
|
};
|
|
1185
1208
|
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Git older than 2.45 has no `--no-lazy-fetch` (and no GIT_NO_LAZY_FETCH): there, every hardened read leaves the flag
|
|
1211
|
+
* out and instead refuses a repository whose config makes lazy fetching possible (lazyFetchSetting), while
|
|
1212
|
+
* `protocol.allow=never` stays on every read and write, so a lazy fetch that slipped past would still find no
|
|
1213
|
+
* transport. Asked once per process, as the plane's other plain git probes run: no system or global config.
|
|
1214
|
+
*/
|
|
1215
|
+
const lazyFetchByGit = new Map();
|
|
1216
|
+
let lazyFetchOverride;
|
|
1217
|
+
/** The git executable `file` names, as the real path it runs (a bare name looked up on PATH), or `file` itself. */
|
|
1218
|
+
function gitExecutablePath(file) {
|
|
1219
|
+
const candidates = file.includes("/") ? [file] : (process.env["PATH"] ?? "").split(delimiter).filter(dir => isAbsolute(dir)).map(dir => join(dir, file));
|
|
1220
|
+
for (const candidate of candidates) {
|
|
1221
|
+
try {
|
|
1222
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
1223
|
+
return realpathSync(candidate);
|
|
1224
|
+
}
|
|
1225
|
+
catch { /* next */ }
|
|
1226
|
+
}
|
|
1227
|
+
return file;
|
|
1228
|
+
}
|
|
1229
|
+
/** Asked once per git executable (eval-harness review 2, follow-up): a fixed git given to viewedGitSync is probed
|
|
1230
|
+
* itself, never answered for by the one PATH names. */
|
|
1231
|
+
export function lazyFetchSupported(gitFile = "git") {
|
|
1232
|
+
if (lazyFetchOverride !== undefined)
|
|
1233
|
+
return lazyFetchOverride;
|
|
1234
|
+
const executable = gitExecutablePath(gitFile);
|
|
1235
|
+
let known = lazyFetchByGit.get(executable);
|
|
1236
|
+
if (known === undefined) {
|
|
1237
|
+
const probe = spawnSync(executable, ["--no-lazy-fetch", "version"], { cwd: "/", encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "pipe"],
|
|
1238
|
+
env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
|
|
1239
|
+
known = probe.status === 0;
|
|
1240
|
+
lazyFetchByGit.set(executable, known);
|
|
1241
|
+
}
|
|
1242
|
+
return known;
|
|
1243
|
+
}
|
|
1244
|
+
/** For tests: forget the probes' answers, or set one for every executable (null forgets). */
|
|
1245
|
+
export function setLazyFetchSupportedForTest(value) { lazyFetchOverride = value ?? undefined; lazyFetchByGit.clear(); }
|
|
1246
|
+
/** The words for a partial clone refused on a git without `--no-lazy-fetch`. */
|
|
1247
|
+
export const PARTIAL_CLONE_REFUSAL = "this project is a partial clone; protected reads need git 2.45 or newer";
|
|
1248
|
+
/** `--no-lazy-fetch` where the installed git has it, else nothing (and lazyFetchSetting refuses partial clones). */
|
|
1249
|
+
export function noLazyFetch() { return lazyFetchSupported() ? ["--no-lazy-fetch"] : []; }
|
|
1250
|
+
/** A caller's own arguments with `--no-lazy-fetch` taken out when the installed git doesn't know it. */
|
|
1251
|
+
function withoutUnsupportedFlags(args, gitFile = "git") {
|
|
1252
|
+
return lazyFetchSupported(gitFile) ? [...args] : args.filter(arg => arg !== "--no-lazy-fetch");
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* The repository setting (local or worktree scope of a `git config --list --show-scope -z` listing) that makes lazy
|
|
1256
|
+
* fetching possible, or null: `extensions.partialClone` set at all, any `remote.<name>.promisor` not plainly false, or
|
|
1257
|
+
* any `remote.<name>.partialCloneFilter` set (git makes that remote a promisor). Fails closed: a listing that can't be
|
|
1258
|
+
* read names a setting. `remote.<name>.vcs` (a foreign-VCS helper) is named too, though the classifier already refuses
|
|
1259
|
+
* it (commandSetting: not in the remote allowlist), as it does every `protocol.*` key and every include.
|
|
1260
|
+
*/
|
|
1261
|
+
export function lazyFetchSetting(listing) {
|
|
1262
|
+
try {
|
|
1263
|
+
const fields = listing.split("\0");
|
|
1264
|
+
for (let at = 0; at + 1 < fields.length; at += 2) {
|
|
1265
|
+
const scope = fields[at];
|
|
1266
|
+
if (scope !== "local" && scope !== "worktree")
|
|
1267
|
+
continue;
|
|
1268
|
+
const entry = fields[at + 1];
|
|
1269
|
+
const newline = entry.indexOf("\n");
|
|
1270
|
+
const key = newline < 0 ? entry : entry.slice(0, newline);
|
|
1271
|
+
const value = newline < 0 ? null : entry.slice(newline + 1);
|
|
1272
|
+
const { section, subsection, variable } = keyParts(key);
|
|
1273
|
+
if (section === "extensions" && variable === "partialclone")
|
|
1274
|
+
return key.toLowerCase();
|
|
1275
|
+
if (section === "remote" && subsection !== null && (variable === "partialclonefilter" || variable === "vcs"))
|
|
1276
|
+
return key.toLowerCase();
|
|
1277
|
+
if (section === "remote" && subsection !== null && variable === "promisor" && !/^(false|no|off|0)$/i.test((value ?? "true").trim()))
|
|
1278
|
+
return key.toLowerCase();
|
|
1279
|
+
}
|
|
1280
|
+
return null;
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
return "a setting that couldn't be read";
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* For the reads that run git directly on a repository rather than through a view: with `--no-lazy-fetch` they run as
|
|
1288
|
+
* they always have (the flag stops any fetch). Without it, they must not check a repository's config and then run git
|
|
1289
|
+
* on it (the config could change in between, or an include could set what the check never saw): `inner`, wrapped so
|
|
1290
|
+
* every git command runs through a frozen read view (checkoutGit "read"), whose own config listing (includes refused,
|
|
1291
|
+
* command and protocol settings refused, a partial clone refused) is the config git then runs with.
|
|
1292
|
+
*/
|
|
1293
|
+
export function lazyFetchSafeGit(inner = run) {
|
|
1294
|
+
// On every git version (eval-harness review 1, finding 7): with --no-lazy-fetch the direct read still took the
|
|
1295
|
+
// repository's config and its includes as git found them, and an include naming a FIFO could hang the plane.
|
|
1296
|
+
return checkoutGit(inner, "read");
|
|
1297
|
+
}
|
package/dist/test-infra.js
CHANGED
|
@@ -20,7 +20,7 @@ const VITEST_CONFIG = /(^|\/)vitest\.config\.[cm]?[jt]s$/;
|
|
|
20
20
|
const EXTENSIONS = ["ts", "tsx", "mts", "cts", "js", "mjs", "cjs", "jsx"];
|
|
21
21
|
/** The product's entry points: the CLI binary, the processes it starts by file name, and the browser bundles. */
|
|
22
22
|
export const PRODUCT_ENTRIES = Object.freeze([
|
|
23
|
-
"src/bin.ts", "src/cli.ts", "src/controller-service.ts", "src/desktop-host.ts", "src/sandbox-launch.ts", "src/tool-launcher.ts",
|
|
23
|
+
"src/bin.ts", "src/cli.ts", "src/controller-service.ts", "src/up-role-child.ts", "src/desktop-host.ts", "src/sandbox-launch.ts", "src/tool-launcher.ts",
|
|
24
24
|
"src/browser/app.tsx", "src/browser/terminal-app.tsx",
|
|
25
25
|
]);
|
|
26
26
|
const join = (dir, spec) => {
|