@hayasaka7/haya-pet 0.2.0 → 0.2.1

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.
@@ -0,0 +1,295 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import {
4
+ watchForApprovedProcess,
5
+ createApprovalWatchCoordinator
6
+ } from "../src/approval-process-watcher.js";
7
+
8
+ // --- watchForApprovedProcess -------------------------------------------------
9
+ // The watcher's contract is event-based, never time-based: it may only report
10
+ // "approved" because a real NEW process appeared under the client and stayed
11
+ // alive across two consecutive polls. A missed detection is always safe (the
12
+ // pet just keeps showing "waiting"); a false detection would hide a pending
13
+ // approval warning, so the persistence filter errs toward not firing.
14
+
15
+ function makeWatcher(snapshots, overrides = {}) {
16
+ // `snapshots` is a queue of process tables the fake lister returns per call.
17
+ const calls = { cleared: 0 };
18
+ const watcher = watchForApprovedProcess({
19
+ rootPid: 100,
20
+ listProcesses: async () => {
21
+ if (snapshots.length === 0) {
22
+ throw new Error("test ran out of snapshots");
23
+ }
24
+ const next = snapshots.shift();
25
+ if (next instanceof Error) {
26
+ throw next;
27
+ }
28
+ return next;
29
+ },
30
+ immediate: false,
31
+ setInterval: () => ({ unref() {} }),
32
+ clearInterval: () => {
33
+ calls.cleared += 1;
34
+ },
35
+ ...overrides
36
+ });
37
+ return { watcher, calls };
38
+ }
39
+
40
+ const ROOT = { pid: 100, ppid: 1 };
41
+ const BASE_CHILD = { pid: 110, ppid: 100 };
42
+
43
+ test("approval watcher ignores descendants that existed at baseline", async () => {
44
+ const approved = [];
45
+ const { watcher } = makeWatcher(
46
+ [
47
+ [ROOT, BASE_CHILD],
48
+ [ROOT, BASE_CHILD],
49
+ [ROOT, BASE_CHILD]
50
+ ],
51
+ { onApproved: (event) => approved.push(event) }
52
+ );
53
+
54
+ await watcher._tick(); // baseline
55
+ await watcher._tick();
56
+ await watcher._tick();
57
+
58
+ assert.deepEqual(approved, []);
59
+ });
60
+
61
+ test("approval watcher fires once when a new descendant persists across two polls", async () => {
62
+ const approved = [];
63
+ const newChild = { pid: 200, ppid: 100 };
64
+ const { watcher, calls } = makeWatcher(
65
+ [
66
+ [ROOT, BASE_CHILD],
67
+ [ROOT, BASE_CHILD, newChild],
68
+ [ROOT, BASE_CHILD, newChild],
69
+ [ROOT, BASE_CHILD, newChild, { pid: 300, ppid: 100 }]
70
+ ],
71
+ { onApproved: (event) => approved.push(event) }
72
+ );
73
+
74
+ await watcher._tick(); // baseline
75
+ await watcher._tick(); // candidate seen once — not yet
76
+ assert.deepEqual(approved, []);
77
+ await watcher._tick(); // candidate persisted — fire
78
+ assert.deepEqual(approved, [{ pid: 200 }]);
79
+
80
+ // Fires once and stops itself; later ticks are no-ops.
81
+ await watcher._tick();
82
+ assert.equal(approved.length, 1);
83
+ assert.equal(calls.cleared, 1);
84
+ });
85
+
86
+ test("approval watcher ignores a short-lived blip (hook spawn)", async () => {
87
+ const approved = [];
88
+ const blip = { pid: 200, ppid: 100 };
89
+ const { watcher } = makeWatcher(
90
+ [
91
+ [ROOT, BASE_CHILD],
92
+ [ROOT, BASE_CHILD, blip],
93
+ [ROOT, BASE_CHILD],
94
+ [ROOT, BASE_CHILD]
95
+ ],
96
+ { onApproved: (event) => approved.push(event) }
97
+ );
98
+
99
+ await watcher._tick(); // baseline
100
+ await watcher._tick(); // blip appears
101
+ await watcher._tick(); // blip gone — must not fire
102
+ await watcher._tick();
103
+
104
+ assert.deepEqual(approved, []);
105
+ });
106
+
107
+ test("approval watcher detects new grandchildren, not just direct children", async () => {
108
+ const approved = [];
109
+ // New process hangs off the pre-existing shell child (root -> shell -> command).
110
+ const grandchild = { pid: 200, ppid: 110 };
111
+ const { watcher } = makeWatcher(
112
+ [
113
+ [ROOT, BASE_CHILD],
114
+ [ROOT, BASE_CHILD, grandchild],
115
+ [ROOT, BASE_CHILD, grandchild]
116
+ ],
117
+ { onApproved: (event) => approved.push(event) }
118
+ );
119
+
120
+ await watcher._tick();
121
+ await watcher._tick();
122
+ await watcher._tick();
123
+
124
+ assert.deepEqual(approved, [{ pid: 200 }]);
125
+ });
126
+
127
+ test("approval watcher does not detect unrelated processes", async () => {
128
+ const approved = [];
129
+ const unrelated = { pid: 200, ppid: 999 };
130
+ const { watcher } = makeWatcher(
131
+ [
132
+ [ROOT, BASE_CHILD],
133
+ [ROOT, BASE_CHILD, unrelated],
134
+ [ROOT, BASE_CHILD, unrelated]
135
+ ],
136
+ { onApproved: (event) => approved.push(event) }
137
+ );
138
+
139
+ await watcher._tick();
140
+ await watcher._tick();
141
+ await watcher._tick();
142
+
143
+ assert.deepEqual(approved, []);
144
+ });
145
+
146
+ test("approval watcher survives lister errors and keeps working", async () => {
147
+ const approved = [];
148
+ const newChild = { pid: 200, ppid: 100 };
149
+ const { watcher } = makeWatcher(
150
+ [
151
+ [ROOT, BASE_CHILD],
152
+ new Error("snapshot failed"),
153
+ [ROOT, BASE_CHILD, newChild],
154
+ [ROOT, BASE_CHILD, newChild]
155
+ ],
156
+ { onApproved: (event) => approved.push(event) }
157
+ );
158
+
159
+ await watcher._tick(); // baseline
160
+ await watcher._tick(); // error — swallowed
161
+ await watcher._tick(); // candidate
162
+ await watcher._tick(); // persisted — fire
163
+
164
+ assert.deepEqual(approved, [{ pid: 200 }]);
165
+ });
166
+
167
+ test("approval watcher stop() prevents any further detection", async () => {
168
+ const approved = [];
169
+ const newChild = { pid: 200, ppid: 100 };
170
+ const { watcher, calls } = makeWatcher(
171
+ [
172
+ [ROOT, BASE_CHILD],
173
+ [ROOT, BASE_CHILD, newChild],
174
+ [ROOT, BASE_CHILD, newChild]
175
+ ],
176
+ { onApproved: (event) => approved.push(event) }
177
+ );
178
+
179
+ await watcher._tick();
180
+ watcher.stop();
181
+ await watcher._tick();
182
+ await watcher._tick();
183
+
184
+ assert.deepEqual(approved, []);
185
+ assert.equal(calls.cleared, 1);
186
+ });
187
+
188
+ // --- createApprovalWatchCoordinator -------------------------------------------
189
+ // Bridges session state changes to per-session watchers: watch only while a
190
+ // session with a known pid sits in waiting_approval; stop on any other state.
191
+
192
+ function makeCoordinator(overrides = {}) {
193
+ const events = { started: [], stopped: [], approved: [] };
194
+ const watchers = new Map();
195
+ const coordinator = createApprovalWatchCoordinator({
196
+ createWatcher: ({ rootPid, onApproved }) => {
197
+ events.started.push(rootPid);
198
+ const watcher = {
199
+ onApproved,
200
+ stop: () => events.stopped.push(rootPid)
201
+ };
202
+ watchers.set(rootPid, watcher);
203
+ return watcher;
204
+ },
205
+ onApproved: (sessionId, event) => events.approved.push({ sessionId, ...event }),
206
+ ...overrides
207
+ });
208
+ return { coordinator, events, watchers };
209
+ }
210
+
211
+ function session(state, overrides = {}) {
212
+ return { sessionId: "sess_a", pid: 100, state, ...overrides };
213
+ }
214
+
215
+ test("coordinator starts a watcher when a session enters waiting_approval", () => {
216
+ const { coordinator, events } = makeCoordinator();
217
+
218
+ coordinator.onSessionChanged(session("thinking"));
219
+ assert.deepEqual(events.started, []);
220
+
221
+ coordinator.onSessionChanged(session("waiting_approval"));
222
+ assert.deepEqual(events.started, [100]);
223
+ });
224
+
225
+ test("coordinator does not start a duplicate watcher for the same session", () => {
226
+ const { coordinator, events } = makeCoordinator();
227
+
228
+ coordinator.onSessionChanged(session("waiting_approval"));
229
+ coordinator.onSessionChanged(session("waiting_approval"));
230
+
231
+ assert.deepEqual(events.started, [100]);
232
+ });
233
+
234
+ test("coordinator stops the watcher when the session leaves waiting_approval", () => {
235
+ const { coordinator, events } = makeCoordinator();
236
+
237
+ coordinator.onSessionChanged(session("waiting_approval"));
238
+ coordinator.onSessionChanged(session("thinking"));
239
+
240
+ assert.deepEqual(events.stopped, [100]);
241
+
242
+ // Re-entering waiting_approval starts a fresh watcher.
243
+ coordinator.onSessionChanged(session("waiting_approval"));
244
+ assert.deepEqual(events.started, [100, 100]);
245
+ });
246
+
247
+ test("coordinator ignores sessions without a pid", () => {
248
+ const { coordinator, events } = makeCoordinator();
249
+
250
+ coordinator.onSessionChanged(session("waiting_approval", { pid: undefined }));
251
+
252
+ assert.deepEqual(events.started, []);
253
+ });
254
+
255
+ test("coordinator ignores undefined session changes", () => {
256
+ const { coordinator, events } = makeCoordinator();
257
+
258
+ coordinator.onSessionChanged(undefined);
259
+
260
+ assert.deepEqual(events.started, []);
261
+ });
262
+
263
+ test("coordinator forwards approval with the sessionId and stops tracking", () => {
264
+ const { coordinator, events, watchers } = makeCoordinator();
265
+
266
+ coordinator.onSessionChanged(session("waiting_approval"));
267
+ watchers.get(100).onApproved({ pid: 200 });
268
+
269
+ assert.deepEqual(events.approved, [{ sessionId: "sess_a", pid: 200 }]);
270
+
271
+ // The fired watcher is forgotten — a later waiting_approval starts a new one.
272
+ coordinator.onSessionChanged(session("waiting_approval"));
273
+ assert.deepEqual(events.started, [100, 100]);
274
+ });
275
+
276
+ test("coordinator tracks multiple sessions independently", () => {
277
+ const { coordinator, events } = makeCoordinator();
278
+
279
+ coordinator.onSessionChanged(session("waiting_approval"));
280
+ coordinator.onSessionChanged(session("waiting_approval", { sessionId: "sess_b", pid: 300 }));
281
+ coordinator.onSessionChanged(session("idle"));
282
+
283
+ assert.deepEqual(events.started, [100, 300]);
284
+ assert.deepEqual(events.stopped, [100]);
285
+ });
286
+
287
+ test("coordinator stopAll stops every active watcher", () => {
288
+ const { coordinator, events } = makeCoordinator();
289
+
290
+ coordinator.onSessionChanged(session("waiting_approval"));
291
+ coordinator.onSessionChanged(session("waiting_approval", { sessionId: "sess_b", pid: 300 }));
292
+ coordinator.stopAll();
293
+
294
+ assert.deepEqual(events.stopped.sort(), [100, 300]);
295
+ });
@@ -0,0 +1,88 @@
1
+ // Cross-OS process-table snapshots: every lister resolves to the full process
2
+ // list as [{ pid, ppid }], so consumers (the approval watcher) never branch on
3
+ // platform. Returns undefined on platforms we don't support — callers treat
4
+ // that as "feature unavailable", never as an error.
5
+ import { execFile as execFileCb } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { readdir as readdirFs, readFile as readFileFs } from "node:fs/promises";
8
+
9
+ const execFileAsync = promisify(execFileCb);
10
+
11
+ // One pid+ppid pair per line, whitespace-separated — both `ps` and our
12
+ // PowerShell query are shaped to emit exactly this.
13
+ function parsePidTable(stdout) {
14
+ const table = [];
15
+ for (const line of String(stdout).split("\n")) {
16
+ const parts = line.trim().split(/\s+/);
17
+ if (parts.length !== 2) {
18
+ continue;
19
+ }
20
+ const pid = Number.parseInt(parts[0], 10);
21
+ const ppid = Number.parseInt(parts[1], 10);
22
+ if (Number.isInteger(pid) && Number.isInteger(ppid)) {
23
+ table.push({ pid, ppid });
24
+ }
25
+ }
26
+ return table;
27
+ }
28
+
29
+ export function createProcessSnapshotLister(options = {}) {
30
+ const {
31
+ platform = process.platform,
32
+ execFile = execFileAsync,
33
+ readdir = readdirFs,
34
+ readFile = readFileFs
35
+ } = options;
36
+
37
+ if (platform === "win32") {
38
+ // CIM query via PowerShell (wmic is removed on current Windows 11). Output
39
+ // is forced to bare "pid ppid" lines to keep parsing trivial.
40
+ const script =
41
+ "Get-CimInstance -ClassName Win32_Process | ForEach-Object { \"$($_.ProcessId) $($_.ParentProcessId)\" }";
42
+ return async () => {
43
+ const { stdout } = await execFile(
44
+ "powershell.exe",
45
+ ["-NoProfile", "-NonInteractive", "-Command", script],
46
+ { windowsHide: true }
47
+ );
48
+ return parsePidTable(stdout);
49
+ };
50
+ }
51
+
52
+ if (platform === "darwin") {
53
+ return async () => {
54
+ const { stdout } = await execFile("ps", ["-axo", "pid=,ppid="]);
55
+ return parsePidTable(stdout);
56
+ };
57
+ }
58
+
59
+ if (platform === "linux") {
60
+ // Read /proc directly — no subprocess at all. stat field 4 is ppid, but the
61
+ // comm field (2) is parenthesised and may contain spaces/parens, so parse
62
+ // from AFTER the last ')'.
63
+ return async () => {
64
+ const entries = await readdir("/proc");
65
+ const table = [];
66
+ for (const entry of entries) {
67
+ if (!/^\d+$/.test(entry)) {
68
+ continue;
69
+ }
70
+ let stat;
71
+ try {
72
+ stat = String(await readFile(`/proc/${entry}/stat`, "utf8"));
73
+ } catch {
74
+ continue; // process exited mid-scan
75
+ }
76
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/);
77
+ const ppid = Number.parseInt(afterComm[1], 10);
78
+ const pid = Number.parseInt(entry, 10);
79
+ if (Number.isInteger(pid) && Number.isInteger(ppid)) {
80
+ table.push({ pid, ppid });
81
+ }
82
+ }
83
+ return table;
84
+ };
85
+ }
86
+
87
+ return undefined;
88
+ }
@@ -0,0 +1,105 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import { createProcessSnapshotLister } from "../src/process-snapshot.js";
4
+
5
+ // Each platform lister returns the same shape — the full process table as
6
+ // [{ pid, ppid }] — so the approval watcher core stays platform-agnostic.
7
+
8
+ test("windows lister parses the PowerShell pid/ppid table", async () => {
9
+ const invocations = [];
10
+ const lister = createProcessSnapshotLister({
11
+ platform: "win32",
12
+ execFile: async (file, args) => {
13
+ invocations.push({ file, args });
14
+ return { stdout: "4 0\r\n100 4\r\n2332 100\r\n\r\n" };
15
+ }
16
+ });
17
+
18
+ const table = await lister();
19
+
20
+ assert.deepEqual(table, [
21
+ { pid: 4, ppid: 0 },
22
+ { pid: 100, ppid: 4 },
23
+ { pid: 2332, ppid: 100 }
24
+ ]);
25
+ assert.equal(invocations.length, 1);
26
+ assert.equal(invocations[0].file, "powershell.exe");
27
+ // Must never load the user's profile or prompt interactively.
28
+ assert.ok(invocations[0].args.includes("-NoProfile"));
29
+ assert.ok(invocations[0].args.includes("-NonInteractive"));
30
+ });
31
+
32
+ test("darwin lister parses ps output with ragged spacing", async () => {
33
+ const lister = createProcessSnapshotLister({
34
+ platform: "darwin",
35
+ execFile: async (file, args) => {
36
+ assert.equal(file, "ps");
37
+ assert.deepEqual(args, ["-axo", "pid=,ppid="]);
38
+ return { stdout: " 1 0\n 455 1\n12034 455\n" };
39
+ }
40
+ });
41
+
42
+ assert.deepEqual(await lister(), [
43
+ { pid: 1, ppid: 0 },
44
+ { pid: 455, ppid: 1 },
45
+ { pid: 12034, ppid: 455 }
46
+ ]);
47
+ });
48
+
49
+ test("linux lister reads /proc stat files, handling names with spaces and parens", async () => {
50
+ const files = {
51
+ "/proc/1/stat": "1 (systemd) S 0 1 1 0 -1 4194560",
52
+ "/proc/455/stat": "455 (my (weird) name) S 1 455 455 0 -1 0",
53
+ "/proc/9999/stat": "9999 (node) R 455 9999 9999 0 -1 0"
54
+ };
55
+ const lister = createProcessSnapshotLister({
56
+ platform: "linux",
57
+ readdir: async (dir) => {
58
+ assert.equal(dir, "/proc");
59
+ return ["1", "455", "9999", "self", "acpi", "cpuinfo"];
60
+ },
61
+ readFile: async (path) => {
62
+ if (!(path in files)) {
63
+ throw new Error(`unexpected read: ${path}`);
64
+ }
65
+ return files[path];
66
+ }
67
+ });
68
+
69
+ assert.deepEqual(await lister(), [
70
+ { pid: 1, ppid: 0 },
71
+ { pid: 455, ppid: 1 },
72
+ { pid: 9999, ppid: 455 }
73
+ ]);
74
+ });
75
+
76
+ test("linux lister skips processes that vanish mid-scan", async () => {
77
+ const lister = createProcessSnapshotLister({
78
+ platform: "linux",
79
+ readdir: async () => ["1", "2"],
80
+ readFile: async (path) => {
81
+ if (path === "/proc/2/stat") {
82
+ throw new Error("ENOENT: gone");
83
+ }
84
+ return "1 (init) S 0 1 1 0 -1 0";
85
+ }
86
+ });
87
+
88
+ assert.deepEqual(await lister(), [{ pid: 1, ppid: 0 }]);
89
+ });
90
+
91
+ test("listers skip malformed lines instead of failing", async () => {
92
+ const lister = createProcessSnapshotLister({
93
+ platform: "win32",
94
+ execFile: async () => ({ stdout: "4 0\nnot numbers\n77\n100 4\n" })
95
+ });
96
+
97
+ assert.deepEqual(await lister(), [
98
+ { pid: 4, ppid: 0 },
99
+ { pid: 100, ppid: 4 }
100
+ ]);
101
+ });
102
+
103
+ test("unsupported platforms yield no lister", () => {
104
+ assert.equal(createProcessSnapshotLister({ platform: "sunos" }), undefined);
105
+ });