antechamber 1.0.36 → 1.0.38
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 +17 -0
- package/README.md +7 -4
- package/dist/commands/doctor.js +9 -3
- package/dist/proc-table-darwin.d.ts +8 -1
- package/dist/proc-table-darwin.js +15 -0
- package/dist/session-hosts.d.ts +21 -0
- package/dist/session-hosts.js +122 -1
- package/dist/session-tree.js +5 -1
- package/extensions/omo-compat/package.json +1 -1
- package/extensions/omo-dag/package.json +1 -1
- package/extensions/omo-work/package.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.38] - 2026-11-03
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Reverting a `/compact` message works again. The revert was rejected as an unknown message and OpenChamber 2.x
|
|
15
|
+
dropped the error silently. It now returns the conversation to where it was before the compaction.
|
|
16
|
+
|
|
17
|
+
## [1.0.37] - 2026-11-03
|
|
18
|
+
|
|
19
|
+
- feat(session-hosts): tear down private OmO hosts on macOS (#85)
|
|
20
|
+
|
|
10
21
|
## [1.0.36] - 2026-11-03
|
|
11
22
|
|
|
12
23
|
### Added
|
|
@@ -16,6 +27,12 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
|
|
16
27
|
keeps reading `/proc`; macOS reads `/bin/ps`, because Node has neither `/proc` nor pidfd there. A process that has
|
|
17
28
|
already exited (zombie) never counts as a survivor.
|
|
18
29
|
|
|
30
|
+
- Tear down OmO's private task hosts on macOS. The connector finds the supervisors it started by the `--socket` session
|
|
31
|
+
shard, entry point and working directory, records every process in their groups, and signals each one by PID only
|
|
32
|
+
after re-checking its group and start time, escalating to `SIGKILL` after a grace window. A process that outlives its
|
|
33
|
+
supervisor is still stopped. `antechamber doctor` reports the check as PASS when `/bin/ps` and `/usr/sbin/lsof`
|
|
34
|
+
exist. Linux keeps the pidfd path.
|
|
35
|
+
|
|
19
36
|
## [1.0.35] - 2026-11-03
|
|
20
37
|
|
|
21
38
|
- test: resolve fixture paths with fileURLToPath (#86)
|
package/README.md
CHANGED
|
@@ -354,10 +354,13 @@ Hardening controls (full reference in [docs/configuration.md](docs/configuration
|
|
|
354
354
|
## Platform notes
|
|
355
355
|
|
|
356
356
|
Linux is the primary supported platform. OmO's private task-host teardown
|
|
357
|
-
(killing detached supervisor processes antechamber spawned)
|
|
358
|
-
pidfds (`os.pidfd_open`, Linux 5.3+) and `python3` on PATH
|
|
359
|
-
API
|
|
360
|
-
|
|
357
|
+
(killing detached supervisor processes antechamber spawned) uses Linux
|
|
358
|
+
pidfds (`os.pidfd_open`, Linux 5.3+) and `python3` on PATH, because Node has no pidfd
|
|
359
|
+
API; on Linux it never signals a numeric PID. macOS has no pidfd, so there it
|
|
360
|
+
reads `/bin/ps` and `/usr/sbin/lsof` and signals each owned process by PID after
|
|
361
|
+
re-checking its process group and start time (see
|
|
362
|
+
[the OmO backend notes](docs/backends/omo.md#private-task-host-teardown)). CI runs the suite on
|
|
363
|
+
macOS. On other platforms that teardown step is skipped with one warning instead of crashing.
|
|
361
364
|
|
|
362
365
|
## Limitations / non-goals
|
|
363
366
|
|
package/dist/commands/doctor.js
CHANGED
|
@@ -84,9 +84,15 @@ export function doctor(options = {}, ctx = createContext(), latestVersion) {
|
|
|
84
84
|
}
|
|
85
85
|
})
|
|
86
86
|
: undefined;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
if (ctx.platform === "darwin") {
|
|
88
|
+
const tools = ["/bin/ps", "/usr/sbin/lsof"];
|
|
89
|
+
const missing = tools.filter((tool) => !ctx.fs.existsSync(tool));
|
|
90
|
+
add("private-host-teardown", missing.length === 0 ? "PASS" : "WARN", missing.length === 0 ? `macOS teardown via ${tools.join(" and ")}` : `requires ${missing.join(" and ")}`);
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
add("private-host-teardown", python === undefined ? "WARN" : "PASS", python === undefined
|
|
94
|
+
? "requires Linux and python3 with working pidfd support"
|
|
95
|
+
: `Linux pidfd support via ${python}`);
|
|
90
96
|
const openchamber = resolveBinary("openchamber", ctx);
|
|
91
97
|
if (openchamber === undefined)
|
|
92
98
|
add("openchamber", "WARN", "openchamber is not on PATH");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ProcessTable } from "./proc-table.js";
|
|
1
|
+
import type { ProcessIdentity, ProcessTable } from "./proc-table.js";
|
|
2
2
|
/**
|
|
3
3
|
* macOS process table, backed by `/bin/ps`. Used by disposal to identify owned survivors, so it must
|
|
4
4
|
* report the same three facts Linux reads from `/proc/<pid>/stat`: PID, process group, and a start
|
|
@@ -13,4 +13,11 @@ export type PsRunner = (args: readonly string[]) => string;
|
|
|
13
13
|
* calls must agree with each other even if the user changes the system language mid-session.
|
|
14
14
|
*/
|
|
15
15
|
export declare function runPs(args: readonly string[]): string;
|
|
16
|
+
/** One `ps -A` row with its state and argv, for callers that match processes by command line. */
|
|
17
|
+
export interface PsProcess extends ProcessIdentity {
|
|
18
|
+
readonly state: string;
|
|
19
|
+
readonly args: string;
|
|
20
|
+
}
|
|
21
|
+
/** Every live (non-zombie) process with its argv. Throws when `ps` fails, so the caller decides. */
|
|
22
|
+
export declare function listDarwinProcesses(run?: PsRunner): PsProcess[];
|
|
16
23
|
export declare function createDarwinProcTable(run?: PsRunner): ProcessTable;
|
|
@@ -40,6 +40,21 @@ function parseLine(line) {
|
|
|
40
40
|
function alive(entry) {
|
|
41
41
|
return !entry.state.startsWith("Z");
|
|
42
42
|
}
|
|
43
|
+
// Under LC_ALL=C, lstart is a fixed 24-character field, so argv starts right after it even when it
|
|
44
|
+
// contains spaces.
|
|
45
|
+
const PS_ARGS_LINE = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.{24})\s?(.*)$/;
|
|
46
|
+
/** Every live (non-zombie) process with its argv. Throws when `ps` fails, so the caller decides. */
|
|
47
|
+
export function listDarwinProcesses(run = runPs) {
|
|
48
|
+
return run(["-ww", "-o", `${PS_COLUMNS},args=`, "-A"])
|
|
49
|
+
.split("\n")
|
|
50
|
+
.flatMap((line) => {
|
|
51
|
+
const match = PS_ARGS_LINE.exec(line);
|
|
52
|
+
if (match === null)
|
|
53
|
+
return [];
|
|
54
|
+
const entry = { pid: Number(match[1]), group: Number(match[2]), state: match[3] ?? "", start: match[4] ?? "" };
|
|
55
|
+
return alive(entry) ? [{ ...entry, args: match[5] ?? "" }] : [];
|
|
56
|
+
});
|
|
57
|
+
}
|
|
43
58
|
function toIdentity(entry) {
|
|
44
59
|
return { pid: entry.pid, group: entry.group, start: entry.start };
|
|
45
60
|
}
|
package/dist/session-hosts.d.ts
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
import { type PsRunner } from "./proc-table-darwin.js";
|
|
1
2
|
/** Capability and warning state are captured once, before any shutdown work. */
|
|
2
3
|
export declare function createSessionHostStopper(platform?: NodeJS.Platform, probe?: () => string | undefined): (created: ReadonlyMap<string, string>) => Promise<void>;
|
|
3
4
|
export declare const stopSessionHosts: (created: ReadonlyMap<string, string>) => Promise<void>;
|
|
5
|
+
/** Injected so the darwin path can be tested without running ps, lsof or a signal. */
|
|
6
|
+
export interface DarwinHostDependencies {
|
|
7
|
+
readonly run: PsRunner;
|
|
8
|
+
readonly cwdOf: (pid: number) => string | undefined;
|
|
9
|
+
readonly realpath: (path: string) => string;
|
|
10
|
+
readonly signal: (pid: number, signal: NodeJS.Signals) => void;
|
|
11
|
+
readonly delay: (ms: number) => Promise<void>;
|
|
12
|
+
readonly graceMs: number;
|
|
13
|
+
readonly pollMs: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* macOS teardown without pidfd. Ownership is the Linux rule: a live group leader whose argv names the
|
|
17
|
+
* supervisor entry point and, as its `--socket` value, the shard of a session this connector created,
|
|
18
|
+
* and whose working directory is that session's directory. Every member of an owned group is recorded
|
|
19
|
+
* with its start time in the same scan, and each later signal goes to one PID only after a fresh scan
|
|
20
|
+
* still shows the same group and start time. Members are tracked individually, so a child that outlives
|
|
21
|
+
* its supervisor is still stopped. A PID can be recycled between that scan and the signal; the window
|
|
22
|
+
* is the same one `PiRpcClient.dispose()` accepts.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createDarwinHostStopper(overrides?: Partial<DarwinHostDependencies>): (created: ReadonlyMap<string, string>) => Promise<void>;
|
package/dist/session-hosts.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { once } from "node:events";
|
|
4
4
|
import { closeSync, constants, openSync, readFileSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
|
|
5
5
|
import { basename } from "node:path";
|
|
6
6
|
import { findPidfdPython } from "./pidfd.js";
|
|
7
|
+
import { listDarwinProcesses, runPs } from "./proc-table-darwin.js";
|
|
8
|
+
/** How long a darwin teardown waits after SIGTERM before escalating, and how often it re-reads the table. */
|
|
9
|
+
const HOST_TERM_GRACE_MS = 5_000;
|
|
10
|
+
const HOST_POLL_MS = 100;
|
|
7
11
|
/** Capability and warning state are captured once, before any shutdown work. */
|
|
8
12
|
export function createSessionHostStopper(platform = process.platform, probe = findPidfdPython) {
|
|
13
|
+
if (platform === "darwin")
|
|
14
|
+
return createDarwinHostStopper();
|
|
9
15
|
const python = platform === "linux" ? probe() : undefined;
|
|
10
16
|
let warned = false;
|
|
11
17
|
return async (created) => {
|
|
@@ -22,6 +28,121 @@ export function createSessionHostStopper(platform = process.platform, probe = fi
|
|
|
22
28
|
};
|
|
23
29
|
}
|
|
24
30
|
export const stopSessionHosts = createSessionHostStopper();
|
|
31
|
+
/** macOS has no `/proc/<pid>/cwd`; lsof prints `p<pid>`, `fcwd`, `n<path>`. */
|
|
32
|
+
function cwdOfHost(pid) {
|
|
33
|
+
try {
|
|
34
|
+
const output = execFileSync("/usr/sbin/lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
timeout: 2_000,
|
|
37
|
+
maxBuffer: 1024 * 1024,
|
|
38
|
+
});
|
|
39
|
+
for (const line of output.split("\n"))
|
|
40
|
+
if (line.startsWith("n"))
|
|
41
|
+
return line.slice(1);
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// No permission, no process, or no lsof: the directory is unconfirmed and nothing is signalled.
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function signalHost(pid, signal) {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, signal);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
|
55
|
+
// Gone since the last scan, or not ours to signal: either way nothing was sent.
|
|
56
|
+
if (code === "ESRCH" || code === "EPERM")
|
|
57
|
+
return;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function socketName(sessionId) {
|
|
62
|
+
return `p-${createHash("sha256").update(`p:${sessionId}`).digest("hex").slice(0, 16)}.sock`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The value of `--socket`, as Linux reads it from argv. `ps` joins argv with spaces, so the value runs
|
|
66
|
+
* to the next ` --` flag (a socket directory may contain spaces). Anything else after it makes the
|
|
67
|
+
* basename miss every shard, which fails closed.
|
|
68
|
+
*/
|
|
69
|
+
function socketArgument(args) {
|
|
70
|
+
const match = /(?:^|\s)--socket\s+(.+?)(?=\s--|$)/.exec(args);
|
|
71
|
+
return match?.[1] === undefined ? undefined : basename(match[1].trimEnd());
|
|
72
|
+
}
|
|
73
|
+
function isSupervisor(args) {
|
|
74
|
+
const tokens = args.split(/\s+/);
|
|
75
|
+
return (tokens.includes("--internal-rpc-host-supervisor") || tokens.some((token) => token.endsWith("/host-lifecycle.js")));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* macOS teardown without pidfd. Ownership is the Linux rule: a live group leader whose argv names the
|
|
79
|
+
* supervisor entry point and, as its `--socket` value, the shard of a session this connector created,
|
|
80
|
+
* and whose working directory is that session's directory. Every member of an owned group is recorded
|
|
81
|
+
* with its start time in the same scan, and each later signal goes to one PID only after a fresh scan
|
|
82
|
+
* still shows the same group and start time. Members are tracked individually, so a child that outlives
|
|
83
|
+
* its supervisor is still stopped. A PID can be recycled between that scan and the signal; the window
|
|
84
|
+
* is the same one `PiRpcClient.dispose()` accepts.
|
|
85
|
+
*/
|
|
86
|
+
export function createDarwinHostStopper(overrides = {}) {
|
|
87
|
+
const deps = {
|
|
88
|
+
run: overrides.run ?? runPs,
|
|
89
|
+
cwdOf: overrides.cwdOf ?? cwdOfHost,
|
|
90
|
+
realpath: overrides.realpath ?? realpathSync,
|
|
91
|
+
signal: overrides.signal ?? signalHost,
|
|
92
|
+
delay: overrides.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
93
|
+
graceMs: overrides.graceMs ?? HOST_TERM_GRACE_MS,
|
|
94
|
+
pollMs: overrides.pollMs ?? HOST_POLL_MS,
|
|
95
|
+
};
|
|
96
|
+
return async (created) => {
|
|
97
|
+
if (created.size === 0)
|
|
98
|
+
return;
|
|
99
|
+
const sockets = new Map([...created].map(([id, directory]) => [socketName(id), directory]));
|
|
100
|
+
const processes = listDarwinProcesses(deps.run);
|
|
101
|
+
const groups = new Set();
|
|
102
|
+
for (const row of processes) {
|
|
103
|
+
if (row.group !== row.pid || !isSupervisor(row.args))
|
|
104
|
+
continue;
|
|
105
|
+
const socket = socketArgument(row.args);
|
|
106
|
+
const directory = socket === undefined ? undefined : sockets.get(socket);
|
|
107
|
+
if (directory === undefined)
|
|
108
|
+
continue;
|
|
109
|
+
const cwd = deps.cwdOf(row.pid);
|
|
110
|
+
if (cwd === undefined)
|
|
111
|
+
continue;
|
|
112
|
+
let expected;
|
|
113
|
+
try {
|
|
114
|
+
expected = deps.realpath(directory);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (cwd === expected)
|
|
120
|
+
groups.add(row.group);
|
|
121
|
+
}
|
|
122
|
+
const members = processes.filter((row) => groups.has(row.group));
|
|
123
|
+
if (members.length === 0)
|
|
124
|
+
return;
|
|
125
|
+
const survivors = () => {
|
|
126
|
+
const now = new Map(listDarwinProcesses(deps.run).map((row) => [row.pid, row]));
|
|
127
|
+
return members
|
|
128
|
+
.filter((member) => {
|
|
129
|
+
const row = now.get(member.pid);
|
|
130
|
+
return row !== undefined && row.group === member.group && row.start === member.start;
|
|
131
|
+
})
|
|
132
|
+
.map((member) => member.pid);
|
|
133
|
+
};
|
|
134
|
+
for (const signal of ["SIGTERM", "SIGKILL"]) {
|
|
135
|
+
for (const pid of survivors())
|
|
136
|
+
deps.signal(pid, signal);
|
|
137
|
+
for (let waited = 0; waited < deps.graceMs; waited += deps.pollMs) {
|
|
138
|
+
await deps.delay(deps.pollMs);
|
|
139
|
+
if (survivors().length === 0)
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`private OmO hosts survived shutdown: ${survivors().join(", ")}`);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
25
146
|
function processInfo(pid) {
|
|
26
147
|
let fd;
|
|
27
148
|
try {
|
package/dist/session-tree.js
CHANGED
|
@@ -319,8 +319,12 @@ export function bindMessageEntries(s, tree) {
|
|
|
319
319
|
s.tree.localShellTurns = localShellTurns.filter((turn) => s.tree.messageEntries[turn.messages[1].info.id] === undefined);
|
|
320
320
|
}
|
|
321
321
|
function entryForMessage(s, tree, messageID) {
|
|
322
|
+
// A compaction's `/compact` row is never bound; its ID carries the native compaction entry ID.
|
|
323
|
+
const compaction = /^msg_compaction_(.+)$/.exec(messageID)?.[1];
|
|
322
324
|
const entryId = s.tree.messageEntries[messageID];
|
|
323
|
-
const entry =
|
|
325
|
+
const entry = entryId !== undefined
|
|
326
|
+
? tree.entries.find((candidate) => candidate.id === entryId)
|
|
327
|
+
: tree.entries.find((candidate) => candidate.type === "compaction" && candidate.id === compaction);
|
|
324
328
|
if (!entry || !s.messages?.some((message) => message.info.id === messageID)) {
|
|
325
329
|
throw new SessionTreeError(`Unknown messageID: ${messageID}`);
|
|
326
330
|
}
|