Skip to content

Commit 4d64164

Browse files
Expose bounded native queue and Computer Use custody metadata
Read-only, fail-closed custody inspection through the existing app-server client.
1 parent ae4198c commit 4d64164

9 files changed

Lines changed: 256 additions & 18 deletions

File tree

‎server/routes/agent.global-custody.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { approvalManager } from '../approvalManager';
99
const app = express();
1010
app.use('/api/agent', agentRouter);
1111
const url = '/api/agent/custody/global';
12+
const nativeUrl = '/api/agent/custody/native-global';
1213
const previousToken = process.env.WORKSTATION_WAKE_TOKEN;
1314
afterEach(() => {
1415
if (previousToken === undefined) delete process.env.WORKSTATION_WAKE_TOKEN;
@@ -17,6 +18,15 @@ afterEach(() => {
1718
});
1819

1920
describe('global custody inventory', () => {
21+
test('native and CUA inventory requires the same local bearer and rejects foreign origins', async () => {
22+
process.env.WORKSTATION_WAKE_TOKEN = 'private-test-token';
23+
expect((await request(app).get(nativeUrl)).status).toBe(401);
24+
expect((await request(app).get(nativeUrl).set('Authorization', 'Bearer wrong')).status).toBe(401);
25+
expect((await request(app).get(nativeUrl).set('Authorization', 'Bearer private-test-token')
26+
.set('Origin', 'https://elsewhere.example')).status).toBe(401);
27+
delete process.env.WORKSTATION_WAKE_TOKEN;
28+
expect((await request(app).get(nativeUrl).set('Authorization', 'Bearer private-test-token')).status).toBe(401);
29+
});
2030
test('fails closed without the configured bearer or from a foreign origin', async () => {
2131
process.env.WORKSTATION_WAKE_TOKEN = 'private-test-token';
2232
expect((await request(app).get(url)).status).toBe(401);

‎server/routes/agent.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getCustomInstructions } from '../configStore';
88
import { getServerJWT } from '../lib/jwtStore';
99
import { AgentModelConfig } from '../../shared/types/model';
1010
import { messageQueueStore } from '../utils/messageQueueStore';
11+
import { readCuaProcessLockCustody } from '../utils/cuaProcessLockMetadata';
1112
import { cleanupACPProvider } from '../utils/acpProvider';
1213
import { getCurrentWorkspace, requireExistingWorkspacePath } from '../utils/workspace';
1314
import { setCurrentTurnMessageId } from '../utils/turnMessageIdRegistry';
@@ -153,6 +154,18 @@ router.get('/custody/global', (req: Request, res: Response) => {
153154
}
154155
});
155156

157+
router.get('/custody/native-global', async (req: Request, res: Response) => {
158+
if (!wakeMutationAllowed(req) ||
159+
!wakeTokenValid(process.env.WORKSTATION_WAKE_TOKEN, req.header('authorization')?.replace(/^Bearer /i, ''))) {
160+
return res.status(401).json({ error: 'Authentication required.' });
161+
}
162+
try {
163+
return res.json({ native: await getCodexService().readGlobalNativeCustody(), cua: readCuaProcessLockCustody() });
164+
} catch {
165+
return res.status(503).json({ error: 'Native or Computer Use custody inspection unavailable.' });
166+
}
167+
});
168+
156169
// An exact opaque ID is required. The thread must match its captured owner;
157170
// a CLI request with no thread binding needs an explicit unbound query instead.
158171
router.get('/custody/approvals/:approvalId', (req: Request, res: Response) => {

‎server/tools/builtin-tools/cua-driver/tools.ts‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { BuiltinToolContext, BuiltinToolDefinition } from '../../builtinToo
1818
import type { ToolCallResponse } from '../../toolTypes';
1919
import { callWindowsCuaDriverTool } from './windowsUia';
2020
import type { BrowserControlStatus } from '../../../../shared/types/browserControl';
21+
import { CUA_PROCESS_LOCK_DIR, cuaLockOwnerForNewCall } from '../../../utils/cuaProcessLockMetadata';
2122
import {
2223
resolveCuaAccessPolicyMode,
2324
type CuaAccessPermissionKind,
@@ -51,7 +52,7 @@ const MAC_CUA_DRIVER_DAEMON_STARTUP_TIMEOUT_MS = 20_000;
5152
const MAC_CUA_DRIVER_TOOL_TIMEOUT_MS = 60_000;
5253
const MAC_CUA_DRIVER_ACTIVITY_TIMEOUT_MS = 5_000;
5354
const MAC_CUA_DRIVER_ACTIVITY_MAX_BUFFER = 10 * 1024 * 1024;
54-
const MAC_CUA_DRIVER_CALL_LOCK_DIR = path.join(os.tmpdir(), 'interpreter-desktop-driver-call.lock');
55+
const MAC_CUA_DRIVER_CALL_LOCK_DIR = CUA_PROCESS_LOCK_DIR;
5556
const MAC_CUA_DRIVER_CALL_LOCK_STALE_MS = 3 * MAC_CUA_DRIVER_TOOL_TIMEOUT_MS;
5657
const MAC_SELECT_OPTION_MENU_WAIT_MS = 3_000;
5758
const MAC_SELECT_OPTION_MENU_POLL_MS = 120;
@@ -3032,15 +3033,12 @@ async function withMacCuaDriverSerialized<T>(fn: () => Promise<T>): Promise<T> {
30323033
return run;
30333034
}
30343035

3035-
async function acquireMacCuaDriverProcessLock(lockDir = MAC_CUA_DRIVER_CALL_LOCK_DIR): Promise<() => void> {
3036+
async function acquireMacCuaDriverProcessLock(lockDir = MAC_CUA_DRIVER_CALL_LOCK_DIR, action = 'unknown', threadId?: string): Promise<() => void> {
30363037
while (true) {
30373038
try {
30383039
fs.mkdirSync(lockDir);
30393040
try {
3040-
fs.writeFileSync(path.join(lockDir, 'owner'), JSON.stringify({
3041-
pid: process.pid,
3042-
acquiredAt: Date.now(),
3043-
}));
3041+
fs.writeFileSync(path.join(lockDir, 'owner'), JSON.stringify(cuaLockOwnerForNewCall(action, threadId)));
30443042
} catch {
30453043
// The directory itself is the atomic lock; owner metadata is best-effort.
30463044
}
@@ -3114,9 +3112,11 @@ export async function withMacCuaDriverApprovalAndProcessLock<T>(
31143112
approve: () => Promise<void>,
31153113
invoke: () => Promise<T>,
31163114
lockDir = MAC_CUA_DRIVER_CALL_LOCK_DIR,
3115+
action = 'unknown',
3116+
threadId?: string,
31173117
): Promise<T> {
31183118
await approve();
3119-
const release = await acquireMacCuaDriverProcessLock(lockDir);
3119+
const release = await acquireMacCuaDriverProcessLock(lockDir, action, threadId);
31203120
try {
31213121
return await withMacCuaDriverSerialized(invoke);
31223122
} finally {
@@ -3425,6 +3425,9 @@ async function callCuaDriverCli(
34253425
return withMacCuaDriverApprovalAndProcessLock(
34263426
() => requestApproval(toolName, approvalArgs ?? args, context),
34273427
() => callMacCuaDriverCli(toolName, args, context, imageOutputPath),
3428+
MAC_CUA_DRIVER_CALL_LOCK_DIR,
3429+
toolName,
3430+
context?.threadId,
34283431
);
34293432
}
34303433
await requestApproval(toolName, approvalArgs ?? args, context);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { afterEach, expect, test } from 'bun:test';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import { cuaLockOwnerForNewCall, readCuaProcessLockCustody } from './cuaProcessLockMetadata';
6+
7+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cua-custody-test-'));
8+
afterEach(() => { for (const file of fs.readdirSync(root)) fs.rmSync(path.join(root, file), { recursive: true, force: true }); });
9+
10+
test('distinguishes clear from legacy held lock with unknown action and identity', () => {
11+
const lock = path.join(root, 'lock');
12+
expect(readCuaProcessLockCustody(lock)).toMatchObject({ held: false, ownerKnown: true });
13+
fs.mkdirSync(lock);
14+
fs.writeFileSync(path.join(lock, 'owner'), JSON.stringify({ pid: process.pid, acquiredAt: 123 }));
15+
expect(readCuaProcessLockCustody(lock)).toMatchObject({ held: true, ownerKnown: true,
16+
action: null, threadId: null, processIdentityVerified: false });
17+
});
18+
19+
test('records action and thread but never arguments or process command line', () => {
20+
const lock = path.join(root, 'lock');
21+
const owner = cuaLockOwnerForNewCall('get_app_state', '01a0d435-bb2b-7047-afe7-0a639be8e5d4');
22+
fs.mkdirSync(lock);
23+
fs.writeFileSync(path.join(lock, 'owner'), JSON.stringify(owner));
24+
const result = readCuaProcessLockCustody(lock);
25+
expect(result).toMatchObject({ held: true, action: 'get_app_state', threadId: owner.threadId });
26+
expect(JSON.stringify(result)).not.toContain('args');
27+
if (process.platform === 'linux') expect(result.processIdentityVerified).toBe(true);
28+
});
29+
30+
test('never follows a symlinked owner or lock directory', () => {
31+
const target = path.join(root, 'target');
32+
const lock = path.join(root, 'lock');
33+
fs.writeFileSync(target, JSON.stringify(cuaLockOwnerForNewCall('click')));
34+
fs.mkdirSync(lock);
35+
fs.symlinkSync(target, path.join(lock, 'owner'));
36+
expect(readCuaProcessLockCustody(lock)).toMatchObject({ held: true, ownerKnown: false });
37+
fs.rmSync(lock, { recursive: true, force: true });
38+
fs.symlinkSync(root, lock);
39+
expect(() => readCuaProcessLockCustody(lock)).toThrow('Invalid CUA lock directory');
40+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
5+
export const CUA_PROCESS_LOCK_DIR = path.join(os.tmpdir(), 'interpreter-desktop-driver-call.lock');
6+
7+
function linuxStartTicks(pid: number): string | null {
8+
if (process.platform !== 'linux') return null;
9+
try {
10+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
11+
const fields = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/);
12+
const ticks = fields[19]; // /proc stat field 22; first remaining field is 3.
13+
return /^\d+$/.test(ticks ?? '') ? ticks : null;
14+
} catch { return null; }
15+
}
16+
17+
export function cuaLockOwnerForNewCall(action: string, threadId?: string) {
18+
return {
19+
pid: process.pid,
20+
acquiredAt: Date.now(),
21+
processStartTicks: linuxStartTicks(process.pid),
22+
action: /^[a-z_]{1,80}$/.test(action) ? action : null,
23+
threadId: threadId && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(threadId) ? threadId : null,
24+
};
25+
}
26+
27+
/** Metadata only. Unknown identity is never interpreted as a clear lock. */
28+
export function readCuaProcessLockCustody(lockDir = CUA_PROCESS_LOCK_DIR) {
29+
let stat: fs.Stats;
30+
try { stat = fs.lstatSync(lockDir); }
31+
catch (error) {
32+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
33+
return { held: false, ownerKnown: true, pid: null, action: null, threadId: null, acquiredAt: null, processIdentityVerified: null };
34+
}
35+
throw error;
36+
}
37+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Invalid CUA lock directory');
38+
const unknown = { held: true, ownerKnown: false, pid: null, action: null, threadId: null, acquiredAt: null, processIdentityVerified: false };
39+
try {
40+
const ownerPath = path.join(lockDir, 'owner');
41+
const ownerStat = fs.lstatSync(ownerPath);
42+
if (!ownerStat.isFile() || ownerStat.isSymbolicLink() || ownerStat.size > 1024) return unknown;
43+
const owner = JSON.parse(fs.readFileSync(ownerPath, 'utf8')) as Record<string, unknown>;
44+
if (!Number.isSafeInteger(owner.pid) || (owner.pid as number) < 1) return unknown;
45+
const pid = owner.pid as number;
46+
let alive = false;
47+
try { process.kill(pid, 0); alive = true; }
48+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'EPERM') alive = true; }
49+
const ticks = linuxStartTicks(pid);
50+
const processIdentityVerified = alive && typeof owner.processStartTicks === 'string' && ticks !== null && owner.processStartTicks === ticks;
51+
return {
52+
held: true, ownerKnown: true, pid,
53+
action: typeof owner.action === 'string' && /^[a-z_]{1,80}$/.test(owner.action) ? owner.action : null,
54+
threadId: typeof owner.threadId === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(owner.threadId) ? owner.threadId : null,
55+
acquiredAt: typeof owner.acquiredAt === 'number' && Number.isSafeInteger(owner.acquiredAt) ? owner.acquiredAt : null,
56+
processIdentityVerified,
57+
};
58+
} catch { return unknown; }
59+
}

‎src/lib/codex/app-server-client.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2362,6 +2362,10 @@ export class CodexAppServerClient {
23622362
return this.rpcRequest(CLIENT_METHOD.threadQueueList, params);
23632363
}
23642364

2365+
async threadLoadedList(params: v2.ThreadLoadedListParams = {}): Promise<v2.ThreadLoadedListResponse> {
2366+
return this.rpcRequest(CLIENT_METHOD.threadLoadedList, params);
2367+
}
2368+
23652369
async threadSetName(params: v2.ThreadSetNameParams): Promise<v2.ThreadSetNameResponse> {
23662370
return this.rpcRequest(CLIENT_METHOD.threadSetName, params);
23672371
}

‎src/lib/codex/protocol.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const CLIENT_METHOD = {
4848
threadList: "thread/list",
4949
threadRead: "thread/read",
5050
threadQueueList: "thread/queue/list",
51+
threadLoadedList: "thread/loaded/list",
5152
modelList: "model/list",
5253
interpreterProviderList: "interpreter/provider/list",
5354
interpreterProviderSet: "interpreter/provider/set",
@@ -250,6 +251,10 @@ export type RequestMap = {
250251
params: { threadId: string; cursor?: string | null; limit?: number | null };
251252
result: { data: Array<{ id: string; clientUserMessageId: string; input: unknown[] }>; nextCursor: string | null };
252253
};
254+
[CLIENT_METHOD.threadLoadedList]: {
255+
params: v2.ThreadLoadedListParams;
256+
result: v2.ThreadLoadedListResponse;
257+
};
253258
[CLIENT_METHOD.modelList]: {
254259
params: v2.ModelListParams;
255260
result: v2.ModelListResponse;

‎src/lib/codex/service.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,47 @@ function createTurn(id: string, status: v2.TurnStatus = "inProgress"): v2.Turn {
2929
}
3030

3131
describe('native custody metadata', () => {
32+
test('enumerates loaded and persisted native queues without input, then verifies stable IDs', async () => {
33+
const id = '01a0d435-bb2b-7047-afe7-0a639be8e5d4';
34+
const client = createFakeClient({}).client;
35+
client.threadLoadedList = async () => ({ data: [id], nextCursor: null });
36+
client.threadList = async () => ({ data: [{ id } as v2.Thread], nextCursor: null, backwardsCursor: null });
37+
let calls = 0;
38+
client.threadQueueList = async () => {
39+
calls++;
40+
return { data: [{ id: 'queue_1', clientUserMessageId: 'opaque_1', input: [{ text: 'PRIVATE' }] }], nextCursor: null };
41+
};
42+
const result = await new CodexService(client).readGlobalNativeCustody();
43+
assert.equal(result.scope, 'all-enumerated-unarchived-native-threads');
44+
assert.equal(result.atomic, false);
45+
assert.equal(result.queuedSubmissionCount, 1);
46+
assert.deepEqual(result.loadedThreadIds, [id]);
47+
assert.equal(calls, 2);
48+
assert.doesNotMatch(JSON.stringify(result), /PRIVATE/);
49+
});
50+
51+
test('rejects a changing native queue rather than claiming safe custody', async () => {
52+
const id = '01a0d435-bb2b-7047-afe7-0a639be8e5d4';
53+
const client = createFakeClient({}).client;
54+
client.threadLoadedList = async () => ({ data: [id], nextCursor: null });
55+
client.threadList = async () => ({ data: [{ id } as v2.Thread], nextCursor: null, backwardsCursor: null });
56+
let calls = 0;
57+
client.threadQueueList = async () => ({ data: calls++ === 0
58+
? [] : [{ id: 'queue_2', clientUserMessageId: 'opaque_2', input: [] }], nextCursor: null });
59+
await assert.rejects(new CodexService(client).readGlobalNativeCustody(), /changed/);
60+
});
61+
62+
test('rejects a loaded native thread omitted by persisted inventory', async () => {
63+
const client = createFakeClient({}).client;
64+
client.threadLoadedList = async () => ({ data: ['01a0d435-bb2b-7047-afe7-0a639be8e5d4'], nextCursor: null });
65+
await assert.rejects(new CodexService(client).readGlobalNativeCustody(), /missing/);
66+
});
67+
68+
test('does not report an empty global queue when native listing is unsupported', async () => {
69+
const client = createFakeClient({}).client;
70+
client.threadLoadedList = async () => { throw new Error('native method unavailable'); };
71+
await assert.rejects(new CodexService(client).readGlobalNativeCustody(), /unavailable/);
72+
});
3273
test('pages native queue through the existing client without returning input', async () => {
3374
const fake = createFakeClient({});
3475
const client = fake.client;
@@ -286,6 +327,9 @@ function createFakeClient(overrides: {
286327
calls.threadListParams.push(params ?? {});
287328
return { data: [], nextCursor: null };
288329
},
330+
async threadLoadedList() {
331+
return { data: [], nextCursor: null };
332+
},
289333
async threadRead(params) {
290334
calls.threadReadParams.push(params);
291335
if (overrides.threadRead) {

0 commit comments

Comments
 (0)