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
28 changes: 18 additions & 10 deletions packages/opencode/src/cli/cmd/lead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ 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,
selectSession,
parseScopes,
parseTtl,
removeGrantFile,
SESSION_TTL,
writeGrantFile,
type SessionCandidate,
} from "@/peer/lead-issue"
Expand Down Expand Up @@ -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)",
Expand All @@ -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\`)`)
Expand All @@ -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 <id>.",
)
}
// 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`,
)
},
})
Expand All @@ -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()}`}`,
)
},
})
Expand All @@ -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
Expand Down
14 changes: 10 additions & 4 deletions packages/opencode/src/peer/lead-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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,
}
}
Expand Down
25 changes: 24 additions & 1 deletion packages/opencode/src/peer/lead-runtime.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -20,14 +21,36 @@ 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`.
* Any failure to read or validate is "not granted" — the default frame.
*/
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 })
}
Expand Down
46 changes: 36 additions & 10 deletions packages/opencode/src/peer/lead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,18 +41,24 @@ 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"
}

export type GrantResult = { ok: true; grant: LeadGrant } | { ok: false; reason: string }

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<string, unknown> {
Expand All @@ -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") {
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
Expand All @@ -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 =
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/src/session/peers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
33 changes: 32 additions & 1 deletion packages/opencode/test/peer/lead-issue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -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 })
}
})
})
Loading
Loading