pi-orchestrator-config 4.27.0 → 4.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/extensions/orchestrator/enforcement-helpers.ts +48 -3
- package/extensions/orchestrator/enforcement.ts +23 -3
- package/extensions/orchestrator/git-helpers.ts +38 -0
- package/extensions/orchestrator/settings-tui-helpers.ts +221 -7
- package/extensions/orchestrator/settings-tui.ts +15 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [4.28.0] - 2026-11-07
|
|
4
|
+
|
|
5
|
+
- Settings backups are scoped to a TUI visit: `/pi-config-settings` takes one restore point per
|
|
6
|
+
session instead of one per changed value, so the state you opened the overlay with survives a
|
|
7
|
+
burst of edits. Writes outside the TUI (allowlist approval, browser save) still back up per
|
|
8
|
+
action, and a snapshot left by a crashed visit is promoted rather than overwritten.
|
|
9
|
+
- The backup lock is a single `O_EXCL` file: a dead owner's lock is taken over atomically, an
|
|
10
|
+
orphaned lock clears inside the same save, and a save that genuinely cannot take the lock still
|
|
11
|
+
refuses instead of shifting the chain and losing another process's restore point.
|
|
12
|
+
- `bash -n file` no longer asks for approval — a pure syntax check runs nothing. Expansions,
|
|
13
|
+
backticks, process substitution, `+` options, `--`, `-c`, quotes and backslashes still ask.
|
|
14
|
+
- `rm -rf` on a literal path inside the project `.pi/tmp` or `~/.pi/tmp` skips approval, with the
|
|
15
|
+
exemption logged as a statement hash. The operand must name the scratch both as written and as
|
|
16
|
+
resolved; `..`, backslashes, globs, quoting, subshells and `sudo` all still ask.
|
|
17
|
+
- A missing `HOME` no longer makes the dangerous-command gate throw instead of deciding.
|
|
18
|
+
|
|
3
19
|
## [4.27.0] - 2026-11-07
|
|
4
20
|
|
|
5
21
|
- Settings writes keep a configurable history: the previous file is copied to `.bak.1` before every
|
|
@@ -1942,6 +1942,26 @@ export function isRmInProjectTmp(stmt: string, cwd: string): boolean {
|
|
|
1942
1942
|
|
|
1943
1943
|
// Resolve and substitute PROJECT_TMP_DIR env var
|
|
1944
1944
|
const projectTmpDir = path.join(cwd, ".pi", "tmp");
|
|
1945
|
+
// The home scratch (~/.pi/tmp) is our own throwaway tree too: the logger fallback and build
|
|
1946
|
+
// scratch tests write there. Same exemption as the project one, same literal-path guards.
|
|
1947
|
+
// Two roots, and BOTH must match: the scratch path as written (which may itself be a symlink —
|
|
1948
|
+
// `~/.pi/tmp` often points into a dotfiles checkout) and where that path really lives. Matching
|
|
1949
|
+
// only the real root made a symlink's target trusted: with `~/.pi/tmp -> ~`, `rm -rf ~/.ssh`
|
|
1950
|
+
// resolves beneath it and would skip approval without ever naming the scratch.
|
|
1951
|
+
// Both resolutions live inside one try: `homedir()` itself can throw when HOME is unset or
|
|
1952
|
+
// points nowhere, and this helper feeds the dangerous-command gate — an exception here would
|
|
1953
|
+
// replace a decision with a crash instead of simply granting no exemption.
|
|
1954
|
+
let lexicalHomeTmp: string | null = null;
|
|
1955
|
+
let resolvedHomeTmp: string | null = null;
|
|
1956
|
+
try {
|
|
1957
|
+
lexicalHomeTmp = path.resolve(realpathSync(homedir()), ".pi", "tmp").replace(/\/+$/, "");
|
|
1958
|
+
resolvedHomeTmp = realpathSync(lexicalHomeTmp);
|
|
1959
|
+
} catch {
|
|
1960
|
+
lexicalHomeTmp = null; // no home, or no scratch in it — nothing to match
|
|
1961
|
+
resolvedHomeTmp = null;
|
|
1962
|
+
}
|
|
1963
|
+
const underHomeTmp = (pathname: string, root: string): boolean =>
|
|
1964
|
+
pathname === root || pathname.startsWith(root + path.sep);
|
|
1945
1965
|
let resolvedCwd: string | null;
|
|
1946
1966
|
try {
|
|
1947
1967
|
resolvedCwd = realpathSync(cwd);
|
|
@@ -1952,14 +1972,21 @@ export function isRmInProjectTmp(stmt: string, cwd: string): boolean {
|
|
|
1952
1972
|
for (const p of paths) {
|
|
1953
1973
|
// Substitute ${PROJECT_TMP_DIR} or $PROJECT_TMP_DIR
|
|
1954
1974
|
const expanded = p.replace(/\$\{?PROJECT_TMP_DIR\}?/g, projectTmpDir);
|
|
1975
|
+
// Refuse `..` outright. `path.resolve` collapses it LEXICALLY, while the kernel applies it to
|
|
1976
|
+
// where a preceding symlink actually points — so `~/.pi/tmp/link/../.ssh/key` looks in-scratch
|
|
1977
|
+
// here and deletes `~/.ssh/key` there. Nothing legitimate needs `..` in a scratch operand.
|
|
1978
|
+
if (/(^|\/)\.\.($|\/)/.test(expanded)) return false;
|
|
1979
|
+
// The shell removes backslashes before `rm` sees the path, so `\.\.` is `..` and `\*` is a
|
|
1980
|
+
// glob that no literal-path check ever saw. A literal scratch path needs none of them.
|
|
1981
|
+
if (expanded.includes("\\")) return false;
|
|
1982
|
+
const lexical = path.resolve(cwd, expanded);
|
|
1955
1983
|
let resolved: string;
|
|
1956
1984
|
try {
|
|
1957
1985
|
// Use realpathSync to resolve symlinks — prevents symlink traversal attacks
|
|
1958
|
-
resolved = realpathSync(
|
|
1986
|
+
resolved = realpathSync(lexical);
|
|
1959
1987
|
} catch {
|
|
1960
1988
|
// Path doesn't exist. For /tmp paths, validate the existing parent via realpathSync
|
|
1961
1989
|
// to catch symlinked parents (e.g., /tmp/evil-link -> / where evil-link exists).
|
|
1962
|
-
const lexical = path.resolve(cwd, expanded);
|
|
1963
1990
|
if (lexical.startsWith("/tmp/") && lexical !== "/tmp" && lexical.length > 5) {
|
|
1964
1991
|
// Block paths containing traversal sequences — even if they resolve under /tmp/,
|
|
1965
1992
|
// they may have escaped and re-entered via ..
|
|
@@ -1980,6 +2007,19 @@ export function isRmInProjectTmp(stmt: string, cwd: string): boolean {
|
|
|
1980
2007
|
// Parent also doesn't exist — safe (entire path is non-existent)
|
|
1981
2008
|
}
|
|
1982
2009
|
resolved = lexical;
|
|
2010
|
+
} else if (resolvedHomeTmp !== null && lexicalHomeTmp !== null && underHomeTmp(lexical, lexicalHomeTmp)) {
|
|
2011
|
+
// Non-existent path under the home scratch — validate the parent the same way.
|
|
2012
|
+
if (expanded.includes("..")) return false;
|
|
2013
|
+
const parentDir = path.dirname(lexical);
|
|
2014
|
+
try {
|
|
2015
|
+
const resolvedParent = realpathSync(parentDir);
|
|
2016
|
+
if (!resolvedParent.startsWith(resolvedHomeTmp + path.sep) && resolvedParent !== resolvedHomeTmp) {
|
|
2017
|
+
return false;
|
|
2018
|
+
}
|
|
2019
|
+
} catch {
|
|
2020
|
+
// Parent also doesn't exist — safe
|
|
2021
|
+
}
|
|
2022
|
+
resolved = lexical;
|
|
1983
2023
|
} else if (resolvedCwd !== null &&
|
|
1984
2024
|
(lexical.startsWith(resolvedCwd + path.sep) || lexical === resolvedCwd) &&
|
|
1985
2025
|
(lexical.includes(`${path.sep}.pi${path.sep}tmp${path.sep}`) ||
|
|
@@ -2012,7 +2052,12 @@ export function isRmInProjectTmp(stmt: string, cwd: string): boolean {
|
|
|
2012
2052
|
resolved.endsWith(`${path.sep}.pi${path.sep}tmp`));
|
|
2013
2053
|
const inSystemTmp = resolved.startsWith("/tmp/") && resolved !== "/tmp" && resolved.length > 5;
|
|
2014
2054
|
|
|
2015
|
-
|
|
2055
|
+
// The path as WRITTEN must name the scratch (a symlinked scratch cannot lend its target out),
|
|
2056
|
+
// and its real location must stay inside the scratch (a symlink inside it cannot escape).
|
|
2057
|
+
const inHomeTmp = lexicalHomeTmp !== null && resolvedHomeTmp !== null &&
|
|
2058
|
+
underHomeTmp(lexical, lexicalHomeTmp) && underHomeTmp(resolved, resolvedHomeTmp);
|
|
2059
|
+
|
|
2060
|
+
if (!inProjectTmp && !inSystemTmp && !inHomeTmp) return false;
|
|
2016
2061
|
}
|
|
2017
2062
|
|
|
2018
2063
|
return true;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* review state tracking and gitignore checks.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
8
9
|
import { createLogger } from "../shared/logger.js";
|
|
9
10
|
import { createBridgedUi } from "../shared/ui-bridge.js";
|
|
10
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
} from "./enforcement-helpers.js";
|
|
28
29
|
import {
|
|
29
30
|
DANGEROUS,
|
|
31
|
+
isNoexecSyntaxCheck,
|
|
30
32
|
dangerousPatternLabel,
|
|
31
33
|
getCurrentBranch,
|
|
32
34
|
isInsideGitWorkTree,
|
|
@@ -458,6 +460,16 @@ async function checkDangerousCommands(command: string, cwd: string, ctx: any): P
|
|
|
458
460
|
const hasDangerous = statements.some((stmt) => {
|
|
459
461
|
// Skip read-only commands with no dangerous subshells
|
|
460
462
|
if (isReadOnlyStatement(stmt)) return false;
|
|
463
|
+
// `bash -n file` only parses: nothing executes, so there is nothing to approve. Logged —
|
|
464
|
+
// an exemption from the gate must leave a trail an operator can find.
|
|
465
|
+
if (isNoexecSyntaxCheck(stmt)) {
|
|
466
|
+
// Audit trail without content: a statement can carry paths, tokens or credentials, so the
|
|
467
|
+
// log records a short hash an operator can match, never the words themselves.
|
|
468
|
+
log.info("dangerous_gate_noexec_syntax_check", {
|
|
469
|
+
statementHash: createHash("sha256").update(stmt).digest("hex").slice(0, 12),
|
|
470
|
+
});
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
461
473
|
return DANGEROUS.some((p) => p.test(stmt));
|
|
462
474
|
});
|
|
463
475
|
if (hasDangerous) {
|
|
@@ -497,9 +509,17 @@ async function checkDangerousCommands(command: string, cwd: string, ctx: any): P
|
|
|
497
509
|
|
|
498
510
|
// Allow rm -rf targeting only .pi/tmp/ paths without confirmation
|
|
499
511
|
const allRmInTmp = statements
|
|
500
|
-
.filter((stmt) => DANGEROUS.some((p) => p.test(stmt)) && !isReadOnlyStatement(stmt))
|
|
512
|
+
.filter((stmt) => DANGEROUS.some((p) => p.test(stmt)) && !isReadOnlyStatement(stmt) && !isNoexecSyntaxCheck(stmt))
|
|
501
513
|
.every((stmt) => isRmInProjectTmp(stmt, cwd));
|
|
502
|
-
if (allRmInTmp)
|
|
514
|
+
if (allRmInTmp) {
|
|
515
|
+
// Never exempt silently: the audit line carries a hash, not the statement (paths can be
|
|
516
|
+
// sensitive), matching the syntax-check exemption above.
|
|
517
|
+
log.info("dangerous_gate_rm_scratch_exempt", {
|
|
518
|
+
statements: statements.length,
|
|
519
|
+
statementHash: createHash("sha256").update(statements.join("\n")).digest("hex").slice(0, 12),
|
|
520
|
+
});
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
503
523
|
|
|
504
524
|
// User-approved allowlist (issue #949): the gate identifies the DANGEROUS
|
|
505
525
|
// STATEMENTS (split out of the chain, benign plumbing tails — descriptor
|
|
@@ -517,7 +537,7 @@ async function checkDangerousCommands(command: string, cwd: string, ctx: any): P
|
|
|
517
537
|
const dangerousStmts = [
|
|
518
538
|
...new Set(
|
|
519
539
|
statements
|
|
520
|
-
.filter((stmt) => DANGEROUS.some((p) => p.test(stmt)) && !isReadOnlyStatement(stmt))
|
|
540
|
+
.filter((stmt) => DANGEROUS.some((p) => p.test(stmt)) && !isReadOnlyStatement(stmt) && !isNoexecSyntaxCheck(stmt))
|
|
521
541
|
.map((stmt) => normalizeDangerousStatement(stmt)),
|
|
522
542
|
),
|
|
523
543
|
];
|
|
@@ -1154,6 +1154,44 @@ export function listUnmergedFiles(cwd?: string): { ok: boolean; files: string[]
|
|
|
1154
1154
|
return { ok: true, files: [...files] };
|
|
1155
1155
|
}
|
|
1156
1156
|
|
|
1157
|
+
/** `bash -n file`, `sh -n script`, `bash -x -n f` — the shell only PARSES: nothing is executed.
|
|
1158
|
+
* A syntax check is not a command to approve, so the gate must not treat it as running a shell.
|
|
1159
|
+
* Only an exact `-n` token counts; anything fuzzier stays dangerous. */
|
|
1160
|
+
export function isNoexecSyntaxCheck(statement: string): boolean {
|
|
1161
|
+
const WRAPPERS =
|
|
1162
|
+
/^(?:(?:[0-9]*>{1,2}|&>)\S*\s+)*(?:\s*(?:[A-Za-z_]\w*=\S*|(?:then|do|else|elif)\b|sudo(?:\s+-\S+(?:\s+\S+)?)*|rtk(?:\s+-\S+)*|(?:command|builtin|exec)\b|env(?:\s+(?:-\S+(?:\s+\S+)?|\S+=\S*))*|timeout\s+(?:-\S+(?:\s+\S+)?\s+)*\S+|nice(?:\s+(?:-\S+(?:\s+\S+)?))*|nohup\b)\s+)*/i;
|
|
1163
|
+
const SHELL = /^(?:\S*\/)?(?:ba|da|z|k|c|tc|fi)?sh\b/i;
|
|
1164
|
+
// The calling shell expands and word-splits before the syntax check runs: `$(...)` executes,
|
|
1165
|
+
// `${IFS}` splits one token into several options (`bash -n${IFS}+n${IFS}-c …`), backticks run,
|
|
1166
|
+
// and `<(...)`/`>(...)` execute. ANY `$` is refused outright — a legitimate `bash -n script.sh`
|
|
1167
|
+
// never needs one. Quotes and backslashes go too: the CALLING shell removes them before Bash
|
|
1168
|
+
// parses, so `bash -n '+n' script.sh` would hand Bash a `+n` option while this predicate saw a
|
|
1169
|
+
// harmless operand.
|
|
1170
|
+
if (/[$`'"\\]|<(?=\()|>(?=\()/.test(statement)) return false;
|
|
1171
|
+
const rest = statement.replace(WRAPPERS, "").trim();
|
|
1172
|
+
const shellMatch = SHELL.exec(rest);
|
|
1173
|
+
if (!shellMatch) return false;
|
|
1174
|
+
const tokens = rest.slice(shellMatch[0].length).trim().split(/\s+/).filter(Boolean);
|
|
1175
|
+
|
|
1176
|
+
// `-n` only counts in the shell's OWN leading option block, and only if nothing turns it back
|
|
1177
|
+
// off. The invocation accepts the `set` options, so `+n` re-enables execution:
|
|
1178
|
+
// `bash script.sh -n` — operand, the script runs
|
|
1179
|
+
// `bash -- -n` — `--` ends the options; it runs a FILE called `-n`
|
|
1180
|
+
// `bash -n +n script` — noexec is switched off again
|
|
1181
|
+
let noexec = false;
|
|
1182
|
+
for (const token of tokens) {
|
|
1183
|
+
if (token === "--") break; // option terminator: everything after is an operand
|
|
1184
|
+
// `+` options enable modes again (`+n`, `+o noexec`, …). Refusing ALL of them keeps this
|
|
1185
|
+
// predicate from having to model the whole `set` option table.
|
|
1186
|
+
if (token.startsWith("+")) return false;
|
|
1187
|
+
if (!token.startsWith("-")) break; // first operand (script name or argument)
|
|
1188
|
+
// `-c` takes the program text — whatever follows is executed as parsed, so never exempt it.
|
|
1189
|
+
if (/^-[a-zA-Z]*c[a-zA-Z]*$/.test(token)) return false;
|
|
1190
|
+
if (/^-[a-zA-Z]*n/.test(token)) noexec = true; // -n, -xn, -nx
|
|
1191
|
+
}
|
|
1192
|
+
return noexec;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1157
1195
|
export const DANGEROUS = [
|
|
1158
1196
|
/\brm\s+(?:-[a-zA-Z]+\s+)*(-[a-zA-Z]*r[a-zA-Z]*|--recursive)/i,
|
|
1159
1197
|
/\bsudo\b/i,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* No pi-coding-agent or pi-tui dependencies.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
8
8
|
import { basename, dirname, join as joinPath, resolve, sep } from "node:path";
|
|
9
9
|
import stripJsonComments from "strip-json-comments";
|
|
@@ -313,6 +313,186 @@ export class SettingsBackupError extends Error {
|
|
|
313
313
|
}
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
+
/** Depth of the interactive editing session (`/pi-config-settings`). While it is open the file
|
|
317
|
+
* is snapshotted ONCE, on the first change: a user who adjusts five values must not rotate the
|
|
318
|
+
* pre-visit state out of existence. Writes outside the TUI are discrete actions and keep their
|
|
319
|
+
* own backup. */
|
|
320
|
+
let editSessionDepth = 0;
|
|
321
|
+
const backedUpInSession = new Set<string>();
|
|
322
|
+
/** Files this visit CREATED: their pre-visit state is "absent", so backing up the first partial
|
|
323
|
+
* version would misrepresent it. */
|
|
324
|
+
const createdInSession = new Set<string>();
|
|
325
|
+
/** Visit snapshots waiting to be promoted into the numbered chain when the visit closes. */
|
|
326
|
+
const visitSnapshots = new Set<string>();
|
|
327
|
+
|
|
328
|
+
/** A visit keeps its own copy, so an external save during the visit (which rotates the numbered
|
|
329
|
+
* chain) cannot destroy the state the user opened the TUI with. The process id is part of the
|
|
330
|
+
* name: two TUI processes editing one file must not overwrite each other's restore point. */
|
|
331
|
+
function visitSnapshotPath(filePath: string): string {
|
|
332
|
+
return `${filePath}.bak.visit-${process.pid}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Serialize chain mutations across processes: rotating or promoting shifts `.bak.N` files, and a
|
|
336
|
+
* concurrent writer interleaving between the shift and the rename would lose a restore point. */
|
|
337
|
+
function withChainLock<T>(filePath: string, fn: () => T): T {
|
|
338
|
+
// The lock is ONE file created with O_EXCL: creating it IS owning it. Splitting that into
|
|
339
|
+
// mkdir-then-write-pid left a window where the directory existed with no owner, and a reader
|
|
340
|
+
// could steal a lock a live writer was still acquiring — both then shifted the chain.
|
|
341
|
+
const lockPath = `${filePath}.bak.lock`;
|
|
342
|
+
let locked = false;
|
|
343
|
+
try {
|
|
344
|
+
for (let attempt = 0; attempt < 100 && !locked; attempt++) {
|
|
345
|
+
try {
|
|
346
|
+
writeFileSync(lockPath, String(process.pid), { flag: "wx", mode: 0o600 });
|
|
347
|
+
locked = true;
|
|
348
|
+
} catch {
|
|
349
|
+
// Take over a lock only when its OWNER IS GONE. Age alone is not evidence: a paused or
|
|
350
|
+
// slow owner could resume and mutate the chain alongside this process.
|
|
351
|
+
let stale = false;
|
|
352
|
+
try {
|
|
353
|
+
const ownerPid = Number(readFileSync(lockPath, "utf-8").trim());
|
|
354
|
+
if (Number.isInteger(ownerPid) && ownerPid > 0) {
|
|
355
|
+
try {
|
|
356
|
+
process.kill(ownerPid, 0); // alive — leave it alone
|
|
357
|
+
} catch {
|
|
358
|
+
stale = true; // owner is gone
|
|
359
|
+
}
|
|
360
|
+
} else {
|
|
361
|
+
// Created but not yet written, or a crash mid-write. A live acquisition writes its pid
|
|
362
|
+
// immediately, so this grace never steals from a live writer, and it is far shorter
|
|
363
|
+
// than the retry budget — an orphaned lock clears inside the same save.
|
|
364
|
+
stale = Date.now() - statSync(lockPath).mtimeMs > 200;
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
// Vanished between the failed create and this read — retry immediately.
|
|
368
|
+
}
|
|
369
|
+
if (stale) {
|
|
370
|
+
// Atomic steal: rename first, so exactly one of several racing writers claims the stale
|
|
371
|
+
// lock. Deleting in place let two writers remove each other's fresh lock.
|
|
372
|
+
const grave = `${lockPath}.stale-${process.pid}-${Date.now()}`;
|
|
373
|
+
try {
|
|
374
|
+
renameSync(lockPath, grave);
|
|
375
|
+
unlinkSync(grave);
|
|
376
|
+
} catch {
|
|
377
|
+
/* another writer claimed it — retry the acquisition */
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (!locked) {
|
|
384
|
+
// NEVER mutate the chain without the lock: a concurrent shift-and-rename loses a restore
|
|
385
|
+
// point, which is worse than refusing the save.
|
|
386
|
+
log.error("settings_backup_lock_timeout", { filePath });
|
|
387
|
+
throw new SettingsBackupError(filePath, new Error("backup lock timeout"));
|
|
388
|
+
}
|
|
389
|
+
return fn();
|
|
390
|
+
} finally {
|
|
391
|
+
if (locked) {
|
|
392
|
+
try {
|
|
393
|
+
// Only release a lock we still own: if it was stolen, it now belongs to someone else.
|
|
394
|
+
if (readFileSync(lockPath, "utf-8").trim() === String(process.pid)) unlinkSync(lockPath);
|
|
395
|
+
} catch {
|
|
396
|
+
/* already gone */
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Promote one visit snapshot into the numbered chain (`.bak.1`), shifting older copies. */
|
|
403
|
+
function promoteSnapshot(snapshotPath: string): void {
|
|
404
|
+
const filePath = snapshotPath.replace(/\.bak\.visit-\d+$/, "");
|
|
405
|
+
if (!existsSync(snapshotPath)) return;
|
|
406
|
+
withChainLock(filePath, () => promoteSnapshotLocked(snapshotPath, filePath));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function promoteSnapshotLocked(snapshotPath: string, filePath: string): void {
|
|
410
|
+
const count = backupCountFor(filePath);
|
|
411
|
+
if (count <= 0) {
|
|
412
|
+
unlinkSync(snapshotPath);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const oldest = settingsBackupPath(filePath, count);
|
|
416
|
+
if (existsSync(oldest)) unlinkSync(oldest);
|
|
417
|
+
for (let slot = count - 1; slot >= 1; slot--) {
|
|
418
|
+
const from = settingsBackupPath(filePath, slot);
|
|
419
|
+
if (existsSync(from)) renameSync(from, settingsBackupPath(filePath, slot + 1));
|
|
420
|
+
}
|
|
421
|
+
renameSync(snapshotPath, settingsBackupPath(filePath, 1));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Snapshots left by a process that is gone are promoted instead of being overwritten by the next
|
|
425
|
+
* visit — a crash must cost nothing. A snapshot owned by a LIVE process is left alone. */
|
|
426
|
+
function reapStaleVisitSnapshots(filePath: string): void {
|
|
427
|
+
const dir = dirname(filePath);
|
|
428
|
+
const marker = `${basename(filePath)}.bak.visit-`;
|
|
429
|
+
let entries: string[] = [];
|
|
430
|
+
try {
|
|
431
|
+
entries = readdirSync(dir);
|
|
432
|
+
} catch {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
for (const name of entries) {
|
|
436
|
+
if (!name.startsWith(marker)) continue;
|
|
437
|
+
const pid = Number(name.slice(marker.length));
|
|
438
|
+
if (!Number.isInteger(pid)) continue;
|
|
439
|
+
// Skip only a snapshot THIS process owns: a pid can be reused, and a reused pid must not
|
|
440
|
+
// cause a crashed editor's copy to be silently overwritten.
|
|
441
|
+
if (pid === process.pid && visitSnapshots.has(joinPath(dir, name))) continue;
|
|
442
|
+
try {
|
|
443
|
+
process.kill(pid, 0);
|
|
444
|
+
continue; // owner still alive
|
|
445
|
+
} catch {
|
|
446
|
+
/* dead: fall through and promote its snapshot */
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
promoteSnapshot(joinPath(dir, name));
|
|
450
|
+
log.info("settings_visit_snapshot_recovered", { filePath, pid });
|
|
451
|
+
} catch (e: any) {
|
|
452
|
+
log.warn("settings_visit_snapshot_recover_failed", { filePath, pid, error: e?.message });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Move each visit snapshot into `.bak.1` (shifting the chain), once, when the visit closes. */
|
|
458
|
+
function promoteVisitSnapshots(): void {
|
|
459
|
+
const failed = new Set<string>();
|
|
460
|
+
for (const snapshot of [...visitSnapshots]) {
|
|
461
|
+
try {
|
|
462
|
+
promoteSnapshot(snapshot);
|
|
463
|
+
} catch (e: any) {
|
|
464
|
+
// Kept in the set so a retry can still promote it; if the process ends first, the next
|
|
465
|
+
// visit reaps it by pid.
|
|
466
|
+
failed.add(snapshot);
|
|
467
|
+
log.warn("settings_visit_snapshot_promote_failed", { snapshot, error: e?.message });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
visitSnapshots.clear();
|
|
471
|
+
for (const snapshot of failed) visitSnapshots.add(snapshot);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function beginSettingsEditSession(): void {
|
|
475
|
+
editSessionDepth++;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function endSettingsEditSession(): void {
|
|
479
|
+
editSessionDepth = Math.max(0, editSessionDepth - 1);
|
|
480
|
+
if (editSessionDepth === 0) {
|
|
481
|
+
promoteVisitSnapshots();
|
|
482
|
+
backedUpInSession.clear();
|
|
483
|
+
createdInSession.clear();
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** True when this write should rotate a backup. Only the settings TUI asks for dedupe; a browser
|
|
488
|
+
* save or an allowlist approval arriving mid-visit is a separate action and keeps its own
|
|
489
|
+
* restore point. */
|
|
490
|
+
function shouldBackUp(filePath: string, dedupe: boolean): boolean {
|
|
491
|
+
if (!dedupe || editSessionDepth === 0) return true;
|
|
492
|
+
if (createdInSession.has(filePath)) return false;
|
|
493
|
+
return !backedUpInSession.has(filePath);
|
|
494
|
+
}
|
|
495
|
+
|
|
316
496
|
/** Path of the newest previous-version copy kept next to a settings file. */
|
|
317
497
|
export function settingsBackupPath(filePath: string, slot = 1): string {
|
|
318
498
|
return `${filePath}.bak.${slot}`;
|
|
@@ -321,8 +501,11 @@ export function settingsBackupPath(filePath: string, slot = 1): string {
|
|
|
321
501
|
/** Keep `count` previous versions: shift `.bak.N-1` → `.bak.N`, then current → `.bak.1`.
|
|
322
502
|
* A count of 0 disables backups. Failures never block the write — they only log. */
|
|
323
503
|
function rotateSettingsBackups(filePath: string, count: number): void {
|
|
324
|
-
pruneExtraBackups(filePath, count);
|
|
325
504
|
if (count <= 0) return;
|
|
505
|
+
withChainLock(filePath, () => rotateSettingsBackupsLocked(filePath, count));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function rotateSettingsBackupsLocked(filePath: string, count: number): void {
|
|
326
509
|
try {
|
|
327
510
|
const oldest = settingsBackupPath(filePath, count);
|
|
328
511
|
if (existsSync(oldest)) unlinkSync(oldest);
|
|
@@ -339,7 +522,11 @@ function rotateSettingsBackups(filePath: string, count: number): void {
|
|
|
339
522
|
}
|
|
340
523
|
}
|
|
341
524
|
|
|
342
|
-
export function writeSettingsFile(
|
|
525
|
+
export function writeSettingsFile(
|
|
526
|
+
filePath: string,
|
|
527
|
+
data: Record<string, unknown>,
|
|
528
|
+
options: { dedupeBackup?: boolean } = {},
|
|
529
|
+
): void {
|
|
343
530
|
// Write directly to target file. Per issue spec: "comments are lost on write — acceptable
|
|
344
531
|
// since the example.jsonc is the reference."
|
|
345
532
|
const dir = dirname(filePath);
|
|
@@ -359,13 +546,40 @@ export function writeSettingsFile(filePath: string, data: Record<string, unknown
|
|
|
359
546
|
|
|
360
547
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
361
548
|
|
|
362
|
-
|
|
549
|
+
const existedBefore = existsSync(filePath);
|
|
550
|
+
if (!existedBefore && options.dedupeBackup === true && editSessionDepth > 0) {
|
|
551
|
+
// Created during this visit: nothing to restore to, and the first partial version is not the
|
|
552
|
+
// pre-visit state, so later writes in the same visit must not snapshot it.
|
|
553
|
+
createdInSession.add(filePath);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (existedBefore) {
|
|
363
557
|
// A corrupt file is recoverable by hand; a cleared one is not. Refuse rather than
|
|
364
558
|
// overwrite — this is exactly how a project's settings were once emptied.
|
|
365
559
|
if (readSettingsFile(filePath) === null) throw new SettingsFileCorruptError(filePath);
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
560
|
+
// Keep the pre-change bytes — `settings_backup_count` of them, newest at `.bak.1`. Inside the
|
|
561
|
+
// settings TUI that happens once per visit, so editing several values keeps the state the user
|
|
562
|
+
// started from. Recorded only after a successful rotation, so a failed one is retried.
|
|
563
|
+
const count = backupCountFor(filePath);
|
|
564
|
+
// Pruning runs on EVERY write: lowering the retention during a visit must drop the slots
|
|
565
|
+
// above the new limit, and inside a visit only the rotation itself is deduped.
|
|
566
|
+
pruneExtraBackups(filePath, count);
|
|
567
|
+
if (options.dedupeBackup === true && editSessionDepth > 0) {
|
|
568
|
+
// Inside a visit the snapshot is the restore point; the chain is only touched at visit end.
|
|
569
|
+
if (shouldBackUp(filePath, true) && count > 0) {
|
|
570
|
+
try {
|
|
571
|
+
reapStaleVisitSnapshots(filePath);
|
|
572
|
+
copyFileSync(filePath, visitSnapshotPath(filePath));
|
|
573
|
+
backedUpInSession.add(filePath);
|
|
574
|
+
visitSnapshots.add(visitSnapshotPath(filePath));
|
|
575
|
+
} catch (e: any) {
|
|
576
|
+
log.error("settings_backup_failed", { filePath, error: e?.message });
|
|
577
|
+
throw new SettingsBackupError(filePath, e);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
} else {
|
|
581
|
+
rotateSettingsBackups(filePath, count);
|
|
582
|
+
}
|
|
369
583
|
}
|
|
370
584
|
const content = JSON.stringify(data, null, 2) + "\n";
|
|
371
585
|
const tmpPath = `${filePath}.${process.pid}.${Date.now().toString(36)}`;
|
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
getFilePathForScope,
|
|
36
36
|
readSettingsFile,
|
|
37
37
|
writeSettingsFile,
|
|
38
|
+
beginSettingsEditSession,
|
|
39
|
+
endSettingsEditSession,
|
|
38
40
|
registerSettingsTuiCommand,
|
|
39
41
|
isSecretNoChange,
|
|
40
42
|
resolveSecretPrefill,
|
|
@@ -373,7 +375,7 @@ function saveChange(key: string, value: unknown, scope: "project" | "global", cw
|
|
|
373
375
|
}
|
|
374
376
|
if (Object.keys(current).length === 0 && !existsSync(filePath)) return { ok: true };
|
|
375
377
|
try {
|
|
376
|
-
writeSettingsFile(filePath, current);
|
|
378
|
+
writeSettingsFile(filePath, current, { dedupeBackup: true });
|
|
377
379
|
} catch (e) {
|
|
378
380
|
return { ok: false, reason: "write_error", error: e };
|
|
379
381
|
}
|
|
@@ -390,7 +392,7 @@ function deleteFromScope(key: string, scope: "project" | "global", cwd: string):
|
|
|
390
392
|
if (!(key in current)) return { ok: true };
|
|
391
393
|
delete current[key];
|
|
392
394
|
try {
|
|
393
|
-
writeSettingsFile(filePath, current);
|
|
395
|
+
writeSettingsFile(filePath, current, { dedupeBackup: true });
|
|
394
396
|
} catch (e) {
|
|
395
397
|
return { ok: false, reason: "write_error", error: e };
|
|
396
398
|
}
|
|
@@ -655,11 +657,17 @@ async function openSettingsTui(ctx: ExtensionCommandContext, initialScope?: stri
|
|
|
655
657
|
// Clear cached settings so TUI always shows fresh values
|
|
656
658
|
clearSettingsCache();
|
|
657
659
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
660
|
+
// One backup per visit, not per keystroke-saved value.
|
|
661
|
+
beginSettingsEditSession();
|
|
662
|
+
try {
|
|
663
|
+
await ctx.ui.custom<undefined>(
|
|
664
|
+
(tui, theme, _kb, done) =>
|
|
665
|
+
new SettingsOverlay(tui, theme, cwd, modelRegistry, editScope, done, (msg, level) => ctx.ui.notify(msg, level)),
|
|
666
|
+
OVERLAY_OPTS,
|
|
667
|
+
);
|
|
668
|
+
} finally {
|
|
669
|
+
endSettingsEditSession();
|
|
670
|
+
}
|
|
663
671
|
}
|
|
664
672
|
|
|
665
673
|
// ── Registration ────────────────────────────────────────────
|