mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A workspace can run a shared local service, such as a dev server, on an automatic port > - Paperclip adopts a live service again after it loses the runtime registry state > - Paperclip must first prove that the port owner runs inside the workspace > - Linux reads the process working directory from `/proc/<pid>/cwd` > - macOS has no `/proc`, so the check returned `null` and adoption always failed > - This pull request reads the process working directory with `lsof` on macOS > - The benefit is that macOS keeps a healthy live service after startup reconciliation, instead of recording it as stopped ## Linked Issues or Issue Description Closes #9911. That pull request reports the same defect and was opened first, on 2026-07-20. Its checks have been red since that day, because its inline issue description does not use the label format the gate parses. It has had no author activity since. This pull request keeps that author's test-fixture commit, with the author unchanged, and adds NUL-delimited parsing, adoption-boundary tests, and fail-closed Darwin registry handling. Maintainers may prefer to land #9911 instead. I will close this one again if they do. This pull request replaces #11600, which I closed earlier as a duplicate. It carries the same work, rebased onto current `master`, with the review feedback from that pull request applied. No public issue exists. The problem follows. **What happened?** On macOS, `readLocalServiceProcessCwd` returned `null`. Startup reconciliation found a live port owner, but it could not verify the working directory. It rejected the candidate and recorded the live service as stopped. **Expected behavior** Paperclip adopts a healthy port owner when the working directory is inside the requested workspace. Paperclip rejects the process when the working directory is outside the workspace, or when it cannot be read. **Steps to reproduce** 1. Build Paperclip from source on macOS. 2. Start a shared workspace runtime service on an automatic port. 3. Remove the runtime registry state while the service stays alive. 4. Run startup reconciliation. 5. Read the result. Unpatched `master` reports `adopted: 0` and `stopped: 1`. **Paperclip version or commit** This branch is based on `master` at `7c8064da1b35527865c1d523c9f0016e304ae46d`. **Deployment mode** Local development from source. **Installation method** Built from source with pnpm. **Operating system** macOS 26.4, Darwin 25.4.0, arm64. **Node.js version** Node.js 22.22.2 on macOS. Node.js 24.19.0 on Linux. pnpm 9.15.4. ## Darwin Registry Adoption Now Fails Closed This pull request changes one existing Darwin registry-adoption behavior in addition to enabling port-owner adoption. Before this change, `readLocalServiceProcessCwd` always returned `null` on Darwin. `isLocalServiceRegistryCwdCompatible` treated a null cwd as compatible on every non-Linux platform, so a service with an existing registry record could still be adopted when its port owner, process group, and command matched, even though Paperclip had not verified the process's real working directory. Darwin can now inspect the process cwd through `lsof`. If that inspection returns `null` — including a missing `lsof`, a command failure, or missing cwd output — registry-backed adoption now fails closed and the stale registry record is removed. This is a deliberate behavior change. It prevents a failed Darwin cwd probe from silently falling back to trusting stored registry metadata. The no-registry port-owner path already rejected a null cwd before this pull request, so its failure behavior has not changed. ## What Changed - Add a Darwin branch to `readLocalServiceProcessCwd`. - Run `lsof -a -d cwd -p <pid> -F0n` to read the process working directory. - Parse the NUL-delimited field output. - Do not trim the path. Do not split it on newlines. A directory name can contain a trailing space or a newline, and a changed path would name a different directory. - Keep the Linux `/proc/<pid>/cwd` path unchanged. - Return `null` for an invalid pid, a missing `lsof`, a command error, or missing output. - Reject a Darwin registry record when the working directory cannot be read. Darwin can now read it, so a failed read means the check failed. It no longer means the platform has no way to check. - Keep the registry fallback only on platforms that cannot read a process working directory. - Run the existing foreign-workspace rejection test on macOS. - Add a test: Paperclip adopts a port owner inside the workspace when no registry record exists. - Add a test: Paperclip rejects a listener in a sibling directory that differs only by a trailing space. - Add helper tests for newline and whitespace parsing, an invalid pid, and a missing `lsof` binary. - Resolve the branch-containment temporary repository root before the path comparison. This test-only commit comes from #9911 and keeps its author. ## Verification Head of this branch: `2be1b74746d8a0db4b680062f0c57995a6ff3912`. **Linux, on this head** ```sh pnpm --filter @paperclipai/server exec vitest run \ src/__tests__/workspace-runtime.test.ts \ src/__tests__/heartbeat-workspace-branch-containment.test.ts ``` Result: 138/138 pass. `workspace-runtime.test.ts` is 132/132. `heartbeat-workspace-branch-containment.test.ts` is 6/6. **macOS, on this head** macOS 26.4, Darwin 25.4.0, arm64, Node.js 22.22.2, pnpm 9.15.4. - Controlled baseline: `workspace runtime startup reconciliation > adopts a live auto-port shared service after runtime state is reset` fails on the rebase base `7c8064da1b35527865c1d523c9f0016e304ae46d` and reports `adopted: 0`, `stopped: 1`. The same test passes on this head. That test uses the normal managed start path, which starts the service detached. - Focused working-directory, registry, adoption, and boundary tests: 8/8 pass. - `heartbeat-workspace-branch-containment.test.ts`: 6/6 pass. Two assertions failed before the fixture change, because `/var/...` and `/private/var/...` name the same macOS directory. - Server typecheck: pass. - Full `workspace-runtime.test.ts`: 131/132 pass. The one failure is `realizeExecutionWorkspace > records teardown and cleanup operations when a recorder is provided`: ```text expected: /var/folders/... received: /private/var/folders/... ``` I ran that same test alone on the rebase base `7c8064da`, with no patch applied, and got the identical failure. It is a pre-existing macOS fixture that builds a path from `os.tmpdir()` and compares it against a realpath. It does not run the changed adoption path. This description does not claim the whole file is green on macOS. **macOS listener evidence** In the `adopts a port owner running inside the workspace when the registry record is gone` scenario, the auto-port listener bound port `54360`: ```text COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME node 6808 <local-user> 12u IPv4 0xee4e36b2c8c094cf 0t0 TCP 127.0.0.1:54360 (LISTEN) ``` To hold the listener open long enough to capture this, that one diagnostic run added a temporary pause, which exceeded the Vitest timeout. The pause was reverted, the unmodified test was run again on this head, and it passed 1/1. The process and the port were then released. Note for maintainers: an existing test already covered this defect. That test never runs on macOS, because CI runs on Linux. A macOS job would have caught it in July. ## Risks Low risk. - Linux keeps the existing procfs implementation. - Other platforms keep the existing registry fallback. - macOS makes one extra `lsof` call, and only when it must read a process working directory. - A probe failure returns `null`. - Darwin port-owner adoption and Darwin registry adoption both fail closed. - The parser keeps significant whitespace and embedded newlines. - There is no database migration and no API change. ## Model Used Claude Opus 5 (`claude-opus-5`), with extended thinking, tool use, and code execution. It wrote the original implementation and the adoption tests, reviewed the branch, ran the Linux test suite, rebased onto current `master`, and prepared this text. OpenAI GPT-5.6-sol, through Hermes Agent, added the failure-mode coverage and ran the macOS checks. A human reviewed the change and controls publication. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (see Verification for the one disclosed macOS baseline failure) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (this change affects an internal helper and tests only) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: tim <tf00185077@i-mps.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: scbailey-build <scott@bequall.com>
711 lines
24 KiB
TypeScript
711 lines
24 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import fsSync from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { promisify } from "node:util";
|
|
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
export interface LocalServiceRegistryRecord {
|
|
version: 1;
|
|
serviceKey: string;
|
|
profileKind: string;
|
|
serviceName: string;
|
|
command: string;
|
|
cwd: string;
|
|
envFingerprint: string;
|
|
port: number | null;
|
|
url: string | null;
|
|
pid: number;
|
|
processGroupId: number | null;
|
|
provider: "local_process";
|
|
runtimeServiceId: string | null;
|
|
reuseKey: string | null;
|
|
startedAt: string;
|
|
lastSeenAt: string;
|
|
metadata: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface LocalServiceIdentityInput {
|
|
profileKind: string;
|
|
serviceName: string;
|
|
cwd: string;
|
|
command: string;
|
|
envFingerprint: string;
|
|
port: number | null;
|
|
scope: Record<string, unknown> | null;
|
|
}
|
|
|
|
function stableStringify(value: unknown): string {
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
|
}
|
|
if (value && typeof value === "object") {
|
|
const rec = value as Record<string, unknown>;
|
|
return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(rec[key])}`).join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function sanitizeServiceKeySegment(value: string, fallback: string): string {
|
|
const normalized = value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
return normalized || fallback;
|
|
}
|
|
|
|
function getRuntimeServicesDir() {
|
|
return path.resolve(resolvePaperclipInstanceRoot(), "runtime-services");
|
|
}
|
|
|
|
function getRuntimeServiceLogsDir() {
|
|
return path.resolve(resolvePaperclipInstanceRoot(), "runtime-service-logs");
|
|
}
|
|
|
|
export function resolveLocalServiceLogPath(serviceKey: string) {
|
|
if (!/^[a-z0-9._-]+$/.test(serviceKey)) {
|
|
throw new Error("Invalid local service key for log path");
|
|
}
|
|
return path.resolve(getRuntimeServiceLogsDir(), `${serviceKey}.log`);
|
|
}
|
|
|
|
/**
|
|
* Open a managed service's durable append-only output file.
|
|
*
|
|
* The returned descriptor is intended to be passed directly to spawn(). The
|
|
* child receives its own duplicate, so the caller can close this handle as soon
|
|
* as spawn returns without tying the service's stdio lifetime to Paperclip's.
|
|
*/
|
|
export async function openLocalServiceLogFile(serviceKey: string) {
|
|
await fs.mkdir(getRuntimeServiceLogsDir(), { recursive: true });
|
|
const logPath = resolveLocalServiceLogPath(serviceKey);
|
|
const handle = await fs.open(logPath, "a+", 0o600);
|
|
const startOffset = (await handle.stat()).size;
|
|
return { handle, logPath, startOffset };
|
|
}
|
|
|
|
function getRuntimeServiceRegistryPath(serviceKey: string) {
|
|
return path.resolve(getRuntimeServicesDir(), `${serviceKey}.json`);
|
|
}
|
|
|
|
function normalizeRegistryRecord(raw: unknown): LocalServiceRegistryRecord | null {
|
|
if (!raw || typeof raw !== "object") return null;
|
|
const rec = raw as Record<string, unknown>;
|
|
if (
|
|
rec.version !== 1 ||
|
|
typeof rec.serviceKey !== "string" ||
|
|
typeof rec.profileKind !== "string" ||
|
|
typeof rec.serviceName !== "string" ||
|
|
typeof rec.command !== "string" ||
|
|
typeof rec.cwd !== "string" ||
|
|
typeof rec.envFingerprint !== "string" ||
|
|
typeof rec.pid !== "number"
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
version: 1,
|
|
serviceKey: rec.serviceKey,
|
|
profileKind: rec.profileKind,
|
|
serviceName: rec.serviceName,
|
|
command: rec.command,
|
|
cwd: rec.cwd,
|
|
envFingerprint: rec.envFingerprint,
|
|
port: typeof rec.port === "number" ? rec.port : null,
|
|
url: typeof rec.url === "string" ? rec.url : null,
|
|
pid: rec.pid,
|
|
processGroupId: typeof rec.processGroupId === "number" ? rec.processGroupId : null,
|
|
provider: "local_process",
|
|
runtimeServiceId: typeof rec.runtimeServiceId === "string" ? rec.runtimeServiceId : null,
|
|
reuseKey: typeof rec.reuseKey === "string" ? rec.reuseKey : null,
|
|
startedAt: typeof rec.startedAt === "string" ? rec.startedAt : new Date().toISOString(),
|
|
lastSeenAt: typeof rec.lastSeenAt === "string" ? rec.lastSeenAt : new Date().toISOString(),
|
|
metadata:
|
|
rec.metadata && typeof rec.metadata === "object" && !Array.isArray(rec.metadata)
|
|
? (rec.metadata as Record<string, unknown>)
|
|
: null,
|
|
};
|
|
}
|
|
|
|
async function safeReadRegistryRecord(filePath: string) {
|
|
try {
|
|
const raw = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
|
|
return normalizeRegistryRecord(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function createLocalServiceKey(input: LocalServiceIdentityInput) {
|
|
const digest = createHash("sha256")
|
|
.update(
|
|
stableStringify({
|
|
profileKind: input.profileKind,
|
|
serviceName: input.serviceName,
|
|
cwd: path.resolve(input.cwd),
|
|
command: input.command,
|
|
envFingerprint: input.envFingerprint,
|
|
port: input.port,
|
|
scope: input.scope ?? null,
|
|
}),
|
|
)
|
|
.digest("hex")
|
|
.slice(0, 24);
|
|
|
|
return `${sanitizeServiceKeySegment(input.profileKind, "service")}-${sanitizeServiceKeySegment(input.serviceName, "service")}-${digest}`;
|
|
}
|
|
|
|
export async function writeLocalServiceRegistryRecord(record: LocalServiceRegistryRecord) {
|
|
await fs.mkdir(getRuntimeServicesDir(), { recursive: true });
|
|
await fs.writeFile(
|
|
getRuntimeServiceRegistryPath(record.serviceKey),
|
|
`${JSON.stringify(record, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
export async function removeLocalServiceRegistryRecord(serviceKey: string) {
|
|
await fs.rm(getRuntimeServiceRegistryPath(serviceKey), { force: true });
|
|
}
|
|
|
|
export async function readLocalServiceRegistryRecord(serviceKey: string) {
|
|
return await safeReadRegistryRecord(getRuntimeServiceRegistryPath(serviceKey));
|
|
}
|
|
|
|
export async function listLocalServiceRegistryRecords(filter?: {
|
|
profileKind?: string;
|
|
metadata?: Record<string, unknown>;
|
|
}) {
|
|
try {
|
|
const entries = await fs.readdir(getRuntimeServicesDir(), { withFileTypes: true });
|
|
const records = await Promise.all(
|
|
entries
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
.map((entry) => safeReadRegistryRecord(path.resolve(getRuntimeServicesDir(), entry.name))),
|
|
);
|
|
|
|
return records
|
|
.filter((record): record is LocalServiceRegistryRecord => record !== null)
|
|
.filter((record) => {
|
|
if (filter?.profileKind && record.profileKind !== filter.profileKind) return false;
|
|
if (!filter?.metadata) return true;
|
|
return Object.entries(filter.metadata).every(([key, value]) => record.metadata?.[key] === value);
|
|
})
|
|
.sort((left, right) => left.serviceKey.localeCompare(right.serviceKey));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: {
|
|
runtimeServiceId: string;
|
|
profileKind?: string;
|
|
}) {
|
|
const records = await listLocalServiceRegistryRecords(
|
|
input.profileKind ? { profileKind: input.profileKind } : undefined,
|
|
);
|
|
const record = records.find((entry) => entry.runtimeServiceId === input.runtimeServiceId) ?? null;
|
|
if (!record) return null;
|
|
|
|
let candidate = record;
|
|
if (!isPidAlive(candidate.pid)) {
|
|
const ownerPid = candidate.port ? await readLocalServicePortOwner(candidate.port) : null;
|
|
if (!ownerPid) {
|
|
await removeLocalServiceRegistryRecord(candidate.serviceKey);
|
|
return null;
|
|
}
|
|
candidate = {
|
|
...candidate,
|
|
pid: ownerPid,
|
|
processGroupId: candidate.processGroupId && isPidAlive(candidate.processGroupId) ? candidate.processGroupId : ownerPid,
|
|
lastSeenAt: new Date().toISOString(),
|
|
};
|
|
await writeLocalServiceRegistryRecord(candidate);
|
|
}
|
|
|
|
if (!(await isLikelyMatchingCommand(candidate))) {
|
|
await removeLocalServiceRegistryRecord(record.serviceKey);
|
|
return null;
|
|
}
|
|
if (!(await doesLocalServiceRecordMatchCwd(candidate))) {
|
|
await removeLocalServiceRegistryRecord(record.serviceKey);
|
|
return null;
|
|
}
|
|
|
|
return candidate;
|
|
}
|
|
|
|
export function isPidAlive(pid: number) {
|
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function isProcessGroupAlive(processGroupId: number | null | undefined) {
|
|
if (process.platform === "win32") return false;
|
|
if (typeof processGroupId !== "number" || !Number.isInteger(processGroupId) || processGroupId <= 0) return false;
|
|
try {
|
|
process.kill(-processGroupId, 0);
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
if (process.platform === "linux") {
|
|
const liveMember = readLinuxProcessGroupActivity(processGroupId);
|
|
if (liveMember !== null) return liveMember;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function readLinuxProcessGroupActivity(processGroupId: number): boolean | null {
|
|
let entries: fsSync.Dirent[];
|
|
try {
|
|
entries = fsSync.readdirSync("/proc", { withFileTypes: true });
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
let foundMember = false;
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
|
|
try {
|
|
const stat = fsSync.readFileSync(`/proc/${entry.name}/stat`, "utf8");
|
|
const commandEnd = stat.lastIndexOf(")");
|
|
if (commandEnd < 0) continue;
|
|
const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
|
|
const state = fields[0];
|
|
const memberProcessGroupId = Number.parseInt(fields[2] ?? "", 10);
|
|
if (memberProcessGroupId !== processGroupId) continue;
|
|
foundMember = true;
|
|
if (state !== "Z" && state !== "X") return true;
|
|
} catch {
|
|
// The process can exit while /proc is scanned.
|
|
}
|
|
}
|
|
|
|
// kill(-pgid, 0) also succeeds for a group that contains only zombies. Such
|
|
// processes cannot run or own a listener and are waiting only for their
|
|
// parent to reap them, so termination is complete for service-control use.
|
|
return foundMember ? false : null;
|
|
}
|
|
|
|
function tokenizeCommandLine(value: string) {
|
|
return value.match(/"(?:\\.|[^"\\])*"|'[^']*'|\S+/g) ?? [];
|
|
}
|
|
|
|
function normalizeCommandToken(value: string) {
|
|
const unquoted = value.replace(/^["']|["']$/g, "");
|
|
const basename = path.basename(unquoted.replace(/\\/g, "/"));
|
|
const launcher = basename.replace(/\.(?:cjs|mjs|js|cmd|exe)$/i, "");
|
|
return /^(?:bun|node|nodejs|npm|npx|pnpm|yarn)$/i.test(launcher) ? launcher : unquoted;
|
|
}
|
|
|
|
/**
|
|
* Return whether the configured shell command has a stable argv that can be
|
|
* compared with the operating system's process command line.
|
|
*
|
|
* Managed local services are started through `shell -lc`. Once a command uses
|
|
* shell control syntax, the surviving process-group leader can be the result of
|
|
* that program rather than the configured shell expression. In that case a
|
|
* literal argv comparison is not evidence that the process belongs to a
|
|
* different service; adoption instead relies on the listener, process group,
|
|
* and workspace cwd checks.
|
|
*/
|
|
export function isLocalServiceCommandLineComparable(recordedCommand: string) {
|
|
let quote: "'" | '"' | null = null;
|
|
let escaped = false;
|
|
|
|
for (const character of recordedCommand) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (character === "\\" && quote !== "'") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (quote) {
|
|
if (character === quote) quote = null;
|
|
continue;
|
|
}
|
|
if (character === "'" || character === '"') {
|
|
quote = character;
|
|
continue;
|
|
}
|
|
if ([";", "|", "&", "<", ">", "\n"].includes(character)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Compare a configured service command with the argv exposed by the OS.
|
|
*
|
|
* Package-manager launchers commonly replace `pnpm dev` with
|
|
* `node /path/to/pnpm.cjs dev` after the shell starts. A literal substring
|
|
* check rejects that surviving process even though the executable and all
|
|
* configured arguments are still present. Normalize executable paths and
|
|
* script extensions, then require the configured argv to remain contiguous.
|
|
*/
|
|
export function doesLocalServiceCommandLineMatch(input: {
|
|
commandLine: string;
|
|
recordedCommand: string;
|
|
serviceName: string;
|
|
}) {
|
|
const normalize = (value: string) => value.replace(/["']/g, "").replace(/\s+/g, " ").trim();
|
|
const normalizedCommandLine = normalize(input.commandLine);
|
|
const normalizedRecordedCommand = normalize(input.recordedCommand);
|
|
if (
|
|
normalizedCommandLine.includes(normalizedRecordedCommand)
|
|
|| normalizedCommandLine.includes(input.serviceName)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const actualTokens = tokenizeCommandLine(input.commandLine).map(normalizeCommandToken);
|
|
const recordedTokens = tokenizeCommandLine(input.recordedCommand).map(normalizeCommandToken);
|
|
if (recordedTokens.length === 0 || recordedTokens.length > actualTokens.length) return false;
|
|
|
|
return actualTokens.some((_, start) => recordedTokens.every(
|
|
(token, offset) => actualTokens[start + offset] === token,
|
|
));
|
|
}
|
|
|
|
async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
|
|
if (process.platform === "win32") return true;
|
|
if (!isLocalServiceCommandLineComparable(record.command)) return true;
|
|
try {
|
|
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
|
|
const commandLine = stdout.trim();
|
|
if (!commandLine) return false;
|
|
return doesLocalServiceCommandLineMatch({
|
|
commandLine,
|
|
recordedCommand: record.command,
|
|
serviceName: record.serviceName,
|
|
});
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export async function findAdoptableLocalService(input: {
|
|
serviceKey: string;
|
|
profileKind?: string | null;
|
|
serviceName?: string | null;
|
|
command?: string | null;
|
|
cwd?: string | null;
|
|
envFingerprint?: string | null;
|
|
port?: number | null;
|
|
url?: string | null;
|
|
}) {
|
|
const record =
|
|
await readLocalServiceRegistryRecord(input.serviceKey)
|
|
?? await adoptLocalServiceFromPortOwner(input);
|
|
if (!record) return null;
|
|
|
|
if (!isPidAlive(record.pid)) {
|
|
await removeLocalServiceRegistryRecord(input.serviceKey);
|
|
return null;
|
|
}
|
|
if (!(await isLikelyMatchingCommand(record))) {
|
|
await removeLocalServiceRegistryRecord(input.serviceKey);
|
|
return null;
|
|
}
|
|
if (!(await doesLocalServiceRecordMatchCwd(record))) {
|
|
await removeLocalServiceRegistryRecord(input.serviceKey);
|
|
return null;
|
|
}
|
|
if (input.command && record.command !== input.command) return null;
|
|
if (input.cwd && path.resolve(record.cwd) !== path.resolve(input.cwd)) return null;
|
|
if (input.envFingerprint && record.envFingerprint !== input.envFingerprint) return null;
|
|
if (input.port !== undefined && input.port !== null && record.port !== input.port) return null;
|
|
return record;
|
|
}
|
|
|
|
export async function readLocalServiceProcessGroupId(pid: number) {
|
|
if (process.platform === "win32") return null;
|
|
try {
|
|
const { stdout } = await execFileAsync("ps", ["-o", "pgid=", "-p", String(pid)]);
|
|
const parsed = Number.parseInt(stdout.trim(), 10);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function isLocalServiceProcessOwnedBy(pid: number, ownerProcessId: number) {
|
|
if (pid === ownerProcessId) return true;
|
|
if (process.platform !== "win32") {
|
|
return (await readLocalServiceProcessGroupId(pid)) === ownerProcessId;
|
|
}
|
|
|
|
try {
|
|
const script = [
|
|
`$currentProcessId = ${pid}`,
|
|
"while ($currentProcessId -gt 0) {",
|
|
" $process = Get-CimInstance Win32_Process -Filter \"ProcessId = $currentProcessId\" -ErrorAction SilentlyContinue",
|
|
" if ($null -eq $process) { break }",
|
|
" $parentProcessId = [int]$process.ParentProcessId",
|
|
" Write-Output $parentProcessId",
|
|
" if ($parentProcessId -eq $currentProcessId) { break }",
|
|
" $currentProcessId = $parentProcessId",
|
|
"}",
|
|
].join("\n");
|
|
const { stdout } = await execFileAsync("powershell.exe", [
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-Command",
|
|
script,
|
|
]);
|
|
return stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => Number.parseInt(line.trim(), 10))
|
|
.some((ancestorPid) => ancestorPid === ownerProcessId);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function adoptLocalServiceFromPortOwner(input: {
|
|
serviceKey: string;
|
|
profileKind?: string | null;
|
|
serviceName?: string | null;
|
|
command?: string | null;
|
|
cwd?: string | null;
|
|
envFingerprint?: string | null;
|
|
port?: number | null;
|
|
url?: string | null;
|
|
}) {
|
|
if (!input.port) return null;
|
|
const ownerPid = await readLocalServicePortOwner(input.port);
|
|
if (!ownerPid) return null;
|
|
|
|
if (input.cwd) {
|
|
const ownerCwd = await readLocalServiceProcessCwd(ownerPid);
|
|
if (!ownerCwd || !(await isLocalServiceProcessInWorkspace(ownerCwd, input.cwd))) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const processGroupId = await readLocalServiceProcessGroupId(ownerPid);
|
|
const pid = processGroupId && isPidAlive(processGroupId) ? processGroupId : ownerPid;
|
|
const now = new Date().toISOString();
|
|
const record: LocalServiceRegistryRecord = {
|
|
version: 1,
|
|
serviceKey: input.serviceKey,
|
|
profileKind: input.profileKind ?? "workspace-runtime",
|
|
serviceName: input.serviceName ?? "service",
|
|
command: input.command ?? input.serviceName ?? "service",
|
|
cwd: input.cwd ?? process.cwd(),
|
|
envFingerprint: input.envFingerprint ?? "",
|
|
port: input.port,
|
|
url: input.url ?? null,
|
|
pid,
|
|
processGroupId: processGroupId ?? pid,
|
|
provider: "local_process",
|
|
runtimeServiceId: null,
|
|
reuseKey: input.envFingerprint ?? null,
|
|
startedAt: now,
|
|
lastSeenAt: now,
|
|
metadata: null,
|
|
};
|
|
|
|
if (!(await isLikelyMatchingCommand(record))) return null;
|
|
await writeLocalServiceRegistryRecord(record);
|
|
return record;
|
|
}
|
|
|
|
export async function touchLocalServiceRegistryRecord(
|
|
serviceKey: string,
|
|
patch?: Partial<Omit<LocalServiceRegistryRecord, "serviceKey" | "version">>,
|
|
) {
|
|
const existing = await readLocalServiceRegistryRecord(serviceKey);
|
|
if (!existing) return null;
|
|
const next: LocalServiceRegistryRecord = {
|
|
...existing,
|
|
...patch,
|
|
version: 1,
|
|
serviceKey,
|
|
lastSeenAt: patch?.lastSeenAt ?? new Date().toISOString(),
|
|
};
|
|
await writeLocalServiceRegistryRecord(next);
|
|
return next;
|
|
}
|
|
|
|
export async function terminateLocalService(
|
|
record: Pick<LocalServiceRegistryRecord, "pid" | "processGroupId"> &
|
|
Partial<Pick<LocalServiceRegistryRecord, "port">>,
|
|
opts?: { signal?: NodeJS.Signals; forceAfterMs?: number; verifyAfterMs?: number },
|
|
) {
|
|
const signal = opts?.signal ?? "SIGTERM";
|
|
const targetProcessGroup = process.platform !== "win32" && record.processGroupId && record.processGroupId > 0;
|
|
|
|
const targetIsGone = async () => {
|
|
const targetAlive = targetProcessGroup
|
|
? isProcessGroupAlive(record.processGroupId)
|
|
: isPidAlive(record.pid);
|
|
if (targetAlive) return false;
|
|
if (!record.port) return true;
|
|
const portOwnerPid = await readLocalServicePortOwner(record.port);
|
|
if (!portOwnerPid) return true;
|
|
const ownerProcessId = targetProcessGroup ? record.processGroupId! : record.pid;
|
|
return !(await isLocalServiceProcessOwnedBy(portOwnerPid, ownerProcessId));
|
|
};
|
|
|
|
const waitUntilGone = async (timeoutMs: number) => {
|
|
const deadline = Date.now() + timeoutMs;
|
|
do {
|
|
if (await targetIsGone()) return true;
|
|
await delay(100);
|
|
} while (Date.now() < deadline);
|
|
return await targetIsGone();
|
|
};
|
|
|
|
try {
|
|
if (targetProcessGroup) {
|
|
process.kill(-record.processGroupId!, signal);
|
|
} else {
|
|
process.kill(record.pid, signal);
|
|
}
|
|
} catch {
|
|
if (await targetIsGone()) return;
|
|
}
|
|
|
|
if (await waitUntilGone(opts?.forceAfterMs ?? 2_000)) return;
|
|
try {
|
|
if (targetProcessGroup) {
|
|
process.kill(-record.processGroupId!, "SIGKILL");
|
|
} else {
|
|
process.kill(record.pid, "SIGKILL");
|
|
}
|
|
} catch {
|
|
// Ignore cleanup races.
|
|
}
|
|
|
|
if (await waitUntilGone(opts?.verifyAfterMs ?? 2_000)) return;
|
|
|
|
const target = targetProcessGroup
|
|
? `process group ${record.processGroupId}`
|
|
: `process ${record.pid}`;
|
|
const listener = record.port ? ` and listener on port ${record.port}` : "";
|
|
throw new Error(`Failed to terminate local service ${target}${listener}`);
|
|
}
|
|
|
|
export async function readLocalServicePortOwner(port: number) {
|
|
if (!Number.isInteger(port) || port <= 0) return null;
|
|
try {
|
|
if (process.platform === "win32") {
|
|
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"]);
|
|
for (const line of stdout.split(/\r?\n/)) {
|
|
const columns = line.trim().split(/\s+/);
|
|
if (columns.length < 5 || columns[0]?.toUpperCase() !== "TCP") continue;
|
|
const localAddress = columns[1] ?? "";
|
|
const separatorIndex = localAddress.lastIndexOf(":");
|
|
const localPort = Number.parseInt(localAddress.slice(separatorIndex + 1), 10);
|
|
const state = columns.at(-2)?.toUpperCase();
|
|
const pid = Number.parseInt(columns.at(-1) ?? "", 10);
|
|
if (localPort === port && state === "LISTENING" && Number.isInteger(pid) && pid > 0) {
|
|
return pid;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
|
|
const firstPid = stdout
|
|
.split("\n")
|
|
.map((line) => Number.parseInt(line.trim(), 10))
|
|
.find((value) => Number.isInteger(value) && value > 0);
|
|
return firstPid ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a running process's working directory.
|
|
*
|
|
* Linux reads it straight off procfs. macOS has no procfs, so it asks `lsof`
|
|
* for the process's `cwd` descriptor — the same tool this module already shells
|
|
* out to for port ownership, so this adds no new dependency. `-d cwd` narrows
|
|
* the output to the working directory.
|
|
*
|
|
* `-F0n` terminates each field with NUL instead of a newline. The parser does
|
|
* not trim the path or split it on newlines. That matters because the caller
|
|
* compares this value against a workspace root: a directory name may contain
|
|
* leading or trailing spaces, or even a newline, and changing the path would
|
|
* report a different directory than the one the process runs in.
|
|
*
|
|
* Returning a real path on macOS is what lets `adoptLocalServiceFromPortOwner`
|
|
* verify a listener actually belongs to the workspace. While this returned
|
|
* null off Linux, that check could never pass, so port-owner adoption always
|
|
* failed there and still-running services were reconciled to `stopped`.
|
|
*/
|
|
export async function readLocalServiceProcessCwd(pid: number) {
|
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
if (process.platform === "linux") {
|
|
try {
|
|
return await fs.readlink(`/proc/${pid}/cwd`);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
if (process.platform === "darwin") {
|
|
try {
|
|
const { stdout } = await execFileAsync("lsof", ["-a", "-d", "cwd", "-p", String(pid), "-F0n"]);
|
|
// Each field ends with NUL. The newline that ends a field set carries
|
|
// into the next field, so drop it before reading the `n` tag; anything
|
|
// after the tag is the path exactly as lsof reported it.
|
|
const cwdField = stdout
|
|
.split("\0")
|
|
.map((field) => field.replace(/^\n+/, ""))
|
|
.find((field) => field.startsWith("n"));
|
|
return cwdField ? cwdField.slice(1) || null : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function isLocalServiceProcessInWorkspace(processCwd: string, workspaceCwd: string) {
|
|
try {
|
|
const [resolvedProcessCwd, resolvedWorkspaceCwd] = await Promise.all([
|
|
fs.realpath(processCwd),
|
|
fs.realpath(workspaceCwd),
|
|
]);
|
|
const relativePath = path.relative(resolvedWorkspaceCwd, resolvedProcessCwd);
|
|
return relativePath === "" || (!relativePath.startsWith(`..${path.sep}`) && relativePath !== "..");
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function isLocalServiceRegistryCwdCompatible(processCwd: string | null, workspaceCwd: string) {
|
|
if (!processCwd) return process.platform !== "linux" && process.platform !== "darwin";
|
|
return isLocalServiceProcessInWorkspace(processCwd, workspaceCwd);
|
|
}
|
|
|
|
async function doesLocalServiceRecordMatchCwd(record: LocalServiceRegistryRecord) {
|
|
if (!record.port) return true;
|
|
const ownerPid = await readLocalServicePortOwner(record.port);
|
|
if (!ownerPid) return false;
|
|
if (!(await isLocalServiceProcessOwnedBy(ownerPid, record.processGroupId ?? record.pid))) {
|
|
return false;
|
|
}
|
|
const ownerCwd = await readLocalServiceProcessCwd(ownerPid);
|
|
return isLocalServiceRegistryCwdCompatible(ownerCwd, record.cwd);
|
|
}
|