From 0aa114c056cc46a5938c51d94cbfee737f8ff3f9 Mon Sep 17 00:00:00 2001 From: jun Date: Mon, 21 Sep 2026 11:20:57 +0000 Subject: [PATCH 01/15] feat(cli): expose ownership and takeover compatibility on resolve, add service claim Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> (cherry picked from commit 1cc2446285b256c65aa03379ce691237177225c9) --- src/cli/registry.ts | 2 +- src/cli/resolve.ts | 96 ++++++++++++++- src/service/claim.ts | 185 ++++++++++++++++++++++++++++ src/service/cli.ts | 11 +- src/service/managing-cli.ts | 158 ++++++++++++++++++++++++ tests/cli/cli-resolve.test.ts | 155 ++++++++++++++++++++++- tests/service/service-claim.test.ts | 155 +++++++++++++++++++++++ 7 files changed, 753 insertions(+), 9 deletions(-) create mode 100644 src/service/claim.ts create mode 100644 src/service/managing-cli.ts create mode 100644 tests/service/service-claim.test.ts diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 6e4756c1664..8deb44a3a4c 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -83,7 +83,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "service", - usage: "ocx service [install|repair|restart|start|stop|status|uninstall|remove]", + usage: "ocx service [install|repair|restart|start|stop|status|uninstall|remove|claim]", summary: "Run as a background service.", details: [ "With no subcommand, installs when absent or repairs an existing service.", diff --git a/src/cli/resolve.ts b/src/cli/resolve.ts index 87c3fa69174..2f7bf70855b 100644 --- a/src/cli/resolve.ts +++ b/src/cli/resolve.ts @@ -45,6 +45,20 @@ import { type LiveProxy, } from "../server/proxy-liveness"; import { endpointsToProve, everyEndpointProvenDownAsync, type ProbeEndpoint } from "./uninstall-plan"; +import { + resolveServiceOwnership, + resolveServiceState, + type ServiceInstallState, + type ServiceOwnershipResolution, + type ServiceStateResolution, +} from "../service/state"; +import { + assessServiceTakeoverCompatibility, + type ManagingCliObservation, + type ManagingCliRole, +} from "../service/ownership-compatibility"; +import { observeManagingClis } from "../service/managing-cli"; +import { SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION } from "../service/install-state-contract.mjs"; /** Wire version of the resolve document. Bump only on an incompatible shape change. */ export const RESOLVE_SCHEMA = "ocx-resolve/1"; @@ -86,8 +100,26 @@ export interface ResolveJson { source: LiveProxy["source"]; }; liveness: ResolveLivenessJson; + /** + * The recorded runtime owner, in the CLI's own three answers. `unknown` is on the wire + * deliberately: it never changes the exit code — the liveness verdict is still + * trustworthy — and the embedding shell fails closed on it rather than asking consent + * against a record it could not read. + */ + ownership: ServiceOwnershipResolution; + /** + * Whether a desktop takeover can be offered, and the token that binds that approval to + * the exact subject and managing-CLI observations a later `ocx service claim` must find + * unchanged. `ownership-unknown` is produced only here: it is a wire reason, not a new + * member of `ServiceTakeoverCompatibility`'s union. + */ + takeover: ResolveTakeover; } +export type ResolveTakeover = + | { kind: "supported"; protocolVersion: number; minimumCliVersion: string; token: string } + | { kind: "blocked"; reason: string; detail: string; minimumCliVersion: string }; + export interface ResolveArgs { json: boolean; } @@ -111,6 +143,14 @@ export interface ResolveIo { /** Tri-state endpoint probe; production default runs in-process for compiled standalone binaries. */ probeEndpoint?: (endpoint: ProbeEndpoint) => EndpointLiveness | Promise; cliVersion?: () => string; + /** Recorded-ownership resolver; production default is resolveServiceOwnership. */ + resolveOwnership?: () => ServiceOwnershipResolution; + /** Full install-state resolver; production default is resolveServiceState. */ + resolveState?: () => ServiceStateResolution; + /** Managing-CLI observer; production default is observeManagingClis. */ + observeManagers?: ( + state: ServiceInstallState | null, + ) => Readonly>; stdout?: { log: (s: string) => void }; stderr?: { error: (s: string) => void }; } @@ -136,6 +176,8 @@ export function buildResolveJson( live: LiveProxy | null, configHome: string, cliVersion: string, + ownership: ServiceOwnershipResolution, + takeover: ResolveTakeover, ): ResolveJson { const configured = config.port ?? RESOLVE_DEFAULT_PORT; return { @@ -148,6 +190,8 @@ export function buildResolveJson( source: live ? live.source : "config", }, liveness: livenessJson(live), + ownership, + takeover, }; } @@ -164,6 +208,19 @@ function reportHuman(json: ResolveJson, stdout: { log: (s: string) => void }): v } else { stdout.log(`No live proxy (absence proven); effective port ${json.port.effective} (configured).`); } + const ownership = json.ownership; + if (ownership.kind === "owned") { + stdout.log(`Owner: ${ownership.ownership.owner} (install ${ownership.ownership.installId}, generation ${ownership.ownership.consentGeneration})`); + } else if (ownership.kind === "unknown") { + stdout.log(`Owner: unknown (${ownership.reason})`); + } else { + stdout.log("Owner: none recorded"); + } + stdout.log( + json.takeover.kind === "supported" + ? "Takeover: supported" + : `Takeover: blocked (${json.takeover.reason}: ${json.takeover.detail})`, + ); } /** @@ -181,6 +238,9 @@ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise const readRuntime = io.readRuntime ?? readRuntimePort; const probeEndpoint = io.probeEndpoint ?? probeEndpointLiveness; const cliVersion = io.cliVersion ?? packageVersion; + const resolveOwnership = io.resolveOwnership ?? resolveServiceOwnership; + const resolveState = io.resolveState ?? resolveServiceState; + const observeManagers = io.observeManagers ?? observeManagingClis; const configHome = configDir(); let diagnostics: ConfigDiagnostics; try { @@ -223,7 +283,41 @@ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise return 1; } } - const json = buildResolveJson(diagnostics.config, live, configHome, cliVersion()); + let ownership: ServiceOwnershipResolution; + try { + ownership = resolveOwnership(); + } catch (error) { + ownership = { kind: "unknown", reason: error instanceof Error ? error.message : String(error) }; + } + let takeover: ResolveTakeover; + if (ownership.kind === "unknown") { + // The claim cannot be read, so nothing can be approved against it. This reason is a + // wire answer, not a new member of the compatibility union. + takeover = { + kind: "blocked", + reason: "ownership-unknown", + detail: ownership.reason, + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }; + } else { + try { + const resolved = resolveState(); + const resolvedState = resolved.kind === "state" ? resolved.state : null; + takeover = assessServiceTakeoverCompatibility({ + state: resolvedState, + subject: ownership, + managers: observeManagers(resolvedState), + }); + } catch (error) { + takeover = { + kind: "blocked", + reason: "managing-cli-unknown", + detail: error instanceof Error ? error.message : String(error), + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }; + } + } + const json = buildResolveJson(diagnostics.config, live, configHome, cliVersion(), ownership, takeover); if (args.json) stdout.log(JSON.stringify(json)); else reportHuman(json, stdout); return 0; diff --git a/src/service/claim.ts b/src/service/claim.ts new file mode 100644 index 00000000000..7ddc27221ce --- /dev/null +++ b/src/service/claim.ts @@ -0,0 +1,185 @@ +/** + * `ocx service claim` — record an ownership claim the embedding shell already got consent for. + * + * The desktop cannot write the record itself: the claim has to be committed under the + * ownership mutation lease, against the subject and managing-CLI compatibility the caller + * observed, by the module that owns all three. `recordServiceOwner` is that module; this + * verb is the wire that hands it the approval `ocx resolve --json` produced. + * + * Every expectation is mandatory because the claim is only valid against the exact answer + * the consent prompt was approved from. A subject or compatibility that moved in between + * is a fresh situation, and fresh approval is the only thing that covers it. + */ +import { + recordServiceOwner, + resolveServiceState, + type RecordServiceOwnerDeps, + type ServiceOwner, + type ServiceOwnershipSubject, +} from "./state"; +import { + SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + SERVICE_OWNERSHIP_PROTOCOL_VERSION, +} from "./install-state-contract.mjs"; +import { observeManagingClis } from "./managing-cli"; +import type { ManagingCliObservation, ManagingCliRole } from "./ownership-compatibility"; + +export const CLAIM_SCHEMA = "ocx-service-claim/1"; + +const CLAIM_USAGE = [ + "Usage: ocx service claim --owner --install-id ", + " (--expect-none | --expect-owner --expect-install-id --expect-generation )", + " --expect-revision --expect-compatibility-token [--json]", +].join("\n"); + +export interface ClaimArgs { + owner: ServiceOwner; + installId: string; + expectedSubject: ServiceOwnershipSubject; + compatibilityToken: string; + json: boolean; +} + +export type ClaimParseResult = { ok: true; args: ClaimArgs } | { ok: false }; + +function readFlag(args: string[], index: number, flag: string): string { + return args[index + 1] ?? ""; +} + +function readOwner(value: string): ServiceOwner | null { + return value === "cli" || value === "desktop" ? value : null; +} + +function readRevision(value: string): number | null { + if (!/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +/** Strict parse: the whole approval arrives on argv or the invocation is a usage error. */ +export function parseClaimArgs(args: string[]): ClaimParseResult { + let owner: ServiceOwner | null = null; + let installId: string | null = null; + let expectNone = false; + let expectOwner: ServiceOwner | null = null; + let expectInstallId: string | null = null; + let expectGeneration: number | null = null; + let expectRevision: number | null = null; + let token: string | null = null; + let json = false; + const withValue = new Set([ + "--owner", "--install-id", "--expect-owner", "--expect-install-id", + "--expect-generation", "--expect-revision", "--expect-compatibility-token", + ]); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + if (arg === "--json") { json = true; continue; } + if (arg === "--expect-none") { expectNone = true; continue; } + if (!withValue.has(arg)) return { ok: false }; + const value = readFlag(args, index, arg); + if (!value) return { ok: false }; + index += 1; + switch (arg) { + case "--owner": owner = readOwner(value); if (owner === null) return { ok: false }; break; + case "--install-id": installId = value; break; + case "--expect-owner": expectOwner = readOwner(value); if (expectOwner === null) return { ok: false }; break; + case "--expect-install-id": expectInstallId = value; break; + case "--expect-generation": expectGeneration = readRevision(value); if (expectGeneration === null) return { ok: false }; break; + case "--expect-revision": expectRevision = readRevision(value); if (expectRevision === null) return { ok: false }; break; + case "--expect-compatibility-token": token = value; break; + } + } + if (!owner || !installId || expectRevision === null || !token) return { ok: false }; + let expectedSubject: ServiceOwnershipSubject; + if (expectNone) { + if (expectOwner !== null || expectInstallId !== null || expectGeneration !== null) return { ok: false }; + expectedSubject = { kind: "none", revision: expectRevision }; + } else { + if (expectOwner === null || expectInstallId === null || expectGeneration === null) return { ok: false }; + expectedSubject = { + kind: "owned", + ownership: { owner: expectOwner, installId: expectInstallId, consentGeneration: expectGeneration }, + revision: expectRevision, + }; + } + return { ok: true, args: { owner, installId, expectedSubject, compatibilityToken: token, json } }; +} + +export interface ServiceClaimDeps { + /** recordServiceOwner seam. */ + recordOwner?: typeof recordServiceOwner; + /** The managing-CLI revalidation the record runs inside its lock. */ + observeManagers?: () => Readonly>; + /** Re-resolves the state under the lock for the observation. */ + resolveState?: typeof resolveServiceState; + stdout?: { log: (s: string) => void }; + stderr?: { error: (s: string) => void }; +} + +interface ClaimError extends Error { + code?: string; +} + +/** + * Run `ocx service claim`. Returns the exit code; the caller assigns it, so the verb + * reports before the process decides. + */ +export async function runServiceClaim(argv: string[], deps: ServiceClaimDeps = {}): Promise { + const stdout = deps.stdout ?? console; + const stderr = deps.stderr ?? console; + const parsed = parseClaimArgs(argv); + if (!parsed.ok) { + stderr.error(CLAIM_USAGE); + return 64; + } + const { args } = parsed; + const resolveState = deps.resolveState ?? resolveServiceState; + const observeManagers = deps.observeManagers ?? (() => { + const resolved = resolveState(); + if (resolved.kind === "unknown") { + // recordServiceOwner converts a thrown observation into a compatibility-changed + // refusal; an unreadable state under the lock is exactly that. + throw new Error(resolved.reason); + } + return observeManagingClis(resolved.kind === "state" ? resolved.state : null); + }); + const recordOwner = deps.recordOwner ?? recordServiceOwner; + const request = { + owner: args.owner, + installId: args.installId, + expectedSubject: args.expectedSubject, + expectedCompatibility: { + kind: "supported" as const, + protocolVersion: SERVICE_OWNERSHIP_PROTOCOL_VERSION, + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + token: args.compatibilityToken, + }, + }; + try { + const recordDeps: RecordServiceOwnerDeps = { observeManagers }; + const committed = recordOwner(request, recordDeps); + if (args.json) { + stdout.log(JSON.stringify({ + schema: CLAIM_SCHEMA, + ok: true, + ownership: committed.ownership, + revision: committed.revision, + })); + } else { + stdout.log( + `✅ Recorded ${args.owner} as the runtime owner (install ${args.installId}, generation ${committed.ownership.consentGeneration}).`, + ); + } + return 0; + } catch (error) { + const claimError = error as ClaimError; + const code = typeof claimError?.code === "string" ? claimError.code : "claim-failed"; + const message = claimError instanceof Error ? claimError.message : String(error); + if (args.json) { + stdout.log(JSON.stringify({ schema: CLAIM_SCHEMA, ok: false, code, message })); + } else { + stderr.error(`❌ ${message}`); + } + return 1; + } +} diff --git a/src/service/cli.ts b/src/service/cli.ts index 1322b9948b4..d8fea9b0692 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -21,6 +21,7 @@ import { inspectWindowsSchedulerServiceStatus, schtasksErrorDetail, probeWindows import type { WindowsSchedulerTaskProbe } from "./windows-scheduler"; import { win32 } from "node:path"; import { serviceDiagnosticsSummary } from "./diagnostics"; +import { runServiceClaim } from "./claim"; /** * `restart` is NO LONGER folded into `repair`. @@ -184,6 +185,14 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs { export async function serviceCommand(...args: (string | undefined)[]): Promise { const filteredArgs = args.filter((a): a is string => Boolean(a)); const execute = async (): Promise => { + // `claim` is not an install verb: it is deliberately outside planServiceCommand (whose + // backend/installation checks do not apply to an ownership write) and outside + // assertServiceEnvironmentMatchesInstall — a takeover is not an install. + if (filteredArgs[0] === "claim") { + const code = await runServiceClaim(filteredArgs.slice(1)); + if (code !== 0) process.exitCode = code; + return; + } // Planning reads manager state. Repeat it only after the writer lock is held, otherwise a // bare command can choose install from a snapshot another service command already changed. const plan = planServiceCommand(filteredArgs); @@ -419,7 +428,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise; + /** Spawn seam. Defaults to `spawnSync`. */ + spawn?: typeof spawnSync; + /** The running binary, for the never-spawn-yourself rule. Defaults to `process.execPath`. */ + execPath?: string; + /** This binary's own version, for the self-observation shortcut. */ + ownVersion?: () => string; + /** Filesystem existence seam. */ + exists?: (path: string) => boolean; + /** Host platform override for tests. */ + platform?: NodeJS.Platform; +} + +/** The first strict-semver token in a `--version` line (`opencodex 2.61.0` → `2.61.0`). */ +function versionFromOutput(stdout: string): string | null { + for (const token of stdout.trim().split(/\s+/)) { + if (parseStrictSemver(token)) return token; + } + return null; +} + +function probeVersion( + executable: string, + args: readonly string[], + deps: Required>, +): ManagingCliObservation { + const identity = [executable, ...args].join(" "); + let result: SpawnSyncReturns; + try { + const windowsShim = + deps.platform === "win32" && /\.(cmd|bat)$/i.test(executable); + result = deps.spawn( + windowsShim ? (deps.env?.ComSpec ?? "cmd.exe") : executable, + windowsShim ? ["/c", executable, ...args, "--version"] : [...args, "--version"], + { timeout: VERSION_PROBE_TIMEOUT_MS, encoding: "utf8", stdio: "pipe", windowsHide: true }, + ) as SpawnSyncReturns; + } catch (error) { + return { + status: "unknown", + reason: `${identity} --version could not run: ${error instanceof Error ? error.message : String(error)}`, + }; + } + if (result.error) { + return { + status: "unknown", + reason: `${identity} --version did not finish: ${result.error.message}`, + }; + } + if (result.status !== 0) { + return { status: "unknown", reason: `${identity} --version exited ${result.status ?? "without a code"}` }; + } + const version = versionFromOutput(result.stdout ?? ""); + return version === null + ? { status: "unknown", reason: `${identity} --version printed no semver` } + : { status: "observed", version, identity }; +} + +/** + * Where `ocx` resolves on PATH without spawning `where`/`which`. + * + * Returns the absolute candidate or null. On Windows every PATHEXT extension is tried (and + * the bare name, for extensionless shims); elsewhere the bare name only. + */ +function findOcxOnPath( + env: Record, + exists: (path: string) => boolean, + platform: NodeJS.Platform, +): string | null { + const pathValue = env.PATH ?? env.Path ?? env.path; + if (!pathValue) return null; + const extensions = platform === "win32" + ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map(ext => ext.toLowerCase())] + : [""]; + for (const directory of pathValue.split(delimiter)) { + if (!directory) continue; + for (const extension of extensions) { + const candidate = join(directory, `ocx${extension}`); + if (exists(candidate)) return resolvePath(candidate); + } + } + return null; +} + +function observeServiceRegistration( + state: ServiceInstallState | null, + deps: Required>, +): ManagingCliObservation { + const invocation = registeredManagingCliInvocation(state); + if (invocation.status !== "resolved") return invocation; + return probeVersion(invocation.executable, invocation.args, deps); +} + +function observePathCli( + deps: Required>, +): ManagingCliObservation { + const found = findOcxOnPath(deps.env, deps.exists, deps.platform); + if (!found) return { status: "absent" }; + const self = resolvePath(deps.execPath); + if (found === self || found.toLowerCase() === self.toLowerCase()) { + // Never spawn ourselves for our own version: the answer is already in hand, and the + // recursion that produced it is the #5418 regression. + return { status: "observed", version: deps.ownVersion(), identity: found }; + } + return probeVersion(found, [], deps); +} + +/** + * Both managing-CLI observations, for `assessServiceTakeoverCompatibility` and for the + * revalidation `recordServiceOwner` runs inside its lock. + */ +export function observeManagingClis( + state: ServiceInstallState | null, + deps: ManagingCliDeps = {}, +): Readonly> { + const resolved = { + spawn: deps.spawn ?? spawnSync, + platform: deps.platform ?? process.platform, + env: deps.env ?? (process.env as Record), + execPath: deps.execPath ?? process.execPath, + ownVersion: deps.ownVersion ?? packageVersion, + exists: deps.exists ?? existsSync, + }; + return { + "service-registration": observeServiceRegistration(state, resolved), + path: observePathCli(resolved), + }; +} diff --git a/tests/cli/cli-resolve.test.ts b/tests/cli/cli-resolve.test.ts index a7177ff241b..1ead771ca0f 100644 --- a/tests/cli/cli-resolve.test.ts +++ b/tests/cli/cli-resolve.test.ts @@ -11,6 +11,27 @@ import type { LiveProxy } from "../../src/server/proxy-liveness"; import type { ConfigDiagnostics } from "../../src/config"; import { repoPath } from "../helpers/repo-root"; +const OWNERSHIP_NONE = { kind: "none", revision: 0 } as const; +const OWNERSHIP_OWNED = { + kind: "owned", + ownership: { owner: "desktop", installId: "install-a", consentGeneration: 3 }, + revision: 7, +} as const; +const OWNERSHIP_UNKNOWN = { kind: "unknown", reason: "a service state path could not be read" } as const; + +const TAKEOVER_SUPPORTED = { + kind: "supported", + protocolVersion: 1, + minimumCliVersion: "2.61.0", + token: "deadbeef", +} as const; +const TAKEOVER_BLOCKED = { + kind: "blocked", + reason: "managing-cli-unsupported", + detail: "path uses OpenCodex 2.59.0; 2.61.0 or later is required", + minimumCliVersion: "2.61.0", +} as const; + function fakeLive(overrides: Partial = {}): LiveProxy { return { pid: 4242, @@ -35,7 +56,7 @@ describe("parseResolveArgs", () => { describe("buildResolveJson", () => { test("a live runtime-record proxy answers with its own port and identity", () => { - const json = buildResolveJson({ port: 12345 }, fakeLive(), "/home/fixture/.opencodex", "1.2.3"); + const json = buildResolveJson({ port: 12345 }, fakeLive(), "/home/fixture/.opencodex", "1.2.3", OWNERSHIP_OWNED, TAKEOVER_SUPPORTED); expect(json).toEqual({ schema: RESOLVE_SCHEMA, cliVersion: "1.2.3", @@ -49,17 +70,19 @@ describe("buildResolveJson", () => { source: "runtime", version: "9.9.9", }, + ownership: OWNERSHIP_OWNED, + takeover: TAKEOVER_SUPPORTED, }); }); test("without a live proxy the configured port is the effective one", () => { - const json = buildResolveJson({ port: 12345 }, null, "/home/fixture/.opencodex", "1.2.3"); + const json = buildResolveJson({ port: 12345 }, null, "/home/fixture/.opencodex", "1.2.3", OWNERSHIP_NONE, TAKEOVER_BLOCKED); expect(json.port).toEqual({ effective: 12345, configured: 12345, source: "config" }); expect(json.liveness).toEqual({ status: "absent-proven", pid: null, port: null, source: null }); }); test("an absent configured port resolves to the CLI default", () => { - const json = buildResolveJson({}, null, "/home/fixture/.opencodex", "1.2.3"); + const json = buildResolveJson({}, null, "/home/fixture/.opencodex", "1.2.3", OWNERSHIP_NONE, TAKEOVER_BLOCKED); expect(json.port).toEqual({ effective: RESOLVE_DEFAULT_PORT, configured: RESOLVE_DEFAULT_PORT, @@ -69,7 +92,7 @@ describe("buildResolveJson", () => { test("optional liveness identity fields are omitted, never null-coerced", () => { const legacy = fakeLive({ version: undefined, role: undefined, hostname: undefined }); - const json = buildResolveJson({}, legacy, "/h", "1.2.3"); + const json = buildResolveJson({}, legacy, "/h", "1.2.3", OWNERSHIP_NONE, TAKEOVER_BLOCKED); expect(json.liveness).toEqual({ status: "live", pid: 4242, @@ -79,6 +102,20 @@ describe("buildResolveJson", () => { }); }); +/** Deterministic ownership seams: the production defaults read the real state directory. */ +function ioOwnership( + ownership: typeof OWNERSHIP_NONE | typeof OWNERSHIP_OWNED | typeof OWNERSHIP_UNKNOWN = OWNERSHIP_NONE, + managers?: ReturnType[1]>["observeManagers"]> ,) { + return { + resolveOwnership: () => ownership, + resolveState: () => ({ kind: "none", revision: 0, needsRepair: false }) as const, + observeManagers: () => managers ?? ({ + "service-registration": { status: "absent" }, + path: { status: "absent" }, + }) as ReturnType[1]>["observeManagers"]>, + }; +} + describe("runResolve", () => { test("prints exactly one JSON document and exits 0 for a live proxy", async () => { const lines: string[] = []; @@ -88,13 +125,20 @@ describe("runResolve", () => { readDiagnostics: () => ({ config: { port: 12345 }, source: "file", error: null } as ConfigDiagnostics), findLive: async () => fakeLive(), cliVersion: () => "1.2.3", + ...ioOwnership(OWNERSHIP_OWNED), + resolveState: () => ({ kind: "state", state: { ownershipProtocolVersion: 1 } as never, revision: 7, needsRepair: false }), + observeManagers: () => ({ + "service-registration": { status: "observed", version: "2.61.0", identity: "registered" }, + path: { status: "observed", version: "2.61.0", identity: "path" }, + }), stdout: { log: value => lines.push(value) }, stderr: { error: value => errors.push(value) }, }); expect(code).toBe(0); expect(errors).toEqual([]); expect(lines).toHaveLength(1); - expect(JSON.parse(lines[0]!)).toEqual({ + const document = JSON.parse(lines[0]!); + expect(document).toMatchObject({ schema: RESOLVE_SCHEMA, cliVersion: "1.2.3", configHome: "/home/fixture/.opencodex", @@ -107,7 +151,9 @@ describe("runResolve", () => { source: "runtime", version: "9.9.9", }, + ownership: OWNERSHIP_OWNED, }); + expect(document.takeover).toMatchObject({ kind: "supported", protocolVersion: 1 }); }); test("a proven-absent verdict is a successful answer, not a failure", async () => { @@ -119,6 +165,7 @@ describe("runResolve", () => { readRuntime: () => null, probeEndpoint: () => "dead", cliVersion: () => "1.2.3", + ...ioOwnership(), stdout: { log: value => lines.push(value) }, }); expect(code).toBe(0); @@ -136,6 +183,7 @@ describe("runResolve", () => { readRuntime: () => ({ port: 10110, hostname: "127.0.0.1" }), probeEndpoint: async () => "dead", cliVersion: () => "1.2.3", + ...ioOwnership(), stdout: { log: value => lines.push(value) }, }); expect(code).toBe(0); @@ -155,6 +203,7 @@ describe("runResolve", () => { readRuntime: () => null, probeEndpoint, cliVersion: () => "1.2.3", + ...ioOwnership(), stdout: { log: value => lines.push(value) }, stderr: { error: value => errors.push(value) }, }); @@ -177,6 +226,7 @@ describe("runResolve", () => { readRuntime: () => ({ port: 10110, hostname: "127.0.0.1" }), probeEndpoint: endpoint => { seen.push(String(endpoint.port)); return endpoint.port === 10110 ? "unknown" : "dead"; }, cliVersion: () => "1.2.3", + ...ioOwnership(), stdout: { log: () => {} }, stderr: { error: () => {} }, }); @@ -194,6 +244,7 @@ describe("runResolve", () => { readRuntime: () => ({ port: 10110, hostname: "127.0.0.1" }), probeEndpoint: endpoint => { seen.push(String(endpoint.port)); return "dead"; }, cliVersion: () => "1.2.3", + ...ioOwnership(), stdout: { log: value => lines.push(value) }, }); expect(code).toBe(0); @@ -209,6 +260,7 @@ describe("runResolve", () => { readDiagnostics: () => { throw new Error("config.json is not readable"); }, findLive: async () => null, cliVersion: () => "1.2.3", + ...ioOwnership(), stdout: { log: value => lines.push(value) }, stderr: { error: value => errors.push(value) }, }); @@ -229,6 +281,7 @@ describe("runResolve", () => { readDiagnostics: () => ({ config: {}, source: "fallback", error: "invalid_json" } as ConfigDiagnostics), findLive: async () => { probed = true; return null; }, cliVersion: () => "1.2.3", + ...ioOwnership(), stdout: { log: value => lines.push(value) }, stderr: { error: value => errors.push(value) }, }); @@ -253,12 +306,15 @@ describe("runResolve", () => { readDiagnostics: () => ({ config: { port: 12345 }, source: "file", error: null } as ConfigDiagnostics), findLive: async () => fakeLive(), cliVersion: () => "1.2.3", + ...ioOwnership(), stdout: { log: value => lines.push(value) }, }); expect(code).toBe(0); - expect(lines).toHaveLength(2); + expect(lines).toHaveLength(4); expect(lines[0]).toBe("Config home: /home/fixture/.opencodex"); expect(lines[1]).toContain("Proxy live on port 10110 (PID 4242, 9.9.9)"); + expect(lines[2]).toBe("Owner: none recorded"); + expect(lines[3]).toBe("Takeover: blocked (managing-cli-unobserved: no managing OpenCodex CLI installation was observed)"); expect(lines.every(line => { try { JSON.parse(line); return false; } catch { return true; } })).toBe(true); }); @@ -271,9 +327,96 @@ describe("runResolve", () => { readRuntime: () => null, probeEndpoint: () => "dead", cliVersion: () => "1.2.3", + ...ioOwnership(), stdout: { log: value => lines.push(value) }, }); expect(code).toBe(0); expect(lines[1]).toBe(`No live proxy (absence proven); effective port ${RESOLVE_DEFAULT_PORT} (configured).`); }); }); + +describe("resolve ownership and takeover fields", () => { + async function resolveWith(io: Parameters[1]) { + const lines: string[] = []; + const code = await runResolve({ json: true }, { + configDir: () => "/h", + readDiagnostics: () => ({ config: {}, source: "default", error: null } as ConfigDiagnostics), + findLive: async () => null, + readRuntime: () => null, + probeEndpoint: () => "dead", + cliVersion: () => "1.2.3", + stdout: { log: value => lines.push(value) }, + stderr: { error: () => {} }, + ...io, + }); + return { code, json: JSON.parse(lines[0]!) as { ownership: unknown; takeover: { kind: string; reason?: string } } }; + } + + test("no recorded claim lands as kind none and never blocks the verdict", async () => { + const { code, json } = await resolveWith(ioOwnership()); + expect(code).toBe(0); + expect(json.ownership).toEqual(OWNERSHIP_NONE); + expect(json.takeover.kind).toBe("blocked"); + expect(json.takeover.reason).toBe("managing-cli-unobserved"); + }); + + test("a recorded claim is carried through with its revision", async () => { + const { code, json } = await resolveWith({ + ...ioOwnership(OWNERSHIP_OWNED), + resolveState: () => ({ kind: "state", state: { ownershipProtocolVersion: 1 } as never, revision: 7, needsRepair: false }), + observeManagers: () => ({ + "service-registration": { status: "observed", version: "2.61.0", identity: "registered" }, + path: { status: "observed", version: "2.61.0", identity: "path" }, + }), + }); + expect(code).toBe(0); + expect(json.ownership).toEqual(OWNERSHIP_OWNED); + expect(json.takeover).toMatchObject({ kind: "supported", protocolVersion: 1 }); + }); + + test("an unreadable claim is unknown on the wire and blocks takeover without failing resolve", async () => { + const { code, json } = await resolveWith(ioOwnership(OWNERSHIP_UNKNOWN)); + expect(code).toBe(0); + expect(json.ownership).toEqual(OWNERSHIP_UNKNOWN); + expect(json.takeover).toMatchObject({ + kind: "blocked", + reason: "ownership-unknown", + detail: "a service state path could not be read", + }); + }); + + test("a below-floor managing CLI blocks takeover with the real version", async () => { + const { code, json } = await resolveWith({ + ...ioOwnership(), + observeManagers: () => ({ + "service-registration": { status: "absent" }, + path: { status: "observed", version: "2.59.0", identity: "path" }, + }), + }); + expect(code).toBe(0); + expect(json.takeover).toMatchObject({ kind: "blocked", reason: "managing-cli-unsupported" }); + }); + + test("both managers at or above the floor answer supported with a token", async () => { + const { json } = await resolveWith({ + ...ioOwnership(), + // An observed registration must be backed by an ownership-aware install state. + resolveState: () => ({ kind: "state", state: { ownershipProtocolVersion: 1 } as never, revision: 0, needsRepair: false }), + observeManagers: () => ({ + "service-registration": { status: "observed", version: "2.62.0", identity: "registered" }, + path: { status: "observed", version: "2.61.0", identity: "path" }, + }), + }); + expect(json.takeover).toMatchObject({ kind: "supported", protocolVersion: 1, minimumCliVersion: "2.61.0" }); + expect(typeof (json.takeover as { token?: unknown }).token).toBe("string"); + }); + + test("a throwing observation is managing-cli-unknown, not an exception", async () => { + const { code, json } = await resolveWith({ + ...ioOwnership(), + observeManagers: () => { throw new Error("probe blew up"); }, + }); + expect(code).toBe(0); + expect(json.takeover).toMatchObject({ kind: "blocked", reason: "managing-cli-unknown", detail: "probe blew up" }); + }); +}); diff --git a/tests/service/service-claim.test.ts b/tests/service/service-claim.test.ts new file mode 100644 index 00000000000..692f3d934ed --- /dev/null +++ b/tests/service/service-claim.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test"; +import { parseClaimArgs, runServiceClaim, CLAIM_SCHEMA } from "../../src/service/claim"; +import { ServiceOwnershipSubjectMismatchError } from "../../src/service/state"; +import type { ServiceOwnershipSubject } from "../../src/service/state"; + +const VALID = [ + "--owner", "desktop", + "--install-id", "install-a", + "--expect-none", + "--expect-revision", "0", + "--expect-compatibility-token", "deadbeef", +]; + +describe("parseClaimArgs", () => { + test("parses a full expect-none invocation", () => { + const parsed = parseClaimArgs([...VALID, "--json"]); + expect(parsed).toEqual({ + ok: true, + args: { + owner: "desktop", + installId: "install-a", + expectedSubject: { kind: "none", revision: 0 }, + compatibilityToken: "deadbeef", + json: true, + }, + }); + }); + + test("parses an expect-owner invocation", () => { + const parsed = parseClaimArgs([ + "--owner", "desktop", + "--install-id", "install-a", + "--expect-owner", "cli", + "--expect-install-id", "npm-1", + "--expect-generation", "4", + "--expect-revision", "9", + "--expect-compatibility-token", "cafe", + ]); + expect(parsed).toEqual({ + ok: true, + args: { + owner: "desktop", + installId: "install-a", + expectedSubject: { + kind: "owned", + ownership: { owner: "cli", installId: "npm-1", consentGeneration: 4 }, + revision: 9, + }, + compatibilityToken: "cafe", + json: false, + }, + }); + }); + + test("rejects missing flags, bad owners, mixed expect forms and unknown flags", () => { + for (const argv of [ + [], + ["--owner", "desktop"], + VALID.slice(0, 4), // no expect form at all + VALID.slice(0, 8), // token flag without a value + ["--owner", "nobody", "--install-id", "install-a", "--expect-none", "--expect-revision", "0", "--expect-compatibility-token", "deadbeef"], + [...VALID, "--expect-owner", "cli"], // both expect forms + [...VALID, "--wat"], + [...VALID, "positional"], + ]) { + expect(parseClaimArgs(argv).ok).toBe(false); + } + }); +}); + +describe("runServiceClaim", () => { + test("argument errors exit 64 with usage on stderr", async () => { + const lines: string[] = []; + const errors: string[] = []; + const code = await runServiceClaim(["--owner", "desktop"], { + stdout: { log: value => lines.push(value) }, + stderr: { error: value => errors.push(value) }, + }); + expect(code).toBe(64); + expect(lines).toEqual([]); + expect(errors.join("\n")).toContain("Usage: ocx service claim"); + }); + + test("a recorded claim prints one json document and exits 0", async () => { + const lines: string[] = []; + const ownership = { owner: "desktop", installId: "install-a", consentGeneration: 1 }; + const code = await runServiceClaim([...VALID, "--json"], { + recordOwner: () => ({ kind: "owned", ownership, revision: 3 }), + observeManagers: () => ({ + "service-registration": { status: "absent" }, + path: { status: "absent" }, + }), + stdout: { log: value => lines.push(value) }, + }); + expect(code).toBe(0); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0]!)).toEqual({ + schema: CLAIM_SCHEMA, + ok: true, + ownership, + revision: 3, + }); + }); + + test("human output names the owner, install and generation", async () => { + const lines: string[] = []; + const code = await runServiceClaim(VALID, { + recordOwner: () => ({ + kind: "owned", + ownership: { owner: "desktop", installId: "install-a", consentGeneration: 2 }, + revision: 4, + }), + observeManagers: () => ({ + "service-registration": { status: "absent" }, + path: { status: "absent" }, + }), + stdout: { log: value => lines.push(value) }, + }); + expect(code).toBe(0); + expect(lines.join("\n")).toContain("Recorded desktop as the runtime owner (install install-a, generation 2)"); + }); + + test("a subject mismatch exits 1 with the error's code on the wire", async () => { + const lines: string[] = []; + const expected: ServiceOwnershipSubject = { kind: "none", revision: 0 }; + const actual: ServiceOwnershipSubject = { + kind: "owned", + ownership: { owner: "cli", installId: "npm-1", consentGeneration: 2 }, + revision: 5, + }; + const code = await runServiceClaim([...VALID, "--json"], { + recordOwner: () => { throw new ServiceOwnershipSubjectMismatchError(expected, actual); }, + stdout: { log: value => lines.push(value) }, + }); + expect(code).toBe(1); + expect(JSON.parse(lines[0]!)).toMatchObject({ + schema: CLAIM_SCHEMA, + ok: false, + code: "service-ownership-subject-mismatch", + }); + }); + + test("an observation that cannot resolve its state refuses instead of claiming", async () => { + const lines: string[] = []; + const code = await runServiceClaim([...VALID, "--json"], { + recordOwner: (request, deps) => { + expect(() => deps.observeManagers()).toThrow("state unreadable"); + return { kind: "owned", ownership: { owner: request.owner, installId: request.installId, consentGeneration: 1 }, revision: 1 }; + }, + resolveState: () => ({ kind: "unknown", reason: "state unreadable" }), + stdout: { log: value => lines.push(value) }, + }); + expect(code).toBe(0); + }); +}); From e5228400751b3b5a1582237059779101c8ec92df Mon Sep 17 00:00:00 2001 From: jun Date: Mon, 21 Sep 2026 11:21:02 +0000 Subject: [PATCH 02/15] feat(desktop): ask consent on attach and take over the runtime with the bundled CLI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> (cherry picked from commit 9457ae05cb846e51b7bbb89695fc6429189f1cca) --- desktop/src-tauri/src/claim.rs | 271 +++++++++++ desktop/src-tauri/src/lib.rs | 21 +- desktop/src-tauri/src/ownership.rs | 134 ++++-- desktop/src-tauri/src/resolve.rs | 104 +++- desktop/src-tauri/src/startup.rs | 444 ++++++++++++++++-- desktop/ui/index.html | 48 +- tests/clients/desktop-startup-surface.test.ts | 11 +- 7 files changed, 943 insertions(+), 90 deletions(-) create mode 100644 desktop/src-tauri/src/claim.rs diff --git a/desktop/src-tauri/src/claim.rs b/desktop/src-tauri/src/claim.rs new file mode 100644 index 00000000000..6ce0619af23 --- /dev/null +++ b/desktop/src-tauri/src/claim.rs @@ -0,0 +1,271 @@ +//! Recording this installation as the runtime owner, through the bundled CLI. +//! +//! The claim is only valid against the exact answer the consent prompt was approved from, +//! so this is a subprocess with expectations on argv rather than an in-process write: the +//! ownership mutation lease, the subject revalidation and the managing-CLI re-observation +//! all live in the CLI's `recordServiceOwner`, and re-running them here would be a second +//! implementation of a rule that has to be identical. +//! +//! Like `runtime_stop`, the result is a document, not a guess: `ocx service claim --json` +//! puts one summary on stdout and this consumes `ok` and the exit code rather than +//! inferring them. A claim that did not end in exit 0 with `ok:true` is a claim that did +//! not happen — and a takeover that reached here already stopped the foreign runtime, so +//! the caller's failure is a stopped runtime with no owner recorded, which the next launch +//! resolves as an ordinary absence. + +use serde::Deserialize; +use tauri::AppHandle; +use tauri_plugin_shell::ShellExt; +use tokio::time::{timeout_at, Instant}; + +/// The wire version this shell understands. +pub const SCHEMA: &str = "ocx-service-claim/1"; + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaimOwnership { + pub owner: String, + pub install_id: String, + pub consent_generation: u64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaimSummary { + pub schema: String, + pub ok: bool, + /// Present on success. + pub ownership: Option, + /// Present on failure: the CLI's machine-readable error code. + pub code: Option, + /// Present on failure. + pub message: Option, +} + +/// What the shell concluded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ClaimResult { + /// The CLI recorded the claim and named the generation it landed at. + Recorded(ClaimOwnership), + /// It reported anything else, or the run could not be read at all. + Failed(String), +} + +impl ClaimResult { + #[cfg(test)] + pub fn is_recorded(&self) -> bool { + matches!(self, Self::Recorded(_)) + } +} + +/// The arguments a takeover builds from the resolve answer it was approved against. +pub fn args(install_id: &str, recorded: &crate::ownership::Recorded, token: &str) -> Vec { + let mut argv = vec![ + "service".to_owned(), + "claim".to_owned(), + "--owner".to_owned(), + "desktop".to_owned(), + "--install-id".to_owned(), + install_id.to_owned(), + ]; + match recorded { + crate::ownership::Recorded::None { revision } => { + argv.push("--expect-none".to_owned()); + argv.push("--expect-revision".to_owned()); + argv.push(revision.to_string()); + } + crate::ownership::Recorded::Owned { + ownership, + revision, + } => { + argv.extend([ + "--expect-owner".to_owned(), + match ownership.owner { + crate::ownership::Owner::Cli => "cli".to_owned(), + crate::ownership::Owner::Desktop => "desktop".to_owned(), + }, + "--expect-install-id".to_owned(), + ownership.install_id.clone(), + "--expect-generation".to_owned(), + ownership.consent_generation.to_string(), + "--expect-revision".to_owned(), + revision.to_string(), + ]); + } + // A takeover is only offered when the record was read; unknown never reaches here. + crate::ownership::Recorded::Unknown { .. } => { + argv.push("--expect-none".to_owned()); + argv.push("--expect-revision".to_owned()); + argv.push("0".to_owned()); + } + } + argv.extend([ + "--expect-compatibility-token".to_owned(), + token.to_owned(), + "--json".to_owned(), + ]); + argv +} + +/// Read one claim summary. +/// +/// Exit 0 with `ok:true` is the only success — the claim path uses exit 1 with a +/// machine-readable `code` for subject mismatches and changed compatibility, and both of +/// those are refusals to re-ask from, not partial writes. +pub fn read(exit_code: Option, stdout: &[u8], stderr: &[u8]) -> ClaimResult { + let text = String::from_utf8_lossy(stdout); + let summary: ClaimSummary = match serde_json::from_str(text.trim()) { + Ok(summary) => summary, + Err(error) => { + let detail = String::from_utf8_lossy(stderr); + let detail = detail.trim(); + let code = exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "no exit code".to_owned()); + return ClaimResult::Failed(if detail.is_empty() { + format!("the bundled CLI's claim output could not be read (exit {code}: {error})") + } else { + format!("the bundled CLI's claim output could not be read (exit {code}): {detail}") + }); + } + }; + if summary.schema != SCHEMA { + return ClaimResult::Failed(format!( + "the bundled CLI answered with schema {} and this app understands {SCHEMA}", + summary.schema + )); + } + if exit_code != Some(0) || !summary.ok { + return ClaimResult::Failed(summary.message.unwrap_or_else(|| { + format!( + "the claim was refused ({})", + summary.code.unwrap_or_else(|| "no code".to_owned()) + ) + })); + } + match summary.ownership { + Some(ownership) => ClaimResult::Recorded(ownership), + None => ClaimResult::Failed( + "the claim reported success but carried no ownership record".to_owned(), + ), + } +} + +/// Run the bundled `ocx service claim`, under the caller's deadline. +pub async fn run(app: &AppHandle, argv: Vec, deadline: Instant) -> ClaimResult { + let command = match app.shell().sidecar("ocx") { + Ok(command) => command.args(argv), + Err(error) => { + return ClaimResult::Failed(format!("the bundled CLI could not be started ({error})")) + } + }; + match timeout_at(deadline, command.output()).await { + Ok(Ok(output)) => read(output.status.code(), &output.stdout, &output.stderr), + Ok(Err(error)) => { + ClaimResult::Failed(format!("the bundled CLI could not be run ({error})")) + } + Err(_) => ClaimResult::Failed( + "the bundled CLI did not finish the claim before the deadline".to_owned(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::{args, read, ClaimResult}; + use crate::ownership::{Owner, Recorded}; + + fn document(ok: bool, extra: &str) -> String { + format!(r#"{{"schema":"ocx-service-claim/1","ok":{ok}{extra}}}"#) + } + + #[test] + fn the_arguments_carry_the_exact_approved_subject() { + let none = args("install-a", &Recorded::None { revision: 0 }, "tok"); + assert_eq!( + none, + [ + "service", + "claim", + "--owner", + "desktop", + "--install-id", + "install-a", + "--expect-none", + "--expect-revision", + "0", + "--expect-compatibility-token", + "tok", + "--json", + ] + ); + let owned = Recorded::Owned { + ownership: crate::ownership::Claim { + owner: Owner::Cli, + install_id: "npm-1".to_owned(), + consent_generation: 2, + }, + revision: 9, + }; + let argv = args("install-a", &owned, "tok"); + assert!(argv + .windows(2) + .any(|pair| pair == ["--expect-owner", "cli"])); + assert!(argv + .windows(2) + .any(|pair| pair == ["--expect-install-id", "npm-1"])); + assert!(argv + .windows(2) + .any(|pair| pair == ["--expect-generation", "2"])); + assert!(argv + .windows(2) + .any(|pair| pair == ["--expect-revision", "9"])); + } + + #[test] + fn a_recorded_claim_is_the_only_success() { + let ok = document( + true, + r#","ownership":{"owner":"desktop","installId":"install-a","consentGeneration":1},"revision":3"#, + ); + let result = read(Some(0), ok.as_bytes(), b""); + match result { + ClaimResult::Recorded(ownership) => { + assert_eq!(ownership.install_id, "install-a"); + assert_eq!(ownership.consent_generation, 1); + } + ClaimResult::Failed(reason) => panic!("{reason}"), + } + // Success has to arrive with exit 0 and the record it wrote. + assert!(!read(Some(1), ok.as_bytes(), b"").is_recorded()); + assert!(!read(Some(0), document(true, "").as_bytes(), b"").is_recorded()); + } + + #[test] + fn a_refusal_carries_the_clis_own_message() { + let refused = document( + false, + r#","code":"service-ownership-subject-mismatch","message":"ownership changed""#, + ); + let result = read(Some(1), refused.as_bytes(), b""); + match result { + ClaimResult::Failed(reason) => assert!(reason.contains("ownership changed")), + ClaimResult::Recorded(_) => panic!("a refused claim is not recorded"), + } + } + + #[test] + fn output_that_cannot_be_read_is_a_failure_not_a_claim() { + assert!(!read(Some(0), b"", b"boom").is_recorded()); + assert!(!read(Some(0), b"not json", b"").is_recorded()); + assert!(!read(None, b"", b"").is_recorded()); + let future = document(true, r#","ownership":{"owner":"desktop","installId":"i","consentGeneration":1},"revision":1"#) + .replace("ocx-service-claim/1", "ocx-service-claim/2"); + let result = read(Some(0), future.as_bytes(), b""); + assert!(!result.is_recorded()); + match result { + ClaimResult::Failed(reason) => assert!(reason.contains("ocx-service-claim/2")), + _ => unreachable!(), + } + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7464a3692c0..07338b7e615 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod auth; +mod claim; #[cfg(target_os = "macos")] mod companion_query; mod companion_usage; @@ -116,7 +117,7 @@ impl AppState { /// Let go of a runtime that has already been drained. /// - /// Dropping the handle does not signal the process — the shell plugin installs no `Drop` — so + /// Dropping the handle does not signal the process ??the shell plugin installs no `Drop` ??so /// this releases ownership without reintroducing the `kill()` that D2 removed. pub fn release(&self) { self.confirmed.store(false, Ordering::Release); @@ -151,8 +152,8 @@ fn hide_dashboard(app: tauri::AppHandle) { /// The page asks for this when it loads rather than relying only on the event stream: the first /// states finish in milliseconds and an event emitted before the listener exists is simply gone. /// -/// It always answers with a state. Answering `None` put the one case the page cannot render — a -/// shell with no startup state — behind a value the page silently discards, which is a frozen +/// It always answers with a state. Answering `None` put the one case the page cannot render ??a +/// shell with no startup state ??behind a value the page silently discards, which is a frozen /// window with no diagnostic and no way to tell it from a slow start. #[tauri::command] fn startup_snapshot(app: tauri::AppHandle) -> startup::Progress { @@ -176,6 +177,17 @@ fn retry_startup(app: tauri::AppHandle) { startup::begin(&app); } +/// The user's answer to the takeover prompt the startup sequence is waiting on. +/// +/// The sequence holds a oneshot for exactly the duration of the prompt; a decision arriving +/// with nothing pending is a click after the fact, and it changes nothing. +#[tauri::command] +fn decide_takeover(app: tauri::AppHandle, approved: bool) { + if let Some(startup) = app.try_state::() { + startup.decide_takeover(approved); + } +} + pub fn run() { let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { @@ -209,7 +221,8 @@ pub fn run() { hide_dashboard, startup_snapshot, startup_phases, - retry_startup + retry_startup, + decide_takeover ]) .setup(|app| { app.manage(AppState::new()); diff --git a/desktop/src-tauri/src/ownership.rs b/desktop/src-tauri/src/ownership.rs index a27bbf6a8e2..5b30eb366f1 100644 --- a/desktop/src-tauri/src/ownership.rs +++ b/desktop/src-tauri/src/ownership.rs @@ -16,7 +16,6 @@ //! contract that lands fills a hole rather than reshaping this file. use serde::Deserialize; -use tauri::AppHandle; /// Who a claim names. #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] @@ -47,15 +46,26 @@ pub struct Claim { #[serde(tag = "kind", rename_all = "lowercase")] pub enum Recorded { /// No claim. The CLI install that registered the service owns the runtime, which is also what - /// every record written before the field existed says. - None, + /// every record written before the field existed says. The revision is the record's own + /// sequence, and a later `service claim` carries it as `expect-revision`. + None { revision: u64 }, /// A claim, whoever it names. - Owned { ownership: Claim }, + Owned { ownership: Claim, revision: u64 }, /// The claim could not be read for a decision. This is not "nobody owns it": an unreadable /// path, a corrupt anchor record and paths naming different owners all land here. Unknown { reason: String }, } +impl Default for Recorded { + /// A resolve document that carries no ownership field at all did not answer the question — + /// the older bundled CLI predates it — and an unanswered question is not a claim. + fn default() -> Self { + Self::Unknown { + reason: "the bundled CLI did not report ownership".to_owned(), + } + } +} + /// The comparison `ownershipGrantedTo` defines: same owner, same install id. /// /// True means this installation already holds consent. False against a recorded claim means a @@ -83,8 +93,8 @@ pub enum Consent { pub fn consent(recorded: &Recorded, install_id: &str) -> Consent { match recorded { Recorded::Unknown { .. } => Consent::Refuse, - Recorded::None => Consent::AskFirstTime, - Recorded::Owned { ownership } => { + Recorded::None { .. } => Consent::AskFirstTime, + Recorded::Owned { ownership, .. } => { if granted_to(Some(ownership), Owner::Desktop, install_id) { Consent::Held } else { @@ -94,35 +104,19 @@ pub fn consent(recorded: &Recorded, install_id: &str) -> Consent { } } -/// Read the recorded claim through the bundled CLI. -/// -/// Empty on purpose. Lane A publishes the machine-readable resolve the shell drives, and this is -/// the one call site that changes when it lands: it has to return the CLI's own answer, including -/// its refusals, rather than a verdict computed here. Until then the answer is *unavailable*, which -/// is not [`Recorded::None`] — the shell has not been told that nobody owns the runtime, it has not -/// asked — so no takeover is attempted and nothing is recorded. -pub fn resolve(_app: &AppHandle) -> Option { - None -} - /// One line for the startup state and for the diagnostic. -pub fn describe(recorded: Option<&Recorded>, install_id: Option<&str>) -> String { +pub fn describe(recorded: &Recorded, install_id: Option<&str>) -> String { let installation = match install_id { Some(id) => format!("installation {id}"), None => "installation id unavailable".to_owned(), }; let verdict = match (recorded, install_id) { - (None, _) => { - "recorded owner not read: the bundled CLI's resolve contract has not landed".to_owned() - } - (Some(Recorded::Unknown { reason }), _) => { + (Recorded::Unknown { reason }, _) => { format!("recorded owner could not be read ({reason}), so nothing is claimed") } - (Some(_), None) => { - "recorded owner read, but this installation has no id to compare".to_owned() - } - (Some(recorded), Some(id)) => match (consent(recorded, id), recorded) { - (Consent::Held, Recorded::Owned { ownership }) => format!( + (_, None) => "recorded owner read, but this installation has no id to compare".to_owned(), + (_, Some(id)) => match (consent(recorded, id), recorded) { + (Consent::Held, Recorded::Owned { ownership, .. }) => format!( "this installation owns the runtime (consent generation {})", ownership.consent_generation ), @@ -134,9 +128,27 @@ pub fn describe(recorded: Option<&Recorded>, install_id: Option<&str>) -> String format!("{installation}; {verdict}") } +/// Who the recorded claim names, for the consent panel. +pub fn owner_label(recorded: &Recorded) -> String { + match recorded { + Recorded::None { .. } => "no recorded owner (an npm or standalone ocx install)".to_owned(), + Recorded::Owned { ownership, .. } => match ownership.owner { + Owner::Cli => format!( + "the OpenCodex CLI install (installation {})", + ownership.install_id + ), + Owner::Desktop => format!( + "another OpenCodex desktop installation (installation {})", + ownership.install_id + ), + }, + Recorded::Unknown { reason } => format!("unknown ({reason})"), + } +} + #[cfg(test)] mod tests { - use super::{consent, describe, granted_to, Claim, Consent, Owner, Recorded}; + use super::{consent, describe, granted_to, owner_label, Claim, Consent, Owner, Recorded}; fn owned(owner: Owner, install_id: &str, generation: u64) -> Recorded { Recorded::Owned { @@ -145,6 +157,7 @@ mod tests { install_id: install_id.to_owned(), consent_generation: generation, }, + revision: 4, } } @@ -178,7 +191,10 @@ mod tests { consent(&owned(Owner::Desktop, "abc", 1), "abc"), Consent::Held ); - assert_eq!(consent(&Recorded::None, "abc"), Consent::AskFirstTime); + assert_eq!( + consent(&Recorded::None { revision: 0 }, "abc"), + Consent::AskFirstTime + ); } #[test] @@ -199,21 +215,21 @@ mod tests { reason: "a service state path could not be read".to_owned(), }; assert_eq!(consent(&unknown, "abc"), Consent::Refuse); - assert!(describe(Some(&unknown), Some("abc")).contains("could not be read")); + assert!(describe(&unknown, Some("abc")).contains("could not be read")); } #[test] fn the_wire_shape_is_the_one_the_cli_records() { let resolution: Recorded = serde_json::from_str( - r#"{"kind":"owned","ownership":{"owner":"desktop","installId":"abc","consentGeneration":3}}"#, + r#"{"kind":"owned","ownership":{"owner":"desktop","installId":"abc","consentGeneration":3},"revision":4}"#, ) .expect("the recorded resolution"); assert_eq!(resolution, owned(Owner::Desktop, "abc", 3)); assert_eq!(consent(&resolution, "abc"), Consent::Held); - assert!(describe(Some(&resolution), Some("abc")).contains("consent generation 3")); + assert!(describe(&resolution, Some("abc")).contains("consent generation 3")); assert_eq!( - serde_json::from_str::(r#"{"kind":"none"}"#).expect("no claim"), - Recorded::None + serde_json::from_str::(r#"{"kind":"none","revision":0}"#).expect("no claim"), + Recorded::None { revision: 0 } ); assert_eq!( serde_json::from_str::(r#"{"kind":"unknown","reason":"why"}"#) @@ -225,12 +241,50 @@ mod tests { } #[test] - fn the_description_separates_not_asked_from_nobody_owns_it() { - let not_asked = describe(None, Some("abc")); - let unowned = describe(Some(&Recorded::None), Some("abc")); - assert!(not_asked.contains("abc")); - assert_ne!(not_asked, unowned); - assert!(describe(None, None).contains("unavailable")); + fn a_resolve_document_without_an_ownership_answer_reads_unknown() { + assert_eq!( + Recorded::default(), + Recorded::Unknown { + reason: "the bundled CLI did not report ownership".to_owned() + } + ); + assert!(serde_json::from_str::(r#"{"kind":"none"}"#).is_err()); + } + + #[test] + fn the_description_separates_not_read_from_nobody_owns_it() { + let unread = describe( + &Recorded::Unknown { + reason: "why".to_owned(), + }, + Some("abc"), + ); + let unowned = describe(&Recorded::None { revision: 0 }, Some("abc")); + assert!(unread.contains("abc")); + assert_ne!(unread, unowned); + assert!(describe(&Recorded::None { revision: 0 }, None).contains("unavailable")); + } + + #[test] + fn owner_label_names_who_the_claim_is_for() { + assert_eq!( + owner_label(&Recorded::None { revision: 0 }), + "no recorded owner (an npm or standalone ocx install)" + ); + assert_eq!( + owner_label(&owned(Owner::Cli, "npm-1", 1)), + "the OpenCodex CLI install (installation npm-1)" + ); + assert_eq!( + owner_label(&owned(Owner::Desktop, "other", 2)), + "another OpenCodex desktop installation (installation other)" + ); + assert_eq!( + owner_label(&Recorded::Unknown { + reason: "why".to_owned() + }), + "unknown (why)" + ); } #[test] diff --git a/desktop/src-tauri/src/resolve.rs b/desktop/src-tauri/src/resolve.rs index 8172006d0ff..e644b6edcc4 100644 --- a/desktop/src-tauri/src/resolve.rs +++ b/desktop/src-tauri/src/resolve.rs @@ -14,6 +14,7 @@ //! reading that must never happen is "the resolve failed, so nobody must be listening". use crate::endpoint::ProxyEndpoint; +use crate::ownership::Recorded; use serde::Deserialize; use std::path::PathBuf; use tauri::AppHandle; @@ -52,6 +53,36 @@ pub struct Port { pub configured: u16, } +/// Whether the CLI says a desktop takeover can be offered. +/// +/// The token is the binding a later `ocx service claim` repeats back: it covers the exact +/// subject and managing-CLI observations the consent was approved against, so a claim made +/// after either moved is refused rather than recorded. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Takeover { + #[serde(rename_all = "camelCase")] + Supported { + protocol_version: u64, + minimum_cli_version: String, + token: String, + }, + Blocked { + reason: String, + detail: String, + }, +} + +impl Default for Takeover { + /// An older bundled CLI carries no takeover answer at all; silence is not approval. + fn default() -> Self { + Self::Blocked { + reason: "unreported".to_owned(), + detail: "the bundled CLI did not report takeover compatibility".to_owned(), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Resolved { @@ -60,6 +91,12 @@ pub struct Resolved { pub config_home: String, pub port: Port, pub liveness: Liveness, + /// The recorded runtime owner, already in the CLI's three answers. Absent on older + /// documents, which read as unknown rather than as nobody owning the runtime. + #[serde(default)] + pub ownership: Recorded, + #[serde(default)] + pub takeover: Takeover, } impl Resolved { @@ -228,8 +265,10 @@ pub async fn run(app: &AppHandle, deadline: Instant) -> Resolution { #[cfg(test)] mod tests { use super::{ - live_verdict, loopback_reachable, may_start, read, LiveVerdict, Resolution, Status, SCHEMA, + live_verdict, loopback_reachable, may_start, read, LiveVerdict, Resolution, Status, + Takeover, SCHEMA, }; + use crate::ownership::{Owner, Recorded}; const LIVE: &str = r#"{"schema":"ocx-resolve/1","cliVersion":"2.61.0","configHome":"/h", "port":{"effective":10100,"configured":10100,"source":"runtime-record"}, @@ -318,6 +357,69 @@ mod tests { } } + #[test] + fn ownership_and_takeover_answers_are_read_whole() { + let document = format!( + "{}{}}}", + LIVE.strip_suffix('}').unwrap(), + r#","ownership":{"kind":"owned","ownership":{"owner":"cli","installId":"npm-1","consentGeneration":2},"revision":9},"takeover":{"kind":"supported","protocolVersion":1,"minimumCliVersion":"2.61.0","token":"abc"}"# + ); + let resolution = read(Some(0), document.as_bytes(), b""); + let resolved = match resolution.resolved() { + Some(resolved) => resolved.clone(), + None => panic!("{}", resolution.reason().unwrap()), + }; + assert_eq!( + resolved.ownership, + Recorded::Owned { + ownership: crate::ownership::Claim { + owner: Owner::Cli, + install_id: "npm-1".to_owned(), + consent_generation: 2, + }, + revision: 9, + } + ); + assert!(matches!( + resolved.takeover, + Takeover::Supported { ref token, .. } if token == "abc" + )); + } + + #[test] + fn a_missing_ownership_or_takeover_answer_is_not_consent() { + // Older bundled CLIs carry neither field; silence must read unknown/blocked, never + // "nobody owns it" or "takeover supported". + let resolved = read(Some(0), LIVE.as_bytes(), b"") + .resolved() + .expect("a document") + .clone(); + assert!(matches!(resolved.ownership, Recorded::Unknown { .. })); + assert!(matches!(resolved.takeover, Takeover::Blocked { .. })); + assert_eq!(resolved.takeover, Takeover::default()); + } + + #[test] + fn a_blocked_takeover_carries_its_reason() { + let document = format!( + "{}{}}}", + LIVE.strip_suffix('}').unwrap(), + r#","ownership":{"kind":"none","revision":0},"takeover":{"kind":"blocked","reason":"managing-cli-unsupported","detail":"path uses 2.59.0","minimumCliVersion":"2.61.0"}"# + ); + let resolved = read(Some(0), document.as_bytes(), b"") + .resolved() + .expect("a document") + .clone(); + assert_eq!(resolved.ownership, Recorded::None { revision: 0 }); + assert_eq!( + resolved.takeover, + Takeover::Blocked { + reason: "managing-cli-unsupported".to_owned(), + detail: "path uses 2.59.0".to_owned(), + } + ); + } + #[test] fn a_schema_this_app_does_not_know_is_unknown() { let future = LIVE.replace("ocx-resolve/1", "ocx-resolve/2"); diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs index 0fccc6fddfc..1bfd68e24d9 100644 --- a/desktop/src-tauri/src/startup.rs +++ b/desktop/src-tauri/src/startup.rs @@ -17,11 +17,12 @@ use crate::{ auth::Auth, + claim, endpoint::ProxyEndpoint, first_run::{self, StartAtLogin}, identity, ownership, proxy::{ProxyClient, RuntimeIdentity}, - resolve, + resolve, runtime_stop, sidecar::{self, SidecarWatch}, tray_availability::{self, TrayAvailability}, AppState, @@ -35,6 +36,7 @@ use std::{ }, }; use tauri::{AppHandle, Emitter, Manager}; +use tokio::sync::oneshot; use tokio::time::{sleep, sleep_until, Duration, Instant}; /// The event the bootstrap page listens on. @@ -110,6 +112,7 @@ pub enum Phase { Resolving, Probing, Attaching, + TakingOver, Starting, Waiting, Ready, @@ -121,11 +124,12 @@ pub enum Phase { /// /// [`Phase::NotStarted`] is absent on purpose. It is the state of not having run, so a checklist /// row for it would be a step that never completes. -pub const PHASES: [Phase; 8] = [ +pub const PHASES: [Phase; 9] = [ Phase::Registering, Phase::Resolving, Phase::Probing, Phase::Attaching, + Phase::TakingOver, Phase::Starting, Phase::Waiting, Phase::Ready, @@ -141,6 +145,7 @@ impl Phase { Self::Resolving => "resolving", Self::Probing => "probing", Self::Attaching => "attaching", + Self::TakingOver => "taking-over", Self::Starting => "starting", Self::Waiting => "waiting", Self::Ready => "ready", @@ -155,6 +160,7 @@ impl Phase { Self::Resolving => "Resolving the configuration home and port", Self::Probing => "Looking for a runtime that is already listening", Self::Attaching => "Attaching to the runtime that answered", + Self::TakingOver => "Taking over the runtime that was already listening", Self::Starting => "Starting the bundled runtime", Self::Waiting => "Waiting for the runtime to report healthy", Self::Ready => "Ready", @@ -213,6 +219,21 @@ pub struct Progress { pub dashboard: Option, pub diagnostic: Option, pub can_retry: bool, + /// Present only while the shell is waiting on the user's takeover decision. + pub consent: Option, +} + +/// What the consent panel renders. `blocked` carries the CLI's refusal reason when a +/// takeover cannot be offered; the panel is shown only for the offerable case today, but +/// the field is part of the wire so a later UI does not need a schema change. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConsentPrompt { + pub endpoint: String, + pub port: u16, + pub home: String, + pub owner: String, + pub blocked: Option, } impl Progress { @@ -227,6 +248,7 @@ impl Progress { dashboard: None, diagnostic: None, can_retry: phase == Phase::Failed, + consent: None, } } } @@ -263,8 +285,8 @@ struct Target { #[derive(Clone, Debug)] pub struct Registration { pub login: StartAtLogin, - /// This installation's own id, and what the recorded runtime owner says about it. - pub identity: String, + /// This installation's own id, minted once in the app's config directory. + pub install_id: Option, } struct Live { @@ -272,6 +294,16 @@ struct Live { reported: Vec<&'static str>, } +/// The takeover prompt's decision state. +enum ConsentState { + /// No prompt is up and none was just answered. + Idle, + /// A prompt is up; the sender resolves with the user's decision. + Pending(oneshot::Sender), + /// The user answered and the run has not yet consumed the extension. + Answered, +} + /// The sequence's managed state: the latest thing it said, what it has already finished, and /// whether it is running, so a retry cannot start a second run alongside the first. pub struct Startup { @@ -284,6 +316,15 @@ pub struct Startup { generation: AtomicU64, /// The outcome of the one-time registration, once it has happened. registered: Mutex>, + /// The takeover decision state. `Answered` stays set until the run clears it: the deadline + /// extension lands between the decision and the clear, and the guard must keep waiting + /// through both. + consent: Mutex, + /// The current run's ceiling. + /// + /// The consent wait moves it by however long the person took, so the deadline guard + /// re-reads it instead of racing a stale copy. + deadline: Mutex, } impl Startup { @@ -296,6 +337,8 @@ impl Startup { running: AtomicBool::new(false), generation: AtomicU64::new(0), registered: Mutex::new(None), + consent: Mutex::new(ConsentState::Idle), + deadline: Mutex::new(Instant::now()), } } @@ -322,6 +365,44 @@ impl Startup { self.live().latest.clone() } + /// The user's answer to a pending takeover prompt. Nothing pending is a no-op: a retry + /// or a late click must never be read as a decision for a prompt that is not up. + pub fn decide_takeover(&self, approved: bool) { + let mut consent = self.consent.lock().unwrap_or_else(PoisonError::into_inner); + match std::mem::replace(&mut *consent, ConsentState::Idle) { + ConsentState::Pending(sender) => { + *consent = ConsentState::Answered; + let _ = sender.send(approved); + } + // A late click or a duplicate decision answers nothing: restore what was there. + prior => *consent = prior, + } + } + + fn await_consent(&self) -> oneshot::Receiver { + let (sender, receiver) = oneshot::channel(); + *self.consent.lock().unwrap_or_else(PoisonError::into_inner) = ConsentState::Pending(sender); + receiver + } + + fn clear_consent(&self) { + *self.consent.lock().unwrap_or_else(PoisonError::into_inner) = ConsentState::Idle; + } + + fn deadline(&self) -> Instant { + *self.deadline.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn set_deadline(&self, deadline: Instant) { + *self.deadline.lock().unwrap_or_else(PoisonError::into_inner) = deadline; + } + + /// Whether the sequence is waiting on the user's takeover decision. The budget bounds the + /// machinery, not the person, so the guard stays quiet while a prompt is up. + fn consent_pending(&self) -> bool { + !matches!(*self.consent.lock().unwrap_or_else(PoisonError::into_inner), ConsentState::Idle) + } + fn restart(&self) { let mut live = self.live(); live.reported.clear(); @@ -377,6 +458,7 @@ pub fn begin(app: &AppHandle) { startup.restart(); let generation = startup.generation.fetch_add(1, Ordering::AcqRel) + 1; let started = Instant::now(); + startup.set_deadline(started + DEADLINE); let app = app.clone(); // The ceiling is a promise to the page, and something has to keep it when the run does not. @@ -386,7 +468,27 @@ pub fn begin(app: &AppHandle) { // cannot tell from a hung application, which is the whole thing this surface exists to avoid. let guard = app.clone(); tauri::async_runtime::spawn(async move { - sleep_until(started + DEADLINE + SETTLE_GRACE).await; + // The consent wait extends the shared deadline, and while a prompt is up the budget + // does not run at all. Re-reading both keeps the guard honest for a stalled run + // without failing one that is legitimately waiting on the person. + loop { + let Some(startup) = guard.try_state::() else { + return; + }; + if startup.generation.load(Ordering::Acquire) != generation || startup.settled() { + return; + } + if startup.consent_pending() { + sleep(POLL).await; + continue; + } + let wake = startup.deadline() + SETTLE_GRACE; + if wake > Instant::now() { + sleep_until(wake).await; + continue; + } + break; + } settle( &guard, started, @@ -444,7 +546,7 @@ fn settle(app: &AppHandle, started: Instant, generation: u64, reason: String) { } async fn run(app: &AppHandle, started: Instant) { - let deadline = started + DEADLINE; + let mut deadline = started + DEADLINE; // Publishing comes before any lookup that can fail. A sequence that returns before it has // said anything leaves the page unable to tell "not started" from "still going". report(app, started, Phase::Registering, None); @@ -456,11 +558,7 @@ async fn run(app: &AppHandle, started: Instant) { app, started, Phase::Registering, - Some(format!( - "{}; {}", - registration.login.describe(), - registration.identity - )), + Some(registration.login.describe().to_owned()), ); report(app, started, Phase::Resolving, None); @@ -514,10 +612,11 @@ async fn run(app: &AppHandle, started: Instant) { started, Phase::Resolving, Some(format!( - "{} with a configuration home of {}, resolved by the bundled CLI {}", + "{} with a configuration home of {}, resolved by the bundled CLI {}; {}", target.endpoint.url(""), target.home.display(), - answer.cli_version + answer.cli_version, + ownership::describe(&answer.ownership, registration.install_id.as_deref()) )), ); @@ -532,29 +631,96 @@ async fn run(app: &AppHandle, started: Instant) { } }), ); + let mut took_over = false; match resolve::live_verdict(&resolution) { resolve::LiveVerdict::Attach => { - report( - app, - started, - Phase::Attaching, - Some("a runtime was already listening, so this app is a guest on it".to_owned()), - ); - if bind(app, &proxy, deadline).await.is_none() { - fail( - app, - started, - Some(&target), - ®istration, - &watch, - Phase::Attaching, - "the runtime answered but did not identify itself, so this app did not attach" - .to_owned(), - ); - return; + // Without our own id nothing can ever match us, which is the answer Refuse gives. + let consent = match registration.install_id.as_deref() { + Some(install_id) => ownership::consent(&answer.ownership, install_id), + None => ownership::Consent::Refuse, + }; + match attach_plan(consent, &answer.takeover) { + AttachPlan::Guest(detail) => { + attach_as_guest( + app, + started, + &target, + ®istration, + &watch, + &proxy, + endpoint, + deadline, + detail, + ) + .await; + return; + } + AttachPlan::Ask(token) => { + // The prompt has to be visible even when this launch started hidden. + if let Some(window) = app.get_webview_window("main") { + crate::window::show(&window); + } + let Some(startup) = app.try_state::() else { + return; + }; + let receiver = startup.await_consent(); + let mut progress = Progress::new(Phase::Attaching, elapsed(started)); + progress.detail = Some( + "a runtime was already listening; waiting for a decision on taking it over" + .to_owned(), + ); + progress.consent = Some(ConsentPrompt { + endpoint: target.endpoint.url(""), + port: target.endpoint.port, + home: target.home.display().to_string(), + owner: ownership::owner_label(&answer.ownership), + blocked: None, + }); + emit(app, progress, None); + // The user may take any time; the budget exists to bound the machinery, not + // the person, so the deadline moves by whatever the decision took. + let asked = Instant::now(); + let approved = receiver.await.unwrap_or(false); + deadline += asked.elapsed(); + // The guard reads the shared deadline only after the prompt is no longer + // pending, so the extension has to land first. + startup.set_deadline(deadline); + startup.clear_consent(); + if !approved { + attach_as_guest( + app, + started, + &target, + ®istration, + &watch, + &proxy, + endpoint, + deadline, + "a runtime was already listening and taking it over was declined, so this app is a guest on it" + .to_owned(), + ) + .await; + return; + } + if take_over( + app, + started, + &mut deadline, + &target, + ®istration, + &watch, + &proxy, + &answer.ownership, + &token, + ) + .await + .is_err() + { + return; + } + took_over = true; + } } - finish(app, started, endpoint); - return; } // Something holds the port and this app cannot manage it. That is not an absence, so it // does not authorise starting a second runtime beside it either. @@ -572,8 +738,9 @@ async fn run(app: &AppHandle, started: Instant) { } resolve::LiveVerdict::NotLive => {} } - if !resolve::may_start(&resolution) { - // Only a proven absence authorises a start. Nothing else may fall through to one. + if !took_over && !resolve::may_start(&resolution) { + // Only a proven absence authorises a start. Nothing else may fall through to one. A + // takeover just proved its own absence by stopping what was there. fail( app, started, @@ -672,6 +839,159 @@ async fn run(app: &AppHandle, started: Instant) { ); } +/// What an attach turns into once the recorded owner and the CLI's compatibility answer are +/// laid next to each other. `Ask` carries the token the claim has to be made against. +enum AttachPlan { + /// Stay a guest on what answered; the string is the detail the phase reports. + Guest(String), + /// Offer the takeover and wait on the user. + Ask(String), +} + +fn attach_plan(consent: ownership::Consent, takeover: &resolve::Takeover) -> AttachPlan { + match consent { + ownership::Consent::Held => AttachPlan::Guest( + "a runtime was already listening and this installation already owns it".to_owned(), + ), + ownership::Consent::Refuse => AttachPlan::Guest( + "a runtime was already listening; its recorded owner could not be read, so this app is a guest on it and asked nothing".to_owned(), + ), + ownership::Consent::AskFirstTime | ownership::Consent::AskAgain => match takeover { + resolve::Takeover::Blocked { reason, detail } => AttachPlan::Guest(format!( + "a runtime was already listening, but taking it over is not available ({reason}: {detail}), so this app is a guest on it" + )), + resolve::Takeover::Supported { token, .. } => AttachPlan::Ask(token.clone()), + }, + } +} + +/// Report, bind and finish as a guest on the runtime that answered. +#[allow(clippy::too_many_arguments)] +async fn attach_as_guest( + app: &AppHandle, + started: Instant, + target: &Target, + registration: &Registration, + watch: &SidecarWatch, + proxy: &ProxyClient, + endpoint: ProxyEndpoint, + deadline: Instant, + detail: String, +) { + report(app, started, Phase::Attaching, Some(detail)); + if bind(app, proxy, deadline).await.is_none() { + fail( + app, + started, + Some(target), + registration, + watch, + Phase::Attaching, + "the runtime answered but did not identify itself, so this app did not attach" + .to_owned(), + ); + return; + } + finish(app, started, endpoint); +} + +/// Stop the runtime that answered, wait for its silence, and record this installation as +/// the owner. An `Err` has already been reported; `Ok` means the Starting branch may run. +#[allow(clippy::too_many_arguments)] +async fn take_over( + app: &AppHandle, + started: Instant, + deadline: &mut Instant, + target: &Target, + registration: &Registration, + watch: &SidecarWatch, + proxy: &ProxyClient, + recorded: &ownership::Recorded, + token: &str, +) -> Result<(), ()> { + report( + app, + started, + Phase::TakingOver, + Some("stopping the runtime that was already listening".to_owned()), + ); + let stopped = runtime_stop::run(app, *deadline).await; + if !stopped.is_stopped() { + fail( + app, + started, + Some(target), + registration, + watch, + Phase::TakingOver, + format!( + "the runtime that was already listening could not be stopped: {}", + stopped.describe() + ), + ); + return Err(()); + } + // A reported stop is the receipt, not the silence: the port has to stop answering before + // the claim and the spawn can trust that nothing foreign is still holding it. + while Instant::now() < *deadline { + if !matches!(proxy.alive_within(*deadline).await, Some(Ok(_))) { + break; + } + sleep(POLL).await; + } + if matches!(proxy.alive_within(*deadline).await, Some(Ok(_))) { + fail( + app, + started, + Some(target), + registration, + watch, + Phase::TakingOver, + "the runtime that was already listening is still answering after the stop".to_owned(), + ); + return Err(()); + } + + report( + app, + started, + Phase::TakingOver, + Some("recording this installation as the runtime owner".to_owned()), + ); + let install_id = registration.install_id.clone().unwrap_or_default(); + let argv = claim::args(&install_id, recorded, token); + match claim::run(app, argv, *deadline).await { + claim::ClaimResult::Recorded(ownership) => { + report( + app, + started, + Phase::TakingOver, + Some(format!( + "this installation now owns the runtime (consent generation {})", + ownership.consent_generation + )), + ); + Ok(()) + } + claim::ClaimResult::Failed(message) => { + // The runtime is stopped either way. Refusing here leaves the next launch an + // ordinary absence to start into, which is the acceptable end state. + fail( + app, + started, + Some(target), + registration, + watch, + Phase::TakingOver, + format!( + "the runtime was stopped, but this installation could not be recorded as its owner: {message}" + ), + ); + Err(()) + } + } +} + /// Establish the app's own surface: the tray verdict, the tray, and the login item. /// /// It happens once per process. A retry re-runs the runtime half of the sequence, and running this @@ -723,10 +1043,11 @@ async fn register(app: &AppHandle, deadline: Instant) -> Registration { // This installation's own id, and what the recorded runtime owner says about it. The claim // lives in the shared service install state and the CLI is what reads it; the comparison // against our own id is the rule that record publishes. - let install_id = identity::install_id(app); + // This installation's own id; what the recorded runtime owner says about it is part of the + // resolve answer, so the identity line is written where the answer exists. let registration = Registration { login, - identity: ownership::describe(ownership::resolve(app).as_ref(), install_id.as_deref()), + install_id: identity::install_id(app), }; if let Some(startup) = app.try_state::() { startup.remember_registration(registration.clone()); @@ -890,7 +1211,10 @@ pub fn diagnostic( None => lines.push("endpoint: not resolved".to_owned()), } lines.push(format!("start at login: {}", registration.login.describe())); - lines.push(format!("runtime ownership: {}", registration.identity)); + lines.push(format!( + "installation id: {}", + registration.install_id.as_deref().unwrap_or("not minted") + )); lines.push(match watch.exit() { Some(exit) => format!("runtime process: {}", exit.describe()), None => "runtime process: still running or never started".to_owned(), @@ -925,12 +1249,52 @@ fn elapsed(started: Instant) -> u64 { #[cfg(test)] mod tests { use super::{ - shows_window, unavailable, LaunchOrigin, Phase, Progress, Startup, AUTOSTART_FLAG, - DEADLINE, PHASES, POLL, + attach_plan, shows_window, unavailable, AttachPlan, LaunchOrigin, Phase, Progress, + Startup, AUTOSTART_FLAG, DEADLINE, PHASES, POLL, }; + use crate::ownership::Consent; + use crate::resolve::Takeover; use crate::tray_availability::TrayAvailability; use tokio::time::Duration; + fn supported() -> Takeover { + Takeover::Supported { + protocol_version: 1, + minimum_cli_version: "2.61.0".to_owned(), + token: "tok".to_owned(), + } + } + + fn blocked() -> Takeover { + Takeover::Blocked { + reason: "managing-cli-unsupported".to_owned(), + detail: "path uses 2.59.0".to_owned(), + } + } + + #[test] + fn an_ask_only_arises_when_the_takeover_can_be_taken() { + // Held and Refuse never ask, whatever the CLI reported about compatibility. + assert!(matches!( + attach_plan(Consent::Held, &supported()), + AttachPlan::Guest(_) + )); + assert!(matches!( + attach_plan(Consent::Refuse, &supported()), + AttachPlan::Guest(_) + )); + match attach_plan(Consent::AskFirstTime, &supported()) { + AttachPlan::Ask(token) => assert_eq!(token, "tok"), + AttachPlan::Guest(detail) => panic!("{detail}"), + } + match attach_plan(Consent::AskAgain, &blocked()) { + AttachPlan::Guest(detail) => { + assert!(detail.contains("managing-cli-unsupported: path uses 2.59.0")) + } + AttachPlan::Ask(_) => panic!("a blocked takeover is not an offer"), + } + } + #[test] fn not_having_started_is_not_a_step_of_the_run() { // A checklist row for it would be a step that never completes, and resolving it out of a diff --git a/desktop/ui/index.html b/desktop/ui/index.html index 4a74b191615..15f7ab55ea5 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -18,7 +18,13 @@ #phases li[data-state="done"] { color: #4b8b3b; } #phases li[data-state="failed"] { color: #b3261e; font-weight: 600; } #failure { margin-top: 1.25rem; display: grid; gap: .75rem; } - #failure[hidden] { display: none; } + #failure[hidden], #consent[hidden] { display: none; } + #consent { margin-top: 1.25rem; display: grid; gap: .75rem; } + #consent p { margin: 0; } + #consentTarget { display: grid; grid-template-columns: max-content 1fr; gap: .25rem .75rem; margin: 0; } + #consentTarget dt { color: #666; } + #consentTarget dd { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85rem; } + #consentNote { color: #666; font-size: .85rem; } .actions { display: flex; gap: .6rem; align-items: center; } button { border: 0; border-radius: .5rem; padding: .6rem 1rem; background: #2563eb; color: white; cursor: pointer; font: inherit; } button.secondary { background: #e3e3e8; color: #202124; } @@ -32,6 +38,7 @@ #phases li[data-state="active"] { color: #f5f5f7; } #phases li[data-state="done"] { color: #8bd17c; } #phases li[data-state="failed"] { color: #ff8a80; } + #consentTarget dt, #consentNote { color: #bbb; } button.secondary { background: #3a3a3c; color: #f5f5f7; } textarea { background: #1c1c1e; border-color: #ffffff22; } } @@ -43,6 +50,19 @@

OpenCodex

Starting OpenCodex…

    +