wkappbot-code 0.3.33 → 0.3.35
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/bin/postinstall.js +5 -1
- package/bin/prepack.js +10 -0
- package/bin/wkappbot-code.js +8 -6
- package/lib/allow-compact.js +4 -1
- package/lib/doctor-path.js +1 -1
- package/lib/fslocked.js +18 -0
- package/lib/hook-wire.js +1 -1
- package/lib/lock-md.js +56 -0
- package/lib/lockwait.js +36 -0
- package/lib/plugin-files.js +6 -0
- package/lib/providers.js +1 -1
- package/lib/python-acquire.js +1 -1
- package/lib/rmguard.js +128 -0
- package/lib/setup-run.js +1 -1
- package/lib/stale-wkskill.js +1 -1
- package/lib/starter-md.js +1 -1
- package/lib/update.js +1 -1
- package/lib/wk-hook.js +3 -0
- package/package.json +1 -1
- package/templates/harness-locks.md +6 -0
package/bin/postinstall.js
CHANGED
|
@@ -52,11 +52,15 @@ async function runWorker() {
|
|
|
52
52
|
const env = withPathFirst({ ...process.env, WKAPPBOT_CODE_NO_UPDATE_CHECK: "1", WKAPPBOT_CODE_SETUP_WORKER: "1" }, globalBinDir(process.env));
|
|
53
53
|
stamp("worker start: node " + args.join(" "));
|
|
54
54
|
const out = openSync(LOG, "a");
|
|
55
|
-
const
|
|
55
|
+
const runInstall = () => new Promise((res) => {
|
|
56
56
|
const c = spawn(process.execPath, args, { stdio: ["ignore", out, out], env });
|
|
57
57
|
c.on("error", () => res(-1));
|
|
58
58
|
c.on("close", (code) => res(code ?? -1));
|
|
59
59
|
});
|
|
60
|
+
let installRc = await runInstall();
|
|
61
|
+
// a permanent file lock makes install exit 75 (lib/lockwait.js EXIT_LOCKED): retry three times, then say what to do and go on (the worker exits 0)
|
|
62
|
+
for (let i = 1; i <= 3 && installRc === 75; i++) { stamp("install rc 75 (a file is locked); retry " + i + " of 3"); installRc = await runInstall(); }
|
|
63
|
+
if (installRc === 75) stamp("install is still locked: close the program holding the file, then run wkappbot-code install again (run wkappbot-code install again)");
|
|
60
64
|
stamp("install rc " + installRc + "; doctor");
|
|
61
65
|
const dr = spawnSync(process.execPath, [CLI, "doctor"], { encoding: "utf8", env, timeout: 300000 });
|
|
62
66
|
const dtext = (dr.stdout || "") + (dr.stderr || "");
|
package/bin/prepack.js
CHANGED
|
@@ -12,6 +12,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFile
|
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { dirname, join, resolve } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { PLUGIN_LIBS } from "../lib/plugin-files.js";
|
|
15
16
|
|
|
16
17
|
const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
18
|
const KIH = resolve(PKG_DIR, "..");
|
|
@@ -20,6 +21,15 @@ const PKG = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8"));
|
|
|
20
21
|
const die = (m) => { console.error("[wkappbot-code prepack] " + m); process.exit(1); };
|
|
21
22
|
const sh = (p) => process.platform === "win32" ? p.replace(/\\/g, "/") : p;
|
|
22
23
|
|
|
24
|
+
// Every relative import of a file install copies beside the plugin must be copied too (0.3.34: wk-hook.js imported ./rmguard.js, install copied wk-hook.js alone,
|
|
25
|
+
// ERR_MODULE_NOT_FOUND, every guard dead). Fail BEFORE a tarball exists. Spec: wkappbot skill read agent-harness-landscape-2026-howto
|
|
26
|
+
{
|
|
27
|
+
const flat = new Set(["wisdom-kernel.js", ...PLUGIN_LIBS]);
|
|
28
|
+
for (const [d, f] of [["plugin", "wisdom-kernel.js"], ...PLUGIN_LIBS.map((n) => ["lib", n])]) {
|
|
29
|
+
const src = readFileSync(join(PKG_DIR, d, f), "utf8");
|
|
30
|
+
for (const m of src.matchAll(/(?:\bfrom\s+|\bimport\s*\(\s*)["'](\.\/[^"']+)["']/g)) if (!flat.has(m[1].slice(2))) die(d + "/" + f + " imports " + m[1] + ", which install does not copy beside the plugin (add it to lib/plugin-files.js)");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
23
33
|
let stage = process.env.WKAPPBOT_CODE_HARNESS_STAGE || "";
|
|
24
34
|
let tmp = "";
|
|
25
35
|
const copy = join(KIH, "tools", "wk-harness-min-copy.sh");
|
package/bin/wkappbot-code.js
CHANGED
|
@@ -37,7 +37,7 @@ import { createHash } from "node:crypto";
|
|
|
37
37
|
import {
|
|
38
38
|
copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync,
|
|
39
39
|
realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, utimesSync, writeFileSync,
|
|
40
|
-
} from "
|
|
40
|
+
} from "../lib/fslocked.js";
|
|
41
41
|
import { homedir, tmpdir } from "node:os";
|
|
42
42
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
43
43
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -45,6 +45,7 @@ import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rm
|
|
|
45
45
|
import { applySets, askEach, buildConfig, configRow, detect, lineDiff, printDetected } from "../lib/config.js";
|
|
46
46
|
import { aiCliRows, installAiClis, removeAiClis } from "../lib/ai-clis.js";
|
|
47
47
|
import { providerCli, providerRow } from "../lib/providers.js";
|
|
48
|
+
import { EXIT_LOCKED, isLockError } from "../lib/lockwait.js"; import { PLUGIN_LIBS } from "../lib/plugin-files.js"; import { installLockMd, removeLockMd } from "../lib/lock-md.js";
|
|
48
49
|
import { hooksRow, wireClaudeHooks } from "../lib/hook-wire.js";
|
|
49
50
|
import { installStarterMd, removeStarterMd } from "../lib/starter-md.js";
|
|
50
51
|
import { addAllows, removeAllows } from "../lib/allow-compact.js";
|
|
@@ -361,7 +362,7 @@ function installPlugin(o, kih, kihSource) {
|
|
|
361
362
|
const dest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
|
|
362
363
|
copyFileSync(join(PKG_DIR, "plugin", "wisdom-kernel.js"), dest);
|
|
363
364
|
// The plugin file exports ONLY the plugin (OpenCode calls every named export as one); its helpers ride beside it.
|
|
364
|
-
copyFileSync(join(PKG_DIR, "lib",
|
|
365
|
+
for (const f of PLUGIN_LIBS) copyFileSync(join(PKG_DIR, "lib", f), join(cfg, "wkappbot-code", f));
|
|
365
366
|
writeFileSync(join(cfg, "wkappbot-code", "package.json"), '{ "type": "module" }\n');
|
|
366
367
|
const url = pathToFileURL(dest).href;
|
|
367
368
|
const tmpl = JSON.parse(readFileSync(join(PKG_DIR, "opencode.json"), "utf8"));
|
|
@@ -481,7 +482,8 @@ function install(o) {
|
|
|
481
482
|
// the compressed allow list (lib/allow-compact.js): five harness-own prefixes, never the wide rows learned on the owner's seats
|
|
482
483
|
st.allows = (o.harness && o.allows) ? addAllows(SETTINGS(), prev.allows || {}, log) : (prev.allows || { skipped: true });
|
|
483
484
|
// the standard global rules for a fresh machine (lib/starter-md.js): only where the user has no ~/.claude/CLAUDE.md
|
|
484
|
-
st.starterMd = o.harness ? installStarterMd({ claudeDir: dirname(SETTINGS()), pkgDir: PKG_DIR, prev: prev.starterMd, log }) : { skipped: true };
|
|
485
|
+
st.starterMd = o.harness ? installStarterMd({ claudeDir: dirname(SETTINGS()), pkgDir: PKG_DIR, prev: prev.starterMd, log }) : { skipped: true };
|
|
486
|
+
st.lockMd = o.harness ? installLockMd({ claudeDir: dirname(SETTINGS()), pkgDir: PKG_DIR, prev: prev.lockMd, log }) : { skipped: true }; // the study-lock rules of the installed sources, in ~/.claude/REFLEX.md (lib/lock-md.js) // Global pre-push dispatcher (owner 2026-11-06): the push-owner gate runs in every repo, then the repo's own hooks.
|
|
485
487
|
// A user's own absolute global core.hooksPath is kept (the script WARNs). Skipped with --no-harness.
|
|
486
488
|
st.pushGate = { skipped: true };
|
|
487
489
|
if (o.harness && kih && existsSync(join(kih, "tools", "wk-global-hooks.sh"))) {
|
|
@@ -576,7 +578,7 @@ function uninstall(o) {
|
|
|
576
578
|
}
|
|
577
579
|
// 3b. the ~/.local/bin/wkappbot link: removed ONLY when it points at the core this install placed (the core file stays)
|
|
578
580
|
const core = (st.bin && st.bin.dest) || binDest(o);
|
|
579
|
-
removeStarterMd(st.starterMd, log); removeStaleWkskill(HARNESS_LINKS(), log); // an older harness install's counterfeit wkskill link (SDK e2e: local-bin=wkskill)
|
|
581
|
+
removeStarterMd(st.starterMd, log); removeLockMd(st.lockMd, log); removeStaleWkskill(HARNESS_LINKS(), log); // an older harness install's counterfeit wkskill link (SDK e2e: local-bin=wkskill)
|
|
580
582
|
let linkRemoved = "";
|
|
581
583
|
for (const p of new Set(shimCandidates().concat([join(homedir(), ".local", "bin", "wkappbot")]))) {
|
|
582
584
|
if (!isSymlink(p) || (st.shims || []).includes(p)) continue;
|
|
@@ -660,7 +662,7 @@ function doctor(o) {
|
|
|
660
662
|
const v = opencodeVersion();
|
|
661
663
|
add("opencode", v === OPENCODE_VERSION, v ? v + (v === OPENCODE_VERSION ? "" : " (pinned " + OPENCODE_VERSION + ")") : "not on PATH", false);
|
|
662
664
|
const pdest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
|
|
663
|
-
add("plugin", existsSync(pdest) && existsSync(join(cfg, "wkappbot-code",
|
|
665
|
+
add("plugin", existsSync(pdest) && PLUGIN_LIBS.every((f) => existsSync(join(cfg, "wkappbot-code", f))), pdest + " + " + PLUGIN_LIBS.join(" + "), true);
|
|
664
666
|
let entry = null;
|
|
665
667
|
try { entry = (JSON.parse(readFileSync(join(cfg, "opencode.json"), "utf8")).plugin || []).find((p) => String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel")); } catch {}
|
|
666
668
|
add("config", !!entry, join(cfg, "opencode.json"), true);
|
|
@@ -746,7 +748,7 @@ if (process.argv.length <= 2 && o.cmd === "help") { const rc = launchOpenCode();
|
|
|
746
748
|
switch (o.cmd) {
|
|
747
749
|
case "--version": case "-v": case "version":
|
|
748
750
|
console.log("wkappbot-code " + PKG.version + " (opencode-ai " + OPENCODE_VERSION + ")"); process.exit(0);
|
|
749
|
-
case "install":
|
|
751
|
+
case "install": { let rc; try { rc = install(o); } catch (e) { if (e && (e.code === "ELOCKBUDGET" || isLockError(e))) { console.error(e.message + "\nwkappbot-code: a file is held by another program; close it and run wkappbot-code install again"); rc = EXIT_LOCKED; } else throw e; } process.exit(rc); }
|
|
750
752
|
case "uninstall": process.exit(uninstall(o));
|
|
751
753
|
case "doctor": process.exitCode = doctor(o); break;
|
|
752
754
|
case "update": process.exit(upd());
|
package/lib/allow-compact.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// are far too wide for a stranger's machine. A fresh install gets ONLY these five read-or-own-store prefixes. Every call
|
|
5
5
|
// still meets the harness PreToolUse hook first, and a hook refusal beats an allow rule, so an entry here skips the
|
|
6
6
|
// permission prompt, never the harness. Uninstall removes exactly the entries this install added.
|
|
7
|
-
import { existsSync, mkdirSync, writeFileSync } from "
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync } from "./fslocked.js";
|
|
8
8
|
import { dirname } from "node:path";
|
|
9
9
|
import { readSettings } from "./hook-wire.js";
|
|
10
10
|
|
|
@@ -13,6 +13,9 @@ export const COMPACT_ALLOWS = [
|
|
|
13
13
|
"Bash(wkappbot file read *)", // read-only file view
|
|
14
14
|
"Bash(wk-verify)", // live hook check, no arguments
|
|
15
15
|
"Bash(wkappbot-code doctor)", // read-only health table
|
|
16
|
+
"Bash(wkappbot-code install)", // what postinstall and the user run to set up (owner 2026-11-13: refused or prompted every time)
|
|
17
|
+
"Bash(wkappbot-code install *)", // the same with flags
|
|
18
|
+
"Bash(node */wkappbot-code/bin/postinstall.js*)", // the postinstall entry npm runs
|
|
16
19
|
];
|
|
17
20
|
|
|
18
21
|
// prev: the state recorded by the last install ({ added, madeAllow, madePerms }); a reinstall keeps owning what it added.
|
package/lib/doctor-path.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Windows: a new cmd.exe whose PATH is machine PATH + user PATH read from the registry.
|
|
5
5
|
// PASS only when each bare-name run prints "wkappbot v". Any other outcome is FAIL for that name.
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
|
-
import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "
|
|
7
|
+
import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "./fslocked.js";
|
|
8
8
|
import { dirname, join, resolve } from "node:path";
|
|
9
9
|
|
|
10
10
|
const OK_MARK = "wkappbot v";
|
package/lib/fslocked.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// fslocked.js -- node:fs for the install road: every name of node:fs, with the writers wrapped in lockRetry, so a file another
|
|
2
|
+
// process holds (EBUSY, EPERM, EACCES, ETXTBSY) is waited for, then reported as ELOCKBUDGET, instead of failing at once.
|
|
3
|
+
// The modules an install runs import from here instead of "node:fs" (owner 2026-11-13). Spec: wkappbot skill read harness-installer-owns-all-setup
|
|
4
|
+
import * as real from "node:fs";
|
|
5
|
+
import { lockRetry } from "./lockwait.js";
|
|
6
|
+
|
|
7
|
+
export * from "node:fs";
|
|
8
|
+
|
|
9
|
+
const wrap = (name, labelAt = 0) => (...a) => lockRetry(() => real[name](...a), { label: String(a[labelAt]) });
|
|
10
|
+
export const writeFileSync = wrap("writeFileSync");
|
|
11
|
+
export const appendFileSync = wrap("appendFileSync");
|
|
12
|
+
export const mkdirSync = wrap("mkdirSync");
|
|
13
|
+
export const copyFileSync = wrap("copyFileSync", 1);
|
|
14
|
+
export const renameSync = wrap("renameSync", 1);
|
|
15
|
+
export const symlinkSync = wrap("symlinkSync", 1);
|
|
16
|
+
export const unlinkSync = wrap("unlinkSync");
|
|
17
|
+
export const rmSync = wrap("rmSync");
|
|
18
|
+
export const chmodSync = wrap("chmodSync");
|
package/lib/hook-wire.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// stays verbatim, and every lite arm is present once. The web installer calls it through the CLI form below when it has
|
|
11
11
|
// node but no python3: node hook-wire.js wire SETTINGS LITE
|
|
12
12
|
import { spawnSync } from "node:child_process";
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "./fslocked.js";
|
|
14
14
|
import { dirname, join, resolve } from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { pickPython, stubWarning } from "./python-pick.js";
|
package/lib/lock-md.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// lock-md.js -- the study-lock rules for the installed harness sources, laid where the installed hooks read them (owner 2026-11-13
|
|
2
|
+
// via the spokesman: "the md files that protect the harness sources are not installed, not in the global md either").
|
|
3
|
+
// The JS study lock reads ~/.claude/CLAUDE.md and ~/.claude/REFLEX.md besides the repo's own; this install writes the rules of
|
|
4
|
+
// templates/harness-locks.md into ~/.claude/REFLEX.md between two markers. A REFLEX.md the user already has keeps every byte
|
|
5
|
+
// of its own text: the block is appended once and refreshed in place. Uninstall removes exactly the block, and the file when
|
|
6
|
+
// the install made it and nothing else is left. Spec: wkappbot skill read harness-installer-owns-all-setup
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "./fslocked.js";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export const BEGIN = "<!-- wk-harness-locks:begin (managed by wkappbot-code install; edit outside this block) -->";
|
|
11
|
+
export const END = "<!-- wk-harness-locks:end -->";
|
|
12
|
+
|
|
13
|
+
const block = (tpl) => BEGIN + "\n" + tpl.replace(/\s+$/, "") + "\n" + END + "\n";
|
|
14
|
+
const cut = (text) => {
|
|
15
|
+
const a = text.indexOf(BEGIN), b = text.indexOf(END);
|
|
16
|
+
return a >= 0 && b > a ? { before: text.slice(0, a), after: text.slice(b + END.length).replace(/^\r?\n/, ""), has: true } : { before: text, after: "", has: false };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// o: { claudeDir, pkgDir, prev ({ path, created }), log } -> { path, created, action }
|
|
20
|
+
export function installLockMd(o) {
|
|
21
|
+
const log = o.log || (() => {});
|
|
22
|
+
const path = join(o.claudeDir, "REFLEX.md");
|
|
23
|
+
const tplPath = join(o.pkgDir, "templates", "harness-locks.md");
|
|
24
|
+
if (!existsSync(tplPath)) { log("lock-md: WARN " + tplPath + " is missing from this package"); return { path, action: "missing-template" }; }
|
|
25
|
+
const mine = block(readFileSync(tplPath, "utf8"));
|
|
26
|
+
if (!existsSync(path)) {
|
|
27
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
28
|
+
writeFileSync(path, mine);
|
|
29
|
+
log("lock-md: wrote the harness study-lock rules to " + path);
|
|
30
|
+
return { path, created: true, action: "written" };
|
|
31
|
+
}
|
|
32
|
+
const cur = readFileSync(path, "utf8"), c = cut(cur);
|
|
33
|
+
const created = !!(o.prev && o.prev.created);
|
|
34
|
+
if (c.has) {
|
|
35
|
+
const next = c.before + mine + c.after;
|
|
36
|
+
if (next === cur) return { path, created, action: "current" };
|
|
37
|
+
writeFileSync(path, next);
|
|
38
|
+
log("lock-md: refreshed the harness study-lock block in " + path);
|
|
39
|
+
return { path, created, action: "refreshed" };
|
|
40
|
+
}
|
|
41
|
+
writeFileSync(path, cur + (cur === "" || cur.endsWith("\n") ? "" : "\n") + (cur === "" ? "" : "\n") + mine);
|
|
42
|
+
log("lock-md: appended the harness study-lock block to your " + path + " (your text is untouched)");
|
|
43
|
+
return { path, created: false, action: "appended" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Removes the block; a file this install made is deleted when nothing but the block was in it.
|
|
47
|
+
export function removeLockMd(rec, log = () => {}) {
|
|
48
|
+
if (!rec || !rec.path || !existsSync(rec.path)) return false;
|
|
49
|
+
const cur = readFileSync(rec.path, "utf8"), c = cut(cur);
|
|
50
|
+
if (!c.has) return false;
|
|
51
|
+
const rest = (c.before.replace(/\s+$/, "") + (c.before.trim() && c.after.trim() ? "\n\n" : "") + c.after.replace(/^\s+/, ""));
|
|
52
|
+
if (rec.created && !rest.trim()) { rmSync(rec.path, { force: true }); log("lock-md: removed " + rec.path + " (it held only the harness block)"); return true; }
|
|
53
|
+
writeFileSync(rec.path, c.before.trim() ? (c.before.replace(/\s+$/, "") + (c.after.trim() ? "\n\n" + c.after.replace(/^\s+/, "") : "\n")) : c.after.replace(/^\s+/, ""));
|
|
54
|
+
log("lock-md: removed the harness study-lock block from " + rec.path + " (your text kept)");
|
|
55
|
+
return true;
|
|
56
|
+
}
|
package/lib/lockwait.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// lockwait.js -- run a file operation, and when another process holds the file (EBUSY, EPERM, EACCES, ETXTBSY) wait for it to
|
|
2
|
+
// leave instead of failing at once (owner 2026-11-13 via the spokesman: install and postinstall failed on a locked file every time).
|
|
3
|
+
// lockRetry(fn, { label, holderDir, budgetMs = 30000, firstMs = 250 })
|
|
4
|
+
// - the wait starts at 250 ms and doubles, through holders.js waitForHolders when holderDir names the harness folder
|
|
5
|
+
// - ONE untruncated "LOCKED <label>: <code> ..." line goes to the log, however many retries follow
|
|
6
|
+
// - past the budget the error has code ELOCKBUDGET (the message holds the whole label and the last OS code)
|
|
7
|
+
// - any other error is thrown at once
|
|
8
|
+
// lib/fslocked.js puts this under every file write an install makes; bin/wkappbot-code.js turns ELOCKBUDGET into exit 75.
|
|
9
|
+
// Spec: wkappbot skill read harness-installer-owns-all-setup
|
|
10
|
+
import { waitForHolders } from "./holders.js";
|
|
11
|
+
|
|
12
|
+
export const LOCK_CODES = new Set(["EBUSY", "EPERM", "EACCES", "ETXTBSY"]);
|
|
13
|
+
export const EXIT_LOCKED = 75;
|
|
14
|
+
export const isLockError = (e) => !!e && LOCK_CODES.has(e.code);
|
|
15
|
+
const sleepMs = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* no wait available */ } };
|
|
16
|
+
const envBudget = () => { const n = Number(process.env.WKAPPBOT_CODE_LOCK_BUDGET_MS); return Number.isFinite(n) && n >= 0 && process.env.WKAPPBOT_CODE_LOCK_BUDGET_MS !== undefined ? n : 30000; };
|
|
17
|
+
|
|
18
|
+
export function lockRetry(fn, opts = {}) {
|
|
19
|
+
const budget = opts.budgetMs ?? envBudget(), nap = opts.sleep || sleepMs, log = opts.log || ((s) => console.error(s));
|
|
20
|
+
const label = String(opts.label || "file"), wait = opts.waitForHolders || waitForHolders;
|
|
21
|
+
let delay = opts.firstMs ?? 250, waited = 0, said = false, last = null;
|
|
22
|
+
for (;;) {
|
|
23
|
+
try { return fn(); } catch (e) {
|
|
24
|
+
if (!isLockError(e)) throw e;
|
|
25
|
+
last = e;
|
|
26
|
+
if (waited >= budget) break;
|
|
27
|
+
if (!said) { log(`LOCKED ${label}: ${e.code} -- another process holds it; waiting up to ${Math.round(budget / 1000)} s`); said = true; }
|
|
28
|
+
const step = Math.min(delay, budget - waited);
|
|
29
|
+
if (opts.holderDir) { wait(opts.holderDir, { maxMs: step, gap: Math.min(50, step), sleep: nap }); } else nap(step);
|
|
30
|
+
waited += step; delay *= 2;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const err = new Error(`LOCKED ${label}: ${last.code} still held after ${waited} ms (budget ${budget} ms)`);
|
|
34
|
+
err.code = "ELOCKBUDGET"; err.cause = last; err.lockedLabel = label;
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// plugin-files.js -- the helper files that ride BESIDE the OpenCode plugin in <config>/wkappbot-code/ (install copies each of
|
|
2
|
+
// lib/<name> there). wisdom-kernel.js imports ./wk-hook.js, and wk-hook.js imports ./rmguard.js, so EVERY relative import of a
|
|
3
|
+
// copied file must be in this list: a missing one made the whole plugin fail to load (0.3.34, SDK run 43333160662:
|
|
4
|
+
// ERR_MODULE_NOT_FOUND, every guard dead). bin/prepack.js refuses to pack when an import is not listed, and
|
|
5
|
+
// selftest-pluginlayout.mjs imports the plugin from an installed layout. Spec: wkappbot skill read agent-harness-landscape-2026-howto
|
|
6
|
+
export const PLUGIN_LIBS = ["wk-hook.js", "rmguard.js"];
|
package/lib/providers.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ("{env:VAR}"); a key or a URL typed where a name belongs is refused and never echoed, and no env var value is read
|
|
4
4
|
// except by the doctor row, which only checks that a variable is set and prints its name.
|
|
5
5
|
// Pure build/merge/remove functions first, then the CLI body. Spec: wkappbot skill read agent-harness-landscape-2026
|
|
6
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "./fslocked.js";
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
8
|
|
|
9
9
|
export const NPM = "@ai-sdk/openai-compatible";
|
package/lib/python-acquire.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// Spec: wkappbot skill read harness-installer-owns-all-setup
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from "
|
|
13
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from "./fslocked.js";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { pickPython, stubWarning } from "./python-pick.js";
|
package/lib/rmguard.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// wkappbot-code/lib/rm-guard.js -- the rm rule every harness family shares (owner 2026-11-12: rm was not blocked; block
|
|
2
|
+
// wildcards and folders, make each file be named, check each file's protection state). JS home of the rule; the sh twin
|
|
3
|
+
// is tools/wkharness-lite.sh rk_eval/rk_rm, and both run the one case table tools/wkjs/rm-guard.cases.txt
|
|
4
|
+
// (tools/wkjs/rm-guard.selftest.js). Spec: wkappbot skill read harness-rm-guard-howto
|
|
5
|
+
// REFUSED: a recursive delete (rm -r, rmdir -r, find -delete, Remove-Item -Recurse, del /s); a glob character in an rm
|
|
6
|
+
// target; an expansion ($, backtick) or xargs rm; a named file that is git-tracked with uncommitted changes, on the
|
|
7
|
+
// harness protected list, or read-only/immutable. EXEMPT: scratch (repo .tmp/, /tmp, TMPDIR, node_modules/ dist/ build/
|
|
8
|
+
// .bak/ inside the repo). A clean tracked file may go (git restores it).
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
const REMEDY = "name each file explicitly, or move scratch work under .tmp/";
|
|
14
|
+
const SCRATCH_SEG = new Set([".tmp", "node_modules", "dist", "build", ".bak"]);
|
|
15
|
+
const PROTECTED_BASE = /^(CLAUDE\.md|REFLEX\.md|HANDOFF\.md|settings(\.local)?\.json|[^/]*\.skill\.json)$/i;
|
|
16
|
+
const PROTECTED_DIR = /(^|\/)(\.claude\/hooks|\.githooks)(\/|$)/i;
|
|
17
|
+
|
|
18
|
+
// Slash form, ".." and "." folded away; relative names resolve against cwd. Returns "" for an escape past the root.
|
|
19
|
+
function norm(p, cwd) {
|
|
20
|
+
let s = String(p).replace(/\\+/g, "/");
|
|
21
|
+
if (!/^([A-Za-z]:)?\//.test(s)) s = String(cwd || ".").replace(/\\+/g, "/").replace(/\/+$/, "") + "/" + s;
|
|
22
|
+
const m = /^([A-Za-z]:)?(\/)?/.exec(s);
|
|
23
|
+
const head = m[0];
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const seg of s.slice(head.length).split("/")) {
|
|
26
|
+
if (seg === "" || seg === ".") continue;
|
|
27
|
+
if (seg === "..") { if (!out.length) return ""; out.pop(); continue; }
|
|
28
|
+
out.push(seg);
|
|
29
|
+
}
|
|
30
|
+
return head + out.join("/");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function tmpRoots(env) {
|
|
34
|
+
const r = ["/tmp", "/var/tmp"];
|
|
35
|
+
for (const t of [env && env.TMPDIR, process.env.TMPDIR, os.tmpdir()]) if (t) r.push(String(t).replace(/\\+/g, "/").replace(/\/+$/, ""));
|
|
36
|
+
return r.filter((x) => x && x !== "/");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// True when target sits under a scratch area. The scratch root itself is NOT scratch (rm -rf /tmp is refused).
|
|
40
|
+
export function isScratch(target, cwd, env) {
|
|
41
|
+
const t = String(target).replace(/^\$\{?TMPDIR\}?(?=\/|\\|$)/, (env && env.TMPDIR) || process.env.TMPDIR || os.tmpdir());
|
|
42
|
+
if (/[$`]/.test(t)) return false;
|
|
43
|
+
const n = norm(t, cwd);
|
|
44
|
+
if (!n) return false;
|
|
45
|
+
const nd = n.replace(/^[A-Za-z]:/, ""); // C:/r/build and an MSYS-style cwd /r name the same place
|
|
46
|
+
const lower = nd.toLowerCase();
|
|
47
|
+
for (const r of tmpRoots(env)) { const rl = r.toLowerCase(); if (lower.startsWith(rl + "/") && lower.length > rl.length + 1) return true; }
|
|
48
|
+
const c = norm(cwd || ".", "/").replace(/^[A-Za-z]:/, "");
|
|
49
|
+
if (c && lower.startsWith(c.toLowerCase() + "/")) {
|
|
50
|
+
const rel = nd.slice(c.length + 1).split("/");
|
|
51
|
+
if (rel.some((seg) => SCRATCH_SEG.has(seg))) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function gitDirty(file, cwd) {
|
|
57
|
+
try {
|
|
58
|
+
const dir = fs.statSync(file).isDirectory() ? file : file.replace(/[\\/][^\\/]*$/, "") || ".";
|
|
59
|
+
const out = execFileSync("git", ["-C", dir, "status", "--porcelain", "--", file], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] });
|
|
60
|
+
return out.split("\n").some((l) => l.length > 3 && !l.startsWith("??") && !l.startsWith("!!"));
|
|
61
|
+
} catch { return false; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function immutable(file) {
|
|
65
|
+
if (process.platform !== "linux") return false;
|
|
66
|
+
try { return /^\S*i\S*\s/.test(execFileSync("lsattr", ["-d", file], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] })); } catch { return false; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A named file target: why it must not be removed, or "".
|
|
70
|
+
export function fileProtection(target, cwd) {
|
|
71
|
+
const abs = norm(target, cwd);
|
|
72
|
+
if (!abs) return "";
|
|
73
|
+
if (PROTECTED_BASE.test(abs.replace(/^.*\//, "")) || PROTECTED_DIR.test(abs)) return "protected harness file";
|
|
74
|
+
let st; try { st = fs.lstatSync(abs); } catch { return ""; }
|
|
75
|
+
if (st.isDirectory()) return "";
|
|
76
|
+
if ((st.mode & 0o222) === 0) return "read-only file";
|
|
77
|
+
if (immutable(abs)) return "immutable file";
|
|
78
|
+
if (gitDirty(abs, cwd)) return "git-tracked with uncommitted changes";
|
|
79
|
+
return "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const isRecFlag = (x) => /^-[A-Za-z]*[rR][A-Za-z]*$/.test(x) || x === "--recursive";
|
|
83
|
+
const PS_RM = /^(remove-item|ri|rm|del|erase|rd|rmdir|rmdir\.exe)$/i;
|
|
84
|
+
|
|
85
|
+
// One simple command's words (quotes already removed by shellSegments). Returns a refusal label or "".
|
|
86
|
+
export function rmRisk(words, cwd, env) {
|
|
87
|
+
const w = words || [];
|
|
88
|
+
if (!w.length) return "";
|
|
89
|
+
const low = w.map((x) => String(x).toLowerCase());
|
|
90
|
+
// find ... -delete / -exec rm: refused unless every search root is scratch
|
|
91
|
+
const fi = low.indexOf("find");
|
|
92
|
+
if (fi >= 0 && fi === low.findIndex((x) => /^(find|gfind)$/.test(x)) && (low.includes("-delete") || /-exec(dir)?/.test(low.join(" ")) && low.includes("rm"))) {
|
|
93
|
+
const roots = w.slice(fi + 1).filter((x, i, a) => !x.startsWith("-") && (i === 0 || !a[i - 1].startsWith("-") || /^-[HLP]$/.test(a[i - 1])));
|
|
94
|
+
if (!roots.length || !roots.every((r) => isScratch(r, cwd, env))) return `find -delete (recursive delete): ${REMEDY}`;
|
|
95
|
+
}
|
|
96
|
+
const xi = low.indexOf("xargs");
|
|
97
|
+
if (xi >= 0 && low.slice(xi + 1).some((x) => /^(rm|rmdir|unlink|shred)$/.test(x))) return `xargs rm (targets unknown): ${REMEDY}`;
|
|
98
|
+
// Remove-Item / del / rd with a recursive switch
|
|
99
|
+
const pi = w.findIndex((x, i) => PS_RM.test(x) && (i === 0 || /^(sudo|cmd|powershell|pwsh|\/c|-command|-c)$/i.test(w[i - 1])));
|
|
100
|
+
const ri = w.indexOf("rm");
|
|
101
|
+
if (ri > 0 && w[ri - 1] === "git") return ""; // git rm stages a removal; git restores it
|
|
102
|
+
const i = ri >= 0 && (ri === 0 || /^(sudo|doas|command|exec|env|time|nice|nohup)$/.test(w[ri - 1])) ? ri : pi;
|
|
103
|
+
if (i < 0 && !/^(unlink|shred)$/.test(low[0])) return "";
|
|
104
|
+
const a = w.slice(i < 0 ? 1 : i + 1);
|
|
105
|
+
const rec = a.some((x) => isRecFlag(x) || /^-recurse$/i.test(x) || /^\/s$/i.test(x));
|
|
106
|
+
const tg = [];
|
|
107
|
+
let dd = false;
|
|
108
|
+
for (const x of a) {
|
|
109
|
+
if (dd) { tg.push(x); continue; }
|
|
110
|
+
if (x === "--") { dd = true; continue; }
|
|
111
|
+
if (x.startsWith("--") || (/^-[A-Za-z]/.test(x) && !/[\\/.]/.test(x)) || /^\/[a-z]$/i.test(x)) continue;
|
|
112
|
+
if (/^-(path|literalpath|include|exclude|filter)$/i.test(x)) continue;
|
|
113
|
+
tg.push(x);
|
|
114
|
+
}
|
|
115
|
+
if (!tg.length) return "";
|
|
116
|
+
for (const t of tg) {
|
|
117
|
+
if (/[$`]/.test(t) && !/^\$\{?TMPDIR\}?[\\/]/.test(t)) return `rm with a shell expansion in the target: ${REMEDY}`;
|
|
118
|
+
}
|
|
119
|
+
const scratch = tg.map((t) => isScratch(t, cwd, env));
|
|
120
|
+
if (rec && !scratch.every(Boolean)) return `rm -r of a folder or tree (recursive delete): ${REMEDY}`;
|
|
121
|
+
if (tg.some((t, k) => /[*?[\]]/.test(t) && !scratch[k])) return `rm with a wildcard in the target: ${REMEDY}`;
|
|
122
|
+
for (let k = 0; k < tg.length; k++) {
|
|
123
|
+
if (scratch[k]) continue;
|
|
124
|
+
const why = fileProtection(tg[k], cwd);
|
|
125
|
+
if (why) return `rm of ${tg[k]} refused, ${why}: ${REMEDY}`;
|
|
126
|
+
}
|
|
127
|
+
return "";
|
|
128
|
+
}
|
package/lib/setup-run.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// A user's own settings.json entries are the installer's job to keep (wkappbot-code.js install); this module only reads.
|
|
10
10
|
// Spec: wkappbot skill read agent-harness-landscape-2026-howto
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "
|
|
12
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "./fslocked.js";
|
|
13
13
|
import { homedir, tmpdir } from "node:os";
|
|
14
14
|
import { delimiter, dirname, join } from "node:path";
|
|
15
15
|
|
package/lib/stale-wkskill.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// (owner 2026-11-11: the fake wkskill is deleted; spokesman 2026-11-12: SDK e2e "after: local-bin=wkskill", RESULT 20/21 on every leg).
|
|
3
3
|
// A link or .cmd twin is removed ONLY while it still points into a harness (its target or body names wkharness or wk-harness);
|
|
4
4
|
// a wkskill file or link of the user's own stays. Spec: wkappbot skill read harness-installer-owns-all-setup
|
|
5
|
-
import { lstatSync, readFileSync, readlinkSync, rmSync } from "
|
|
5
|
+
import { lstatSync, readFileSync, readlinkSync, rmSync } from "./fslocked.js";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
|
|
8
8
|
const isHarness = (s) => /wkharness|wk-harness/.test(String(s || ""));
|
package/lib/starter-md.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// NO global CLAUDE.md; refreshed only while it still equals what this install wrote (sha256 kept in install-state.json);
|
|
6
6
|
// a file the user changed or wrote is never touched. Uninstall removes it only when it is still unchanged.
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "./fslocked.js";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
|
|
11
11
|
const sha = (s) => createHash("sha256").update(s).digest("hex");
|
package/lib/update.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// WKAPPBOT_CODE_NO_UPDATE_CHECK=1 turns the quiet check off. Spec: wkappbot skill read agent-harness-landscape-2026-howto
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
|
-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "
|
|
8
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "./fslocked.js";
|
|
9
9
|
import { homedir, tmpdir } from "node:os";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
|
package/lib/wk-hook.js
CHANGED
|
@@ -9,6 +9,7 @@ import { appendFileSync, existsSync, mkdirSync, statSync, writeFileSync } from "
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { dirname, join, resolve } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { rmRisk } from "./rmguard.js";
|
|
12
13
|
|
|
13
14
|
// OpenCode tool id -> Claude tool name the kih guards already know.
|
|
14
15
|
export const TOOL_MAP = {
|
|
@@ -271,6 +272,8 @@ export function riskClass(tool, input, cwd) {
|
|
|
271
272
|
if (tg.some((x) => GIT_SEG.test(x))) return "write under .git";
|
|
272
273
|
}
|
|
273
274
|
}
|
|
275
|
+
const rr = rmRisk(w, cwd, process.env); // owner 2026-11-12: recursive, wildcard and unprotected-file deletes (lib/rmguard.js)
|
|
276
|
+
if (rr) return rr;
|
|
274
277
|
if (w.some((x) => GIT_SEG.test(x)) && (seg.redirect || /^(rm|mv|cp|tee|truncate|dd|ln|chmod|sed)$/.test(w[0] || ""))) return "write under .git";
|
|
275
278
|
}
|
|
276
279
|
return "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wkappbot-code",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.35",
|
|
4
4
|
"description": "wkappbot-code -- Wisdom Kernel: the pinned OpenCode bridge plus the reflex harness plugin, the wkappbot core, the web harness and the latest public skills in one install",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
## Study locks for the installed harness sources (wkappbot-code install, owner 2026-11-13)
|
|
2
|
+
## An Edit or Write of an installed harness source is refused until the skills below are read, even after an emergency-edit grant.
|
|
3
|
+
## harness:skill (?i)[/\\]\.wk-harness[/\\](?:tools|\.githooks|\.claude[/\\]hooks|wkappbot-code[/\\]lib)[/\\]
|
|
4
|
+
wkappbot skill read harness-installer-owns-all-setup
|
|
5
|
+
wkappbot skill read agent-harness-landscape-2026-howto
|
|
6
|
+
wkappbot skill read verify-tool-or-guard-synthetic-live-static-howto
|