fix(native): require verified remote process exit receipts (#17799)

Require fresh ESRCH or an exact matching zombie generation before treating a remote Runner as stopped. Preserve uncertain ownership through adoption and transport close, retire local controller resources after failed close, and check Node before remote launch.

Bind the shared transport change in Copilot profile 62 while retaining historical profile decoding. Add red-green process, permission, launch, adoption and cleanup regressions.

Thanks to @hsm-lv for the original investigation in #17073.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-26 21:39:04 +00:00
1 parent 051b425fdf
commit 718c73dafe
16 files changed
+448 -37

No files matched your search

+18
View File
@@ -1832,6 +1832,24 @@ replacement. Missing or conflicting identity evidence, failed reads, and an
exhausted probe budget retain the existing ownership and recovery safeguards.
They must not launch a competing listener or overwrite the original marker.
Remote process verification uses one Node probe for liveness, command identity,
and the saved Linux boot identity and start ticks. If metadata disappears during
the probe, only a fresh `ESRCH` response confirms exit. A zombie also confirms
exit when its generation matches the saved marker. Permission errors, unreadable
metadata, and changed generations remain uncertain. Adopted-runner checks reject
that uncertainty rather than returning the same `false` value as a confirmed
exit. Launch checks that Node can run the probe before creating launch state or
detaching the process. These receipts do not release historical recovery holds
or prove that workspace files and provider state have been saved.
The transport propagates rejected adopted-process checks through close and
command waits. It must not turn a rejected check into a positive exit observation.
When a process check rejects close, the transport retires its local controller,
timers and notification queue without signalling the remote Runner or replacing
its checkpoint. Repeated close retains the same failed ownership result.
Copilot profile 62 binds this shared transport implementation. Profile 61 remains
decodable for recovery, but exact launch admission uses the current profile and
digest. Earlier live qualification results keep their original profile identity.
Healthy native warm turns retain their runner and provider processes. After a
controller crash, continuation can recover a completed remote session from its
saved checkpoint and exact sandbox process identity. The prior controller must
+5
View File
@@ -8,6 +8,11 @@ Native finalization deadline reconciliation records unfinished workspace
preservation through the existing server-owned recovery path. It grants no
additional agent API, provider-stop authority or permission to replay work.
Remote process probes remain server-owned. Confirmed exit requires `ESRCH` or a
zombie with the saved generation. Unknown adopted-process ownership rejects the
probe and grants no permission to replace a runner, signal a different process,
or replay work. Launch requires a working Node probe before detachment.
Ordinary board Retry can reauthorize saved GitHub input for the same App after
reconnect. It pins current runtime authority and preserves sender, repository,
conversation and task checks. Pending input takes precedence over the older
+9 -1
View File
@@ -198,8 +198,16 @@ Active remote process supervision distinguishes a failed health read from a
confirmed exit. The [executor tests](../../server/src/services/native-runtime/native-session-executor.test.ts)
exercise timeout recovery with a real listening process, unchanged ownership,
and rejected identity evidence. The Runner restart tests require positive exit
evidence before replacement. This coverage does not prove recovery of a
evidence before replacement. The executor regressions also retire a real child
during its metadata read, keep unknown adopted-process ownership held, and reject
launch before detachment when Node is unavailable. The diagnostic tests cover
permission failures and generation-bound zombie receipts. This coverage does not prove recovery of a
historical retained sandbox or behavior during a live provider outage.
The [transport regression](../../packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts)
also retains a real authenticated Runner and its journal when its close probe
rejects, without signalling it, checkpointing it, or starting a replacement.
The failed close still retires its local connections, event timers and
notification stream. Repeated close cannot report successful retirement.
For a release change during a remote provider session, verify that reattachment
uses the controller's admitted pack manifest and runner digest. Check useful
+2 -2
View File
@@ -32,12 +32,12 @@
"requiresProviderPolicy": true
},
"copilot": {
"agentProfileVersion": 61,
"agentProfileVersion": 62,
"agentServerPackage": "@github/copilot",
"agentServerVersion": "1.0.88",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3",
"commandDigest": "sha256:aeeb80bc186569c78911aeaa445c1fdf5e81420cfeadb563d5620497c58dd0f4",
"requiresProviderPolicy": true
},
"claude": {
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
agent_runtime_package: None,
agent_runtime_version: None,
command_digest:
"sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3",
"sha256:aeeb80bc186569c78911aeaa445c1fdf5e81420cfeadb563d5620497c58dd0f4",
requires_provider_policy: true,
},
"claude" => AcpxReleaseProfile {
@@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const fixture = resolve(root, "test/fixtures/copilot-profile-v61-identity.json");
const fixture = resolve(root, "test/fixtures/copilot-profile-v62-identity.json");
const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8"));
const declaration = { ...historical.declaration, agentProfileVersion: 61 };
const declaration = { ...historical.declaration, agentProfileVersion: 62 };
delete declaration.permissionIdentitySourceSha256;
const sources = {
displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts",
@@ -87,9 +87,24 @@ vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi
describe("Copilot build-owned installation", () => {
it("admits the current profile with environment launch preparation", () => {
expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(61);
expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(62);
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined();
expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3");
expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:aeeb80bc186569c78911aeaa445c1fdf5e81420cfeadb563d5620497c58dd0f4");
});
it("preserves v61 while binding uncertain process checks and controller retirement in v62", async () => {
const priorBytes = readFileSync(new URL("../../../test/fixtures/copilot-profile-v61-identity.json", import.meta.url));
expect(createHash("sha256").update(priorBytes).digest("hex")).toBe("ec3c8561bc123a599a01da356978b73e6d48ab030842af9f0d63d1f30413e1a5");
const prior = JSON.parse(priorBytes.toString("utf8"));
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v62-identity.json", import.meta.url), "utf8"));
expect(prior.commandDigest).toBe("sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3");
expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([
"agentProfileVersion", "profileCompatibilitySourceSha256", "runtimeRequestBridgeSourceSha256",
]);
expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort());
expect(isSupportedAcpxProfileVersion("copilot", 61)).toBe(true);
await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot,
agentProfileVersion: 61, commandDigest: prior.commandDigest })).rejects.toThrow("exact pinned");
});
it("preserves v60 while binding controller journal retirement in v61", async () => {
@@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
"protocolVersion": 1,
"acpxVersion": "0.13.1",
"agent": "copilot",
"agentProfileVersion": 61,
"agentProfileVersion": 62,
"agentServerPackage": "@github/copilot",
"agentServerVersion": "1.0.88",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3",
"commandDigest": "sha256:aeeb80bc186569c78911aeaa445c1fdf5e81420cfeadb563d5620497c58dd0f4",
"permissionPolicy": "interactive"
},
"claude": {
@@ -2,13 +2,13 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js";
/** Decodable historical profile revisions, not permission to launch them.
* Exact current profile/package/digest admission is checked separately. */
type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60;
type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61;
type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"];
export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion;
const historicalVersions: Readonly<Record<string, readonly HistoricalAcpxProfileVersion[]>> = {
pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5],
grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60],
grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61],
cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
};
@@ -8322,6 +8322,7 @@ async function verifyLiveRunnerAdoption(
goalMidTurn = false,
detachBeforeCleanup = false,
startWithoutCheckpoint = false,
uncertainAtClose = false,
) {
const stateDirectory = await mkdtemp(join(tmpdir(), "runnerd-live-adopt-"));
const server = createServer();
@@ -8344,7 +8345,7 @@ async function verifyLiveRunnerAdoption(
authority = next;
return {
connectUrl: `ws://127.0.0.1:${address.port}/runner`,
...(mismatchedArtifact ? { checkpoint } : {}),
...(mismatchedArtifact || uncertainAtClose ? { checkpoint } : {}),
release: async () => {
if (authority === next) authority = null;
},
@@ -8371,6 +8372,7 @@ async function verifyLiveRunnerAdoption(
let runnerPid: number | null = null;
let adopted: ReturnType<typeof createCapabilityRunnerdCodexTransport> | null =
null;
let restoreIntervalSpies: () => void = () => undefined;
try {
let opened: Record<string, unknown>;
try {
@@ -8438,8 +8440,14 @@ async function verifyLiveRunnerAdoption(
});
const openedThread = opened.thread as Record<string, unknown>;
const signal = vi.fn(() => true);
const probeError = new Error("fixture remote process identity is unavailable");
let rejectLiveness = false;
const intervals = uncertainAtClose ? vi.spyOn(globalThis, "setInterval") : null;
const clearedIntervals = uncertainAtClose ? vi.spyOn(globalThis, "clearInterval") : null;
restoreIntervalSpies = () => { intervals?.mockRestore(); clearedIntervals?.mockRestore(); };
adopted = createCapabilityRunnerdCodexTransport({
...sharedOptions,
...(uncertainAtClose ? { closeGraceMs: 2_000 } : {}),
// Hash different stable bytes without replacing the real runner artifact
// used by concurrent tests. Adoption must never execute this path.
...(mismatchedArtifact
@@ -8462,6 +8470,7 @@ async function verifyLiveRunnerAdoption(
startedAt: new Date().toISOString(),
signal,
isAlive: () => {
if (rejectLiveness) return Promise.reject(probeError);
try {
process.kill(runnerPid!, 0);
return true;
@@ -8540,6 +8549,30 @@ async function verifyLiveRunnerAdoption(
expect(observed.params).toMatchObject({ threadId: "codex-thread-1", turnId: "provider-goal-turn-1" });
}
expect(duplicateLauncher).not.toHaveBeenCalled();
if (uncertainAtClose) {
const before = JSON.parse(await readFile(controlPlaneStatePath, "utf8"));
const retainedAuthority = authority as DurablePrpControlPlane | null;
rejectLiveness = true;
await expect(adopted.transport.close()).rejects.toBe(probeError);
expect(retainedAuthority?.activeRunnerConnectionCount()).toBe(0);
expect(intervals!.mock.results.length).toBeGreaterThan(0);
for (const interval of intervals!.mock.results) if (interval.type === "return") expect(clearedIntervals).toHaveBeenCalledWith(interval.value);
const notificationsClosed = (async () => { for await (const _event of adopted!.transport.notifications()) {} return true; })();
let queueDeadline: ReturnType<typeof setTimeout> | undefined;
try {
expect(await Promise.race([notificationsClosed, new Promise<boolean>(resolve => { queueDeadline = setTimeout(() => resolve(false), 250); })])).toBe(true);
} finally { clearTimeout(queueDeadline); }
await expect(adopted.transport.close()).rejects.toBe(probeError);
expect(adopted.transport.processInfo()).toMatchObject({ exited: false });
expect(() => process.kill(runnerPid!, 0)).not.toThrow();
expect(signal).not.toHaveBeenCalled();
expect(checkpoint).not.toHaveBeenCalled();
expect(duplicateLauncher).not.toHaveBeenCalled();
const retained = JSON.parse(await readFile(controlPlaneStatePath, "utf8"));
expect(retained.identity).toEqual(before.identity);
expect(retained.commands).toEqual(before.commands);
return;
}
expect(adopted.evidence().diagnostics).toContain(
`adopted runner ${runnerPid} authenticated to its durable PRP authority`,
);
@@ -8563,6 +8596,15 @@ async function verifyLiveRunnerAdoption(
}
} finally {
await adopted?.transport.close().catch(() => undefined);
if (uncertainAtClose) {
const providerPid = adopted?.evidence().codexPid;
if (providerPid) {
try { process.kill(providerPid, "SIGKILL"); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
}
await (authority as DurablePrpControlPlane | null)?.stop();
}
restoreIntervalSpies();
if (runnerPid) {
try {
process.kill(-runnerPid, "SIGKILL");
@@ -8586,6 +8628,8 @@ it(
30_000,
);
it("retains authenticated adopted-runner ownership when a close probe rejects", () => verifyLiveRunnerAdoption(false, false, false, false, false, true), 30_000);
it(
"blocks adopted runner artifact drift without duplicate launch, checkpoint replacement, or process signals",
() => verifyLiveRunnerAdoption(false, true),
@@ -4513,6 +4513,14 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.#closed = true;
this.#resolveClosedSignal();
this.#turnStartAdmission?.resolve(false);
await this.#retireControllerForRecovery(deadline);
this.#handle = null;
this.#diagnostic(
"controller authority detached for restart; durable runner left alive",
);
}
async #retireControllerForRecovery(deadline: number): Promise<void> {
if (this.#pump !== null) clearInterval(this.#pump);
this.#pump = null;
if (this.#adoptedRunnerMonitor !== null)
@@ -4523,21 +4531,14 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.#controlPlaneRelease = null;
await Promise.resolve(release?.("detach")).catch((error: unknown) => {
this.#diagnostic(
`controller route release failed during restart detach: ${String(error)}`,
`controller route release failed during retirement: ${String(error)}`,
);
});
// A successor must not share this journal with an admitted async write.
// A timeout rejects detachment, including repeated calls, instead of
// reporting that the old writer has retired.
try {
await this.#stopAndDrainAuthority(deadline);
} finally {
this.#queue.close();
}
this.#handle = null;
this.#diagnostic(
"controller authority detached for restart; durable runner left alive",
);
}
#trackStartup<T>(start: () => Promise<T>): Promise<T> {
@@ -4557,6 +4558,20 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
}
async #closeOnce(): Promise<void> {
const deadline = Date.now() + runnerCloseGraceMs(this.options);
try {
await this.#closeAndSettle();
} catch (error) {
this.#queue.close();
try { await this.#retireControllerForRecovery(deadline); }
catch (retirementError) {
this.#diagnostic(`controller retirement after failed close is unconfirmed: ${String(retirementError)}`);
}
throw error;
}
}
async #closeAndSettle(): Promise<void> {
this.#closed = true;
this.#resolveClosedSignal();
const { preparationDeadline, closeDeadline } = runnerCloseDeadlines(Date.now(), runnerCloseGraceMs(this.options));
@@ -6898,11 +6913,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
if (this.#handle) return this.#handle.isExitConfirmed?.() ?? this.#handle.child.exitCode !== null;
const adoptedRunner = this.options.adoptExistingRunner;
if (!adoptedRunner) return true;
try {
return !(await adoptedRunner.isAlive());
} catch {
return true;
}
return !(await adoptedRunner.isAlive());
}
#watchAdoptedRunner(
@@ -0,0 +1,85 @@
{
"commandDigest": "sha256:aeeb80bc186569c78911aeaa445c1fdf5e81420cfeadb563d5620497c58dd0f4",
"declaration": {
"schema": "paperclip.acpx_profile_declaration.v1",
"agent": "copilot",
"agentProfileVersion": 62,
"acpxVersion": "0.13.1",
"agentServerVersion": "1.0.88",
"protocolPolicyRevision": "copilot-agent-manual-v1",
"acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1",
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
"agentFilesBinding": "registered-runtime-context-v1",
"systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1",
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
"innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a",
"upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0",
"patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679",
"messageIdentityContract": "copilot-native-message-id-v1",
"ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1",
"permissionContextContract": "copilot-edit-permission-context-v1",
"permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b",
"permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668",
"permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9",
"permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f",
"permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4",
"semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2",
"semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791",
"semanticBridgeSourceSha256": "8764b8c9032fdc7d1415b01e75a9fc57c550e7a5e11da4bed7d9100082872ae5",
"toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706",
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
"semanticSidecarSourceSha256": "17361d1782c0dcebb2b7972635f45c890f2a4081f8bc0ef2d2276550e0b91629",
"semanticDirectDriverSourceSha256": "ff572638f2cb5d9f8249ff8e2f412311c1e3af209ef0bf7e5ea9f10a21d91103",
"semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975",
"semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e",
"semanticCompletionContractSourceSha256": "3343df6362252d44d12b079ff78c8f6539d1544f3ce5ec36ff36db70ab0fac31",
"semanticValidatorsSourceSha256": "f22d9caf2035b600abc1a6625ae23ca97f1409d7e29ed5b7264bb8f9784c7dc9",
"semanticSchemaBundleSourceSha256": "016391a087cc217aafed4ba1f4efafc6e4881a7525952dce824b7f4d24530603",
"semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236",
"semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1",
"displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877",
"displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e",
"configuredEnvironmentSourceSha256": "5c9008d121979a3a8987e6b8a5752172eafdc4a2b5d725a73f3bbb8fb83be8ff",
"configuredEnvironmentRustSourceSha256": "85c87fae62b8a9377e4f0a4c00302c7519fae4397b1c48253f8f2b7ba7faf994",
"profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282",
"profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571",
"profileInstallationSourceSha256": "3e61c4cdd4b78e38fcb16eaadae4f1fd65ce3c7127184ea7a80948c8e5d77f88",
"attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2",
"singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f",
"nativeSessionRuntimeSourceSha256": "1fe6fa9e6b96d84d70432b85a57c473f833606d4a521563d5022b4d3c41576ce",
"nativeRuntimeContextSourceSha256": "71607f80dbb8dc5cc6c123d0b8f3995d6c5407277695a8c82cbedc90fb8f4842",
"nativeRuntimePromptsSourceSha256": "103b83066da72434602e9e461e6278dfb40bb993d2f3b49b2e93df81bbb08b72",
"nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90",
"harnessSessionContractSourceSha256": "03f4cd7f76f34f6d82e009b66aa51f18f6e23dc235b660778060e35b6ea07412",
"harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249",
"harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94",
"transportContractSourceSha256": "03fca93d36a89b0cc0917335e293ad097fdf04356d21b307827bb0ea4c13f523",
"nativeBackendSourceSha256": "e7b12d4418c7a71f5f2474b046697d3009eae3613b284bec381df5e9965a1c37",
"nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44",
"nativeInstructionsSourceSha256": "9173ebccf09cb56a781d2bf5c7eb27fdafb13d3450d99342c6aff86b42d6b43a",
"providerLaunchSourceSha256": "fa8db1c42c378898287c141d8cea9069166981e369c9b9c51422cb6177de5a2c",
"launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296",
"runtimeRequestBridgeSourceSha256": "9be35e006ffffcca48205f9e53548dd453ec6a8cbcf0aa4dd735194e9619dfc7",
"runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295",
"runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211",
"guardedInstallationSourceSha256": "56a177d3e702914818dca4ca50e061f0d25f988bfc4c0582c36ce23c1696d82f",
"nativeExecutionSourceSha256": "3a30d52bffad68c7f02349193be84761050eb99039fc0556c2b449891a36e918",
"profileCompatibilitySourceSha256": "be60656e66d7f61a290f8d1c9a3c9ec511fb13b6541825a087b470e971ccc020",
"installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d",
"assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c",
"assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f",
"credentialBindingSourceSha256": "7586d44b47c83395498dc8806287cd3538ef58177ba7c5949f5766ed105a0161",
"sidecarErrorSourceSha256": "b09af4577f8bf42eee7036b2ad9842202cfc763e72b53c3287f82c6090cdad80",
"profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456",
"metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727",
"permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
"semanticContinuationSourceSha256": "d248e47a0fe7f2536d1e64cea0b0b410f34104800cd643d995b4473104ecc8e0",
"runtimeHostSourceSha256": "94a73749267f5a80b5df19413a65b4b9263c00a9bf7e98fda00f00b8f9fd32ec",
"runtimeAdapterSourceSha256": "e4c764c2c3c7dd655a965b39472bd1ad8d577d1e7258b25594b171c9429752b8",
"runtimeSandboxSourceSha256": "92dfa53f5a3ed0062913ae417eeccec243b430d444495efde2a8e9c4c02e78ea",
"cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca",
"agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba",
"nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b"
}
}
@@ -61,7 +61,7 @@ import { createHash, generateKeyPairSync, randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { nativeSha256 } from "./canonical.js";
import { resolveRemoteProviderPackAuthority } from "./remote-provider-pack-authority.js";
import { RemoteRunnerRecoveryError, readRemoteRunnerRecoveryError } from "./remote-runner-recovery-diagnostics.js";
import { REMOTE_RECOVERY_PROCESS_CHECK_SCRIPT, RemoteRunnerRecoveryError, readRemoteRunnerRecoveryError } from "./remote-runner-recovery-diagnostics.js";
import { agentDirectoryWorkingCopyService } from "../agent-directory-working-copies.js";
import { probeAgentDirectory } from "../agent-directory-probe.js";
import { AGENT_FILES_CONTRACT } from "../agent-file-store.js";
@@ -529,6 +529,10 @@ describe("remote controller restart adoption", () => {
exitCode: 4,
timedOut: false,
});
await expect(adopted.isAlive()).rejects.toMatchObject({ remoteRunnerRecovery: {
stage: "process_verification", reason: "probe_failed",
} });
execute.mockResolvedValueOnce({ stdout: "", stderr: "", exitCode: 45, timedOut: false });
expect(await adopted.isAlive()).toBe(false);
});
it.each([
@@ -724,7 +728,9 @@ describe("remote controller restart adoption", () => {
if (change === "start ticks") await writeFile(join(proc, String(pid), "stat"), stat(101));
else if (change === "boot identity") await writeFile(join(proc, "sys/kernel/random/boot_id"), "abcd-5678\n");
else await rm(join(proc, String(pid), "stat"));
expect(await adopted.isAlive()).toBe(false);
await expect(adopted.isAlive()).rejects.toMatchObject({ remoteRunnerRecovery: {
stage: "process_verification", reason: change === "fingerprint probe" ? "fingerprint_unavailable" : "process_generation_changed",
} });
await expect(verifyRemoteRunnerReattachment({ claim, target: target as never, identity, runId: "run", normalizedSessionId: "session" }))
.rejects.toMatchObject({ message: "runner_remote_process_identity_unavailable", remoteRunnerRecovery: {
stage: "process_verification", reason: change === "fingerprint probe" ? "fingerprint_unavailable" : "process_generation_changed",
@@ -778,6 +784,81 @@ describe("remote controller restart adoption", () => {
describe("remote runner launch fingerprint compatibility", () => {
it("verifies the generation emitted by the actual launch writer", async () => {
const root = await mkdtemp(join(tmpdir(), "runner-generation-roundtrip-"));
const marker = join(root, "identity");
const statPath = join(root, "stat");
const bootPath = join(root, "boot_id");
const commandPath = join(root, "cmdline");
const stat = (pid: number) => `${pid} (runner (child)) ${["S", ...Array(18).fill("0"), 100].join(" ")}\n`;
await writeFile(statPath, stat(1));
await writeFile(bootPath, "abcd-1234\n");
await writeFile(commandPath, "runner\0--runner-id\0writer-verifier\0");
const writer = REMOTE_RUNNER_CHILD_LAUNCH_SCRIPT
.replaceAll('"/proc/"+process.argv[1]+"/stat"', JSON.stringify(statPath))
.replaceAll("/proc/sys/kernel/random/boot_id", bootPath);
const child = spawn("/bin/sh", ["-c", writer, "writer", marker, "nonce", "writer-verifier", join(root, "diagnostics"),
process.execPath, "-e", 'process.stdout.write("ready");setInterval(() => {}, 1000)', "--", "--runner-id", "writer-verifier"], { stdio: ["ignore", "pipe", "pipe"] });
const exited = new Promise<void>(resolve => child.once("exit", () => resolve()));
try {
await new Promise<void>((resolve, reject) => {
child.stdout.once("data", () => resolve());
child.once("error", reject);
child.once("exit", code => reject(new Error(`launch writer exited before readiness: ${code}`)));
});
await writeFile(statPath, stat(child.pid!));
const generation = (await readFile(marker, "utf8")).split("\n")[4]!;
expect(generation).not.toBe("");
const verifier = REMOTE_RECOVERY_PROCESS_CHECK_SCRIPT
.replaceAll('"/proc/" + pid + "/cmdline"', JSON.stringify(commandPath))
.replaceAll('"/proc/" + pid + "/stat"', JSON.stringify(statPath))
.replaceAll('"/proc/sys/kernel/random/boot_id"', JSON.stringify(bootPath));
expect(execFileSync("sh", ["-c", verifier, "verifier", marker, "nonce", "writer-verifier", String(child.pid), generation], { encoding: "utf8" })).toBe("");
expect(() => execFileSync("sh", ["-c", verifier, "verifier", marker, "nonce", "writer-verifier", String(child.pid), `${generation}-changed`], { stdio: "pipe" }))
.toThrow();
expect(() => process.kill(child.pid!, 0)).not.toThrow();
} finally {
child.kill("SIGKILL");
await exited;
await rm(root, { recursive: true, force: true });
}
});
it.each(["missing", "broken", "working"] as const)("checks the Node probe before creating launch state (%s)", async runtime => {
const root = await mkdtemp(join(tmpdir(), "runner-launch-probe-"));
const bin = join(root, "bin");
const marker = join(root, "state", "identity");
const detached = join(root, "detached");
let launch: { command: string; args?: string[] } | undefined;
const execute = vi.fn(async (request: { command: string; args?: string[] }) => {
if (request.args?.[2] === "paperclip-runner-launch") launch = request;
return { exitCode: 1, timedOut: false, stdout: "", stderr: "" };
});
try {
await mkdir(bin);
for (const name of ["dirname", "mkdir", "sh"]) await symlink(execFileSync("sh", ["-c", `command -v ${name}`], { encoding: "utf8" }).trim(), join(bin, name));
await writeFile(join(bin, "nohup"), `#!/bin/sh\nprintf detached > '${detached}'\n`, { mode: 0o755 });
if (runtime === "working") await symlink(process.execPath, join(bin, "node"));
if (runtime === "broken") await writeFile(join(bin, "node"), "#!/bin/sh\nexit 7\n", { mode: 0o755 });
const handle = createRemoteRunnerProcessLauncher({
target: { kind: "remote", transport: "sandbox", environmentId: "environment", leaseId: "lease", remoteCwd: root },
runner: { execute } as never, remoteBinary: "/runtime/runnerd", processIdentityPath: marker,
stateDirectory: root, diagnosticsDirectory: join(root, "diagnostics"), runnerInstanceId: "runner-launch",
})({ command: "/runtime/runnerd", args: [], cwd: root, environment: {} });
await expect(handle.completion).rejects.toThrow("runner_remote_process_launch_failed");
expect(launch).toBeDefined();
let status = 0;
try { execFileSync(launch!.command, launch!.args, { env: { ...process.env, PATH: bin }, stdio: "pipe" }); }
catch (error) { status = (error as { status: number }).status; }
if (status === 0) await vi.waitFor(async () => expect(await readFile(detached, "utf8")).toBe("detached"));
expect(status === 0).toBe(runtime === "working");
if (runtime !== "working") {
await expect(access(join(root, "state"))).rejects.toMatchObject({ code: "ENOENT" });
await expect(access(detached)).rejects.toMatchObject({ code: "ENOENT" });
}
} finally { await rm(root, { recursive: true, force: true }); }
});
it.each([true, false])("launches with the guaranteed Node runtime when boot identity is available: %s", async (hasBootIdentity) => {
const root = await mkdtemp(join(tmpdir(), "remote-launch-generation-"));
try {
@@ -803,6 +884,77 @@ describe("remote runner launch fingerprint compatibility", () => {
});
describe("remote runner process supervision", () => {
it.each(["exit", "unreadable", "changed generation"] as const)("requires an exit receipt when process metadata changes during supervision (%s)", async failure => {
const root = await mkdtemp(join(tmpdir(), "runner-exit-receipt-"));
const marker = join(root, "identity");
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "--", "--runner-id", "runner-exit"], { stdio: "ignore" });
const exited = new Promise<void>(resolve => child.once("exit", () => resolve()));
const proc = join(root, "proc");
const statPath = join(proc, String(child.pid), "stat");
const hook = join(root, "probe-hook.cjs");
await mkdir(join(proc, String(child.pid)), { recursive: true });
await mkdir(join(proc, "sys/kernel/random"), { recursive: true });
await writeFile(statPath, `${child.pid} (runner (child)) ${["S", ...Array(18).fill("0"), failure === "changed generation" ? 101 : 100].join(" ")}\n`);
await writeFile(join(proc, String(child.pid), "cmdline"), "runner\0--runner-id\0runner-exit\0");
await writeFile(join(proc, "sys/kernel/random/boot_id"), "abcd-1234\n");
await writeFile(hook, `const fs = require("node:fs");
const read = fs.readFileSync;
fs.readFileSync = function(path, ...args) {
if (path === ${JSON.stringify(statPath)}) {
if (${JSON.stringify(failure)} === "unreadable") throw Object.assign(new Error("denied"), { code: "EACCES" });
if (${JSON.stringify(failure)} === "exit") {
process.kill(${child.pid}, "SIGKILL");
const until = Date.now() + 5000;
while (true) {
try { process.kill(${child.pid}, 0); }
catch (error) { if (error.code === "ESRCH") break; throw error; }
if (Date.now() > until) throw new Error("fixture child was not reaped");
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
}
fs.rmSync(path);
}
}
return read.call(this, path, ...args);
};`);
let savedMarker = "";
const ok = { stdout: "", stderr: "", exitCode: 0, signal: null, timedOut: false };
const execute = vi.fn(async (request: { command: string; args?: string[] }) => {
if (request.args?.[2] === "paperclip-runner-launch") {
savedMarker = `${request.args[4]}\n${child.pid}\n2026-10-26T00:00:00.000Z\nrunner-exit\nlinux:abcd-1234:100\n`;
await writeFile(marker, savedMarker);
return ok;
}
const args = request.args?.map(arg => arg.replaceAll("/proc/", `${proc}/`)) ?? [];
return new Promise<typeof ok>(resolve => execFile(request.command, args, {
env: { ...process.env, NODE_OPTIONS: `--require=${hook}` }, timeout: 8_000,
}, (error, stdout, stderr) => resolve({ ...ok, stdout, stderr,
exitCode: error ? typeof error.code === "number" ? error.code : -1 : 0, timedOut: !!error?.killed })));
});
const handle = createRemoteRunnerProcessLauncher({
target: { kind: "remote", transport: "sandbox", environmentId: "environment", leaseId: "lease", remoteCwd: root },
runner: { execute } as never, remoteBinary: "/runtime/runnerd", processIdentityPath: marker,
stateDirectory: root, diagnosticsDirectory: join(root, "diagnostics"), runnerInstanceId: "runner-exit",
})({ command: "/runtime/runnerd", args: [], cwd: root, environment: {} });
try {
if (failure === "exit") {
await expect(handle.completion).resolves.toMatchObject({ code: null });
expect(handle.isExitConfirmed?.()).toBe(true);
} else {
expect(await handle.completion.catch(readRemoteRunnerRecoveryError)).toEqual({ stage: "process_verification",
reason: failure === "unreadable" ? "fingerprint_unavailable" : "process_generation_changed" });
expect(handle.isExitConfirmed?.()).toBe(false);
expect(() => process.kill(child.pid!, 0)).not.toThrow();
}
expect(await readFile(marker, "utf8")).toBe(savedMarker);
expect(execute.mock.calls.filter(([request]) => request.args?.[2] === "paperclip-runner-launch")).toHaveLength(1);
} finally {
child.kill("SIGKILL");
await exited;
await handle.completion.catch(() => undefined);
await rm(root, { recursive: true, force: true });
}
}, 15_000);
it("preserves a real listening process and its marker when a health RPC times out", async () => {
const root = await mkdtemp(join(tmpdir(), "runner-health-listener-"));
const marker = join(root, "identity");
@@ -11217,7 +11217,7 @@ export async function verifyRemoteRunnerReattachment(input: {
command: "sh",
args: [
"-c",
`${REMOTE_RECOVERY_PROCESS_CHECK_SCRIPT}; if ${REMOTE_RUNNER_PROCESS_FINGERPRINT_SCRIPT}; then :; else exit 46; fi; test "$process_fingerprint" = "$5" || exit 47` +
REMOTE_RECOVERY_PROCESS_CHECK_SCRIPT +
(signal ? '; kill -"$6" "$pid"' : ""),
"paperclip-runner-recovery-check",
identityPath,
@@ -11230,8 +11230,12 @@ export async function verifyRemoteRunnerReattachment(input: {
bypassSession: true,
},
});
return { alive: result.exitCode === 0 && !result.timedOut,
reason: remoteRecoveryProbeReason("process_verification", result.exitCode, result.timedOut) };
const alive = result.exitCode === 0 && !result.timedOut;
const reason = remoteRecoveryProbeReason("process_verification", result.exitCode, result.timedOut);
if (!alive && reason !== "process_not_running") {
throw new RemoteRunnerRecoveryError({ stage: "process_verification", reason });
}
return { alive, reason };
};
const verified = await check(undefined, probeDeadline);
if (!verified.alive) throw new RemoteRunnerRecoveryError({ stage: "process_verification", reason: verified.reason });
@@ -11417,7 +11421,7 @@ export function createRemoteRunnerProcessLauncher(input: {
command: "sh",
args: [
"-c",
'set -eu; identity_path=$1; identity_nonce=$2; runner_instance_id=$3; child_script=$4; shift 4; umask 077; identity_dir=$(dirname -- "$identity_path"); mkdir -p -- "$identity_dir"; if command -v setsid >/dev/null 2>&1; then nohup setsid sh -c "$child_script" paperclip-runner-child "$identity_path" "$identity_nonce" "$runner_instance_id" "$@" </dev/null >/dev/null 2>&1 & else nohup sh -c "$child_script" paperclip-runner-child "$identity_path" "$identity_nonce" "$runner_instance_id" "$@" </dev/null >/dev/null 2>&1 & fi',
'set -eu; node -e "process.kill(process.pid, 0)"; identity_path=$1; identity_nonce=$2; runner_instance_id=$3; child_script=$4; shift 4; umask 077; identity_dir=$(dirname -- "$identity_path"); mkdir -p -- "$identity_dir"; if command -v setsid >/dev/null 2>&1; then nohup setsid sh -c "$child_script" paperclip-runner-child "$identity_path" "$identity_nonce" "$runner_instance_id" "$@" </dev/null >/dev/null 2>&1 & else nohup sh -c "$child_script" paperclip-runner-child "$identity_path" "$identity_nonce" "$runner_instance_id" "$@" </dev/null >/dev/null 2>&1 & fi',
"paperclip-runner-launch",
input.processIdentityPath,
identityNonce,
@@ -11496,9 +11500,7 @@ export function createRemoteRunnerProcessLauncher(input: {
command: "sh",
args: [
"-c",
REMOTE_RECOVERY_PROCESS_CHECK_SCRIPT + (identity.processStartFingerprint
? `; if ${REMOTE_RUNNER_PROCESS_FINGERPRINT_SCRIPT}; then :; else exit 46; fi; test "$process_fingerprint" = "$5" || exit 47`
: ""),
REMOTE_RECOVERY_PROCESS_CHECK_SCRIPT,
"paperclip-runner-monitor",
input.processIdentityPath,
identityNonce,
@@ -18,6 +18,40 @@ function shellExit(script: string, args: string[]) {
}
describe("remote recovery diagnostic shell receipts", () => {
it("keeps permission-denied liveness uncertain", async () => {
const root = await mkdtemp(join(tmpdir(), "remote-probe-denied-"));
const marker = join(root, "marker");
const hook = join(root, "denied.cjs");
try {
await writeFile(marker, `nonce\n${process.pid}\n2026-10-26T00:00:00.000Z\nrunner\n`);
await writeFile(hook, 'process.kill = () => { throw Object.assign(new Error("denied"), { code: "EPERM" }); };');
const script = `NODE_OPTIONS="--require=${hook}"; export NODE_OPTIONS; kill() { return 1; }; ` + REMOTE_RECOVERY_PROCESS_CHECK_SCRIPT;
expect(shellExit(script, [marker, "nonce", "runner", String(process.pid)])).toBe(46);
expect(() => process.kill(process.pid, 0)).not.toThrow();
} finally { await rm(root, { recursive: true, force: true }); }
});
it.each([
{ fingerprint: "linux:abcd-1234:100", code: 45 },
{ fingerprint: "linux:abcd-1234:101", code: 47 },
{ fingerprint: "", code: 44 },
])("requires the saved generation before accepting a zombie receipt (%j)", async ({ fingerprint, code }) => {
const root = await mkdtemp(join(tmpdir(), "remote-probe-zombie-"));
const marker = join(root, "marker");
try {
await writeFile(marker, `nonce\n${process.pid}\n2026-10-26T00:00:00.000Z\nrunner\n`);
await writeFile(join(root, "cmdline"), "");
await writeFile(join(root, "stat"), `${process.pid} (runner (child)) ${["Z", ...Array(18).fill("0"), 100].join(" ")}\n`);
await writeFile(join(root, "boot_id"), "abcd-1234\n");
const script = REMOTE_RECOVERY_PROCESS_CHECK_SCRIPT
.replaceAll('"/proc/$pid/cmdline"', JSON.stringify(join(root, "cmdline")))
.replaceAll('"/proc/" + pid + "/cmdline"', JSON.stringify(join(root, "cmdline")))
.replaceAll('"/proc/" + pid + "/stat"', JSON.stringify(join(root, "stat")))
.replaceAll('"/proc/sys/kernel/random/boot_id"', JSON.stringify(join(root, "boot_id")));
expect(shellExit(script, [marker, "nonce", "runner", String(process.pid), fingerprint])).toBe(code);
} finally { await rm(root, { recursive: true, force: true }); }
});
it("distinguishes missing, unsafe and valid marker reads without exposing their contents", async () => {
const root = await mkdtemp(join(tmpdir(), "remote-recovery-read-"));
const marker = join(root, "marker");
@@ -69,8 +69,45 @@ export const REMOTE_RECOVERY_PROCESS_CHECK_SCRIPT = [
'test -f "$identity_path" || exit 41',
'{ IFS= read -r nonce; IFS= read -r pid; IFS= read -r started_at; IFS= read -r runner_id; } < "$identity_path" || exit 43',
'test "$nonce" = "$expected_nonce" && test "$runner_id" = "$expected_runner_id" && test "$pid" = "$expected_pid" && test -n "$started_at" || exit 44',
'kill -0 "$pid" 2>/dev/null || exit 45',
'if test -r "/proc/$pid/cmdline"; then command_line=$(tr "\\000" "\\n" < "/proc/$pid/cmdline"); printf "%s\\n" "$command_line" | grep -Fqx -- "--runner-id" || exit 44; printf "%s\\n" "$command_line" | grep -Fqx -- "$expected_runner_id" || exit 44; fi',
String.raw`node -e '
const fs = require("node:fs");
const [pid, runnerId, expectedGeneration] = process.argv.slice(1);
if (!/^[1-9][0-9]*$/.test(pid) || !Number.isSafeInteger(Number(pid))) process.exit(44);
function requireLiveProcess() {
try { process.kill(Number(pid), 0); }
catch (error) { process.exit(error.code === "ESRCH" ? 45 : 46); }
}
function verifyGeneration() {
try {
const stat = fs.readFileSync("/proc/" + pid + "/stat", "utf8");
const end = stat.lastIndexOf(") ");
const fields = stat.slice(end + 2).trim().split(/\s+/);
const ticks = fields[19];
const boot = fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
if (end < 0 || !stat.startsWith(pid + " (") || !/^[0-9]+$/.test(ticks || "") || !/^[-a-fA-F0-9]+$/.test(boot)) process.exit(46);
if ("linux:" + boot + ":" + ticks !== expectedGeneration) process.exit(47);
if (fields[0] === "Z") process.exit(45);
} catch (error) {
if (error.code === "ENOENT") requireLiveProcess();
process.exit(46);
}
}
requireLiveProcess();
const commandPath = "/proc/" + pid + "/cmdline";
if (process.platform !== "linux" && !expectedGeneration && !fs.existsSync(commandPath)) process.exit(0);
let args;
try { args = fs.readFileSync(commandPath, "utf8").split("\0"); }
catch (error) {
requireLiveProcess();
if (error.code === "ENOENT" && expectedGeneration) verifyGeneration();
process.exit(46);
}
if (expectedGeneration) verifyGeneration();
if (!args.includes("--runner-id") || !args.includes(runnerId)) {
requireLiveProcess();
process.exit(44);
}
' "$pid" "$expected_runner_id"` + ' "${5:-}"',
].join("; ");
export function remoteRecoveryProbeReason(stage: RemoteRunnerRecoveryDiagnostic["stage"], exitCode: number | null, timedOut: boolean): RemoteRunnerRecoveryDiagnostic["reason"] {