From 3923fa75317fb7bdbca587167e78954c978dc271 Mon Sep 17 00:00:00 2001 From: AndreasS Date: Fri, 2 Oct 2026 23:30:46 +0200 Subject: [PATCH] feat(lead): a grant that lasts until the lead session ends, bound to its process A grant with expiresAt: null needs lead.procStart (the OS-reported start time of the lead process) and is only valid while that pid is alive AND still has that start time, so a reused pid cannot inherit it. Unreadable start time fails closed with its own reason. 'opencode lead set' now defaults to the session length (--ttl 8h etc. still work, max 24h for timed grants); show/verify/frame say 'until the lead session ends'. Proved against real processes, including a simulated pid reuse; five guards mutation-checked. Co-Authored-By: Claude Sonnet 5.5 --- packages/opencode/src/cli/cmd/lead.ts | 28 +++++---- packages/opencode/src/peer/lead-issue.ts | 14 +++-- packages/opencode/src/peer/lead-runtime.ts | 25 +++++++- packages/opencode/src/peer/lead.ts | 46 ++++++++++---- packages/opencode/src/session/peers.ts | 5 +- .../opencode/test/peer/lead-issue.test.ts | 33 +++++++++- .../opencode/test/peer/lead-runtime.test.ts | 54 ++++++++++++++++- packages/opencode/test/peer/lead.test.ts | 60 +++++++++++++++++++ packages/opencode/test/session/peers.test.ts | 6 ++ 9 files changed, 242 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/cli/cmd/lead.ts b/packages/opencode/src/cli/cmd/lead.ts index 2ffff32e654d..121041b0a549 100644 --- a/packages/opencode/src/cli/cmd/lead.ts +++ b/packages/opencode/src/cli/cmd/lead.ts @@ -8,7 +8,7 @@ import type { Argv } from "yargs" import { cmd } from "./cmd" import { UI } from "../ui" import { LEAD_SCOPES, readGrantFile, verifyLead, type Sender } from "@/peer/lead" -import { grantPath } from "@/peer/lead-runtime" +import { grantPath, processStartTime } from "@/peer/lead-runtime" import { buildGrant, identifyCaller, @@ -16,6 +16,7 @@ import { parseScopes, parseTtl, removeGrantFile, + SESSION_TTL, writeGrantFile, type SessionCandidate, } from "@/peer/lead-issue" @@ -89,7 +90,7 @@ const SetCommand = cmd({ builder: (yargs: Argv) => yargs .option("scope", { type: "string", describe: `comma list of: ${LEAD_SCOPES.join(", ")} (default: all)` }) - .option("ttl", { type: "string", describe: "lifetime, e.g. 30m or 8h (default 8h, max 24h)" }) + .option("ttl", { type: "string", describe: "`session` (default): until the lead session ends, bound to its process; or a time such as 30m or 8h (max 24h)" }) .option("session", { type: "string", describe: "exact session id or pid to designate (required for opencode sessions; Claude Code is found automatically)", @@ -102,7 +103,7 @@ const SetCommand = cmd({ const scopes = parseScopes(args.scope) if ("error" in scopes) return fail(scopes.error) const ttlMs = parseTtl(args.ttl) - if (typeof ttlMs !== "number") return fail(ttlMs.error) + if (typeof ttlMs === "object") return fail(ttlMs.error) const sessions = await knownSessions() const lead = args.session ? selectSession(sessions, String(args.session)) : identifyCaller(ancestry(process.ppid), sessions) if (args.session && !lead) return fail(`no live session matches "${args.session}" by exact id or pid (see \`opencode agents\`)`) @@ -112,18 +113,25 @@ const SetCommand = cmd({ "Run it from the session you want as lead (in Claude Code: `! opencode lead set`), or name an opencode session with --session .", ) } + // A session-length grant is only as good as the identity it is bound to, so a start time that cannot + // be read refuses the grant rather than issuing one that could outlive its session. + const procStart = processStartTime(lead.pid) + if (ttlMs === SESSION_TTL && procStart === undefined) { + return fail("could not read the lead process start time, so a session-length grant cannot be bound to it; use --ttl 8h") + } + const duration = ttlMs === SESSION_TTL ? "for as long as that session runs" : `for ${Math.round(ttlMs / 60_000)} minutes` if (!args.noConfirm) { const confirmed = confirmLead( - `Make ${lead.harness} session ${lead.sessionID ?? lead.pid} (${lead.name ?? "unnamed"}) your lead for ${scopes.join(", ")}, for ${Math.round(ttlMs / 60_000)} minutes?`, + `Make ${lead.harness} session ${lead.sessionID ?? lead.pid} (${lead.name ?? "unnamed"}) your lead for ${scopes.join(", ")}, ${duration}?`, realDeps, ) if (!confirmed.ok) return fail(confirmed.reason) } - const grant = buildGrant({ lead, scopes, ttlMs, now: Date.now(), issuedBy: "user:cli" }) + const grant = buildGrant({ lead: { ...lead, ...(procStart ? { procStart } : {}) }, scopes, ttlMs, now: Date.now(), issuedBy: "user:cli" }) writeGrantFile(grantPath(), grant) UI.println( `lead set: ${lead.harness} session ${lead.sessionID ?? lead.pid} (${lead.name ?? "unnamed"}) — scopes ${scopes.join(", ")}, ` + - `expires ${new Date(grant.expiresAt).toISOString()}. Followers need experimental.follow_lead. Revoke: opencode lead off`, + `${grant.expiresAt === null ? "lasts until that session ends" : `expires ${new Date(grant.expiresAt).toISOString()}`}. Followers need experimental.follow_lead. Revoke: opencode lead off`, ) }, }) @@ -140,11 +148,11 @@ const ShowCommand = cmd({ command: "show", describe: "show the current lead grant, or why there is none", handler: async () => { - const result = readGrantFile(grantPath(), { now: Date.now(), pidAlive, uid: process.getuid?.() ?? -1 }) + const result = readGrantFile(grantPath(), { now: Date.now(), pidAlive, startTime: processStartTime, uid: process.getuid?.() ?? -1 }) if (!result.ok) return UI.println(`no active lead grant (${result.reason})`) const g = result.grant UI.println( - `lead ${g.lead.name ?? g.lead.sessionID ?? g.lead.pid} [${g.lead.harness}] grant ${g.id} scopes ${g.scopes.join(", ")} expires ${new Date(g.expiresAt).toISOString()}`, + `lead ${g.lead.name ?? g.lead.sessionID ?? g.lead.pid} [${g.lead.harness}] grant ${g.id} scopes ${g.scopes.join(", ")} ${g.expiresAt === null ? "until the lead session ends" : `expires ${new Date(g.expiresAt).toISOString()}`}`, ) }, }) @@ -160,10 +168,10 @@ const VerifyCommand = cmd({ const sender: Sender = opencodeSession ? { harness: "opencode-skein", sessionID: opencodeSession } : { harness: "claude-code", pid: pid ? Number(pid) : undefined, address: from } - const result = readGrantFile(grantPath(), { now: Date.now(), pidAlive, uid: process.getuid?.() ?? -1 }) + const result = readGrantFile(grantPath(), { now: Date.now(), pidAlive, startTime: processStartTime, uid: process.getuid?.() ?? -1 }) const verdict = result.ok ? verifyLead(result.grant, sender, { follow: true, now: Date.now() }) : { granted: false as const, reason: result.reason } if (verdict.granted) { - UI.println(`GRANTED scopes=${verdict.scopes.join(",")} grant=${verdict.grantID} expires=${new Date(verdict.expiresAt).toISOString()}`) + UI.println(`GRANTED scopes=${verdict.scopes.join(",")} grant=${verdict.grantID} expires=${verdict.expiresAt === null ? "session-end" : new Date(verdict.expiresAt).toISOString()}`) } else { UI.println(`DENIED ${verdict.reason}`) process.exitCode = 1 diff --git a/packages/opencode/src/peer/lead-issue.ts b/packages/opencode/src/peer/lead-issue.ts index 491caa6aa88c..ecf78fea2f91 100644 --- a/packages/opencode/src/peer/lead-issue.ts +++ b/packages/opencode/src/peer/lead-issue.ts @@ -27,6 +27,8 @@ export interface SessionCandidate { sessionID?: string address?: string name?: string + /** The OS-reported start time of the session's process; recorded for a session-length grant. */ + procStart?: string } /** Nearest ancestor (starting at the process itself) that is a known session. */ @@ -59,8 +61,11 @@ export function parseScopes(input: string | undefined): LeadScope[] | { error: s return scopes.length > 0 ? scopes : { error: "no scopes given" } } -export function parseTtl(input: string | undefined): number | { error: string } { - if (!input) return DEFAULT_GRANT_TTL_MS +/** Lasts as long as the lead session does, bound to its process identity. */ +export const SESSION_TTL = "session" as const + +export function parseTtl(input: string | undefined): number | typeof SESSION_TTL | { error: string } { + if (!input || input === SESSION_TTL) return SESSION_TTL const match = input.match(/^(\d+)(m|h)$/) if (!match) return { error: "ttl must look like 30m or 8h" } const ms = Number(match[1]) * (match[2] === "h" ? 3_600_000 : 60_000) @@ -72,7 +77,7 @@ export function parseTtl(input: string | undefined): number | { error: string } export function buildGrant(input: { lead: SessionCandidate scopes: LeadScope[] - ttlMs: number + ttlMs: number | typeof SESSION_TTL now: number issuedBy: LeadGrant["issuedBy"] }): LeadGrant { @@ -85,11 +90,12 @@ export function buildGrant(input: { ...(input.lead.sessionID ? { sessionID: input.lead.sessionID } : {}), ...(input.lead.address ? { address: input.lead.address } : {}), ...(input.lead.name ? { name: input.lead.name.replace(/[^\x20-\x7e]/g, "").slice(0, 80) } : {}), + ...(input.lead.procStart ? { procStart: input.lead.procStart } : {}), }, scopes: input.scopes, delegates: [], issuedAt: input.now, - expiresAt: input.now + input.ttlMs, + expiresAt: input.ttlMs === SESSION_TTL ? null : input.now + input.ttlMs, issuedBy: input.issuedBy, } } diff --git a/packages/opencode/src/peer/lead-runtime.ts b/packages/opencode/src/peer/lead-runtime.ts index 2e317279f621..6691eb1e5971 100644 --- a/packages/opencode/src/peer/lead-runtime.ts +++ b/packages/opencode/src/peer/lead-runtime.ts @@ -1,6 +1,7 @@ // Runtime glue between the pure grant check (./lead) and the delivery paths. // Kept apart from ./lead so that module stays free of global paths and can be // tested without them. +import { spawnSync } from "child_process" import { join } from "path" import { Global } from "@opencode-ai/core/global" import { readGrantFile, verifyLead, type Sender, type Verdict } from "./lead" @@ -20,6 +21,28 @@ function pidAlive(pid: number): boolean { } } +/** + * The start time the OS reports for a process, or undefined when it cannot be read. This is what makes + * a pid mean "that session" for as long as it lives: a reused pid has a different start time. The value + * is only ever compared for equality with what was recorded by the same function. + */ +export function processStartTime(pid: number): string | undefined { + try { + const out = + process.platform === "win32" + ? spawnSync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter "ProcessId=${Math.trunc(pid)}").CreationDate.ToString('o')`], + { encoding: "utf8", timeout: 5_000 }, + ) + : spawnSync("ps", ["-o", "lstart=", "-p", String(Math.trunc(pid))], { encoding: "utf8", timeout: 5_000 }) + const text = (out.stdout ?? "").trim() + return out.status === 0 && text.length > 0 ? text : undefined + } catch { + return undefined + } +} + /** * The verdict for one inbound sender. Reads the file every time: grants are * short-lived and revocable, so a cached "yes" would outlive `/lead off`. @@ -27,7 +50,7 @@ function pidAlive(pid: number): boolean { */ export function leadVerdictFor(sender: Sender, follow: boolean, now = Date.now(), path = grantPath()): Verdict { if (!follow) return { granted: false, reason: "this session does not follow a lead" } - const result = readGrantFile(path, { now, pidAlive, uid: process.getuid?.() ?? -1 }) + const result = readGrantFile(path, { now, pidAlive, startTime: processStartTime, uid: process.getuid?.() ?? -1 }) if (!result.ok) return { granted: false, reason: result.reason } return verifyLead(result.grant, sender, { follow, now }) } diff --git a/packages/opencode/src/peer/lead.ts b/packages/opencode/src/peer/lead.ts index 761a87add625..b8a30c5a4f45 100644 --- a/packages/opencode/src/peer/lead.ts +++ b/packages/opencode/src/peer/lead.ts @@ -27,7 +27,11 @@ export type LeadScope = (typeof LEAD_SCOPES)[number] export type LeadHarness = "opencode-skein" | "claude-code" -/** A forgotten grant must not live forever. */ +/** + * A timed grant must not live forever. A grant with `expiresAt: null` lasts as long as the lead + * SESSION does instead, and is only accepted together with the identity of the lead process + * (`lead.procStart`), because "the same pid" stops meaning "the same session" once the OS reuses it. + */ export const MAX_GRANT_LIFETIME_MS = 24 * 60 * 60 * 1000 // Ids are rendered into a frame the receiving model reads as trusted, so the @@ -37,11 +41,12 @@ const SAFE_ID = /^[A-Za-z0-9_-]{1,64}$/ export interface LeadGrant { version: 1 id: string - lead: { harness: LeadHarness; sessionID?: string; pid: number; address?: string; name?: string } + lead: { harness: LeadHarness; sessionID?: string; pid: number; address?: string; name?: string; procStart?: string } scopes: LeadScope[] delegates: { kind: string; scopes: string[] }[] issuedAt: number - expiresAt: number + /** null = until the lead session ends; then `lead.procStart` is required. */ + expiresAt: number | null issuedBy: "user:tui" | "user:cli" } @@ -49,6 +54,11 @@ export type GrantResult = { ok: true; grant: LeadGrant } | { ok: false; reason: export interface ParseDeps { pidAlive: (pid: number) => boolean + /** + * The start time the OS reports for a pid, or undefined when it cannot be read. Compared with the + * grant's `lead.procStart`: a different value means the pid was reused by another process. + */ + startTime?: (pid: number) => string | undefined } function isRecord(value: unknown): value is Record { @@ -71,11 +81,11 @@ export function parseGrant(raw: unknown, now: number, deps: ParseDeps): GrantRes const lead = raw.lead if (!isRecord(lead)) return fail("lead is not a mapping") - const leadExtra = unknownKeys(lead, ["harness", "sessionID", "pid", "address", "name"]) + const leadExtra = unknownKeys(lead, ["harness", "sessionID", "pid", "address", "name", "procStart"]) if (leadExtra.length > 0) return fail(`unknown key in lead: ${leadExtra.join(", ")}`) if (lead.harness !== "opencode-skein" && lead.harness !== "claude-code") return fail("lead.harness is invalid") if (typeof lead.pid !== "number" || !Number.isInteger(lead.pid) || lead.pid <= 0) return fail("lead.pid is invalid") - for (const key of ["sessionID", "address", "name"] as const) { + for (const key of ["sessionID", "address", "name", "procStart"] as const) { if (lead[key] !== undefined && typeof lead[key] !== "string") return fail(`lead.${key} is not a string`) } if (lead.harness === "opencode-skein" && typeof lead.sessionID !== "string") { @@ -102,12 +112,26 @@ export function parseGrant(raw: unknown, now: number, deps: ParseDeps): GrantRes delegates.push({ kind: entry.kind, scopes: entry.scopes as string[] }) } - if (typeof raw.issuedAt !== "number" || typeof raw.expiresAt !== "number") return fail("issuedAt/expiresAt must be numbers") - if (raw.expiresAt <= now) return fail("grant has expired") - if (raw.expiresAt - raw.issuedAt > MAX_GRANT_LIFETIME_MS) return fail("grant lifetime exceeds 24h") + if (typeof raw.issuedAt !== "number") return fail("issuedAt must be a number") + if (raw.expiresAt === null) { + // Until the lead session ends: only with a process identity to bind it to. + if (typeof lead.procStart !== "string" || lead.procStart.length === 0) { + return fail("a grant without an expiry needs the lead's process identity (lead.procStart)") + } + } else { + if (typeof raw.expiresAt !== "number") return fail("expiresAt must be a number or null") + if (raw.expiresAt <= now) return fail("grant has expired") + if (raw.expiresAt - raw.issuedAt > MAX_GRANT_LIFETIME_MS) return fail("grant lifetime exceeds 24h") + } if (raw.issuedBy !== "user:tui" && raw.issuedBy !== "user:cli") return fail("issuedBy must be a user action") if (!deps.pidAlive(lead.pid)) return fail("the lead process is not running") + // Bound to the process, not just its pid: a reused pid is a different session. + if (typeof lead.procStart === "string") { + const current = deps.startTime?.(lead.pid) + if (current === undefined) return fail("could not read the lead process start time, so it cannot be confirmed") + if (current !== lead.procStart) return fail("the pid is not the process that was designated (it was reused)") + } return { ok: true, @@ -120,6 +144,7 @@ export function parseGrant(raw: unknown, now: number, deps: ParseDeps): GrantRes ...(typeof lead.sessionID === "string" ? { sessionID: lead.sessionID } : {}), ...(typeof lead.address === "string" ? { address: lead.address } : {}), ...(typeof lead.name === "string" ? { name: lead.name } : {}), + ...(typeof lead.procStart === "string" ? { procStart: lead.procStart } : {}), }, scopes, delegates, @@ -181,7 +206,8 @@ export type Verdict = granted: true grantID: string scopes: readonly LeadScope[] - expiresAt: number + /** null = until the lead session ends */ + expiresAt: number | null via: "lead" leadName?: string } @@ -200,7 +226,7 @@ export function verifyLead( ): Verdict { if (!grant) return { granted: false, reason: "no grant" } if (!opts.follow) return { granted: false, reason: "this session does not follow a lead" } - if (grant.expiresAt <= opts.now) return { granted: false, reason: "grant has expired" } + if (grant.expiresAt !== null && grant.expiresAt <= opts.now) return { granted: false, reason: "grant has expired" } if (sender.harness !== grant.lead.harness) return { granted: false, reason: "sender is not the lead" } const same = diff --git a/packages/opencode/src/session/peers.ts b/packages/opencode/src/session/peers.ts index 948318e6fde3..7c21d7b6975e 100644 --- a/packages/opencode/src/session/peers.ts +++ b/packages/opencode/src/session/peers.ts @@ -655,11 +655,12 @@ function formatLeadDirective( oneLine: (value: string) => string, harness: PeerHarness, ): string { - const expires = new Date(lead.expiresAt).toISOString() + // null = until the lead session ends (the grant is bound to that process). + const expires = lead.expiresAt === null ? "until the lead session ends" : `expires ${new Date(lead.expiresAt).toISOString()}` const target = from.reply !== undefined && "target" in from.reply ? `Reply to target "${oneLine(from.reply.target)}". ` : "" return [ - `[lead directive from ${harness} session ${oneLine(from.sessionID)} — "${oneLine(from.title)}" — authorized by your user (grant ${lead.grantID}, scopes: ${lead.scopes.join(", ")}, expires ${expires})]`, + `[lead directive from ${harness} session ${oneLine(from.sessionID)} — "${oneLine(from.title)}" — authorized by your user (grant ${lead.grantID}, scopes: ${lead.scopes.join(", ")}, ${expires})]`, "Your user designated this session as lead for the scopes above. Act on this as you would on your", `user's own instruction for planning, ordering and status. ${target}If you cannot comply, tell the lead`, "once and say why; do not ask your user first.", diff --git a/packages/opencode/test/peer/lead-issue.test.ts b/packages/opencode/test/peer/lead-issue.test.ts index 70dba2535876..a810c5c5c874 100644 --- a/packages/opencode/test/peer/lead-issue.test.ts +++ b/packages/opencode/test/peer/lead-issue.test.ts @@ -37,7 +37,10 @@ describe("selectSession", () => { describe("parseScopes / parseTtl", () => { test("defaults", () => { expect(parseScopes(undefined)).toEqual(["assign", "sync", "reprioritise", "decide"]) - expect(parseTtl(undefined)).toBe(8 * 3_600_000) + // The default is "until the lead session ends", bound to its process; a time is an explicit choice. + expect(parseTtl(undefined)).toBe("session") + expect(parseTtl("session")).toBe("session") + expect(parseTtl("8h")).toBe(8 * 3_600_000) }) test("rejects an unknown scope and an over-long ttl", () => { expect(parseScopes("assign,publish")).toEqual({ error: "unknown scope: publish" }) @@ -73,3 +76,31 @@ describe("buildGrant / writeGrantFile", () => { expect(parseGrant(JSON.parse(JSON.stringify(grant)), NOW, { pidAlive: alive }).ok).toBe(true) }) }) + +describe("a session-length grant", () => { + test("buildGrant records no expiry and carries the process identity", () => { + const grant = buildGrant({ + lead: { ...claude, procStart: "Thu Oct 2 10:10:00 2026" }, + scopes: ["assign"], + ttlMs: "session", + now: NOW, + issuedBy: "user:cli", + }) + expect(grant.expiresAt).toBeNull() + expect(grant.lead.procStart).toBe("Thu Oct 2 10:10:00 2026") + }) + + test("what the CLI writes is accepted by the reader while the process is the same", () => { + const dir = mkdtempSync(join(tmpdir(), "lead-session-")) + try { + const path = join(dir, "lead.json") + const grant = buildGrant({ lead: { ...claude, procStart: "T0" }, scopes: ["assign"], ttlMs: "session", now: NOW, issuedBy: "user:cli" }) + writeGrantFile(path, grant) + const base = { now: NOW + 40 * 24 * 3_600_000, pidAlive: alive, uid: process.getuid?.() ?? 0 } + expect(readGrantFile(path, { ...base, startTime: () => "T0" }).ok).toBe(true) + expect(readGrantFile(path, { ...base, startTime: () => "T1" }).ok).toBe(false) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/opencode/test/peer/lead-runtime.test.ts b/packages/opencode/test/peer/lead-runtime.test.ts index 8e16632dcc5a..fc2567d6a990 100644 --- a/packages/opencode/test/peer/lead-runtime.test.ts +++ b/packages/opencode/test/peer/lead-runtime.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "fs" import { tmpdir } from "os" import { join } from "path" -import { leadVerdictFor } from "../../src/peer/lead-runtime" +import { leadVerdictFor, processStartTime } from "../../src/peer/lead-runtime" const NOW = 1_790_000_000_000 @@ -62,3 +62,55 @@ describe("leadVerdictFor", () => { expect(verdict).toEqual({ granted: false, reason: expect.stringContaining("no grant") }) }) }) + +describe("a session-length grant against REAL processes", () => { + const own = { harness: "claude-code" as const, pid: process.pid, address: "uds:/tmp/cc-socks/1.sock" } + const write = (procStart: string | undefined, run: (path: string) => void) => { + const dir = mkdtempSync(join(tmpdir(), "lead-real-")) + const path = join(dir, "lead.json") + try { + writeFileSync( + path, + JSON.stringify({ + version: 1, + id: "lead-real1", + lead: { harness: "claude-code", pid: process.pid, address: "uds:/tmp/cc-socks/1.sock", ...(procStart ? { procStart } : {}) }, + scopes: ["assign"], + delegates: [], + issuedAt: Date.now() - 1000, + expiresAt: null, + issuedBy: "user:cli", + }), + ) + chmodSync(path, 0o600) + run(path) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + } + + test("the OS reports a stable start time for a live process, and nothing for one that does not exist", () => { + const first = processStartTime(process.pid) + expect(first).toBeTruthy() + expect(processStartTime(process.pid)).toBe(first) + expect(processStartTime(2_147_483_000)).toBeUndefined() + }) + + test("granted while the recorded start time is the real one — however old the grant is", () => { + write(processStartTime(process.pid), (path) => { + expect(leadVerdictFor(own, true, Date.now() + 90 * 24 * 3_600_000, path)).toMatchObject({ granted: true, expiresAt: null }) + }) + }) + + test("a pid that was REUSED (a different start time recorded) is not the lead", () => { + write("Mon Jan 1 00:00:00 2001", (path) => { + expect(leadVerdictFor(own, true, Date.now(), path).granted).toBe(false) + }) + }) + + test("a session grant with no process identity at all is refused", () => { + write(undefined, (path) => { + expect(leadVerdictFor(own, true, Date.now(), path).granted).toBe(false) + }) + }) +}) diff --git a/packages/opencode/test/peer/lead.test.ts b/packages/opencode/test/peer/lead.test.ts index 05071b9fe496..b102a7907963 100644 --- a/packages/opencode/test/peer/lead.test.ts +++ b/packages/opencode/test/peer/lead.test.ts @@ -6,6 +6,9 @@ import { parseGrant, readGrantFile, verifyLead, type LeadGrant } from "../../src const NOW = 1_790_000_000_000 const alive = () => true +// What the OS reports as the lead process's start time. A reused pid has a different one. +const STARTED = "Thu Oct 2 10:10:00 2026" +const sameProcess = () => STARTED function grant(overrides: Record = {}): Record { return { @@ -192,3 +195,60 @@ describe("readGrantFile — the file is the authority, so the file is checked", }) }) }) + +describe("a grant that lasts until the lead session ends", () => { + const session = (over: Record = {}) => + grant({ + expiresAt: null, + lead: { harness: "claude-code", pid: 4242, address: "uds:/tmp/cc-socks/4242.sock", name: "main", procStart: STARTED }, + ...over, + }) + const deps = { pidAlive: alive, startTime: sameProcess } + + test("is valid while the same process is alive, however much time has passed", () => { + expect(parseGrant(session(), NOW, deps).ok).toBe(true) + expect(parseGrant(session(), NOW + 30 * 24 * 3_600_000, deps).ok).toBe(true) + }) + + test("is refused when the pid now belongs to a DIFFERENT process (pid reuse)", () => { + const reused = { pidAlive: alive, startTime: () => "Fri Oct 3 09:00:00 2026" } + expect(parseGrant(session(), NOW, reused)).toEqual({ ok: false, reason: expect.stringContaining("not the process") }) + }) + + test("is refused when the process start time cannot be read — fail closed, and says why", () => { + // The comparison below it would refuse too; the explicit branch exists so the operator is told the + // real cause (unreadable) instead of a misleading "pid was reused". + expect(parseGrant(session(), NOW, { pidAlive: alive, startTime: () => undefined })).toEqual({ ok: false, reason: expect.stringContaining("could not read") }) + expect(parseGrant(session(), NOW, { pidAlive: alive })).toEqual({ ok: false, reason: expect.stringContaining("could not read") }) + }) + + test("is refused when the lead process is gone", () => { + expect(parseGrant(session(), NOW, { pidAlive: () => false, startTime: sameProcess }).ok).toBe(false) + }) + + test("never expires without a process identity: no procStart means it is refused, not trusted", () => { + const bare = session({ lead: { harness: "claude-code", pid: 4242, address: "uds:/tmp/cc-socks/4242.sock" } }) + expect(parseGrant(bare, NOW, deps)).toEqual({ ok: false, reason: expect.stringContaining("process identity") }) + }) + + test("a timed grant that carries a procStart is bound to it too", () => { + const timed = grant({ lead: { harness: "claude-code", pid: 4242, procStart: STARTED } }) + expect(parseGrant(timed, NOW, { pidAlive: alive, startTime: () => "other" }).ok).toBe(false) + expect(parseGrant(timed, NOW, deps).ok).toBe(true) + }) + + test("a timed grant without procStart is unchanged (no process identity demanded)", () => { + expect(parseGrant(grant(), NOW, { pidAlive: alive }).ok).toBe(true) + }) + + test("verifyLead never treats it as expired", () => { + const g = (parseGrant(session(), NOW, deps) as { ok: true; grant: LeadGrant }).grant + const v = verifyLead(g, { harness: "claude-code", pid: 4242, address: "uds:/tmp/cc-socks/4242.sock" }, { follow: true, now: NOW + 365 * 24 * 3_600_000 }) + expect(v).toMatchObject({ granted: true, expiresAt: null }) + }) + + test("a non-lead is still denied", () => { + const g = (parseGrant(session(), NOW, deps) as { ok: true; grant: LeadGrant }).grant + expect(verifyLead(g, { harness: "claude-code", pid: 9999, address: "uds:/tmp/cc-socks/9999.sock" }, { follow: true, now: NOW }).granted).toBe(false) + }) +}) diff --git a/packages/opencode/test/session/peers.test.ts b/packages/opencode/test/session/peers.test.ts index 1a4efb3d9e22..b9c8a9c93ccd 100644 --- a/packages/opencode/test/session/peers.test.ts +++ b/packages/opencode/test/session/peers.test.ts @@ -322,6 +322,12 @@ describe("formatPeerMessage — lead directive frame", () => { expect(text).not.toContain("not a user instruction") }) + test("a session-length grant says so instead of showing an expiry date", () => { + const text = formatPeerMessage({ sessionID: "4242", title: "main", lead: { ...lead, expiresAt: null } }, "take change foo") + expect(text.split("\n")[0]).toContain("until the lead session ends") + expect(text.split("\n")[0]).not.toContain("expires") + }) + test("tells the receiver to reply to the lead and not to ask its own user first", () => { const text = formatPeerMessage({ sessionID: "4242", title: "main", lead }, "take change foo") expect(text).toContain("do not ask your user first")