test(cua): cover consent independently of permission-page controls
This commit is contained in:
1 parent
15332e42e6
commit
40047a4577
4 files changed
+69
-7
No files matched your search
@@ -192,7 +192,7 @@ export class ApplicationAccess {
|
||||
rejected.set(identity.id, { id: identity.id, name: identity.name, executablePath: identity.executablePath })
|
||||
this.rejected.set(agent, rejected)
|
||||
}
|
||||
private invalidate(id: CuaApplicationId, agent?: Agent): NativeClient[] {
|
||||
private invalidate(id: CuaApplicationId, agent?: Agent): void {
|
||||
const clients = new Set<NativeClient>()
|
||||
for (const [client, state] of this.clients) for (const selection of state.handles.values()) if (selection.identity.id === id
|
||||
&& (agent === undefined || state.agent === agent)) {
|
||||
@@ -206,7 +206,6 @@ export class ApplicationAccess {
|
||||
clients.add(operation.client)
|
||||
}
|
||||
for (const client of clients) client.invalidate()
|
||||
return [...clients]
|
||||
}
|
||||
private reconcile(): void {
|
||||
for (const state of this.clients.values()) for (const selection of state.handles.values()) {
|
||||
|
||||
@@ -246,6 +246,7 @@ it('cancels native work when saved consent is removed from configuration', async
|
||||
await entered.promise
|
||||
await f.save([])
|
||||
f.ctx.emit('loader/volatile-update', [['allowedApps']])
|
||||
f.ctx.emit('loader/volatile-update', [['allowedApps']])
|
||||
await rejected
|
||||
expect(f.client.disposed).toBe(true)
|
||||
await expect(f.access.run(f.client, handle, signal, async () => null)).rejects.toThrow('APP_ACCESS_REVOKED')
|
||||
@@ -257,3 +258,60 @@ it('cancels native work when saved consent is removed from configuration', async
|
||||
f.access.forget(f.agent)
|
||||
await expect(f.access.run(replacement, handle, signal, async () => null)).rejects.toThrow('APP_ACCESS_REQUIRED')
|
||||
})
|
||||
|
||||
it('fails closed without a human answerer and never offers persistence without its owner', async () => {
|
||||
const f = await setup('Always allow', { writable: false, questions: false })
|
||||
await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('APP_APPROVAL_UNAVAILABLE')
|
||||
await f.ctx.plugin(UserQuestions)
|
||||
await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED')
|
||||
expect(f.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an answer for a helper that exits while the human question is pending', async () => {
|
||||
const f = await setup()
|
||||
const answer = Promise.withResolvers<AskUserQuestionAnswer>()
|
||||
const entered: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
onTestFinished(() => { answer.resolve({ answers: [{ id: 'cua-app-access', selected: ['Deny'] }] }) })
|
||||
f.answer.mockImplementationOnce(() => { entered.resolve(); return answer.promise })
|
||||
const selected = f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
const rejected = expect(selected).rejects.toThrow('NATIVE_CLOSED')
|
||||
await entered.promise
|
||||
f.client.invalidate()
|
||||
answer.resolve({ answers: [{ id: 'cua-app-access', selected: ['Allow for this task'] }] })
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('refuses closed helpers and forgets one task without removing another task', async () => {
|
||||
const f = await setup()
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
const owner = await f.ctx.agents.create({ sessionId: SessionId('other-consented-task') })
|
||||
const other = new NativeClient(f.ctx, nativeConfig)
|
||||
await f.access.select(owner.agent, other, handle, identity, signal)
|
||||
f.ctx.emit('loader/volatile-update', [['allowedApps']])
|
||||
f.access.forget(f.agent)
|
||||
await expect(f.access.run(other, handle, signal, async () => 'kept')).resolves.toBe('kept')
|
||||
other.invalidate()
|
||||
await expect(f.access.run(other, handle, signal, async () => null)).rejects.toThrow('NATIVE_CLOSED')
|
||||
await expect(f.access.select(owner.agent, other, handle, identity, signal)).rejects.toThrow('NATIVE_CLOSED')
|
||||
})
|
||||
|
||||
it('keeps an active task operation running when a different task denies the same application', async () => {
|
||||
const f = await setup()
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
const entered = Promise.withResolvers<AbortSignal>()
|
||||
const release = Promise.withResolvers<null>()
|
||||
const pending = f.access.run(f.client, handle, signal, async (operationSignal) => {
|
||||
entered.resolve(operationSignal)
|
||||
return release.promise
|
||||
})
|
||||
try {
|
||||
const operationSignal = await entered.promise
|
||||
const owner = await f.ctx.agents.create({ sessionId: SessionId('denying-task') })
|
||||
const other = new NativeClient(f.ctx, nativeConfig)
|
||||
f.answer.mockResolvedValueOnce({ answers: [{ id: 'cua-app-access', selected: ['Deny'] }] })
|
||||
await expect(f.access.select(owner.agent, other, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED')
|
||||
expect(operationSignal.aborted).toBe(false)
|
||||
release.resolve(null)
|
||||
await expect(pending).resolves.toBeNull()
|
||||
} finally { release.resolve(null); await pending }
|
||||
})
|
||||
@@ -87,7 +87,7 @@ async function selectApplication(f: Awaited<ReturnType<typeof setup>>) {
|
||||
await f.ctx.plugin(UserQuestions)
|
||||
f.ctx.on('user-questions/request', async () => ({ answers: [{ id: 'cua-app-access', selected: ['Allow for this task'] }] }))
|
||||
native.request.mockResolvedValueOnce({ appHandle: 'app-one', pid: 17, name: 'Fixture',
|
||||
authorization: { id: 'a'.repeat(64), name: 'Fixture', executablePath: '/fixture/app', persistable: false } })
|
||||
authorization: { id: 'a'.repeat(64), name: 'Fixture', executablePath: '/fixture/app', persistable: true } })
|
||||
expect((await f.execute('getApp', { selector: 'Fixture' })).isError).toBe(false)
|
||||
}
|
||||
|
||||
@@ -473,13 +473,18 @@ it('stores signed application consent in the current profile and refuses unavail
|
||||
await f.ctx.plugin(ConfigEditor)
|
||||
await mountRootInclude(f.ctx, join(dir, 'cordis.yml'), readProfilePatches('test', profile))
|
||||
await f.ctx.loader.await()
|
||||
await f.ctx.plugin(UserQuestions)
|
||||
const answer = vi.fn(async () => ({ answers: [{ id: 'cua-app-access', selected: ['Allow for this task'] }] }))
|
||||
f.ctx.on('user-questions/request', answer)
|
||||
native.request.mockResolvedValue({ appHandle: 'task-app', pid: 17, name: 'Fixture',
|
||||
authorization: { id: 'a'.repeat(64), name: 'Fixture', executablePath: '/fixture/app', persistable: true } })
|
||||
expect((await f.execute('getApp', { selector: 'Fixture' })).isError).toBe(false)
|
||||
await f.ctx.plugin(Settings)
|
||||
await f.ctx.plugin(Timer)
|
||||
await f.ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
await f.ctx.hmr.runExclusive(async () => {})
|
||||
expect(f.ctx.settings.describe().find(row => row.ns === 'cua')?.autoGenerate).toBe(false)
|
||||
await f.ctx.plugin(UserQuestions)
|
||||
f.ctx.on('user-questions/request', async () => ({ answers: [{ id: 'cua-app-access', selected: ['Always allow'] }] }))
|
||||
answer.mockResolvedValue({ answers: [{ id: 'cua-app-access', selected: ['Always allow'] }] })
|
||||
const authorization = { id: 'a'.repeat(64), name: 'Fixture', executablePath: '/fixture/app', persistable: true }
|
||||
native.request.mockResolvedValue({ appHandle: 'signed-app', pid: 17, name: 'Fixture', authorization })
|
||||
expect((await f.execute('getApp', { selector: 'Fixture' })).isError).toBe(false)
|
||||
|
||||
@@ -29,7 +29,7 @@ Use the workflow tool ONLY when the user explicitly asks for a workflow or for l
|
||||
|
||||
Start independent subagent delegations together in one assistant message and continue useful work while they run.
|
||||
|
||||
Use cua with method getCapabilities and params {} to inspect permissions and sessionLocked. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI attempts and ask the user to unlock macOS. A null sessionLocked means no usable console session was verified. After unlocking, read fresh state; screenshots from a locked desktop are not current observations. Use listApps and getApp to select an already running application. Keep the returned appHandle; every app operation requires it. listWindows returns window IDs and point bounds. getState returns a complete AX tree and snapshotId. Use its element indices only with that snapshotId. Select a windowId explicitly for screenshots and coordinate actions. Coordinates are window-local macOS points, which may differ from screenshot pixels. A verified window can expose no AX elements. Its empty snapshot permits screenshots and coordinate input; element-index actions require an element from a fresh AX observation.
|
||||
Use cua with method getCapabilities and params {} to inspect permissions and sessionLocked. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI attempts and ask the user to unlock macOS. A null sessionLocked means no usable console session was verified. After unlocking, read fresh state; screenshots from a locked desktop are not current observations. Use listApps and getApp to select an already running application. getApp requests user consent before application observations or input. If access is denied or revoked, stop using that application until the user explicitly grants access again. Never switch tools to bypass a refusal. Keep the returned appHandle; every app operation requires it. listWindows returns window IDs and point bounds. getState returns a complete AX tree and snapshotId. Use its element indices only with that snapshotId. Select a windowId explicitly for screenshots and coordinate actions. Coordinates are window-local macOS points, which may differ from screenshot pixels. A verified window can expose no AX elements. Its empty snapshot permits screenshots and coordinate input; element-index actions require an element from a fresh AX observation.
|
||||
|
||||
Initialize the object API once in persistent PTC. Reuse its variables in later calls; put temporary declarations inside { ... } blocks to avoid redeclaration errors. All object methods are async. Await getWindow before using the returned window object. getAXState returns text, not a structured state object.
|
||||
|
||||
@@ -57,6 +57,6 @@ App and Window methods:
|
||||
|
||||
Raw operation tools use { method, params }, including params: {} for parameterless methods. getApp selects without activating the app. Keep applications in the background for observation and routine input. Do not activate or raise an app during initialization, before each action, or merely because another app is foreground. Prefer advertised AX actions. If a hidden window, another Space or Stage Manager prevents usable observation, explain the foreground-dependent step before app.activate() or an advertised raise action, then read fresh state. Avoid repeated activation when the user switches away; continue background-capable steps and report what requires foreground access. Applications may still change focus in response to input.
|
||||
|
||||
Await operations sequentially. After input, read fresh state to verify the effect. A successful action means dispatched, not verified completion. Cancellation may leave partial effects; never retry uncertain input without observing. STALE_APP requires getApp again; STALE_SNAPSHOT requires getState again. WINDOW_CHANGED requires fresh state before any retry; use an advertised AX action or explicitly restore the window before screenshot or coordinate input, then observe again. PERMISSION_DENIED requires the user to grant macOS Accessibility or Screen Recording permission.
|
||||
Await operations sequentially. After input, read fresh state to verify the effect. A successful action means dispatched, not verified completion. Cancellation may leave partial effects; never retry uncertain input without observing. STALE_APP requires getApp again; STALE_SNAPSHOT requires getState again. WINDOW_CHANGED requires fresh state before any retry; use an advertised AX action or explicitly restore the window before screenshot or coordinate input, then observe again. PERMISSION_DENIED requires the user to grant macOS Accessibility or Screen Recording access in System Settings. After granting access, observe fresh state before continuing; do not automatically replay prior input.
|
||||
|
||||
Window objects expose observation and pointer/AX actions; keyboard input remains app-scoped and follows current focus. Scroll deltas are integer lines from -10 to 10; positive values reveal content above or left. Paste inserts plain text using native Unicode input without changing the clipboard; rich-text formats are unavailable. Browser applications can be controlled with getApp like other native apps; browser tabs and app launching are unavailable. The execution host must install the object API and own variable persistence; this plugin does not preload a cua global. In persistent PTC, use the host-provided initialization snippet below once. Its installation-owned file URL works independently of the project directory. Retain app objects between calls. End screenshot calls with void await app.getScreenshot() or void await app.getAXStateAndScreenshot() so image bytes are not serialized as the program completion value.
|
||||
Reference in new issue
Block a user