Files
PaperClipAI/server/src/services/owner-proof.ts
T
Dotta a3e7e63330 Guard Native recovery with process-space proof and explicit containment (#19813)
## Thinking Path

> - Paperclip manages agents and their work.
> - Recovery must preserve execution ownership and accepted results.
> - A saved PID proves nothing outside its recorded process namespace.
> - Uncertain unfinished work must stay held until its original owners
stop.
> - Operators need a source-bound containment action and a separate
ordinary Retry.
> - Accepted results must use the existing finalizer without another
provider turn.
> - This PR keeps those paths safe, recoverable, and quiet between real
obligations.

## Linked Issues or Issue Description

Refs: #19713

Refs: #19591

Recovery could use a local PID to release or replace a controller from
another process namespace. Connected-chat Retry also rejected successful
containment on a task in `todo`. The new containment action used a
broader permission than the existing owner-stop action. A later review
found that result-less guards also rejected the existing accepted-result
confirmation action.

## What Changed

- Record and verify the controller's kernel/PID namespace. Keep unknown
and foreign unfinished ownership held. Preserve independent durable stop
evidence and the existing remote lease fences.
- Use the shared accepted-result ownership reconciler. Bind settlement
to the observed lease token, generation, boot, PID, namespace and
process birth. Retain finalizer, cleanup, current-controller, live-owner
and remote exclusions.
- Share the remote-owner reader across recovery consumers. Scope runner
leases to the same company, task and run. Do not make local PID probes
for remote accepted-result ownership.
- Gate original-host containment on `runtime:confirm_owner_stop`. Bind
it to the current task, source, assignee, original expiry, lease and
Stop state. Audit separate controller/provider containment and reviewed
action outcomes.
- Keep containment separate from ordinary Retry. Use one canonical
committed receipt and the normal admission, budget, workspace and
lineage checks. Preserve the recorded external-action outcomes.
- Keep the controller-only confirmation actionable for both result-less
work and accepted results. Result-less work gets its next source-bound
attention in the same transaction. Accepted results keep their original
result and use normal shared settlement/finalization.
- Keep identity inspection quiet. Preserve the original foreign-owner
expiry and use the existing scoped finalizer in accepted-result recovery
controls.
- Update the recovery card, validators, execution projection, docs and
access maps. Reuse existing canonical columns; add no migration or
ownership backfill.

## Verification

Current publication head: `71ea76b568de09d6313d8992e803131d9d5c54f3`.

**The current fix has a causal red/green check.** On the test-only
revision, both foreign-local stopped/fenced controller cases failed
because recovery probed saved runner, process-group and provider PIDs in
this host's namespace. This measured the wrong probes; it did not run
duplicate providers. After the fix, the same two cases passed every
no-probe, no-claim, no-controller-stamp, no-stop-receipt and
unchanged-authority assertion. Three existing remote preparation, grace
and fenced-controller progress cases also passed. Total: **5 passed, 0
failed;170 filtered rows excluded**. All three normal affected App
TypeScript programs passed on this head (660,681 and428 roots).

The fix separates a controller wait from execution stop proof.
Independent live/grace evidence still schedules recovery. A
stopped/fenced foreign controller can pass this namespace guard only for
remote execution, which keeps its later checkpoint and never-launched
checks. Foreign local execution stays held before runner or provider PID
inspection.

Earlier source-scoped results remain evidence, not fresh executions on
the current head:

- On `c86dd741`, the complete failed-owner file passed **25/25**, with
no skips or filters. Empty scans wrote no controller stamps. Foreign
ownership kept its original expiry. Normal returned-claim settlement and
attention at the exact deadline passed.
- On `c86dd741`, **15 selected accepted-result/restart controls
passed**. The81 filtered rows are excluded. Both completion cases used
the existing scoped finalizer and kept their accepted result without
another provider turn.
- Trusted CI at synthetic merge `50d072a5` (actual master `1b7fe334`
plus `c86dd741`) passed the complete **17-case maintenance-server
file**. This includes the original90-real-second quiet observation and
two qualified hourly windows. The quiet case took118,918ms. The four
jobs that failed on the earlier publication all passed on that synthetic
merge.
- On `6243b32c`, the complete ownership-report file passed **29/29**.
Native NULL namespace stayed unprovable and generic compatibility
remained. Three selected recovery controls passed;170 filtered rows are
excluded.
- On `f81f2c3c`,356 business cases passed: whole ownership-route221,
recoveryUI70, owner-proof38 and controller-confirmation8; selected
accepted-result authority18; and one real connected-chat containment,
separate authorized Retry, useful Native result and downstream reply
case. The2,024 filtered rows are excluded. A separate earlier
accepted-result causal case kept the original result and completed after
restart without replay.

Normal Shared exports, App programs, UI typecheck, token gates and map
checks passed on their recorded revisions. Unchanged DB, adapter, Eval
and Runner inputs/outputs have explicit carry proofs. The clean normal
merge with fetched master `8fe524f7` preserves all31 exclusive owned
paths and14,827 unrelated master entries. The nine overlapping paths
were reviewed, including the upstream unidentified-run stop action.
Fresh CI must validate that actual merge composition.

PG cases used existing external PG17, fresh owned databases, pools at
most4, one worker and no SQL observer during cases. Registered clients
and recorded process groups closed. Independent final SQL audits found
zero campaign sessions before normal database cleanup. Original failing
fixtures remain retained.

Original failures are preserved. One new test first failed compilation
due to a missing getter import; an import-only correction passed its
full owning programs before the causal red/green check. A local quiet
attempt stopped in the normal prerequisite hook before any business case
and its collector rejected two normal build markers. The later17-case CI
pass establishes quiet recovery; it does not turn that failed local
collector into a pass. An earlier useful case passed before its private
cleanup collector failed on an owned read-only directory; normal cleanup
completed after all owners closed. Earlier runner-shutdown cancellation
and a separate private whole-suite failure remain unexplained.

Current-head checks are complete:57 success,1 neutral and3 skipped,
including the successful aggregate CI gate. The legacy status passed.
Greptile is5/5 and Ponytail gives an explicit5/5 Ship verdict for this
head. All seven review threads are resolved. Full recursive typecheck,
the full test suite and release build were not repeated locally. No
result here proves deployed suspension, a live provider or termination
on another host.

## Risks

- Unknown physical ownership can require an owner/admin action. A false
containment statement can be unsafe.
- New work, reassignment, Stop, renewed authority, accepted results or
pending cleanup can invalidate an action. The operator must refresh.
- Controller confirmation does not prove provider/tool containment.
Completed external actions must not be replayed.
- Generic legacy and child-process compatibility remains separate from
the strict Native controller policy.
- Existing saved state and canonical schema are preserved. Permanent
safety refusals remain held with their normal named recovery path.

## Model Used

OpenAI `gpt-6.1-sol` agents implemented and tested the changes with
reasoning, repository tools and code execution. A GPT-6-based Codex
managing agent reviewed and integrated them. The host does not expose
the manager's exact build or the context-window sizes.

## 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 and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [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
2026-11-09 12:06:11 -06:00

367 lines
18 KiB
TypeScript

import { hostname } from "node:os";
import {
currentControllerBootId,
currentControllerDeployment,
currentControllerProcessStamp,
currentProcessSpaceHash,
observedProcessStartedAt,
processExists,
processStartsDefinitelyDiffer,
PROCESS_START_PRECISION_MS,
UNKNOWN_PROCESS_SPACE,
} from "./controller-process-identity.js";
import type { WorkspaceWorker } from "./native-runtime/native-workspace-platform-proof.js";
import { ephemeralServerEnabled } from "./ephemeral-server-mode.js";
import { proveControllerInstance, registeredControllerInstanceDb, type ControllerInstanceState } from "./controller-instance-lease.js";
/** `processSpace` value written when a board operator confirmed that this exact
* recorded owner stopped (native-controller-host-recovery.ts). The attestation
* is the proof. */
export const OPERATOR_ATTESTED_PROCESS_SPACE = "operator_attested";
/**
* The owner recorded on an in-progress durable record (plan section A).
*
* A local owner is a process in a PID space. A remote owner is a fenced lease:
* ownership moves by fence token and generation, never by a process proof,
* because a remote runner keeps running across a control-plane replacement.
*/
export type OwnerStamp = LocalOwnerStamp | RemoteOwnerStamp;
export type LocalOwnerStamp = {
kind: "local";
/** Record kind or subsystem that wrote the stamp, for reports only. */
scope: string;
generation: number | null;
fenceToken?: string | null;
/** Hostname at stamp time. Informational; `processSpace` is the proof key. */
host: string | null;
/** Hash of host, kernel boot and PID namespace (`currentProcessSpaceHash`). */
processSpace: string | null;
bootId: string | null;
pid: number | null;
processStartedAt: string | null;
processGroupId?: number | null;
/** Start time of the process group's leader (PID = `processGroupId`). When
* the owner leads its own group this is `processStartedAt`. */
processGroupStartedAt?: string | null;
/** The platform deployment that ran the owner, when the platform names one. */
worker?: WorkspaceWorker | null;
/** The platform volume mounted by that deployment, when the stamp recorded one. */
volumeId?: string | null;
/** False when the owner's deployment was not volume-exclusive. Missing on older stamps. */
exclusiveVolume?: boolean | null;
/**
* The record was saved before its kind stored a PID space (`processSpace`
* is null for that reason, not because the space is unknown). Such rows keep
* the previous local proof for one release on single-host servers only, and
* the proof says so (`legacyHostFallback`) so the caller can record each use.
* An ephemeral (multi-host) server never judges them.
*/
processSpaceUnrecorded?: boolean;
requireRecordedProcessSpace?: boolean;
/** When the owned record was last written, if the caller knows (for the controller lease proof). */
touchedAt?: string | null;
};
export type RemoteOwnerStamp = {
kind: "remote";
scope: string;
generation: number | null;
/** The fence or lease token the owner holds, such as `native_run_finalizations.lease_owner`. */
fenceToken: string | null;
provider?: string | null;
};
export type OwnerVerdict = "gone" | "alive" | "unprovable";
export type OwnerProof = {
verdict: OwnerVerdict;
reason: OwnerProofReason;
/** The local proof judged a row saved before its PID space was recorded. */
legacyHostFallback?: true;
/** The caller named every run this process executes, so a record of another
* run is not held here (`settleOwnableKind` with `currentProcessIdle`). */
localExecutionsKnown?: true;
};
export type OwnerProofReason =
// gone
| "pid_dead"
| "pid_reused"
| "fence_superseded"
| "generation_superseded"
| "provider_stopped"
| "deployment_replaced"
| "operator_attested"
/** The owner's server boot stopped cleanly, or a newer boot fenced it after its lease expired. */
| "controller_stopped"
| "controller_fenced"
/** This process owns the record and is not executing its run. Only a
* caller that knows this process's executions may conclude it
* (`settleOwnableKind` with `currentProcessIdle`); the prover never does. */
| "current_process_idle"
// alive
| "current_process"
| "pid_alive"
| "process_group_alive"
| "fence_held"
| "controller_lease_held"
| "controller_lease_grace"
| "provider_running"
// unprovable
| "missing_identity"
| "unknown_process_space"
| "different_process_space"
| "different_host"
| "process_space_unrecorded"
| "pid_start_unreadable"
| "pid_start_inconsistent"
| "process_group_ambiguous"
| "fence_expired_without_provider_proof";
/** The state a remote owner is judged against, read by the caller from the
* durable record that holds the fence. */
export type RemoteFenceState = {
/** The token that currently holds the fence, or null when released. */
currentFenceToken: string | null;
currentGeneration: number | null;
/** When the current fence expires. Expiry alone never proves an owner gone. */
leaseExpiresAt: Date | null;
/** A provider proof for the stamped lease, when the provider gives one
* (for example a remote termination receipt). */
provider?: "stopped" | "running" | "unknown";
};
/** OS access, injectable so the proof matrix is testable without real PIDs. */
export type ProcessProbe = {
bootId: string;
host: string;
processSpace: string;
pidExists(pid: number): boolean;
processGroupExists(processGroupId: number): boolean;
readStartedAt(pid: number): Promise<Date | null>;
/** This process's platform deployment. `exclusiveVolume` is true only when
* the platform stops every earlier deployment of the service before it
* starts this one (a Railway service with an attached volume). */
deployment?: { worker: WorkspaceWorker; exclusiveVolume: boolean; volumeId?: string | null } | null;
/** The lease state of another server boot (controller_instances), when this server can read it. */
controllerInstance?: (bootId: string, touchedAt: Date | null) => Promise<ControllerInstanceState | null>;
/** Report without changing any lease row (a read-only reconciliation pass). */
readOnly?: boolean;
};
function processGroupExists(processGroupId: number): boolean {
if (process.platform === "win32") return false;
try {
process.kill(-processGroupId, 0);
return true;
} catch (error) {
// EPERM: the group has members owned by another user. Alive.
return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM";
}
}
/** Railway mounts a volume into one deployment of a service at a time: it stops
* the previous deployment before the new one starts, so the two never overlap.
* A service with a volume cannot have replicas. */
export function currentDeploymentIdentity(env: NodeJS.ProcessEnv = process.env): ProcessProbe["deployment"] {
return currentControllerDeployment(env);
}
export function currentProcessProbe(): ProcessProbe {
return {
deployment: currentDeploymentIdentity(),
bootId: currentControllerBootId(),
host: hostname(),
processSpace: currentProcessSpaceHash(),
pidExists: processExists,
processGroupExists,
controllerInstance: async function (this: ProcessProbe, bootId, touchedAt) {
const db = registeredControllerInstanceDb();
return db ? proveControllerInstance(db, bootId, touchedAt, { readOnly: this?.readOnly === true }) : null;
},
readStartedAt: async pid => {
const startedAt = await observedProcessStartedAt(pid);
return startedAt === null ? null : new Date(startedAt);
},
};
}
/** The stamp this process writes when it takes ownership of a record. It does
* no I/O: the start time comes from an earlier resolved identity read, and is
* null until one has happened. A stamp without a start time still proves exit
* when its PID is gone; a live PID then stays unprovable, which fails closed.
*
* It records no process group: the control-plane server owns the record, and
* its provider children are tracked by their own records (the run's process
* identity, the runner's startup attempt). A caller that stamps a child it
* leads must set `processGroupId` and `processGroupStartedAt` itself; without
* them a surviving group is never judged at all. */
export function currentLocalOwnerStamp(scope: string, generation: number | null = null): LocalOwnerStamp {
const controller = currentControllerProcessStamp();
return {
kind: "local", scope, generation, host: hostname(), processSpace: controller.hostHash,
bootId: controller.bootId, pid: controller.pid, processStartedAt: controller.processStartedAt,
processGroupId: null,
};
}
const validPid = (pid: number | null | undefined): pid is number =>
typeof pid === "number" && Number.isInteger(pid) && pid > 0;
/**
* Proves whether the owner in a stamp is gone. Proof is by identity, never by
* age. `unprovable` must never release a record.
*
* Local rules (one prover for every kind):
* - this process's boot id is alive;
* - the stamp must name this PID space (host, kernel boot, PID namespace); a
* different host or namespace is never proof;
* - the PID must be dead, or reused: a live PID that started at least 1 s
* after the stamp (`ps lstart` has 1 s precision). An earlier start is
* inconsistent and unprovable. EPERM is alive;
* - the recorded process group must be empty, or led by a process that
* started definitely after the recorded group leader. A non-empty group without a
* recorded leader start time is unprovable: it may hold surviving children.
*
* Remote rules: a superseded fence token or generation, or a provider proof
* that the stamped lease stopped, is gone. A matching live fence is alive. A
* matching fence that only expired, with no provider proof, is unprovable.
*/
export async function proveOwnerGone(
stamp: LocalOwnerStamp,
probe?: ProcessProbe,
): Promise<OwnerProof>;
export async function proveOwnerGone(
stamp: RemoteOwnerStamp,
fence: RemoteFenceState,
now?: Date,
): Promise<OwnerProof>;
export async function proveOwnerGone(
stamp: OwnerStamp,
context?: ProcessProbe | RemoteFenceState,
now: Date = new Date(),
): Promise<OwnerProof> {
if (stamp.kind === "remote") return proveRemoteOwnerGone(stamp, context as RemoteFenceState, now);
return proveLocalOwnerGone(stamp, (context as ProcessProbe | undefined) ?? currentProcessProbe());
}
async function proveLocalOwnerGone(stamp: LocalOwnerStamp, probe: ProcessProbe): Promise<OwnerProof> {
const proof = await proveLocalProcessGone(stamp, probe);
if (proof.verdict !== "unprovable" || (!CROSS_SPACE_REASONS.has(proof.reason) && !(stamp.requireRecordedProcessSpace && proof.reason === "process_space_unrecorded")) || !stamp.bootId || !probe.controllerInstance
|| validPid(stamp.processGroupId ?? null)) return proof;
// Another host or PID space: the owner's server boot answers through its lease. Only a record the
// server process itself held qualifies; agent processes in a recorded group may outlive it.
const instance = await probe.controllerInstance(stamp.bootId, stamp.touchedAt ? new Date(stamp.touchedAt) : null);
if (instance === "stopped") return { verdict: "gone", reason: "controller_stopped" };
if (instance === "fenced") return { verdict: "gone", reason: "controller_fenced" };
if (instance === "live") return { verdict: "alive", reason: "controller_lease_held" };
// Just past its last sign of life: kept as alive so the settle watch re-checks it after the grace.
if (instance === "grace") return { verdict: "alive", reason: "controller_lease_grace" };
return proof;
}
const CROSS_SPACE_REASONS = new Set<OwnerProofReason>(["different_process_space", "different_host", "unknown_process_space"]);
async function proveLocalProcessGone(stamp: LocalOwnerStamp, probe: ProcessProbe): Promise<OwnerProof> {
if (stamp.bootId && stamp.bootId === probe.bootId) return { verdict: "alive", reason: "current_process" };
// The platform stopped the owner's whole deployment, with every process and
// orphaned child in it, before it started this one. Host and PID are moot.
if (deploymentReplaced(stamp, probe.deployment)) return { verdict: "gone", reason: "deployment_replaced" };
if (stamp.processSpace === OPERATOR_ATTESTED_PROCESS_SPACE) return { verdict: "gone", reason: "operator_attested" };
// A row saved before its kind stored a PID space: one-release local
// fallback on single-host servers; an ephemeral server cannot assume the
// PID was recorded here, so a board operator decides.
const legacy = !stamp.processSpace && stamp.processSpaceUnrecorded === true;
if (legacy && (stamp.requireRecordedProcessSpace || ephemeralServerEnabled())) return { verdict: "unprovable", reason: "process_space_unrecorded" };
if (!validPid(stamp.pid) || (!stamp.processSpace && !legacy)) return { verdict: "unprovable", reason: "missing_identity" };
if (legacy) {
const proof = await proveLocalOwnerGone({ ...stamp, processSpace: probe.processSpace, host: null,
processSpaceUnrecorded: false }, probe);
return proof.verdict === "gone" ? { ...proof, legacyHostFallback: true } : proof;
}
if (stamp.processSpace === UNKNOWN_PROCESS_SPACE || probe.processSpace === UNKNOWN_PROCESS_SPACE) {
return { verdict: "unprovable", reason: "unknown_process_space" };
}
if (stamp.host && stamp.host !== probe.host) return { verdict: "unprovable", reason: "different_host" };
if (stamp.processSpace !== probe.processSpace) return { verdict: "unprovable", reason: "different_process_space" };
let processReason: "pid_dead" | "pid_reused";
if (!probe.pidExists(stamp.pid)) {
processReason = "pid_dead";
} else {
const observed = await probe.readStartedAt(stamp.pid);
if (!stamp.processStartedAt || observed === null) {
return { verdict: "unprovable", reason: "pid_start_unreadable" };
}
const order = compareStarts(stamp.processStartedAt, observed);
if (order === "same") return { verdict: "alive", reason: "pid_alive" };
// A reused PID belongs to a later process. An earlier start means the
// stamp is inconsistent (clock skew or a migrated value): never proof.
if (order !== "later") return { verdict: "unprovable", reason: "pid_start_inconsistent" };
processReason = "pid_reused";
}
const group = stamp.processGroupId;
if (validPid(group) && probe.processGroupExists(group)) {
// A non-empty group can hold the owner's surviving children, including
// detached ones started after the owner. The kernel never reuses a PID
// while a group with that id has members, so the group is a different one
// only when its leader is alive and is definitely not the recorded leader.
if (!probe.pidExists(group)) return { verdict: "alive", reason: "process_group_alive" };
const recordedLeaderStart = group === stamp.pid ? stamp.processStartedAt : stamp.processGroupStartedAt ?? null;
const leaderStartedAt = await probe.readStartedAt(group);
if (!recordedLeaderStart || leaderStartedAt === null) {
return { verdict: "unprovable", reason: "process_group_ambiguous" };
}
const order = compareStarts(recordedLeaderStart, leaderStartedAt);
if (order === "same") return { verdict: "alive", reason: "process_group_alive" };
if (order !== "later") return { verdict: "unprovable", reason: "process_group_ambiguous" };
}
return { verdict: "gone", reason: processReason };
}
function deploymentReplaced(stamp: LocalOwnerStamp, current: ProcessProbe["deployment"]): boolean {
const stamped = stamp.worker;
if (!stamped || !current?.exclusiveVolume) return false;
// A stamp that recorded its volume must name this same volume. Older stamps
// predate the field; the service and environment then name the volume.
if (stamp.exclusiveVolume === false) return false;
if (stamp.volumeId != null && stamp.volumeId !== current.volumeId) return false;
const worker = current.worker;
return stamped.provider === worker.provider && stamped.projectId === worker.projectId
&& stamped.environmentId === worker.environmentId && stamped.serviceId === worker.serviceId
&& typeof stamped.deploymentId === "string" && stamped.deploymentId.length > 0
&& stamped.deploymentId !== worker.deploymentId;
}
/** "same" within `ps lstart` precision (1 s), "later" when the observed process
* started definitely after the recorded one, "earlier" when definitely before,
* "unknown" when either time is unreadable. */
function compareStarts(recorded: string, observed: Date): "same" | "later" | "earlier" | "unknown" {
const left = Date.parse(recorded);
const right = observed.getTime();
if (!Number.isFinite(left) || !Number.isFinite(right)) return "unknown";
if (!processStartsDefinitelyDiffer(recorded, observed)) return "same";
return right - left >= PROCESS_START_PRECISION_MS ? "later" : "earlier";
}
function proveRemoteOwnerGone(stamp: RemoteOwnerStamp, fence: RemoteFenceState, now: Date): OwnerProof {
// The identity check comes first: a provider proof for the current lease
// cannot be tied to a stamp that names no token.
if (!stamp.fenceToken) return { verdict: "unprovable", reason: "missing_identity" };
if (fence.provider === "stopped") return { verdict: "gone", reason: "provider_stopped" };
if (stamp.generation !== null && fence.currentGeneration !== null && fence.currentGeneration > stamp.generation) {
return { verdict: "gone", reason: "generation_superseded" };
}
if (fence.currentFenceToken !== stamp.fenceToken) {
// Released (null) or handed to another owner: the stamped owner no longer
// holds authority, whether or not its process still runs.
return { verdict: "gone", reason: "fence_superseded" };
}
if (fence.provider === "running") return { verdict: "alive", reason: "provider_running" };
if (fence.leaseExpiresAt && fence.leaseExpiresAt > now) return { verdict: "alive", reason: "fence_held" };
return { verdict: "unprovable", reason: "fence_expired_without_provider_proof" };
}