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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/copilot-stable-process-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed
- Copilot conversation ownership uses the kernel's stable process birth, so clock corrections no
longer reset a verified binding or make a previously denied fallback eligible again.
14 changes: 8 additions & 6 deletions server/copilot-store.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { constants, closeSync, fstatSync, lstatSync, openSync, opendirSync, readFileSync, readlinkSync, readSync, realpathSync, statSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
import { createHash, randomUUID, type Hash } from "node:crypto";
import { processAlive, processStartedAt } from "./process-start.ts";
import { readProcessBirth } from "./process-owner.ts";

const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const HEADER_BYTES = 64 * 1024;
Expand All @@ -22,7 +22,7 @@ interface ForegroundCheckpoint {
}
const foregroundCheckpoints = new Map<number, ForegroundCheckpoint>();
interface InitialLogRead { dev: bigint; ino: bigint; path: string; size: number; hash: string }
const foregroundLogProcesses = new Map<number, { started: number; observed: boolean; initialRead?: InitialLogRead }>();
const foregroundLogProcesses = new Map<number, { started: string; observed: boolean; initialRead?: InitialLogRead }>();

/** Revalidate every consumed byte: unchanged boundary samples cannot prove an append. */
function logPrefix(fd: number, size: number): Hash | null {
Expand All @@ -46,8 +46,10 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg
}

function scanForegroundLog(pid: number, logDirectories: readonly string[]): ForegroundBinding | null | undefined | typeof VERIFIED_UNREGISTER | typeof LOG_READ_CHANGED {
const started = processStartedAt(pid);
if (started === null) return undefined;
if (process.platform !== "linux") return undefined;
const birth = readProcessBirth(pid);
if (birth.state !== "identified") return undefined;
const started = birth.birth.value;
let owner = foregroundLogProcesses.get(pid);
if (owner?.started !== started) {
foregroundLogProcesses.delete(pid);
Expand All @@ -57,8 +59,8 @@ function scanForegroundLog(pid: number, logDirectories: readonly string[]): Fore
if (owner === undefined) {
if (foregroundLogProcesses.size >= 256) {
for (const [previousPid, previous] of foregroundLogProcesses) {
const current = processStartedAt(previousPid);
if ((current !== null && current !== previous.started) || !processAlive(previousPid)) {
const current = readProcessBirth(previousPid);
if (current.state === "absent" || (current.state === "identified" && current.birth.value !== previous.started)) {
foregroundLogProcesses.delete(previousPid);
foregroundCheckpoints.delete(previousPid);
}
Expand Down
6 changes: 3 additions & 3 deletions server/copilot.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { herdrRpc, paneProcessInfo, sessionSnapshot, workspaceClose, workspaceCr
import { forgetTranscriptState } from "./conversation.ts";
import { copilotHandoffSession, copilotProcessSession } from "./copilot-store.ts";
import { processEnviron } from "./omo.ts";
import * as processStart from "./process-start.ts";
import * as processOwner from "./process-owner.ts";

const id = "12aa83d8-48bc-41f2-a498-7055c5c44a01";
const other = "12aa83d8-48bc-41f2-a498-7055c5c44a02";
Expand Down Expand Up @@ -173,8 +173,8 @@ const handoffTest = async () => {
const ancestry = spyOn(fs, "readFileSync").mockImplementation(alteredStat);
try { expect(handoff()).toBeNull(); } finally { ancestry.mockRestore(); }
expect(handoff()?.sessionId).toBe(handoffId);
const realStarted = processStart.processStartedAt;
const unavailableStart = spyOn(processStart, "processStartedAt").mockImplementation((pid) => pid === parent ? null : realStarted(pid));
const realBirth = processOwner.readProcessBirth;
const unavailableStart = spyOn(processOwner, "readProcessBirth").mockImplementation((pid) => pid === parent ? { state: "unknown" } : realBirth(pid));
try {
writeFileSync(parentLog, new Date().toISOString() + " [INFO] Registering foreground session: " + other + "\n");
expect(copilotProcessSession(processEnviron(parent), parentProcess.argv, parent, "unregistered")).toBeNull();
Expand Down
59 changes: 57 additions & 2 deletions server/copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { parseConversationMetadata } from "./conversation-metadata.ts";
import * as herdr from "./herdr/client.ts";
import * as omo from "./omo.ts";
import * as processStart from "./process-start.ts";
import * as processOwner from "./process-owner.ts";
import type { SessionSnapshot } from "../shared/protocol.ts";

const session = "12aa83d8-48bc-41f2-a498-7055c5c44a01";
Expand Down Expand Up @@ -68,16 +69,70 @@ async function logFixture(held: readonly string[]) {
};
}

for (const revoked of [false, true]) {
it.skipIf(process.platform !== "linux")(`keeps ${revoked ? "denied" : "registered"} Copilot ownership when epoch start time changes`, async () => {
const fixture = await logFixture([session]);
const { environment, log, pid, binding } = fixture;
const realStarted = processStart.processStartedAt;
const started = realStarted(pid)!;
let correction = 0;
const epoch = spyOn(processStart, "processStartedAt").mockImplementation((current) =>
current === pid ? started + correction : realStarted(current));
try {
appendFileSync(log, register(session));
const original = binding();
expect(original?.sessionId).toBe(session);
if (revoked) {
appendFileSync(log, register(session, "Unregistering"));
expect(binding()).toBeNull();
writeFileSync(log, "ordinary replacement without lifecycle evidence\n");
expect(copilotProcessSession(environment, ["copilot"], pid)).toBeNull();
}
for (correction of [-2000, 3000, -1000]) {
if (revoked) expect(copilotProcessSession(environment, ["copilot"], pid)).toBeNull();
else expect(binding()?.generation).toBe(original?.generation);
}
} finally { epoch.mockRestore(); await fixture.close(); }
});
}

it.skipIf(process.platform !== "linux")("retains denial on unknown birth but recognizes a genuinely replaced Copilot process", async () => {
const fixture = await logFixture([session]);
const { environment, log, pid } = fixture;
const realBirth = processOwner.readProcessBirth;
const original = realBirth(pid);
let birth = original;
const reading = spyOn(processOwner, "readProcessBirth").mockImplementation((current) =>
current === pid ? birth : realBirth(current));
const read = () => copilotProcessSession(environment, ["copilot"], pid);
try {
expect(original.state).toBe("identified");
if (original.state !== "identified") throw new Error("Fixture process birth unavailable");
appendFileSync(log, "CLI started\n");
expect(read()?.sessionId).toBe(session);
appendFileSync(log, register(session, "Unregistering"));
expect(read()).toBeNull();
writeFileSync(log, "replacement CLI started\n");
expect(read()).toBeNull();
birth = { state: "unknown" };
expect(read()).toBeNull();
birth = original;
expect(read()).toBeNull();
birth = { state: "identified", birth: { platform: "linux", value: original.birth.value + "0" } };
expect(read()?.sessionId).toBe(session);
} finally { reading.mockRestore(); await fixture.close(); }
});

it.skipIf(process.platform !== "linux")("never reuses cached handoff unregister evidence when the process identity lookup fails", async () => {
const fixture = await logFixture([session]);
const { environment, log, pid } = fixture;
const read = () => copilotProcessSession(environment, ["copilot"], pid, "unregistered");
const realStarted = processStart.processStartedAt;
const realBirth = processOwner.readProcessBirth;
try {
appendFileSync(log, register(session) + register(session, "Unregistering"));
expect(read()?.sessionId).toBe(session);
writeFileSync(log, register(other));
const missing = spyOn(processStart, "processStartedAt").mockImplementation((current) => current === pid ? null : realStarted(current));
const missing = spyOn(processOwner, "readProcessBirth").mockImplementation((current) => current === pid ? { state: "unknown" } : realBirth(current));
try { expect(read()).toBeNull(); } finally { missing.mockRestore(); }
expect(read()).toBeNull();
expect(copilotProcessSession(environment, ["copilot"], pid)?.sessionId).toBe(other);
Expand Down
Loading