fix(automation): remove the active-conversation targeting requirement (#746)

## Summary

Remove the requirement that the invoking agent's conversation must
already be active before CodeNomad UI automation can inspect or navigate
the application. That restriction prevented agents from using the UI to
return to their own conversation after a session or project switch.

- Target the inspected native window/run independently of the selected
conversation or project, including views with no active session.
- Keep the actual selected context in inspection results as descriptive
metadata.
- Pin actions to the inspected native window/run. Keep accessibility
references document-scoped: page replacement, reload and native target
changes still require fresh inspection, while conversation selection
alone does not.
- Keep authenticated backend discovery and browser-preview session
attachments intact.
- Document the behavior and add CDP/route regressions for navigation,
empty views and native target changes.

## Validation

- 27 targeted CDP, automation route and plugin tests passed.
- `npx tsc -p packages/server/tsconfig.json` and `git diff --check`
passed.
- Compiled shared server modules installed in the usual Windows Tauri
installation.
- The actual native automation bridge navigated **CodeNomad → opencode →
gaffer → openchamber → CodeNomad** from the same invoking session,
successfully inspecting each different active conversation and returning
to the original conversation.
- Native restart/reconnection, final inspection and screenshot
succeeded.

The implementation is shared by Electron and Tauri; no host-specific
code changes are needed. Installed desktop validation was performed on
Windows Tauri.

Maintenance note: the touched `packages/server/src/developer-cdp.ts` is
approximately 564 lines; this PR does not refactor it solely for length.
This commit is contained in:
Pascal André 2026-09-22 10:15:37 +02:00 • committed by GitHub
parent 71ba5a8106
commit e68ac52413
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 93 additions and 31 deletions

View file

@ -36,6 +36,7 @@
- Profile environment variables are applied server-side before each native session prompt, custom command or session shell request, after ownership and worktree-mutation admission. Build a complete execution-host snapshot with `workspaces/session-environment.ts`; never send the profile environment through the browser or skip the per-send write using a cache. Reads and settings edits do not mutate native sessions. Keep native environment failures fail-closed and redact SDK request bodies. See `dev-docs/SESSION_ENVIRONMENT.md`.
- One bundled `codenomad.automation` V2 plugin uses native discovery and backend presence. All browser/developer tools are available without a Developer Mode toggle; native instrumentation starts with the desktop host. Loading, tool availability and execution targeting are separate: retain the authenticated bridge and session/window fences. Tauri preview children receive no application capabilities; primary-renderer reload must dispose them and final-window checks must count native windows. See `dev-docs/BROWSER_AUTOMATION.md`.
- Developer UI tools target the inspected native window/run, not the currently selected project or conversation. Keep backend ownership discovery separate from UI focus; agents must be able to inspect and navigate back from another session themselves. Accessibility refs expire on document/target replacement, not merely on session selection. Browser previews retain their own session attachments.
- Desktop automation and pruning provisioning follows the authenticated OpenCode connection and its `config.get` global discovery directory, including reconnects. Never derive a running daemon's roots from backend/startup environment or CLI `debug paths`. OpenCode watches the entire discovery root recursively: keep changing presence leases outside it, in the sibling `.codenomad/<root-hash>/` namespace. Retain existing outside-root storage; migrate inside-root storage while reading older backends' leases without writing there. WSL translates the daemon-reported paths through the selected distro only for filesystem access. Verify heartbeat stability with the isolated native automation fixture.

View file

@ -10,6 +10,7 @@ Browser previews are an ordinary desktop feature. They must work in user project
- Provisioning follows the authenticated connection, including reconnects. `config.get` supplies the first (global) discovery directory; neither the backend environment nor CLI `debug paths` identifies an existing daemon's roots. OpenCode recursively watches that whole root: a lease mtime update beneath it reloads every loaded location. New bundles and leases live in the sibling `.codenomad/<SHA-256 of discovery root>/` namespace. Existing outside-root storage remains valid; inside-root storage migrates and the plugin reads both old and new presence directories so older backends remain represented. Only the new directory receives new heartbeats. WSL translates native paths to the selected distro's UNC path only for filesystem access. Missing authoritative discovery information is reported rather than replaced with a guessed directory.
- The plugin exposes `browser`, `inspect`, `act`, and `screenshot` while a desktop backend is present. Tool availability is separate from plugin loading; there is no Developer Mode access gate.
- Browser and developer calls share the backend's loopback registration and private native transport. Native instrumentation is prepared at normal startup, so tool access does not require a toggle or an additional restart. Execution still verifies the selected backend, supported native capabilities and invoking session.
- Developer inspection targets the native window, independently of its selected project/conversation (including empty views). The invoking session still identifies an owning backend; it need not be visible. Actions retain the inspected window/run and document-scoped accessibility references, so agents can navigate back themselves. Session/project selection changes do not invalidate window automation. Reloads, replaced pages and changed native targets require fresh inspection. Browser-preview tools retain their separate per-session attachment rules.
- `packages/server/src/server/routes/automation-plugin.ts` serves the existing `/api/opencode-plugin/automation` endpoint with the existing per-process token.
- `browser-claim` verifies that exactly one CodeNomad instance owns the session before an `open` action asks the UI to create a preview.
- `browser-probe` also requires a visible native preview registered to that session.

View file

@ -0,0 +1,26 @@
import type { DeveloperCdpIdentity } from "../developer-cdp"
/** Pin the inspected native window/run, independently of the selected project
* or conversation. A new native target requires a fresh inspection. */
export class DeveloperInspectionTargets {
private readonly targets = new Map<string, DeveloperCdpIdentity>()
remember(sessionId: string, identity: DeveloperCdpIdentity): void {
this.targets.delete(sessionId)
this.targets.set(sessionId, identity)
if (this.targets.size > 128) this.targets.delete(this.targets.keys().next().value!)
}
get(sessionId: string, native: { runId: string; windowId: string; endpoint: string }): DeveloperCdpIdentity {
const target = this.targets.get(sessionId)
if (!target || target.runId !== native.runId || target.windowId !== native.windowId || target.endpoint !== native.endpoint) {
this.targets.delete(sessionId)
throw new Error("Run codenomad.inspect before acting on or restarting this CodeNomad window")
}
return target
}
forget(sessionId: string): void {
this.targets.delete(sessionId)
}
}

View file

@ -106,6 +106,22 @@ const identity = {
}
describe("DeveloperCdp", () => {
it("keeps window-scoped tools usable across session/project navigation and empty views", async () => {
const chrome = new FakeChrome()
const client = chrome.client()
const window = { endpoint: identity.endpoint, runId: identity.runId, windowId: identity.windowId }
try {
const first = await client.inspect(window)
chrome.context = { ...chrome.context, sessionId: "another-session", instanceId: "another-project" }
await client.act({ ...window, kind: "click", ref: first.nodes[0].ref! })
assert.equal((await client.inspect(window)).context.sessionId, "another-session")
chrome.context = { windowId: "window-1", sessionId: null, instanceId: null } as never
assert.equal((await client.inspect(window)).context.sessionId, null)
chrome.context = { ...chrome.context, windowId: "another-window" }
await assert.rejects(client.screenshot(window), /window or active session changed/)
} finally { client.close() }
})
it("matches out-of-order protocol responses and drains runtime diagnostics on inspect", async () => {
const chrome = new FakeChrome()
const client = chrome.client()

View file

@ -23,17 +23,17 @@ export interface DeveloperCdpSelection {
endpoint: string
runId: string
windowId: string
sessionId: string
sessionId?: string
}
export interface DeveloperCdpIdentity extends DeveloperCdpSelection {
instanceId: string
instanceId?: string
}
export interface DeveloperCdpContext {
windowId: string
instanceId: string
sessionId: string
instanceId: string | null
sessionId: string | null
}
export interface DeveloperCdpNode {
@ -108,7 +108,7 @@ interface PendingCommand {
interface RunState {
endpoint: string
windowId: string
sessionId: string
sessionId?: string
instanceId?: string
context?: DeveloperCdpContext
target?: CdpTarget
@ -242,6 +242,7 @@ export class DeveloperCdp {
return this.exclusive(selection.runId, async () => (await this.ensure(selection)).context!)
}
private async ensure(identity: DeveloperCdpSelection | DeveloperCdpIdentity): Promise<RunState> {
if (!identity.runId.trim()) throw new Error("runId is required")
const endpoint = discoveryUrl(identity.endpoint)
@ -260,7 +261,7 @@ export class DeveloperCdp {
return this.ensureTarget(state)
}
private newState(endpoint: string, windowId: string, sessionId: string, instanceId?: string): RunState {
private newState(endpoint: string, windowId: string, sessionId?: string, instanceId?: string): RunState {
return {
endpoint,
windowId,
@ -340,15 +341,16 @@ export class DeveloperCdp {
returnByValue: true,
})
const value = (response.result as { value?: unknown } | undefined)?.value as Partial<DeveloperCdpContext> | undefined
if (!value || typeof value.windowId !== "string" || typeof value.instanceId !== "string" || typeof value.sessionId !== "string"
|| value.windowId.length > 256 || value.instanceId.length > 256 || value.sessionId.length > 256) {
throw new Error("CodeNomad page has no active session context")
const optionalId = (id: unknown) => id === null || (typeof id === "string" && id.length <= 256)
if (!value || typeof value.windowId !== "string" || value.windowId.length > 256
|| !optionalId(value.instanceId) || !optionalId(value.sessionId)) {
throw new Error("CodeNomad page has no valid window context")
}
return { windowId: value.windowId, instanceId: value.instanceId, sessionId: value.sessionId }
return { windowId: value.windowId, instanceId: value.instanceId!, sessionId: value.sessionId! }
}
private matchesContext(state: RunState, context: DeveloperCdpContext): boolean {
return context.windowId === state.windowId && context.sessionId === state.sessionId
return context.windowId === state.windowId && (state.sessionId === undefined || context.sessionId === state.sessionId)
&& (state.instanceId === undefined || context.instanceId === state.instanceId)
}

View file

@ -4,7 +4,7 @@ import Fastify from "fastify"
import { AUTOMATION_BRIDGE_PATH } from "../../opencode/automation-plugin"
import { registerAutomationPluginRoute } from "./automation-plugin"
test("fences Developer Mode by the visible owned session and forwards CDP actions", async () => {
test("targets the inspected window independently of the visible conversation", async () => {
const app = Fastify({ logger: false })
const nativeCalls: Array<{ method: string; params: unknown }> = []
let state = "ready"
@ -34,8 +34,8 @@ test("fences Developer Mode by the visible owned session and forwards CDP action
},
},
developerCdp: {
context: async (identity: { sessionId: string }) => {
if (identity.sessionId !== visibleSession) throw new Error("active session mismatch")
context: async (identity: { sessionId?: string }) => {
assert.equal(identity.sessionId, undefined)
return { windowId: "window-1", instanceId: "workspace-1", sessionId: visibleSession }
},
inspect: async (identity: unknown) => {
@ -48,6 +48,10 @@ test("fences Developer Mode by the visible owned session and forwards CDP action
}
},
close: () => undefined,
act: async (identity: { sessionId?: string; instanceId?: string }) => {
assert.equal(identity.sessionId, undefined)
assert.equal(identity.instanceId, undefined)
},
},
workspaceManager: {
getSharedServiceClient: async () => ({ session: { get: async () => ({ location: { directory: "D:\\project" } }) } }),
@ -70,18 +74,20 @@ test("fences Developer Mode by the visible owned session and forwards CDP action
endpoint: "http://127.0.0.1:9222",
runId: "run-1",
windowId: "window-1",
sessionId: "session-1",
instanceId: "workspace-1",
})
assert.equal(inspect.json().result.context.sessionId, "session-1")
visibleSession = "session-2"
assert.equal((await request({ mode: "developer-execute", sessionID: "session-1", command: { action: "click", ref: "ax1" } })).statusCode, 200)
const restart = await request({ mode: "developer-execute", sessionID: "session-1", command: { action: "restart" } })
assert.equal(restart.statusCode, 200)
assert.deepEqual(nativeCalls.slice(-2), [
{ method: "developer.status", params: {} },
{ method: "developer.restart", params: {} },
])
assert.equal((await request({ mode: "developer-probe", sessionID: "session-2" })).statusCode, 404)
assert.equal((await request({ mode: "developer-probe", sessionID: "session-2" })).statusCode, 200)
assert.equal((await request({ mode: "developer-execute", sessionID: "session-1", command: { action: "restart" } })).statusCode, 404)
state = "stopped"
assert.equal((await request({ mode: "developer-probe", sessionID: "session-2" })).statusCode, 404)

View file

@ -6,6 +6,7 @@ import type { DeveloperCdpIdentity, DeveloperCdpSelection } from "../../develope
import type { NativeParent } from "../../native-parent"
import { AUTOMATION_BRIDGE_PATH, parseBrowserAction, parseDeveloperAction } from "../../opencode/automation-plugin"
import type { WorkspaceManager } from "../../workspaces/manager"
import { DeveloperInspectionTargets } from "../../automation/developer-inspection-targets"
interface AutomationPluginRouteDeps {
authManager: AuthManager
@ -40,6 +41,7 @@ export function isAutomationPluginRequest(
}
export function registerAutomationPluginRoute(app: FastifyInstance, deps: AutomationPluginRouteDeps): void {
const inspectedTargets = new DeveloperInspectionTargets()
app.post(AUTOMATION_BRIDGE_PATH, { bodyLimit: 32 * 1024 }, async (request, reply) => {
if (!isAutomationPluginRequest(request, deps)) return reply.code(401).send({ error: "Unauthorized automation bridge" })
const body = request.body as { mode?: unknown; sessionID?: unknown; command?: unknown } | undefined
@ -101,36 +103,44 @@ export function registerAutomationPluginRoute(app: FastifyInstance, deps: Automa
endpoint: status.cdpUrl!,
runId: status.runId!,
windowId: status.windowId!,
sessionId: body.sessionID,
}
let context
let command
if (body.mode !== "developer-probe") {
try {
command = parseDeveloperAction(body.command)
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) })
}
}
let identity: DeveloperCdpIdentity | undefined
try {
context = await deps.developerCdp.context(selection)
if (body.mode === "developer-probe" || command?.action === "inspect") {
await deps.developerCdp.context(selection)
} else {
identity = inspectedTargets.get(body.sessionID, selection)
await deps.developerCdp.context(identity)
}
} catch (error) {
if (body.mode !== "developer-probe") inspectedTargets.forget(body.sessionID)
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) })
}
if (!await deps.workspaceManager.ownsLocation(context.instanceId, location)) {
return reply.code(404).send({ error: "The visible CodeNomad workspace does not own this OpenCode session" })
}
const identity: DeveloperCdpIdentity = { ...selection, instanceId: context.instanceId }
identity ??= selection
if (body.mode === "developer-probe") {
return reply.send({ result: { available: true, nativeIdentity: status.nativeIdentity, runId: status.runId } })
}
let command
try {
command = parseDeveloperAction(body.command)
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) })
}
if (!command) return reply.code(400).send({ error: "Missing developer command" })
try {
if (command.action === "restart") {
inspectedTargets.forget(body.sessionID)
const result = await deps.nativeParent.request<DeveloperNativeStatus["status"]>("developer.restart", {})
deps.developerCdp.close(status.runId)
return reply.send({ result })
}
if (command.action === "inspect") {
return reply.send({ result: await deps.developerCdp.inspect(identity) })
const result = await deps.developerCdp.inspect(identity)
inspectedTargets.remember(body.sessionID, identity)
return reply.send({ result })
}
if (command.action === "screenshot") {
const image = await deps.developerCdp.screenshot(identity)