Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7c2debc
fix(agent-hooks): drop hook status whose cwd disproves its pane
kunsanglee Jul 28, 2026
e2488d1
fix(agent-hooks): resolve dot segments before comparing hook cwd
kunsanglee Jul 28, 2026
897e9c3
docs(agent-hooks): document the cwd-attribution test fixture
kunsanglee Jul 28, 2026
683e179
fix(agent-hooks): keep a collapsed drive root readable as Windows not…
kunsanglee Jul 28, 2026
3989d46
refactor(agent-hooks): reuse the shared path layer for the cwd guard
kunsanglee Jul 28, 2026
36cd6e2
fix(agent-hooks): report a mis-attributing daemon once per runtime
kunsanglee Jul 28, 2026
ddbced3
test(agent-hooks): pin the per-runtime telemetry reset
kunsanglee Jul 28, 2026
23d8ab1
Merge upstream/main into fix/agent-hook-pane-attribution-guard
kunsanglee Jul 30, 2026
43c2034
fix(agent-hooks): forward sourceCwd across the SSH relay ingest hop
brennanb2025 Jul 30, 2026
30bf75c
fix(agent-hooks): refuse foreign-cwd hooks before listener state, res…
brennanb2025 Jul 30, 2026
79dc97a
fix(agent-hooks): strip alias-cleared cwd at the relay egress choke p…
brennanb2025 Jul 30, 2026
85058e5
fix(agent-hooks): keep the row when only one side of a cwd contradict…
brennanb2025 Jul 30, 2026
4853bb4
fix(agent-hooks): correct the relay strip invariant comment
brennanb2025 Jul 30, 2026
2f88731
Merge upstream/main into fix/agent-hook-pane-attribution-guard
kunsanglee Aug 2, 2026
1e1e1ab
Merge remote-tracking branch 'upstream/main' into pr11094-conflicts
kunsanglee Aug 2, 2026
c236c97
Merge remote-tracking branch 'upstream/main' into pr11094-conflicts2
kunsanglee Aug 4, 2026
8179a0a
Merge remote-tracking branch 'upstream/main' into pr11094-conflicts2
kunsanglee Aug 11, 2026
fd9fe14
Merge upstream/main into pr11094-conflicts2
kunsanglee Aug 15, 2026
77458d4
Merge upstream/main into fix/agent-hook-pane-attribution-guard
kunsanglee Aug 17, 2026
c44cfea
Merge upstream/main into fix/agent-hook-pane-attribution-guard
kunsanglee Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 239 additions & 0 deletions src/main/agent-hooks/server-cwd-attribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentHookServer } from './server'
import { makePaneKey } from '../../shared/stable-pane-id'

const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() }))

vi.mock('../telemetry/client', () => ({ track: trackMock }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))

const AGENT_PANE = makePaneKey('tab-agent', '11111111-1111-4111-8111-111111111111')
const AGENT_WORKTREE = 'repo-agent::/Users/dev/workspace/agent'

/**
* Hook body reproducing the shared-daemon leak: a session running in one project posts
* the pane identity it inherited from the pane that first spawned the agent daemon.
*/
function buildBody(payload: Record<string, unknown>): Record<string, unknown> {
return {
paneKey: AGENT_PANE,
tabId: 'tab-agent',
worktreeId: AGENT_WORKTREE,
env: 'production',
payload
}
}

/** Relay a hook for the agent pane from a session running somewhere else entirely. */
function ingestForeign(server: AgentHookServer, prompt: string): void {
server.ingestRemote(
{
paneKey: AGENT_PANE,
tabId: 'tab-agent',
worktreeId: AGENT_WORKTREE,
// Why: the relay forwards cwd beside the payload — normalization strips it from the payload itself.
sourceCwd: '/srv/other-project',
payload: { state: 'working', prompt }
},
'conn-1'
)
}

function unattributedCallCount(): number {
return trackMock.mock.calls.filter(([name]) => name === 'agent_hook_unattributed').length
}

beforeEach(() => {
trackMock.mockReset()
})

describe('AgentHookServer cwd attribution guard', () => {
it('drops an HTTP hook whose session cwd belongs to another workspace', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})

await expect(
postHook({
hook_event_name: 'UserPromptSubmit',
prompt: 'own session',
cwd: '/Users/dev/workspace/agent'
})
).resolves.toMatchObject({ status: 204 })
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: AGENT_PANE, prompt: 'own session' })
])

await expect(
postHook({
hook_event_name: 'UserPromptSubmit',
prompt: 'foreign session',
cwd: '/Users/dev/projects/api'
})
).resolves.toMatchObject({ status: 204 })

expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: AGENT_PANE, prompt: 'own session' })
])
expect(trackMock).toHaveBeenCalledWith('agent_hook_unattributed', {
reason: 'cwd_worktree_mismatch'
})
} finally {
server.stop()
}
})

