Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -402,4 +402,4 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run process and terminal regressions
run: bun test tests/daemon-command.test.ts tests/daemon-orphan.test.ts tests/host-exec-lifetime.test.ts tests/host-exec-tty.test.ts tests/shell-observation.test.ts
run: bun test tests/daemon-command.test.ts tests/daemon-orphan.test.ts tests/host-exec-lifetime.test.ts tests/host-exec-tty.test.ts tests/shell-observation.test.ts tests/shared-host-lifecycle.test.ts
5 changes: 5 additions & 0 deletions docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ still be reconnecting. When the wrong listener would matter, pair `onConflict: "
`lifecycle.up.before` hook that verifies the listener's identity (for example, a request to a known
endpoint) and fails when it does not match.

The [shared host helper lifecycle contract](./reference/shared-host-lifecycle.md) defines the
identity-verified alternative: adoption only after the listener names the expected helper and upstream,
exact PID-plus-start-time claims, serialized starters, and release that never signals a foreign
process. It is a library contract today; `singleton` does not use it yet.

## Config

```json
Expand Down
1 change: 1 addition & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

- [Environment model](./env.md)
- [Lifecycle & startup](./lifecycle.md)
- [Shared host helper lifecycle](./reference/shared-host-lifecycle.md) — identity-verified adoption, exact process claims and release rules for helpers shared across branches
- [Sessions](./sessions.md)
- [Agent-first setup](./guides/agent-first-setup.md)

Expand Down
254 changes: 254 additions & 0 deletions docs/reference/shared-host-lifecycle.md

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions src/lib/private-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,10 @@ async function observeReplaceableTarget(opts: {
* unreadable file is never mistaken for an empty one and overwritten.
*/
export async function readReplaceablePrivateFile(
opts: ReplaceablePrivateFileOptions
opts: ReplaceablePrivateFileOptions & {
/** Largest accepted file size in bytes; a larger file throws `refusal()` before it is read. */
readonly maxBytes?: number;
}
): Promise<ReplaceablePrivateFile | null> {
const directories = await captureRealDirectories(opts);
const observed = await observeReplaceableTarget(opts);
Expand All @@ -303,11 +306,19 @@ export async function readReplaceablePrivateFile(
if (
!opened.isFile() ||
opened.dev !== observed.dev ||
opened.ino !== observed.ino
opened.ino !== observed.ino ||
(opts.maxBytes !== undefined && opened.size > opts.maxBytes)
) {
throw opts.refusal();
}
const text = await file.readFile({ encoding: "utf8" });
if (
opts.maxBytes !== undefined &&
Buffer.byteLength(text, "utf8") > opts.maxBytes
) {
// The file grew in place after the size check.
throw opts.refusal();
}
await recheckRealDirectories({ directories, refusal: opts.refusal });
return { text, mode: opened.mode & 0o7777 };
} finally {
Expand Down
28 changes: 23 additions & 5 deletions src/lib/process-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,31 @@ export type ProcessIdentityRow = {
readonly pid: number;
readonly parentPid: number;
readonly birth: string;
/**
* The process has exited but its parent has not reaped it yet (`ps` state
* `Z`). A zombie runs no code and holds no listener; its PID cannot be
* reused until it is reaped.
*/
readonly zombie: boolean;
};
const IDENTITY_ROW = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/;
const IDENTITY_ROW = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/;

/** Capture only process identity and lineage; never command arguments or environment. */
/**
* Capture only process identity and lineage; never command arguments or environment.
*
* `birth` is the `lstart` start time rendered in UTC with the C locale. `ps`
* prints `lstart` in local time, so without a fixed zone two clients with
* different `TZ` values, or one host whose zone changes, would see different
* births for the same live process. Births may be persisted and compared across
* processes, so the zone is pinned here (POSIX `UTC0` needs no zoneinfo).
*
* Zombie rows are kept and flagged, not dropped: each caller decides whether
* an exited, unreaped process still counts for its purpose.
*/
export async function readProcessIdentities(): Promise<ProcessIdentityRow[]> {
try {
const proc = Bun.spawn(["ps", "-A", "-o", "pid=,ppid=,lstart="], {
env: { ...process.env, LC_ALL: "C" },
const proc = Bun.spawn(["ps", "-A", "-o", "pid=,ppid=,stat=,lstart="], {
env: { ...process.env, LC_ALL: "C", TZ: "UTC0" },
stdin: "ignore",
stdout: "pipe",
stderr: "ignore",
Expand All @@ -26,7 +43,8 @@ export async function readProcessIdentities(): Promise<ProcessIdentityRow[]> {
{
pid: Number(match[1]),
parentPid: Number(match[2]),
birth: (match[3] ?? "").replace(/\s+/g, " "),
birth: (match[4] ?? "").replace(/\s+/g, " "),
zombie: (match[3] ?? "").startsWith("Z"),
},
]
: [];
Expand Down
Loading
Loading