Files
orca/src/main/codex/codex-structured-visuals.test.ts
T
Brennan Benson 9b1e83a7d8 refactor(native-chat): rename the chat visuals folder to the chat scratch folder (#28389)
* fix(files): never write through a dangling symlink

A path that doesn't exist yet was authorized by walking up to the nearest
existing folder. A dangling symlink fails realpath with ENOENT, so the walk
stepped over it and approved the link's own path; the write then followed
the link and created its target outside the allowed folders. The walk now
continues from the link's target, and returns that real destination.

* refactor(native-chat): rename the chat visuals folder to the chat scratch folder

The per-chat folder agents write into is the chat's scratch space, not only
a visuals folder. Its root becomes <state>/chat-scratch/<hash> (the hash is
unchanged) and the agent variable becomes ORCA_CHAT_SCRATCH_DIR. The old
ORCA_CHAT_VISUALS_DIR is never set and is still stripped from what every
agent child inherits, Pi included. The visuals skill names the new variable
and says the folder is temporary.

* test(native-chat): keep the delivery test to the rename

The launch parity snapshot already pins that every agent drops the
pre-rename variable.

* fix(files): refuse any dangling symlink in the missing-path walk

Following a dangling link by hand resolved relative targets against the
lexical parent, not the link's real folder: through a folder link the walk
could loop forever or authorize the wrong file. Refuse instead: realpath
can't say where a dangling link leads, but a write would follow it.

* test(files): drop the loop case that duplicated the dangling-link refusal
2026-10-13 22:08:57 -07:00

227 lines
8.8 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
NATIVE_CHAT_SCRATCH_DIR_ENV,
type NativeChatVisualsLaunch
} from '../native-chat/native-chat-visuals-delivery'
import { CodexAppServerRequestError } from './codex-app-server-request-error'
import { CodexAppServerTimeoutError } from './codex-app-server-session'
import {
adapterFor,
fakeCodex,
identityFor,
THREAD_ID
} from './codex-structured-session-adapter-fixture'
import { createCodexStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture'
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
import {
CODEX_VISUALS_SETUP_BUDGET_MS,
prepareCodexThreadForVisuals
} from './codex-structured-visuals'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
const VISUALS: NativeChatVisualsLaunch = {
folder: '/state/chat-scratch/abc',
skill: { pluginDir: '/app/native-chat-visuals', skillsRoot: '/app/native-chat-visuals/skills' }
}
const MANUAL = codexStructuredPermissionPolicy('ask')
const YOLO = codexStructuredPermissionPolicy('full-access')
type Call = { method: string; params?: Record<string, unknown>; timeoutMs?: number }
function connectionAnswering(routes: Record<string, () => unknown>) {
const calls: Call[] = []
const request = vi.fn(
async (method: string, params?: Record<string, unknown>, options?: { timeoutMs?: number }) => {
calls.push({ method, params, timeoutMs: options?.timeoutMs })
const route = routes[method]
return route ? route() : {}
}
)
return { calls, connection: { request } }
}
const configWithRoots = (roots: unknown) => () => ({
config: { sandbox_workspace_write: { writable_roots: roots, network_access: true } }
})
describe('setting up a Codex app-server for a chat with visuals', () => {
it("names the skill root and adds the chat folder to the user's writable roots", async () => {
const { calls, connection } = connectionAnswering({
'config/read': configWithRoots(['/home/me/scratch'])
})
const config = await prepareCodexThreadForVisuals(connection, {
cwd: '/work/repo',
visuals: VISUALS,
permissionPolicy: MANUAL
})
expect(config).toEqual({
'sandbox_workspace_write.writable_roots': ['/home/me/scratch', VISUALS.folder]
})
expect(calls).toEqual(
expect.arrayContaining([
{
method: 'skills/extraRoots/set',
params: { extraRoots: [VISUALS.skill.skillsRoot] },
timeoutMs: CODEX_VISUALS_SETUP_BUDGET_MS
},
{
method: 'config/read',
params: { cwd: '/work/repo' },
timeoutMs: CODEX_VISUALS_SETUP_BUDGET_MS
}
])
)
})
it('grants just the folder when the user has no writable roots of their own', async () => {
for (const answer of [() => ({ config: {} }), configWithRoots([])]) {
const { connection } = connectionAnswering({ 'config/read': answer })
await expect(
prepareCodexThreadForVisuals(connection, { cwd: '/w', visuals: VISUALS })
).resolves.toEqual({ 'sandbox_workspace_write.writable_roots': [VISUALS.folder] })
}
})
it('overrides nothing when the user roots cannot be read, so none are dropped', async () => {
const logger = { warn: vi.fn(), error: vi.fn() }
for (const route of [
() => Promise.reject(new CodexAppServerTimeoutError('config/read exceeded 2000ms')),
configWithRoots([42]),
() => ({ nothing: true })
]) {
const { connection } = connectionAnswering({ 'config/read': route })
await expect(
prepareCodexThreadForVisuals(
connection,
{ cwd: '/w', visuals: VISUALS, permissionPolicy: MANUAL },
{ logger, sessionId: 's-1' }
)
).resolves.toBeNull()
}
expect(logger.warn).toHaveBeenCalledTimes(3)
})
it('asks for no writable root under full access, and still names the skill root', async () => {
const { calls, connection } = connectionAnswering({})
await expect(
prepareCodexThreadForVisuals(connection, {
cwd: '/w',
visuals: VISUALS,
permissionPolicy: YOLO
})
).resolves.toBeNull()
expect(calls.map((call) => call.method)).toEqual(['skills/extraRoots/set'])
})
it('opens without the skill on a Codex that predates skill roots, or one that fails or hangs', async () => {
const failures = [
new CodexAppServerRequestError(
'skills/extraRoots/set',
-32600,
'Invalid request: unknown variant `skills/extraRoots/set`, expected one of `initialize`'
),
new CodexAppServerRequestError('skills/extraRoots/set', -32601, 'method not found'),
new CodexAppServerTimeoutError('codex app-server skills/extraRoots/set exceeded 2000ms'),
new Error('broken pipe')
]
for (const failure of failures) {
const logger = { warn: vi.fn(), error: vi.fn() }
const { connection } = connectionAnswering({
'skills/extraRoots/set': () => Promise.reject(failure),
'config/read': configWithRoots([])
})
await expect(
prepareCodexThreadForVisuals(
connection,
{ cwd: '/w', visuals: VISUALS },
{ logger, sessionId: 's-1' }
)
).resolves.toEqual({ 'sandbox_workspace_write.writable_roots': [VISUALS.folder] })
expect(logger.warn).toHaveBeenCalledWith(
expect.stringMatching(
failure.message.includes('variant') || failure.message.includes('not found')
? /cannot load skills/
: /did not take/
),
expect.objectContaining({ scope: 'nativeChatVisuals.codex', error: failure })
)
}
})
it('sends nothing for a chat without visuals', async () => {
const { calls, connection } = connectionAnswering({})
await expect(prepareCodexThreadForVisuals(connection, { cwd: '/w' })).resolves.toBeNull()
expect(calls).toEqual([])
})
})
/** A chat acquired through the launch its registration composes, over a shell env of `env`. */
async function acquired(
codex: ReturnType<typeof fakeCodex>,
launch: {
visuals?: NativeChatVisualsLaunch
resumeThreadId?: string | null
env?: Record<string, string>
}
): Promise<void> {
const record = agentSessionRecordFixture({
sessionId: 'session-1',
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/home/work/.codex' },
providerHandleChain: launch.resumeThreadId
? [
{
linkId: 'link-1',
handle: codexProviderHandle(launch.resumeThreadId),
origin: 'resumed',
mintedAtFence: 1,
observedAt: 1
}
]
: []
})
const adapter = adapterFor(codex, {}, [], {
resolveLaunch: createCodexStructuredLaunchResolver({
store: { getRecord: () => record, pinLaunchDirectory: vi.fn() },
resolveWorkspacePath: async () => '/work/repo',
resolveEnvironment: async () => launch.env ?? {},
resolveCommand: () => 'codex',
resolveLaunchArgs: () => [],
resolveRollout: async () => null,
prepareVisuals: async () => launch.visuals ?? null
})
})
await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' })
}
describe('a Codex chat acquisition with visuals', () => {
it('sets up skills and the folder after initialize and before the thread opens, on start and resume', async () => {
for (const resumeThreadId of [null, THREAD_ID]) {
const codex = fakeCodex({ 'config/read': configWithRoots(['/home/me/scratch']) })
await acquired(codex, { visuals: VISUALS, resumeThreadId })
const connection = codex.connections[0]!
const methods = connection.calls.map((call) => call.method)
const open = resumeThreadId ? 'thread/resume' : 'thread/start'
expect(methods.indexOf('skills/extraRoots/set')).toBeLessThan(methods.indexOf(open))
expect(methods.indexOf('config/read')).toBeLessThan(methods.indexOf(open))
expect(connection.calls.find((call) => call.method === open)?.params).toMatchObject({
...MANUAL,
config: { 'sandbox_workspace_write.writable_roots': ['/home/me/scratch', VISUALS.folder] }
})
expect(connection.launch.env?.[NATIVE_CHAT_SCRATCH_DIR_ENV]).toBe(VISUALS.folder)
}
})
it('opens the thread exactly as before for a chat without visuals', async () => {
const codex = fakeCodex()
await acquired(codex, { env: { [NATIVE_CHAT_SCRATCH_DIR_ENV]: '/other' } })
const connection = codex.connections[0]!
const methods = connection.calls.map((call) => call.method)
expect(methods[0]).toBe('thread/start')
expect(methods).not.toContain('skills/extraRoots/set')
expect(connection.calls[0]?.params).not.toHaveProperty('config')
expect(connection.launch.env).not.toHaveProperty(NATIVE_CHAT_SCRATCH_DIR_ENV)
})
})