neurastream 0.1.4 → 0.1.5
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/package.json +1 -1
- package/src/adapters/omp/doctor.ts +10 -4
- package/src/adapters/omp/index.ts +9 -5
- package/src/adapters/omp/install.ts +29 -9
- package/src/adapters/omp/package-ops.ts +28 -0
- package/src/core/guards.ts +19 -4
- package/src/core/main-start.ts +3 -1
- package/src/core/queue/lib.ts +92 -3
package/package.json
CHANGED
|
@@ -36,9 +36,13 @@ export function adapterLiveFiles(home: string, root: string = PACKAGE_ROOT): Liv
|
|
|
36
36
|
for (const bin of packageBins(root)) {
|
|
37
37
|
files.push({ live: join(home, ".local/bin", bin.name), source: bin.source });
|
|
38
38
|
}
|
|
39
|
+
// P9: the extension symlink is gone (ONE load route — the plugin below);
|
|
40
|
+
// what the adapter installs under the agent dir is the omp plugin link,
|
|
41
|
+
// whose realpath is an installed neurastream package root. `omp plugin
|
|
42
|
+
// link` owns this file; install must never ensureLink it.
|
|
39
43
|
files.push({
|
|
40
|
-
live: join(home, ".omp/
|
|
41
|
-
source:
|
|
44
|
+
live: join(home, ".omp/plugins/node_modules/neurastream"),
|
|
45
|
+
source: root,
|
|
42
46
|
});
|
|
43
47
|
files.push({
|
|
44
48
|
live: join(home, ".omp/agent/queue/lib.ts"),
|
|
@@ -68,8 +72,10 @@ function bunLiveFiles(home: string, root: string): LiveFile[] {
|
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
function packageRootOf(file: string): string | null {
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
// `file` itself may be the package root (the omp plugin link's realpath is
|
|
76
|
+
// an installed neurastream root), so the self dir is checked first.
|
|
77
|
+
let dir = file;
|
|
78
|
+
for (let i = 0; i < 9; i++) {
|
|
73
79
|
const manifest = join(dir, "package.json");
|
|
74
80
|
if (existsSync(manifest)) {
|
|
75
81
|
try {
|
|
@@ -20,11 +20,13 @@ import { appendFileSync, mkdirSync, openSync, readFileSync, rmSync, statSync, wr
|
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import type { Decision } from "../../core/adapter.ts";
|
|
22
22
|
import { loadConfig, type Config } from "../../core/config.ts";
|
|
23
|
-
import { checkInject, checkPlanEdit, checkStop, checkTool, recordMainResult } from "../../core/guards.ts";
|
|
23
|
+
import { checkInject, checkPlanEdit, checkStop, checkTool, MAIN_PLAN_FILE, recordMainResult } from "../../core/guards.ts";
|
|
24
24
|
import { applyQueueEnv } from "../../core/queue/env.ts";
|
|
25
25
|
import { mainStartText } from "../../core/main-start.ts";
|
|
26
26
|
import { buildHandoff, holdsBlock, notePath, noteRequest, type RolloverState, rolloverState } from "../../core/rollover.ts";
|
|
27
27
|
import * as q from "../../core/queue/lib.ts";
|
|
28
|
+
// Registers the plugin-command matcher into core's writeTargets (side effect).
|
|
29
|
+
import "./package-ops.ts";
|
|
28
30
|
|
|
29
31
|
/** Subset of the harness extension host the adapter needs (pi.on; herdr also uses pi.events). */
|
|
30
32
|
export interface ExtensionHost {
|
|
@@ -86,11 +88,14 @@ function adapterLog(cfg: Config, line: string): void {
|
|
|
86
88
|
// Background queue-close for every plan Main marked complete (owner 2026-10-15). Ported from the
|
|
87
89
|
// original autoClose: same filter, same "--- auto-close attempt" log line, detached child with
|
|
88
90
|
// stdio on the per-plan close log. QUEUE_CLOSE_BIN overrides the binary (tests); without it the
|
|
89
|
-
// package's own
|
|
91
|
+
// package's own executable bin/queue-close runs (its `#!/usr/bin/env bun` shebang picks bun).
|
|
92
|
+
// Never process.execPath: inside the harness that is the harness binary itself, so the child was
|
|
93
|
+
// a new agent session that loaded this plugin and auto-closed again — a process fork bomb
|
|
94
|
+
// (2026-10-21 OOMs, ~175 processes).
|
|
90
95
|
function closeChildArgs(plan: string): [string, string[]] {
|
|
91
96
|
const bin = process.env.QUEUE_CLOSE_BIN;
|
|
92
97
|
if (bin) return [bin, [plan]];
|
|
93
|
-
return [
|
|
98
|
+
return [join(import.meta.dir, "..", "..", "..", "bin", "queue-close"), [plan]];
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
export function autoClose(cfg: Config): string[] {
|
|
@@ -134,7 +139,6 @@ function ledgerText(cfg: Config): string {
|
|
|
134
139
|
|
|
135
140
|
// Plan files a subagent write/edit may touch: snapshot before the call so tool_result can verify
|
|
136
141
|
// and restore (the original snapshotted inside checkCall; core returns pure decisions now).
|
|
137
|
-
const MAIN_PLAN = /\/docs\/plans\/(?!archive\/)[^/]+\.md$/;
|
|
138
142
|
function snapshotPlans(tool: string, input: Record<string, unknown>, cwd: string, snap: Map<string, string>, main: boolean): void {
|
|
139
143
|
if (tool !== "write" && tool !== "edit") return;
|
|
140
144
|
const plans = q.activePlans();
|
|
@@ -142,7 +146,7 @@ function snapshotPlans(tool: string, input: Record<string, unknown>, cwd: string
|
|
|
142
146
|
if (snap.has(p)) continue;
|
|
143
147
|
if (main) {
|
|
144
148
|
// Main's own plan text is checked too; a plan that did not exist yet snapshots as "".
|
|
145
|
-
if (!
|
|
149
|
+
if (!MAIN_PLAN_FILE.test(p)) continue;
|
|
146
150
|
try {
|
|
147
151
|
snap.set(p, readFileSync(p, "utf8"));
|
|
148
152
|
} catch {
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
// Does not read or write the real user's agent directory: every path is under `home`.
|
|
3
3
|
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { dirname, join, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { configFile } from "../../core/config.ts";
|
|
10
10
|
|
|
11
11
|
import { adapterLiveFiles } from "./doctor.ts";
|
|
12
|
+
// Registers the plugin-command matcher into core's writeTargets (side effect).
|
|
13
|
+
import "./package-ops.ts";
|
|
12
14
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
13
15
|
|
|
14
16
|
const OVERLAY = `adapter:
|
|
@@ -18,11 +20,14 @@ const OVERLAY = `adapter:
|
|
|
18
20
|
extensions: "~/.omp/agent/extensions"
|
|
19
21
|
`;
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
// P8: the plugin dir is adapter surface and lands in the config's
|
|
24
|
+
// guards.protected, so core's protected check covers it without naming a
|
|
25
|
+
// harness path. installOmp writes it via the same overlay pass.
|
|
26
|
+
const PROTECTED = ["~/.omp/agent/agents/", "~/.omp/agent/extensions/", "~/.omp/plugins/"];
|
|
22
27
|
|
|
23
28
|
export interface InstallResult {
|
|
24
|
-
extension: string;
|
|
25
29
|
plugin: string;
|
|
30
|
+
removedStaleLink: string | null;
|
|
26
31
|
pinsCopied: string[];
|
|
27
32
|
pinsKept: string[];
|
|
28
33
|
overlay: "written" | "unchanged";
|
|
@@ -46,7 +51,9 @@ function ensureLink(link: string, target: string): void {
|
|
|
46
51
|
|
|
47
52
|
function linkInstalled(home: string): void {
|
|
48
53
|
for (const file of adapterLiveFiles(home)) {
|
|
49
|
-
|
|
54
|
+
// agents/ are copied, not linked; the plugin link is `omp plugin link`'s
|
|
55
|
+
// own output (linkPlugin below), never ours to ensureLink.
|
|
56
|
+
if (file.live.includes("/.omp/agent/agents/") || file.live.includes("/.omp/plugins/")) continue;
|
|
50
57
|
ensureLink(file.live, file.source);
|
|
51
58
|
}
|
|
52
59
|
}
|
|
@@ -109,24 +116,37 @@ function linkPlugin(home: string): string {
|
|
|
109
116
|
return join(home, ".omp/plugins/node_modules/neurastream");
|
|
110
117
|
}
|
|
111
118
|
|
|
112
|
-
/** Link the
|
|
119
|
+
/** Link the plugin, copy missing pins, write the overlay. HOME is the sandbox or the caller's home. */
|
|
113
120
|
export function installOmp(home: string): InstallResult {
|
|
114
121
|
const file = configFile(home);
|
|
115
122
|
if (!existsSync(file)) throw new Error(`no config at ${file}; run neurastream init first`);
|
|
123
|
+
// P9: ONE load route. omp loads extensions-dir files AND a plugin package's
|
|
124
|
+
// package.json#omp.extensions, so an extensions/neurastream.ts link beside
|
|
125
|
+
// `omp plugin link` runs every hook twice. Only the plugin route remains —
|
|
126
|
+
// the one guard parity locks (~/.omp/plugins/ + the package root). A stale
|
|
127
|
+
// link an older install made is removed, but only when it is a symlink: a
|
|
128
|
+
// real file there is never ours to delete.
|
|
116
129
|
const extension = join(home, ".omp/agent/extensions/neurastream.ts");
|
|
117
|
-
|
|
118
|
-
|
|
130
|
+
let removedStaleLink: string | null = null;
|
|
131
|
+
try {
|
|
132
|
+
if (lstatSync(extension).isSymbolicLink()) {
|
|
133
|
+
unlinkSync(extension);
|
|
134
|
+
removedStaleLink = extension;
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// absent: nothing stale to remove
|
|
138
|
+
}
|
|
119
139
|
mkdirSync(join(home, ".omp/agent/sessions"), { recursive: true });
|
|
120
140
|
const pins = copyPins(join(home, ".omp/agent/agents"));
|
|
121
141
|
linkInstalled(home);
|
|
122
142
|
const plugin = linkPlugin(home);
|
|
123
143
|
const overlay = writeOverlay(file);
|
|
124
|
-
return {
|
|
144
|
+
return { plugin, removedStaleLink, pinsCopied: pins.copied, pinsKept: pins.kept, overlay };
|
|
125
145
|
}
|
|
126
146
|
|
|
127
147
|
export function printInstall(result: InstallResult): void {
|
|
128
|
-
console.log(`neurastream adapter install omp: linked ${result.extension}`);
|
|
129
148
|
console.log(`neurastream adapter install omp: plugin ${result.plugin}`);
|
|
149
|
+
if (result.removedStaleLink) console.log(`neurastream adapter install omp: removed stale extension link ${result.removedStaleLink}`);
|
|
130
150
|
console.log(
|
|
131
151
|
`neurastream adapter install omp: pins copied ${result.pinsCopied.length}, kept ${result.pinsKept.length}; overlay ${result.overlay}`,
|
|
132
152
|
);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Adapter-owned package-operation shapes for the P8 guard parity: core's
|
|
2
|
+
// writeTargets never names a harness, so the plugin-command matcher lives here
|
|
3
|
+
// and registers itself into core at module load. Every plugin command writes
|
|
4
|
+
// under the plugin directory, which src/adapters/omp/install.ts lists in the
|
|
5
|
+
// config's guards.protected — so mapping the commands onto that directory puts
|
|
6
|
+
// them behind the owner's unlock like every other guard file.
|
|
7
|
+
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { registerPackageOpTarget, type PackageOpMatcher } from "../../core/queue/lib.ts";
|
|
10
|
+
|
|
11
|
+
const PLUGIN_ACTIONS = ["install", "uninstall", "link", "unlink", "update"];
|
|
12
|
+
|
|
13
|
+
/** Target for `<harness> plugin <action>` segments; null when none matches. */
|
|
14
|
+
export const pluginCommandTarget: PackageOpMatcher = (code: string): string | null => {
|
|
15
|
+
for (const seg of code.split(/&&|\|\||;|\n|\|/)) {
|
|
16
|
+
const toks = seg.trim().split(/\s+/).filter(Boolean);
|
|
17
|
+
let i = 0;
|
|
18
|
+
while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i] ?? "")) i++;
|
|
19
|
+
const cmd = (toks[i] ?? "").replace(/^["']|["']$/g, "").replace(/^.*\//, "");
|
|
20
|
+
if (cmd !== "omp") continue;
|
|
21
|
+
const sub = (toks[i + 1] ?? "").replace(/^["']+|["']+$/g, "");
|
|
22
|
+
if (sub === "plugin" && PLUGIN_ACTIONS.includes((toks[i + 2] ?? "").replace(/^["']+|["']+$/g, "")))
|
|
23
|
+
return `${homedir()}/.omp/plugins/`;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
registerPackageOpTarget(pluginCommandTarget);
|
package/src/core/guards.ts
CHANGED
|
@@ -338,6 +338,9 @@ function mainTool(e: ToolEvent, input: Record<string, unknown>, cfg: Config): De
|
|
|
338
338
|
return editWindow(e, input, cfg);
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
/** A live plan file (archived plans excluded); Main's plan-text checks run on these. */
|
|
342
|
+
export const MAIN_PLAN_FILE = /\/docs\/plans\/(?!archive\/)[^/]+\.md$/;
|
|
343
|
+
|
|
341
344
|
/**
|
|
342
345
|
* pre-tool: plan-guard checkCall for subs (protected files, budget, plan-edit rules, blockers)
|
|
343
346
|
* plus, for Main, the spawn gates and the project-file edit window.
|
|
@@ -358,6 +361,13 @@ export function checkTool(e: ToolEvent, cfg: Config): Decision {
|
|
|
358
361
|
);
|
|
359
362
|
}
|
|
360
363
|
if (e.agent === "main") {
|
|
364
|
+
// The plan-text checks (checkPlanEdit) see only edit/write results; a shell redirect,
|
|
365
|
+
// sed -i or heredoc into a plan would skip them, so plans change through edit/write only.
|
|
366
|
+
const shellPlan = e.tool !== "write" && e.tool !== "edit" ? writes.find(p => MAIN_PLAN_FILE.test(p)) : undefined;
|
|
367
|
+
if (shellPlan) {
|
|
368
|
+
log(cfg.paths.plan_guard_log, `BLOCK main tool=${e.tool} shell write to plan ${shellPlan}`);
|
|
369
|
+
return deny(`plan-guard: ${shellPlan} is a plan file: change it with the edit or write tool (close or cancel it with queue-close / queue-cancel).`);
|
|
370
|
+
}
|
|
361
371
|
const d = mainTool(e, input, cfg);
|
|
362
372
|
// A task brief names paths it does not read; only the other tools' paths count as reads.
|
|
363
373
|
if (d.allow && e.tool !== "task") recordMainReads(paths.filter(p => !writes.includes(p)));
|
|
@@ -462,23 +472,28 @@ export function flagCancel(text: string, cfg: Config): string {
|
|
|
462
472
|
|
|
463
473
|
/**
|
|
464
474
|
* prompt/owner message: inject the queue summary, flag cancel wording, reset Main's edit window
|
|
465
|
-
* and the stop counters (the original resets them at every user message).
|
|
475
|
+
* and the stop counters (the original resets them at every user message). A turn the harness
|
|
476
|
+
* starts from this guard's own stop continuation ("main-guard: …") is not an owner message: it
|
|
477
|
+
* keeps the counters and the edit window, else every continuation reset the idle streak and a
|
|
478
|
+
* model that only answers text was nudged again forever (2026-10-21 OOM: ~205 agent starts in one
|
|
479
|
+
* non-interactive session).
|
|
466
480
|
*/
|
|
467
481
|
export function checkInject(e: InjectEvent, cfg: Config): Decision {
|
|
468
482
|
if (e.agent !== "main") return ALLOW;
|
|
469
|
-
|
|
483
|
+
const continuation = e.inject.trimStart().startsWith("main-guard:");
|
|
484
|
+
if (!continuation) writeWindow({ used: [], windowStart: 0 });
|
|
470
485
|
let summary: string;
|
|
471
486
|
try {
|
|
472
487
|
summary = q.queueSummary();
|
|
473
488
|
} catch (err) {
|
|
474
489
|
summary = `Queue unreadable (${q.INDEX}): ${err}`;
|
|
475
490
|
}
|
|
476
|
-
const cancel = flagCancel(e.inject, cfg);
|
|
491
|
+
const cancel = continuation ? "" : flagCancel(e.inject, cfg);
|
|
477
492
|
return {
|
|
478
493
|
allow: true,
|
|
479
494
|
reason: "",
|
|
480
495
|
inject: `${summary}\n(main-guard: read ${q.INDEX} and the listed repo INDEX.md files are summarised above; work the open plans, close finished ones with queue-close.)${cancel}`,
|
|
481
|
-
state: { callsSinceStop: 0, idleStops: 0 },
|
|
496
|
+
...(continuation ? {} : { state: { callsSinceStop: 0, idleStops: 0 } }),
|
|
482
497
|
};
|
|
483
498
|
}
|
|
484
499
|
|
package/src/core/main-start.ts
CHANGED
|
@@ -49,8 +49,10 @@ export function mainStartText(args: string[], cfg: Config): string {
|
|
|
49
49
|
if (lines.length) say(lines.join("\n"));
|
|
50
50
|
} catch {}
|
|
51
51
|
say("=== doctor");
|
|
52
|
+
// The CLI file itself (shebang `#!/usr/bin/env bun`), never process.execPath: inside a host
|
|
53
|
+
// harness that is the harness binary, which would start a new agent session instead.
|
|
52
54
|
const cli = join(dirname(fileURLToPath(import.meta.url)), "../cli/main.ts");
|
|
53
|
-
const doctor = spawnSync(
|
|
55
|
+
const doctor = spawnSync(cli, ["doctor"], { encoding: "utf8", env: process.env });
|
|
54
56
|
const text = `${doctor.stdout ?? ""}${doctor.stderr ?? ""}`;
|
|
55
57
|
out.push(text.endsWith("\n") || text === "" ? text : `${text}\n`);
|
|
56
58
|
if (doctor.error) say(doctor.error.message);
|
package/src/core/queue/lib.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Queue model: main index, repo INDEX.md, plan shape (templates/queue/TEMPLATE.md).
|
|
2
2
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
|
|
@@ -306,6 +306,60 @@ export function changesFiles(toolName: string, input: Record<string, unknown>):
|
|
|
306
306
|
return SHELL_TOOLS.has(toolName) && MUTATING.test(String(input?.command ?? input?.code ?? ""));
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
// P8: package-manager and cutover commands change the running guard package.
|
|
310
|
+
// Their write target is the protected root they touch, so the ordinary
|
|
311
|
+
// protected-path check refuses them without the owner's unlock. Matched per
|
|
312
|
+
// shell segment, with leading KEY=VALUE assignments stripped; the cutover
|
|
313
|
+
// scripts stay free when the same segment pins HOME to /tmp (how every sandbox
|
|
314
|
+
// test and the P9 session test run them). Harness-owned command shapes are NOT
|
|
315
|
+
// matched here: the adapter registers them through registerPackageOpTarget,
|
|
316
|
+
// because core never names a harness.
|
|
317
|
+
function isNeurastreamSpec(t: string): boolean {
|
|
318
|
+
const base = t.replace(/^["']|["']$/g, "").replace(/^\.?\//, "").replace(/^.*\//, "");
|
|
319
|
+
return base === "neurastream" || base.startsWith("neurastream@") || /^neurastream-[^/]*\.tgz$/.test(base);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Adapter-registered matcher for harness package operations (plugin install,
|
|
323
|
+
// link, ...). Returns the protected root the command writes, or null.
|
|
324
|
+
export type PackageOpMatcher = (code: string) => string | null;
|
|
325
|
+
export let packageOpMatcher: PackageOpMatcher | null = null;
|
|
326
|
+
|
|
327
|
+
export function registerPackageOpTarget(fn: PackageOpMatcher): void {
|
|
328
|
+
packageOpMatcher = fn;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function packageOpTarget(code: string): string | null {
|
|
332
|
+
for (const seg of code.split(/&&|\|\||;|\n|\|/)) {
|
|
333
|
+
const toks = seg.trim().split(/\s+/).filter(Boolean);
|
|
334
|
+
let i = 0;
|
|
335
|
+
while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i] ?? "")) i++;
|
|
336
|
+
const cmd = (toks[i] ?? "").replace(/^["']|["']$/g, "").replace(/^.*\//, "");
|
|
337
|
+
const rest = toks.slice(i + 1).map(t => t.replace(/^["']+|["']+$/g, ""));
|
|
338
|
+
const sub = rest[0] ?? "";
|
|
339
|
+
const hasGlobal = rest.some(t => t === "-g" || t === "--global");
|
|
340
|
+
const names = rest.slice(1).filter(t => !t.startsWith("-"));
|
|
341
|
+
if (cmd === "bun" && ["add", "install", "remove", "rm", "update", "link", "unlink"].includes(sub) && hasGlobal) {
|
|
342
|
+
if (!names.length || names.some(isNeurastreamSpec)) return `${bunGlobalRoot()}/install/global/node_modules/neurastream`;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (cmd === "npm" && ["install", "i", "uninstall", "update", "link"].includes(sub) && hasGlobal) {
|
|
346
|
+
if (!names.length || names.some(isNeurastreamSpec)) return `${bunGlobalRoot()}/install/global/node_modules/neurastream`;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (cmd === "neurastream" && sub === "adapter" && ["install", "uninstall"].includes(rest[1] ?? ""))
|
|
350
|
+
return `${bunGlobalRoot()}/install/global/node_modules/neurastream`;
|
|
351
|
+
if ((cmd === "bash" || cmd === "sh") && rest.some(t => {
|
|
352
|
+
const base = t.replace(/^.*\//, "");
|
|
353
|
+
return base === "cutover.sh" || base === "rollback.sh";
|
|
354
|
+
})) {
|
|
355
|
+
const envHome = toks.slice(0, i).find(t => t.startsWith("HOME="));
|
|
356
|
+
if (envHome && envHome.slice("HOME=".length).replace(/^["']|["']$/g, "").startsWith("/tmp/")) continue;
|
|
357
|
+
return `${agentDir()}/extensions/`;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return packageOpMatcher?.(code) ?? null;
|
|
361
|
+
}
|
|
362
|
+
|
|
309
363
|
export function writeTargets(toolName: string, input: Record<string, unknown>, cwd: string): string[] {
|
|
310
364
|
const out = new Set<string>();
|
|
311
365
|
const add = (raw: string, from: string) => {
|
|
@@ -334,7 +388,10 @@ export function writeTargets(toolName: string, input: Record<string, unknown>, c
|
|
|
334
388
|
}
|
|
335
389
|
if (!SHELL_TOOLS.has(toolName)) return [];
|
|
336
390
|
const code = String(input?.command ?? input?.code ?? "");
|
|
337
|
-
|
|
391
|
+
// A package/plugin/cutover command is a write even though none of the
|
|
392
|
+
// generic mutating heuristics fire on its text.
|
|
393
|
+
const pkgOp = packageOpTarget(code);
|
|
394
|
+
if (!MUTATING.test(code) && !pkgOp) return [];
|
|
338
395
|
let dir = typeof input?.cwd === "string" ? path.resolve(cwd, expandHome(input.cwd)) : cwd;
|
|
339
396
|
for (const seg of code.split(/&&|\|\||;|\n|\|/)) {
|
|
340
397
|
const s = seg.trim();
|
|
@@ -364,6 +421,7 @@ export function writeTargets(toolName: string, input: Record<string, unknown>, c
|
|
|
364
421
|
/\bshutil\.\w+\(\s*["'][^"']+["']\s*,\s*(["'][^"']+["'])/g,
|
|
365
422
|
])
|
|
366
423
|
for (const m of code.matchAll(re)) add((m[1] ?? "").replace(/`/g, ""), dir);
|
|
424
|
+
if (pkgOp) out.add(pkgOp);
|
|
367
425
|
return [...out];
|
|
368
426
|
}
|
|
369
427
|
|
|
@@ -372,9 +430,29 @@ export function unlockPath(): string {
|
|
|
372
430
|
}
|
|
373
431
|
export let UNLOCK = unlockPath();
|
|
374
432
|
export const UNLOCK_MINUTES = 30;
|
|
433
|
+
// P8 (2026-10-21): after the npm cutover the running guards load from the
|
|
434
|
+
// installed package, so the package itself is guard surface. The package root
|
|
435
|
+
// is only claimed when this file really is part of a published neurastream
|
|
436
|
+
// package (package.json name check): the pre-cutover standalone copy under
|
|
437
|
+
// <agent>/queue/ has no package.json above it and must not protect the home.
|
|
438
|
+
function packageManifest(): { root: string; bins: string[] } | null {
|
|
439
|
+
try {
|
|
440
|
+
const root = realpathSync(path.resolve(path.dirname(import.meta.path), "..", "..", ".."));
|
|
441
|
+
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as { name?: string; bin?: Record<string, string> };
|
|
442
|
+
if (pkg.name !== "neurastream") return null;
|
|
443
|
+
return { root, bins: Object.keys(pkg.bin ?? {}) };
|
|
444
|
+
} catch {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function bunGlobalRoot(): string {
|
|
450
|
+
return process.env.BUN_INSTALL ? path.resolve(expandHome(process.env.BUN_INSTALL)) : `${HOME}/.bun`;
|
|
451
|
+
}
|
|
452
|
+
|
|
375
453
|
function protectedPaths(): string[] {
|
|
376
454
|
const agent = agentDir();
|
|
377
|
-
|
|
455
|
+
const paths = [
|
|
378
456
|
`${agent}/extensions/`,
|
|
379
457
|
`${agent}/queue/`,
|
|
380
458
|
`${agent}/blockers/`,
|
|
@@ -395,7 +473,18 @@ function protectedPaths(): string[] {
|
|
|
395
473
|
`${HOME}/.local/bin/git`,
|
|
396
474
|
`${HOME}/.local/bin/git-secret-gate`,
|
|
397
475
|
`${HOME}/.config/git-secret-gate/`,
|
|
476
|
+
// Where `bun add -g neurastream` lands; writeTargets points package
|
|
477
|
+
// operations here so they need the owner's unlock like any guard edit.
|
|
478
|
+
// The plugin dir is adapter surface: the installer lists it in the
|
|
479
|
+
// config's guards.protected (core never names a harness path).
|
|
480
|
+
`${bunGlobalRoot()}/install/global/node_modules/neurastream/`,
|
|
398
481
|
];
|
|
482
|
+
const pkg = packageManifest();
|
|
483
|
+
if (pkg) {
|
|
484
|
+
paths.push(`${pkg.root}/`);
|
|
485
|
+
for (const name of pkg.bins) paths.push(`${HOME}/.bun/bin/${name}`);
|
|
486
|
+
}
|
|
487
|
+
return paths;
|
|
399
488
|
}
|
|
400
489
|
export let PROTECTED = protectedPaths();
|
|
401
490
|
|