mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 05:06:37 +00:00
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:
parent
71ba5a8106
commit
e68ac52413
7 changed files with 93 additions and 31 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue