tickmarkr 2.6.7 → 2.6.9
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/dist/adapters/catalog-remote.js +5 -2
- package/dist/adapters/codex.d.ts +1 -0
- package/dist/adapters/codex.js +18 -10
- package/dist/adapters/model-lints.js +21 -11
- package/dist/adapters/model-windows.js +0 -1
- package/dist/adapters/registry.js +7 -3
- package/dist/adapters/types.d.ts +2 -0
- package/dist/cli/commands/beat.d.ts +3 -3
- package/dist/cli/commands/beat.js +39 -19
- package/dist/cli/commands/doctor.js +3 -0
- package/dist/cli/commands/fleet.js +2 -0
- package/dist/cli/commands/plan.js +16 -18
- package/dist/cli/commands/verify.js +4 -2
- package/dist/cli/help.js +5 -2
- package/dist/config/config.js +6 -5
- package/dist/drivers/herdr.js +2 -2
- package/dist/drivers/orca.d.ts +12 -4
- package/dist/drivers/orca.js +200 -39
- package/dist/gates/evidence.js +3 -3
- package/dist/gates/review.d.ts +1 -1
- package/dist/gates/review.js +62 -13
- package/dist/gates/run-gates.d.ts +3 -0
- package/dist/gates/run-gates.js +115 -20
- package/dist/gates/test-manifest.d.ts +36 -2
- package/dist/gates/test-manifest.js +55 -10
- package/dist/run/beat-lifecycle.d.ts +109 -24
- package/dist/run/beat-lifecycle.js +1173 -216
- package/dist/run/daemon.d.ts +4 -0
- package/dist/run/daemon.js +111 -33
- package/dist/run/git-trust.d.ts +78 -3
- package/dist/run/git-trust.js +175 -19
- package/dist/run/git.d.ts +24 -3
- package/dist/run/git.js +140 -21
- package/dist/run/merge.js +2 -2
- package/dist/run/verification-job.d.ts +2 -0
- package/dist/run/verification-job.js +32 -10
- package/dist/tui/ink/fleet-app.d.ts +7 -2
- package/dist/tui/ink/fleet-app.js +81 -23
- package/package.json +4 -1
- package/skills/tickmarkr-auto/SKILL.md +58 -14
- package/skills/tickmarkr-loop/SKILL.md +65 -14
- package/skills/tickmarkr-overseer/SKILL.md +97 -28
- package/skills/tickmarkr-overseer/scripts/watch-journal.sh +9 -7
package/dist/run/daemon.d.ts
CHANGED
|
@@ -140,6 +140,10 @@ export declare const watchCommand: (runId: string) => string;
|
|
|
140
140
|
* skip stays satisfied; a gate that ran and passed stays satisfied.
|
|
141
141
|
*/
|
|
142
142
|
export declare const gateSatisfied: (g: GateResult) => boolean;
|
|
143
|
+
/** OBS-1010 / v2.6.8 T1: the review seats a journal's demotions carry into a resumed run as a SOFT
|
|
144
|
+
* preference (ranked last, never excluded) — every review-pool-demotion except the launch-caused ones, which
|
|
145
|
+
* never demote a seat (live or replayed). plan.ts previews a resume with this same fold. */
|
|
146
|
+
export declare function replayedReviewerDemotions(events: readonly JournalEvent[]): Set<string>;
|
|
143
147
|
/**
|
|
144
148
|
* T4 (OBS-265): the journal with the review objections a round did NOT hinge on removed. Judge and
|
|
145
149
|
* review are now launched together, so a round can journal a failed review that the serial walk would
|
package/dist/run/daemon.js
CHANGED
|
@@ -33,7 +33,7 @@ import { executionSignal, remainingExecutionMs, withExecutionBudget, withoutExec
|
|
|
33
33
|
import { repairSelectionDecision, repairSelectionEnabled } from "./repair-selection.js";
|
|
34
34
|
import { failureDisposition, reserveInfrastructureRetry } from "./recovery.js";
|
|
35
35
|
import { runEnvironment } from "./environment.js";
|
|
36
|
-
import { changeRepresented, cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER, preserveWorktree, producerFields, removeWorktree, resolvedCapacity, runWithForkBudget, runWithVerificationBudget, sameCapacity, sameVerification, sh, shGit, SUITE_PARENT_ENV, verificationProtocol, WORKTREE_LAYOUT_CONTRACT, WORKTREES_DIR, worktreePath } from "./git.js";
|
|
36
|
+
import { addDetachedWorktree, changeRepresented, cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER, preserveWorktree, producerFields, removeWorktree, resolvedCapacity, runWithForkBudget, runWithVerificationBudget, sameCapacity, sameVerification, sh, shGit, SUITE_PARENT_ENV, verificationProtocol, WORKTREE_LAYOUT_CONTRACT, WORKTREES_DIR, worktreePath } from "./git.js";
|
|
37
37
|
import { MASK } from "./redact.js";
|
|
38
38
|
import { runInteractiveSeed } from "./interactive-seed.js";
|
|
39
39
|
import { classifyRepairDisposition, resolveScopeHints } from "./repair-disposition.js";
|
|
@@ -376,9 +376,26 @@ function classifyInfraResult(g) {
|
|
|
376
376
|
// diagnostics (never-started files, a worker RPC timeout) that make such a red infrastructure-SHAPED.
|
|
377
377
|
// Shape alone never parks anything: it only buys one isolated re-observation before a charge. A
|
|
378
378
|
// fresh-fingerprint section masks digits to `#`, so both spellings of a number are read.
|
|
379
|
-
/**
|
|
380
|
-
*
|
|
381
|
-
|
|
379
|
+
/** v2.6.8 T1: a seat that never LAUNCHED (any launchCause — checkout-proof-timeout, none, or other
|
|
380
|
+
* detail) is not a seat that failed to answer — the same predicate run-gates applies to its run-scoped
|
|
381
|
+
* tally, read here for the live task strike and the resume reconstruction. It never strikes. */
|
|
382
|
+
const launchFailureNote = (data) => data.cause === "seat-launch-failed";
|
|
383
|
+
/** v2.6.8 T1: a journaled review-pool-demotion a launch failure caused. The pre-2.6.8 engine struck seats
|
|
384
|
+
* that never launched, so an older journal can carry a demotion whose trigger was a launch failure, or whose
|
|
385
|
+
* two-strike pair counted one beside a single real no-verdict that was not silent on its own. */
|
|
386
|
+
const launchCausedDemotion = (data) => {
|
|
387
|
+
if (launchFailureNote(data))
|
|
388
|
+
return true;
|
|
389
|
+
const silent = data.seatAuthoredBytes === 0 || data.cause === "silent" || data.cause === "launch-never-started";
|
|
390
|
+
return Array.isArray(data.causes) && !silent && data.causes.filter((cause) => cause !== "seat-launch-failed").length < 2;
|
|
391
|
+
};
|
|
392
|
+
/** OBS-1010 / v2.6.8 T1: the review seats a journal's demotions carry into a resumed run as a SOFT
|
|
393
|
+
* preference (ranked last, never excluded) — every review-pool-demotion except the launch-caused ones, which
|
|
394
|
+
* never demote a seat (live or replayed). plan.ts previews a resume with this same fold. */
|
|
395
|
+
export function replayedReviewerDemotions(events) {
|
|
396
|
+
return new Set(events.filter((event) => event.event === "review-pool-demotion" && typeof event.data.reviewer === "string"
|
|
397
|
+
&& !launchCausedDemotion(event.data)).map((event) => event.data.reviewer));
|
|
398
|
+
}
|
|
382
399
|
/** C: the gate-fresh-forced reason that bounds the out-of-scope re-execution, one per journaled subject. */
|
|
383
400
|
const OUTSIDE_SCOPE_RED = "no-commit-out-of-scope-red";
|
|
384
401
|
const NAMED_FAILURE_FILE_RE = /^\s*(\S+\.(?:test|spec)\.[cm]?[jt]sx?)\s+>/gm;
|
|
@@ -2232,25 +2249,25 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2232
2249
|
process.on("SIGINT", onTermination);
|
|
2233
2250
|
process.on("SIGTERM", onTermination);
|
|
2234
2251
|
journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
//
|
|
2239
|
-
//
|
|
2240
|
-
//
|
|
2241
|
-
//
|
|
2242
|
-
const
|
|
2252
|
+
// OBS-1010 / v2.6.8 T1: a demotion the JOURNAL carries into this session is replayed as what it was
|
|
2253
|
+
// live — a SOFT preference (reviewer role only; the worker channel is untouched — channel-demotion is
|
|
2254
|
+
// the worker fold, replayExcludedChannels). The seat ranks last, even over prefer, and may still be
|
|
2255
|
+
// re-tried at the end of the rotation; a resume never hardens one strike into an exclusion. Retirement
|
|
2256
|
+
// is the only hard rule: two real no-verdict strikes (the tally below) put a seat out for the run,
|
|
2257
|
+
// in-session and after resume alike, whatever prefer or history say. A launch-caused demotion an older
|
|
2258
|
+
// engine journaled is not replayed at all — neither soft nor hard.
|
|
2259
|
+
const demotedReviewers = replayedReviewerDemotions(journal.read());
|
|
2243
2260
|
// OBS-1025 add.2 / OBS-1052 (Leg-2): the run-scoped no-verdict tally run-gates retires a seat from at two
|
|
2244
2261
|
// strikes. execTask's badReviewers already excludes a seat after ONE strike within a task, so this map
|
|
2245
2262
|
// is what bounds a seat that flakes once per task (or once per session) across the run. Seeded from the
|
|
2246
2263
|
// journal's review-no-verdict rows — the same notes noteReviewEvent appends — so a resumed daemon keeps
|
|
2247
2264
|
// the count; run-gates appends to it in place, exactly as it does demotedReviewers.
|
|
2248
|
-
//
|
|
2265
|
+
// v2.6.8 T1: a launch failure never struck the live tally (run-gates), so a resume never seeds one.
|
|
2249
2266
|
const reviewNoVerdicts = new Map();
|
|
2250
2267
|
for (const event of journal.read()) {
|
|
2251
2268
|
if (event.event !== "review-no-verdict" || event.data.noVerdict !== true || typeof event.data.reviewer !== "string")
|
|
2252
2269
|
continue;
|
|
2253
|
-
if (
|
|
2270
|
+
if (launchFailureNote(event.data))
|
|
2254
2271
|
continue;
|
|
2255
2272
|
reviewNoVerdicts.set(event.data.reviewer, [...(reviewNoVerdicts.get(event.data.reviewer) ?? []), String(event.data.cause)]);
|
|
2256
2273
|
}
|
|
@@ -2777,7 +2794,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2777
2794
|
const pristine = mkdtempSync(join(parent, `baseline-recapture-${process.pid}-`));
|
|
2778
2795
|
const staged = `${baselinePath}.${process.pid}.tmp`;
|
|
2779
2796
|
try {
|
|
2780
|
-
|
|
2797
|
+
// the detached worktree capability (see addDetachedWorktree); a commondir moved while it ran is refused
|
|
2798
|
+
const added = await addDetachedWorktree(repoRoot, pristine, baseRef);
|
|
2781
2799
|
if (added.code !== 0)
|
|
2782
2800
|
throw new Error(`baseline recapture: journaled base ${baseRef} cannot be checked out: ${added.stderr.trim()}`);
|
|
2783
2801
|
if (!linkNodeModules(repoRoot, pristine, { force: true }))
|
|
@@ -3413,15 +3431,19 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3413
3431
|
await park(t, `${bootstrapExhausted} failed at bootstrap and no eligible channel remains after resume${poolExhaustion(t, cfg)}`, "infra", assignment, rs?.attempts ?? 0, startMs, 0, 0, undefined, 0, "fresh", { cause: "bootstrap", channel: bootstrapExhausted });
|
|
3414
3432
|
return;
|
|
3415
3433
|
}
|
|
3416
|
-
// Keep one live list: recovery retries must see exclusions added by onGate during the round.
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
//
|
|
3434
|
+
// Keep one live list: recovery retries must see exclusions added by onGate during the round. It starts
|
|
3435
|
+
// empty on resume too: a replayed demotion is soft (demotedReviewers), retirement is the run tally.
|
|
3436
|
+
const badReviewers = [];
|
|
3437
|
+
// v2.6.8 T1: a seat that failed to launch (any launchCause) is excluded for the CURRENT round only —
|
|
3438
|
+
// after its one bounded same-seat launch retry (run-gates), so an always-refusing pool still ends the
|
|
3439
|
+
// round — and is never struck task-wide or run-wide: a later round may seat it again.
|
|
3420
3440
|
let roundTimeouts = [];
|
|
3421
|
-
const noteReviewEvent = (
|
|
3441
|
+
const noteReviewEvent = (note) => {
|
|
3442
|
+
// The same-seat launch retry is bounded per candidate: its row carries the commit it was spent on.
|
|
3443
|
+
const e = note.name === "review-infra-retry" && gateSubject ? { ...note, payload: { ...note.payload, commit: gateSubject.commit } } : note;
|
|
3422
3444
|
journal.append(e.name, t.id, e.payload);
|
|
3423
3445
|
if (e.name === "review-no-verdict" && typeof e.payload.reviewer === "string") {
|
|
3424
|
-
const strikes =
|
|
3446
|
+
const strikes = launchFailureNote(e.payload) ? roundTimeouts : badReviewers;
|
|
3425
3447
|
if (!strikes.includes(e.payload.reviewer))
|
|
3426
3448
|
strikes.push(e.payload.reviewer);
|
|
3427
3449
|
}
|
|
@@ -3434,7 +3456,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3434
3456
|
}
|
|
3435
3457
|
};
|
|
3436
3458
|
// Keep the measured siblings, including a red judge, while replacing only a missing review.
|
|
3437
|
-
// Every
|
|
3459
|
+
// Every seat that returned no verdict is excluded for this task — a launch failure for this round only,
|
|
3460
|
+
// after its one bounded relaunch — and a retired seat for the run, so the loop is bounded by the eligible pool.
|
|
3438
3461
|
const runReviewRecovery = async (task, ctx, allowSelection = true) => {
|
|
3439
3462
|
ctx.authorizeInfraRetry = cfg.executionPolicy?.boundedInfrastructure
|
|
3440
3463
|
? (subject, cause) => reserveInfrastructureRetry(journal.read(), task.id, subject, journal.append.bind(journal), cause) : undefined;
|
|
@@ -3460,28 +3483,83 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3460
3483
|
&& row.event === "phase-start" && row.data.phase === "gates").length,
|
|
3461
3484
|
};
|
|
3462
3485
|
roundTimeouts = [];
|
|
3486
|
+
// v2.6.8 T1: the same-seat launch retries this candidate already spent, replayed from the journal so
|
|
3487
|
+
// a recovery round, a recheck or a resume never re-grants one (and never turns one into a strike).
|
|
3488
|
+
const launchRetried = () => journal.read().filter((row) => row.taskId === task.id && row.event === "review-infra-retry"
|
|
3489
|
+
&& row.data.sameSeat === true && row.data.commit === gateSubject?.commit && typeof row.data.reviewer === "string")
|
|
3490
|
+
.map((row) => row.data.reviewer);
|
|
3463
3491
|
// C: every gate-round boundary — a returned or a THROWN round — publishes the one row held behind
|
|
3464
3492
|
// a still-pending sibling and forgets the pending set, before any park or task-failed row lands.
|
|
3493
|
+
// v2.6.8 T1: a review result whose recovery pool is retired-only is classified BEFORE it is published
|
|
3494
|
+
// (retiredOnly), and the round returns that same parked result, so the journal row and the result the
|
|
3495
|
+
// park reads state one outcome — an infrastructure no-verdict, never a failed review.
|
|
3465
3496
|
const gateRound = async (subject) => {
|
|
3497
|
+
const parked = new Map();
|
|
3466
3498
|
try {
|
|
3467
|
-
|
|
3499
|
+
const done = await runGates(subject, { ...ctx, excludeReviewers: [...badReviewers, ...roundTimeouts], launchRetried: launchRetried(),
|
|
3500
|
+
onGate: async (e) => {
|
|
3501
|
+
const park = e.phase === "end" && e.gate === "review" ? retiredOnly(e.result) : undefined;
|
|
3502
|
+
if (park === undefined || e.phase !== "end")
|
|
3503
|
+
return ctx.onGate?.(e);
|
|
3504
|
+
parked.set(e.result, park);
|
|
3505
|
+
return ctx.onGate?.({ ...e, result: park });
|
|
3506
|
+
} });
|
|
3507
|
+
return { ...done, results: done.results.map((g) => parked.get(g) ?? g) };
|
|
3468
3508
|
}
|
|
3469
3509
|
finally {
|
|
3470
3510
|
settleParallelRound();
|
|
3471
3511
|
}
|
|
3472
3512
|
};
|
|
3473
|
-
const round = await gateRound(task);
|
|
3474
|
-
let review = round.results.find((g) => g.gate === "review");
|
|
3475
3513
|
// The recovery pick carries every preserved author, exactly as reviewGate's own pick does.
|
|
3476
3514
|
const authors = ctx.carriedAuthors?.length ? [...ctx.carriedAuthors] : [channelKey(ctx.author)];
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3515
|
+
// Recovery must honor the gate's floor, including the seats that just failed to
|
|
3516
|
+
// return a verdict; a below-floor alternative cannot replace the infra result. This round's
|
|
3517
|
+
// launch failures count too — runGates folds every excluded seat into its own floor.
|
|
3518
|
+
const pick = (exclude) => pickReviewer(ctx.author, ctx.channels, exclude, cfg.review.prefer ?? [], gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, [...(ctx.priorReviewers ?? []), ...badReviewers, ...roundTimeouts]).floor, reviewHistory, undefined, demotedReviewers, carriedAuthorVendors(ctx.channels, authors), authors);
|
|
3519
|
+
// v2.6.8 T1: soft preference is not retirement. demotedReviewers — live or replayed on resume — only
|
|
3520
|
+
// ranks a seat last, so a once-demoted seat stays seatable; a seat two REAL no-verdict strikes retired
|
|
3521
|
+
// is out for the run, so every recovery pick excludes it outright — prefer and history can never
|
|
3522
|
+
// resurrect it, and runGates would refuse it anyway. A launch failure is neither: it buys one bounded
|
|
3523
|
+
// same-seat relaunch per candidate (launchRetried) and then leaves the seat out of THIS round only.
|
|
3524
|
+
const retiredSeats = () => [...reviewNoVerdicts].filter(([, causes]) => causes.length >= 2).map(([seat]) => seat);
|
|
3525
|
+
// The retired-only recovery park: when retirement is what empties the pool (the pick without the
|
|
3526
|
+
// retired exclusion would seat a retired seat), record the seat the pool cannot seat and why, buy
|
|
3527
|
+
// no dispatch, and park infrastructure naming it out for the run — never a soft-ranked pick that
|
|
3528
|
+
// dispatches it again, never a gate-fail. The tally is seeded from the journal, so every resume
|
|
3529
|
+
// or recheck replays this same row and park, whether its round dispatched a seat or found none.
|
|
3530
|
+
// It is decided where the review result is published (gateRound): a round that found no eligible
|
|
3531
|
+
// reviewer, or a no-verdict no recovery pick can replace — and once more after the round,
|
|
3532
|
+
// should another task retire a seat in between. The parked result is the same no-verdict shape every
|
|
3533
|
+
// review.ts no-verdict has: in memory `pass: false` (GateResult.pass is a boolean, and an unreviewed
|
|
3534
|
+
// candidate fails closed — it never merges), `noVerdict` + `infra`; the gate-result row written for a
|
|
3535
|
+
// noVerdict review below drops `pass` and adds `skipped`, so outcome readers, round budgets and
|
|
3536
|
+
// failure counters read one infrastructure outcome, and the infra park — not the gate-fail one — is
|
|
3537
|
+
// what the task gets.
|
|
3538
|
+
const retiredOnly = (r) => {
|
|
3539
|
+
if (r.meta?.cannotSeat !== undefined || (r.meta?.noEligibleReviewer !== true && (r.meta?.noVerdict !== true
|
|
3540
|
+
|| pick([...badReviewers, ...roundTimeouts, ...retiredSeats()]))))
|
|
3541
|
+
return undefined;
|
|
3542
|
+
const retired = retiredSeats();
|
|
3543
|
+
const blocked = pick([...badReviewers, ...roundTimeouts].filter((seat) => !retired.includes(seat)));
|
|
3544
|
+
if (!blocked || !retired.includes(channelKey(blocked)))
|
|
3545
|
+
return undefined;
|
|
3546
|
+
const seat = channelKey(blocked);
|
|
3547
|
+
const causes = reviewNoVerdicts.get(seat) ?? [];
|
|
3548
|
+
journal.append("review-infra-retry", t.id, { reviewer: seat, cause: "retired", cannotSeat: true, causes });
|
|
3549
|
+
const { noEligibleReviewer: _none, ...meta } = r.meta ?? {};
|
|
3550
|
+
return { ...r, pass: false, meta: { cause: "retired", ...meta, noVerdict: true, cannotSeat: seat, infra: true, classification: "infra" },
|
|
3551
|
+
details: `${r.details}\nreview recovery cannot seat ${seat}: two real no-verdicts (${causes.join(", ")}) retired it — it is out for the run, and no other eligible seat remains` };
|
|
3552
|
+
};
|
|
3553
|
+
const round = await gateRound(task);
|
|
3554
|
+
let review = round.results.find((g) => g.gate === "review");
|
|
3555
|
+
while (review?.meta?.noVerdict === true && review.meta.cannotSeat === undefined) {
|
|
3556
|
+
const next = pick([...badReviewers, ...roundTimeouts, ...retiredSeats()]);
|
|
3557
|
+
if (!next) {
|
|
3558
|
+
const parked = retiredOnly(review);
|
|
3559
|
+
if (parked)
|
|
3560
|
+
round.results = round.results.map((g) => g === review ? parked : g);
|
|
3484
3561
|
break;
|
|
3562
|
+
}
|
|
3485
3563
|
journal.append("review-infra-retry", t.id, { reviewer: channelKey(next), cause: review.meta.cause });
|
|
3486
3564
|
const retried = await gateRound({ ...task, gates: ["review"] });
|
|
3487
3565
|
const replacement = retried.results.find((g) => g.gate === "review");
|
|
@@ -3510,7 +3588,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3510
3588
|
gate: "review", flaked: rr.flaked, retried: rr.retried,
|
|
3511
3589
|
...(g.meta?.unparseable === true ? { secondUnparseable: true } : {}),
|
|
3512
3590
|
});
|
|
3513
|
-
//
|
|
3591
|
+
// v2.6.8 T1: a seat re-routed after a launch failure is excluded for this round only.
|
|
3514
3592
|
if (!roundTimeouts.includes(rr.flaked))
|
|
3515
3593
|
badReviewers.push(rr.flaked);
|
|
3516
3594
|
}
|
package/dist/run/git-trust.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** A covered git invocation refused before spawn; `path` names the offending metadata. */
|
|
2
2
|
export declare class GitTrustRefusal extends Error {
|
|
3
3
|
readonly path: string;
|
|
4
|
-
|
|
4
|
+
readonly detail: string;
|
|
5
|
+
constructor(path: string, detail: string, options?: ErrorOptions);
|
|
5
6
|
}
|
|
6
7
|
/**
|
|
7
8
|
* The canonical, independently expected common git directory for the checkout enclosing `cwd` — never what
|
|
@@ -15,13 +16,87 @@ export declare function trustedCommonDir(cwd: string): string;
|
|
|
15
16
|
* extensions.worktreeConfig and the linked gitdir holds a config.worktree.
|
|
16
17
|
*/
|
|
17
18
|
export declare function assertGitTrust(cwd: string): void;
|
|
19
|
+
/** The child of tickmarkr's own git in a linked checkout: its pinned environment, the post-exit recheck and its release. */
|
|
20
|
+
export interface OwnGitPin {
|
|
21
|
+
/** The whole child environment: the caller's, with the pin set and every unpinned inherited ref backend removed. */
|
|
22
|
+
env: NodeJS.ProcessEnv;
|
|
23
|
+
/** Throws GitTrustRefusal naming commondir when it changed in ANY way since the check that derived `env`. */
|
|
24
|
+
recheck: () => void;
|
|
25
|
+
/** Removes this attempt's private ref view; called once its child has exited, or when it never spawned. */
|
|
26
|
+
release: () => void;
|
|
27
|
+
/** git names ref paths through the view; its output names the trusted common directory instead. */
|
|
28
|
+
unview: (text: string) => string;
|
|
29
|
+
/** The trusted common directory this attempt derived from the layout: the authority a raced worktree add rolls back through. */
|
|
30
|
+
store: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* v2.6.8 T2: what an own-git CALLER declares at its call site — never read from the command text, its tokens, login or
|
|
34
|
+
* cwd, and never a way out of any pin: every own-git child keeps the path pin, the ref-store pin and the recheck.
|
|
35
|
+
* `createsWorktree: "branch"` is declared by the three branch-creating adds (git writes a branch add's new HEAD as the
|
|
36
|
+
* symbolic branch ref, which the pin leaves intact). The two DETACHED adds declare the capability by calling
|
|
37
|
+
* `addDetachedWorktree` (src/run/git.ts), because under the pin git stubs a detached add's new HEAD as
|
|
38
|
+
* `ref: refs/heads/.invalid`.
|
|
39
|
+
*/
|
|
40
|
+
export interface OwnGitCapability {
|
|
41
|
+
createsWorktree?: "branch";
|
|
42
|
+
rollback?: WorktreeRollback;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* What a worktree-creating call site declares it creates, so a result refused AFTER its child ran (a commondir raced
|
|
46
|
+
* mid-child) is rolled back: the checkout `dir` and its registration, and — when the add creates or moves one — the
|
|
47
|
+
* branch (`prior` restores a moved branch; without it the new branch is deleted). The rollback runs through the
|
|
48
|
+
* attempt's own trusted common directory (TrustedStoreCapability), never the caller's checkout, so it completes while
|
|
49
|
+
* the caller's commondir stays hostile.
|
|
50
|
+
*/
|
|
51
|
+
export interface WorktreeRollback {
|
|
52
|
+
dir: string;
|
|
53
|
+
branch?: {
|
|
54
|
+
name: string;
|
|
55
|
+
prior?: string;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Internal to addDetachedWorktree's add step: its ref store is pinned to the trusted common directory itself, not to a
|
|
60
|
+
* view. git stubs the new checkout's HEAD under ANY ref payload and, through a view, would resolve that stub into a
|
|
61
|
+
* shared `refs/heads/.invalid`; naming the common directory keeps it in the new gitdir, where the next step replaces
|
|
62
|
+
* it. That step writes no ref of the CALLER's checkout, so a commondir removed under it has no HEAD to redirect.
|
|
63
|
+
*/
|
|
64
|
+
export interface DetachedAddCapability {
|
|
65
|
+
createsWorktree: "detached";
|
|
66
|
+
rollback: WorktreeRollback;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* v2.6.8 T2: cleanup authority. The child reads and writes ONLY `store` — a trusted common directory derived once from
|
|
70
|
+
* the trusted layout (gitfile and `<common>/worktrees/<name>`, never a mutable commondir) — as an ordinary repository:
|
|
71
|
+
* GIT_DIR names it and every inherited redirect (common dir, work tree, ref backend, index, objects) is removed. It never
|
|
72
|
+
* consults the caller's checkout, so removal completes even while that checkout's commondir stays hostile.
|
|
73
|
+
*/
|
|
74
|
+
export interface TrustedStoreCapability {
|
|
75
|
+
store: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* v2.6.8 T2 (P3): the same uncached check, returning the child of tickmarkr's OWN git in a linked checkout — `env`
|
|
79
|
+
* (the caller's) with GIT_DIR, GIT_COMMON_DIR and GIT_WORK_TREE set to the canonical gitdir, trusted common directory
|
|
80
|
+
* and worktree root derived above, AND the ref store: GIT_REFERENCE_BACKEND names this attempt's private view of the
|
|
81
|
+
* trusted store (refStoreView), since git's ref store otherwise resolves shared refs through the gitdir's commondir
|
|
82
|
+
* FILE even under GIT_COMMON_DIR. So a commondir rewritten after this check (by a live worker or a surviving
|
|
83
|
+
* descendant) is followed for neither config, hooks, info/, objects nor refs: HEAD, branches, status and diffs read
|
|
84
|
+
* the real repository and every ref write lands in the trusted store, whatever an inherited GIT_CONFIG_* or ref backend
|
|
85
|
+
* says; a commondir REMOVED after it cannot move the main checkout's branch. Before such a child spawns, the git it
|
|
86
|
+
* would run must prove it honours that pin (refStorePinHonoured); one that cannot (git < 2.54) runs without it, below.
|
|
87
|
+
* Nothing here reads the command, and no caller leaves any pin. `recheck` (after the child exits) fails closed on ANY change to commondir since the check: the result
|
|
88
|
+
* of that child is never returned. An ordinary checkout or a non-repository gets no pin.
|
|
89
|
+
* NAMED RESIDUAL: git still reads $GIT_DIR/config.worktree when the trusted common config already enables
|
|
90
|
+
* extensions.worktreeConfig, so a config.worktree planted AFTER this check is not closed.
|
|
91
|
+
*/
|
|
92
|
+
export declare function ownGitPin(cwd: string, env: NodeJS.ProcessEnv, capability?: OwnGitCapability | DetachedAddCapability | TrustedStoreCapability): OwnGitPin | undefined;
|
|
18
93
|
/** The inert hooks directory every protected git child receives (no hook can exist beneath it). */
|
|
19
94
|
export declare const INERT_HOOKS_PATH = "/dev/null";
|
|
20
95
|
/**
|
|
21
96
|
* A copy of `env` whose git children run with core.fsmonitor=false and an inert core.hooksPath, despite repo
|
|
22
97
|
* config and inherited GIT_CONFIG_COUNT/GIT_CONFIG_PARAMETERS: git applies GIT_CONFIG_PARAMETERS after
|
|
23
98
|
* GIT_CONFIG_COUNT and the last value wins, so the forced pair is appended last. Every other value survives.
|
|
24
|
-
* Named keys only — this does not neutralize arbitrary executable drivers.
|
|
25
|
-
*
|
|
99
|
+
* Named keys only — this does not neutralize arbitrary executable drivers. v2.6.8 T2: only tickmarkr's OWN git
|
|
100
|
+
* children (shGit/shGitOk) receive it; payload shells (gate commands, suites) keep the operator's git config.
|
|
26
101
|
*/
|
|
27
102
|
export declare function protectedGitEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
package/dist/run/git-trust.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
3
4
|
import { basename, dirname, join } from "node:path";
|
|
4
5
|
// v2.6.7 T4 (GHSA-2): a seat-run linked worktree's gitdir is seat-writable (the Codex worker grant), so its
|
|
5
6
|
// `commondir` and `config.worktree` can redirect every later unsandboxed host git in that checkout to a
|
|
@@ -10,10 +11,12 @@ import { basename, dirname, join } from "node:path";
|
|
|
10
11
|
/** A covered git invocation refused before spawn; `path` names the offending metadata. */
|
|
11
12
|
export class GitTrustRefusal extends Error {
|
|
12
13
|
path;
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
detail;
|
|
15
|
+
constructor(path, detail, options) {
|
|
16
|
+
super(`git trust refusal: ${path}: ${detail}`, options);
|
|
15
17
|
this.name = "GitTrustRefusal";
|
|
16
18
|
this.path = path;
|
|
19
|
+
this.detail = detail;
|
|
17
20
|
}
|
|
18
21
|
}
|
|
19
22
|
/** The nearest `.git` entry at or above cwd, or undefined outside any checkout. */
|
|
@@ -113,37 +116,55 @@ export function trustedCommonDir(cwd) {
|
|
|
113
116
|
throw new GitTrustRefusal(cwd, "no checkout encloses this directory");
|
|
114
117
|
return authorityOf(dotGit).common;
|
|
115
118
|
}
|
|
116
|
-
/** Read failures and unparseable values never become clean authority. */
|
|
117
|
-
function
|
|
119
|
+
/** One key of the trusted common config. Read failures and unparseable values never become clean authority. */
|
|
120
|
+
function trustedConfig(common, key, type) {
|
|
118
121
|
const config = join(common, "config");
|
|
119
122
|
if (!present(config))
|
|
120
123
|
throw new GitTrustRefusal(config, "the trusted common config is missing");
|
|
121
124
|
try {
|
|
122
|
-
return execFileSync("git", ["config", "--file", config,
|
|
125
|
+
return execFileSync("git", ["config", "--file", config, ...(type ? [`--type=${type}`] : []), "--get", key], {
|
|
123
126
|
cwd: "/", encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15_000,
|
|
124
|
-
}).trim()
|
|
127
|
+
}).trim();
|
|
125
128
|
}
|
|
126
129
|
catch (error) {
|
|
127
130
|
const e = error;
|
|
128
131
|
if (e.status === 1 && !e.stderr?.trim())
|
|
129
|
-
return
|
|
130
|
-
throw new GitTrustRefusal(config, `cannot read
|
|
132
|
+
return undefined; // the key is absent
|
|
133
|
+
throw new GitTrustRefusal(config, `cannot read ${key} (${(e.stderr ?? String(error)).trim().split("\n")[0]})`);
|
|
131
134
|
}
|
|
132
135
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
136
|
+
const worktreeConfigEnabled = (common) => trustedConfig(common, "extensions.worktreeConfig", "bool") === "true";
|
|
137
|
+
/** commondir's identity: ctime moves on every write, rename, unlink/recreate or restore, and no caller can set it. */
|
|
138
|
+
const stampOf = (path) => {
|
|
139
|
+
try {
|
|
140
|
+
const s = lstatSync(path, { bigint: true });
|
|
141
|
+
return `${s.dev}:${s.ino}:${s.ctimeNs}`;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const isSymlink = (path) => { try {
|
|
148
|
+
return lstatSync(path).isSymbolicLink();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
} };
|
|
153
|
+
/** The checked authority for `cwd` (see assertGitTrust), with the canonical worktree root the gitfile sits in. */
|
|
154
|
+
function checkedAuthority(cwd) {
|
|
140
155
|
const dotGit = dotGitOf(cwd);
|
|
141
156
|
if (!dotGit)
|
|
142
|
-
return;
|
|
157
|
+
return undefined;
|
|
143
158
|
const { gitdir, common, linked } = authorityOf(dotGit);
|
|
144
159
|
const commondir = join(gitdir, "commondir");
|
|
160
|
+
// stamped BEFORE its bytes are read, so any change after this read moves the stamp
|
|
161
|
+
const stamp = linked ? stampOf(commondir) : undefined;
|
|
145
162
|
if (linked && !present(commondir))
|
|
146
163
|
throw new GitTrustRefusal(commondir, `is missing, so git would treat the seat-writable gitdir as its own repository; expected ${common}`);
|
|
164
|
+
// git writes commondir as a regular file; through a symlink the bytes git reads could be rewritten and restored
|
|
165
|
+
// without moving the link's own stamp, so that layout is refused rather than trusted
|
|
166
|
+
if (linked && isSymlink(commondir))
|
|
167
|
+
throw new GitTrustRefusal(commondir, "is a symbolic link, so its target could change without moving its stamp");
|
|
147
168
|
if (present(commondir)) {
|
|
148
169
|
// read as git reads it (resolveAsGit), never trimmed or lexically normalized: either would let a seat-written
|
|
149
170
|
// path that names the real common directory to us lead git somewhere else
|
|
@@ -161,6 +182,141 @@ export function assertGitTrust(cwd) {
|
|
|
161
182
|
if (linked && present(worktreeConfig) && worktreeConfigEnabled(common)) {
|
|
162
183
|
throw new GitTrustRefusal(worktreeConfig, `extensions.worktreeConfig is enabled in ${join(common, "config")}, so git would read this seat-writable file`);
|
|
163
184
|
}
|
|
185
|
+
return { gitdir, common, linked, worktree: dirname(dotGit), stamp };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The uncached pre-spawn check for a covered git invocation at `cwd`. Outside any checkout it is a no-op
|
|
189
|
+
* (unrelated shell commands stay compatible). Inside one it refuses, naming the file, when the commondir git
|
|
190
|
+
* would follow resolves anywhere but the expected common directory, or when the trusted common config enables
|
|
191
|
+
* extensions.worktreeConfig and the linked gitdir holds a config.worktree.
|
|
192
|
+
*/
|
|
193
|
+
export function assertGitTrust(cwd) {
|
|
194
|
+
checkedAuthority(cwd);
|
|
195
|
+
}
|
|
196
|
+
function storePin(store, env) {
|
|
197
|
+
if (!present(join(store, "HEAD")) || present(join(store, "commondir")))
|
|
198
|
+
throw new GitTrustRefusal(store, "is not an ordinary git directory");
|
|
199
|
+
const child = { ...env, GIT_DIR: store };
|
|
200
|
+
for (const k of ["GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_REFERENCE_BACKEND", "GIT_REF_STORAGE_FORMAT", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"])
|
|
201
|
+
delete child[k];
|
|
202
|
+
return { env: child, store, recheck: () => { }, release: () => { }, unview: (text) => text };
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* v2.6.8 T2: the ref store an own-git child in a linked checkout is pinned to — a private directory of this attempt
|
|
206
|
+
* whose `refs`, `packed-refs`, `logs`, `worktrees` and `HEAD` are links into the TRUSTED common directory, beside a
|
|
207
|
+
* `HEAD.lock` nothing removes. git decides "linked" from the gitdir's commondir FILE alone: while it is present the
|
|
208
|
+
* child reads this checkout's own HEAD from <view>/worktrees/<id> (its trusted gitdir) and shared refs from the trusted
|
|
209
|
+
* store; when a seat REMOVES it before the child starts, git treats the view as the repository, so no HEAD-relative
|
|
210
|
+
* write (commit, merge, cherry-pick, reset, checkout, update-ref HEAD) can take the view's HEAD lock and nothing lands
|
|
211
|
+
* on the main checkout's branch — the recheck then refuses the result. That lock also refuses a write of the MAIN
|
|
212
|
+
* worktree's HEAD from a linked checkout's own git. Only the files format can be viewed this way: any other
|
|
213
|
+
* `extensions.refStorage` is refused by name, never run unpinned.
|
|
214
|
+
*/
|
|
215
|
+
function refStoreView(common) {
|
|
216
|
+
const view = realpathSync(mkdtempSync(join(tmpdir(), "tickmarkr-refs-")));
|
|
217
|
+
const release = () => rmSync(view, { recursive: true, force: true }); // unlinks the links, never their targets
|
|
218
|
+
try {
|
|
219
|
+
for (const name of ["refs", "packed-refs", "logs", "worktrees", "HEAD"])
|
|
220
|
+
symlinkSync(join(common, name), join(view, name));
|
|
221
|
+
writeFileSync(join(view, "HEAD.lock"), "");
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
release();
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
return { uri: `files://${view}`, release, unview: (text) => text.split(view).join(common) };
|
|
228
|
+
}
|
|
229
|
+
function assertFilesRefStore(common) {
|
|
230
|
+
const format = trustedConfig(common, "extensions.refStorage") ?? "files";
|
|
231
|
+
if (format !== "files") {
|
|
232
|
+
throw new GitTrustRefusal(join(common, "config"), `extensions.refStorage is ${format}; tickmarkr's own git can pin only the files ref store, so it is refused instead of running unpinned`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** A detached auto gc or maintenance would outlive its child and read refs through a view already released. */
|
|
236
|
+
const NO_BACKGROUND_GIT = "'gc.auto=0' 'maintenance.auto=false'";
|
|
237
|
+
const PROBE_REF = "refs/tickmarkr/ref-pin-probe", PROBE_TARGET = "refs/tickmarkr/ref-pin-honoured";
|
|
238
|
+
/**
|
|
239
|
+
* Whether, before the child spawns, the git its PATH resolves honours GIT_REFERENCE_BACKEND (git >= 2.54): a fresh
|
|
240
|
+
* probe repository whose own refs lack PROBE_REF, pinned to a fresh store that holds it as a symbolic ref. A git that
|
|
241
|
+
* ignores the pin reads the probe repository's own refs. Nothing is cached: each attempt probes again.
|
|
242
|
+
*/
|
|
243
|
+
function refStorePinHonoured(env) {
|
|
244
|
+
const dir = mkdtempSync(join(tmpdir(), "tickmarkr-ref-pin-"));
|
|
245
|
+
try {
|
|
246
|
+
const repo = join(dir, "repo"), store = join(dir, "store");
|
|
247
|
+
for (const d of [join(repo, "objects"), join(repo, "refs"), join(store, "refs", "tickmarkr")])
|
|
248
|
+
mkdirSync(d, { recursive: true });
|
|
249
|
+
for (const head of [join(repo, "HEAD"), join(store, "HEAD")])
|
|
250
|
+
writeFileSync(head, "ref: refs/heads/main\n");
|
|
251
|
+
writeFileSync(join(store, PROBE_REF), `ref: ${PROBE_TARGET}\n`);
|
|
252
|
+
const probe = { ...env, GIT_DIR: repo, GIT_REFERENCE_BACKEND: `files://${store}` };
|
|
253
|
+
for (const k of ["GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"])
|
|
254
|
+
delete probe[k];
|
|
255
|
+
let read = "";
|
|
256
|
+
try {
|
|
257
|
+
read = execFileSync("git", ["symbolic-ref", PROBE_REF], { cwd: dir, env: probe, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15_000 }).trim();
|
|
258
|
+
}
|
|
259
|
+
catch { /* refused below */ }
|
|
260
|
+
return read === PROBE_TARGET;
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
rmSync(dir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
let warnedRefStorePin = false;
|
|
267
|
+
/** Said once per process: the fallback below is a weaker guard, and the operator should know why and what lifts it. */
|
|
268
|
+
function warnRefStorePinUnavailable() {
|
|
269
|
+
if (warnedRefStorePin)
|
|
270
|
+
return;
|
|
271
|
+
warnedRefStorePin = true;
|
|
272
|
+
console.error("tickmarkr: the git on this PATH does not honour GIT_REFERENCE_BACKEND (needs git 2.54 or newer), so tickmarkr's own git in linked checkouts runs without its ref-store pin — path pins and the post-exit commondir recheck only, the 2.6.7 guard; upgrade git for the full pin");
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* v2.6.8 T2 (P3): the same uncached check, returning the child of tickmarkr's OWN git in a linked checkout — `env`
|
|
276
|
+
* (the caller's) with GIT_DIR, GIT_COMMON_DIR and GIT_WORK_TREE set to the canonical gitdir, trusted common directory
|
|
277
|
+
* and worktree root derived above, AND the ref store: GIT_REFERENCE_BACKEND names this attempt's private view of the
|
|
278
|
+
* trusted store (refStoreView), since git's ref store otherwise resolves shared refs through the gitdir's commondir
|
|
279
|
+
* FILE even under GIT_COMMON_DIR. So a commondir rewritten after this check (by a live worker or a surviving
|
|
280
|
+
* descendant) is followed for neither config, hooks, info/, objects nor refs: HEAD, branches, status and diffs read
|
|
281
|
+
* the real repository and every ref write lands in the trusted store, whatever an inherited GIT_CONFIG_* or ref backend
|
|
282
|
+
* says; a commondir REMOVED after it cannot move the main checkout's branch. Before such a child spawns, the git it
|
|
283
|
+
* would run must prove it honours that pin (refStorePinHonoured); one that cannot (git < 2.54) runs without it, below.
|
|
284
|
+
* Nothing here reads the command, and no caller leaves any pin. `recheck` (after the child exits) fails closed on ANY change to commondir since the check: the result
|
|
285
|
+
* of that child is never returned. An ordinary checkout or a non-repository gets no pin.
|
|
286
|
+
* NAMED RESIDUAL: git still reads $GIT_DIR/config.worktree when the trusted common config already enables
|
|
287
|
+
* extensions.worktreeConfig, so a config.worktree planted AFTER this check is not closed.
|
|
288
|
+
*/
|
|
289
|
+
export function ownGitPin(cwd, env, capability = {}) {
|
|
290
|
+
if ("store" in capability)
|
|
291
|
+
return storePin(capability.store, env);
|
|
292
|
+
const { createsWorktree } = capability;
|
|
293
|
+
const authority = checkedAuthority(cwd);
|
|
294
|
+
if (!authority?.linked)
|
|
295
|
+
return undefined;
|
|
296
|
+
const { gitdir, common, worktree, stamp } = authority;
|
|
297
|
+
const child = { ...env, GIT_DIR: gitdir, GIT_COMMON_DIR: common, GIT_WORK_TREE: worktree,
|
|
298
|
+
GIT_CONFIG_PARAMETERS: `${env.GIT_CONFIG_PARAMETERS?.trim() ?? ""} ${NO_BACKGROUND_GIT}`.trim() };
|
|
299
|
+
// GIT_REF_STORAGE_FORMAT only chooses a NEW repository's format; an inherited one is never a store this child follows
|
|
300
|
+
delete child.GIT_REF_STORAGE_FORMAT;
|
|
301
|
+
assertFilesRefStore(common);
|
|
302
|
+
const commondir = join(gitdir, "commondir");
|
|
303
|
+
const recheck = () => {
|
|
304
|
+
if (stampOf(commondir) !== stamp) {
|
|
305
|
+
throw new GitTrustRefusal(commondir, "changed while tickmarkr's own git ran in this checkout, so that result is refused");
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
// D-1484: a git older than 2.54 cannot take the ref-store pin. Refusing it would refuse tickmarkr's own git in every
|
|
309
|
+
// linked checkout on most installed gits, and a hostile git binary ignores every pin anyway; so it runs with the
|
|
310
|
+
// path pins and the post-exit recheck (the 2.6.7 guard). NAMED RESIDUAL there: refs still resolve through the
|
|
311
|
+
// commondir file, so a rewrite between check and exit can reach the hostile store before the recheck refuses.
|
|
312
|
+
if (!refStorePinHonoured(child)) {
|
|
313
|
+
delete child.GIT_REFERENCE_BACKEND;
|
|
314
|
+
warnRefStorePinUnavailable();
|
|
315
|
+
return { env: child, store: common, release: () => { }, unview: (text) => text, recheck };
|
|
316
|
+
}
|
|
317
|
+
const view = createsWorktree === "detached" ? { uri: `files://${common}`, release: () => { }, unview: (text) => text } : refStoreView(common);
|
|
318
|
+
child.GIT_REFERENCE_BACKEND = view.uri;
|
|
319
|
+
return { env: child, store: common, release: view.release, unview: view.unview, recheck };
|
|
164
320
|
}
|
|
165
321
|
/** The inert hooks directory every protected git child receives (no hook can exist beneath it). */
|
|
166
322
|
export const INERT_HOOKS_PATH = "/dev/null";
|
|
@@ -169,8 +325,8 @@ const FORCED_GIT_CONFIG = `'core.fsmonitor=false' 'core.hooksPath=${INERT_HOOKS_
|
|
|
169
325
|
* A copy of `env` whose git children run with core.fsmonitor=false and an inert core.hooksPath, despite repo
|
|
170
326
|
* config and inherited GIT_CONFIG_COUNT/GIT_CONFIG_PARAMETERS: git applies GIT_CONFIG_PARAMETERS after
|
|
171
327
|
* GIT_CONFIG_COUNT and the last value wins, so the forced pair is appended last. Every other value survives.
|
|
172
|
-
* Named keys only — this does not neutralize arbitrary executable drivers.
|
|
173
|
-
*
|
|
328
|
+
* Named keys only — this does not neutralize arbitrary executable drivers. v2.6.8 T2: only tickmarkr's OWN git
|
|
329
|
+
* children (shGit/shGitOk) receive it; payload shells (gate commands, suites) keep the operator's git config.
|
|
174
330
|
*/
|
|
175
331
|
export function protectedGitEnv(env) {
|
|
176
332
|
const prior = env.GIT_CONFIG_PARAMETERS?.trim();
|