it('drops a relayed hook whose session cwd belongs to another workspace', () => {
const server = new AgentHookServer()
ingestForeign(server, 'foreign session')

expect(server.getStatusSnapshot()).toEqual([])
expect(trackMock).toHaveBeenCalledWith('agent_hook_unattributed', {
reason: 'cwd_worktree_mismatch'
})
})

it('reports a mis-attributing daemon once per runtime', () => {
// Why: the telemetry per-session ceiling never refills, so a daemon that mis-attributes
// every hook it hosts would otherwise silence every other event in the session.
const server = new AgentHookServer()
for (const prompt of ['first', 'second', 'third']) {
ingestForeign(server, prompt)
}

expect(server.getStatusSnapshot()).toEqual([])
expect(unattributedCallCount()).toBe(1)
})

it('reports again after a restart, so one runtime does not silence the next', () => {
const server = new AgentHookServer()
ingestForeign(server, 'before restart')
server.stop()
ingestForeign(server, 'after restart')

expect(unattributedCallCount()).toBe(2)
})

it('refuses a foreign hook before it can seed the pane subagent roster', async () => {
// Why: normalization mutates per-pane listener state; if the drop happens after it, a
// foreign SubagentStart still plants a roster row that the pane's own next event re-emits.
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})

await expect(
postHook({
hook_event_name: 'SubagentStart',
agent_id: 'sa-foreign',
cwd: '/Users/dev/projects/api'
})
).resolves.toMatchObject({ status: 204 })
await expect(
postHook({
hook_event_name: 'UserPromptSubmit',
prompt: 'own session',
cwd: '/Users/dev/workspace/agent'
})
).resolves.toMatchObject({ status: 204 })

const rows = server.getStatusSnapshot()
expect(rows).toEqual([
expect.objectContaining({ paneKey: AGENT_PANE, prompt: 'own session' })
])
expect(rows[0]?.subagents ?? []).toEqual([])
} finally {
server.stop()
}
})

it('keeps a status whose cwd is only a symlink alias of the worktree path', async () => {
// Why: Orca stores the workspace path as picked while agents report physical getcwd
// (macOS /tmp is /private/tmp); one directory spelled two ways is not a foreign session.
const base = mkdtempSync(join(tmpdir(), 'orca-hook-cwd-'))
const real = join(base, 'real-workspace')
mkdirSync(real, { recursive: true })
const link = join(base, 'linked-workspace')
try {
symlinkSync(real, link, process.platform === 'win32' ? 'junction' : 'dir')
} catch {
rmSync(base, { recursive: true, force: true })
return // Restricted hosts that cannot create links have nothing to verify here.
}
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const res = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify({
paneKey: AGENT_PANE,
tabId: 'tab-agent',
worktreeId: `repo-agent::${link}`,
env: 'production',
payload: {
hook_event_name: 'UserPromptSubmit',
prompt: 'aliased session',
cwd: realpathSync(real)
}
})
})
expect(res.status).toBe(204)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: AGENT_PANE, prompt: 'aliased session' })
])
expect(trackMock).not.toHaveBeenCalledWith('agent_hook_unattributed', {
reason: 'cwd_worktree_mismatch'
})
} finally {
server.stop()
rmSync(base, { recursive: true, force: true })
}
})

it('keeps hooks that report no cwd, so sources without one stay attributed', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: AGENT_PANE,
tabId: 'tab-agent',
worktreeId: AGENT_WORKTREE,
payload: { state: 'working', prompt: 'no cwd reported' }
},
'conn-1'
)

expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: AGENT_PANE, prompt: 'no cwd reported' })
])
expect(trackMock).not.toHaveBeenCalledWith('agent_hook_unattributed', {
reason: 'cwd_worktree_mismatch'
})
})
})
8 changes: 6 additions & 2 deletions src/main/agent-hooks/server-last-status-write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ describe('Last-status persistence', () => {
}
})

it('does not write prompt interaction keys to last-status.json', async () => {
it('does not write transport-only fields to last-status.json', async () => {
const server = new AgentHookServer()
await server.start({
env: 'production',
Expand All @@ -301,14 +301,18 @@ describe('Last-status persistence', () => {
hook_event_name: 'MessagePart',
role: 'user',
text: 'persist status only',
messageID: 'opencode-local-message-id'
messageID: 'opencode-local-message-id',
cwd: '/srv/session'
}),
'/hook/opencode'
)
server.flushStatusPersistSync()
const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8'))
expect(file.entries[PANE].payload.prompt).toBe('persist status only')
expect(file.entries[PANE].promptInteractionKey).toBeUndefined()
// Why: hydrate's whitelist drops both, so persisting them leaves the write dedupe
// comparing against bytes hydration can never reproduce.
expect(file.entries[PANE].sourceCwd).toBeUndefined()
} finally {
server.stop()
}
Expand Down
Loading
Loading