CLI --yolo sandbox is lost after app-server restart
What version of Codex CLI is running?
Reproduced with the installed codex-cli 0.158.0 binary. Source trace checked against 88e9a8329d17bd141e4f3f0e7c78d07a7baa8b23 on main.
What subscription do you have?
The isolated reproduction uses a local mock Responses endpoint with requires_openai_auth = false. It does not require a subscription or make OpenAI API calls.
Which model were you using?
mock-model in the reproduction. The behavior occurs during thread restoration, independently of model output.
What platform is your computer?
Linux 6.17.0-1025-oem x86_64.
What terminal emulator and version are you using (if applicable)?
The isolated reproduction drives codex app-server over stdio, without a terminal emulator. The original symptom was observed in the CLI TUI using the shared daemon.
Codex doctor report
Not collected for this report. The reproduction uses a temporary CODEX_HOME, two isolated app-server processes, and a local mock endpoint. No production configuration or credentials are needed.
What issue are you seeing?
A thread started with the legacy permission fields used by CLI --yolo loses its full-access sandbox after app-server restart and an ID-only cold resume. The approval policy survives, producing never + workspace-write instead of never + danger-full-access.
Observed on the same installed 0.158.0 binary before and after restart:
| Stage | approvalPolicy | sandbox.type | activePermissionProfile |
|---|---|---|---|
| thread/start response | never | dangerFullAccess | null |
| Persisted turn_context | never | danger-full-access | null |
| Cold thread/resume response | never | workspaceWrite | null |
In the original TUI session, this followed:
Reconnected. No input was resent. Review uncertain submissions before retrying; recovered queues remain paused.
The recovered-queue pause is intentional and is not the bug. This report concerns the permission loss. The isolated reproduction confirms sandbox loss; it does not demonstrate a change from never to on-request.
What steps can reproduce the bug?
Use a temporary CODEX_HOME and a local mock Responses server. Do not restart a daemon serving real work.
Configure the isolated server with
approval_policy = "on-request",sandbox_mode = "workspace-write", and a mock provider withrequires_openai_auth = false.Start
codex app-serverover stdio and sendinitialize, theninitialized.Send
thread/startwith a temporary workspace and the legacy YOLO-equivalent fields:{"approvalPolicy":"never","sandbox":"danger-full-access"}Submit one
turn/startand let the mock provider return a short completed response. This is necessary to persist a resumable rollout; thread/start alone was insufficient.Verify the resulting
turn_contextcontainsapproval_policy = "never",sandbox_policy.type = "danger-full-access", and no active named permission profile.Close the first server cleanly. In the reproduction it exited with code 0.
Start a second instance of the same binary with the same temporary home and unchanged configuration. Initialize it, then send:
{"method":"thread/resume","params":{"threadId":"<saved-thread-id>","excludeTurns":true}}The response now reports
approvalPolicy = "never",sandbox.type = "workspaceWrite", andactivePermissionProfile = null.
No mobile handoff, manual CLI resume without --yolo, mixed client/server versions, or external model request is involved in this reproduction.
What is the expected behavior?
An automatic reconnect of a CLI launched with --yolo should preserve its explicit permission choice for subsequent input, unless the user changes that choice or host policy rejects it. Recovered queues should remain paused as they do today.
Additional information
The installed-binary experiment proves the app-server cold-resume transition. The connection to automatic TUI reconnect is established by the current source path:
startup_orchestration.rsmaps--yoloto the legacy sandbox and approval overrides.reconnect.rsusesPreserveExistingThread;app_server_session.rsconsequently sends only the thread ID.persisted_resume_settings.rsextracts approval settings and an active named profile, but not the legacy sandbox.thread_processor.rsrestores the profile ID when present.- The existing server test
cold_resume_without_active_permission_profile_uses_current_configexplicitly expects fallback to current configuration for legacy permissions. - The TUI can retain picker-selected runtime permissions, but
startup.rsinitializes both runtime permission overrides toNone, including for an explicit YOLO launch.
A focused TUI fix can retain explicit launch permissions using the existing runtime override mechanism, then apply them only to fresh input after reconnect. That would not repair work the daemon already recovered before the client reattached. A broader option is representing explicit YOLO access with a persistable built-in permission profile, subject to compatibility and server-policy review.
Related: #47454 and #29054. This report isolates app-server restart and ID-only resume, rather than mobile continuation or manually resuming without repeating --yolo.
Save as repro.mjs, then run node repro.mjs /absolute/path/to/codex. It creates a temporary home, uses one loopback mock response, and stops only its two app-server children. The temporary home is retained for inspection.
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import readline from 'node:readline';
if (!process.argv[2]) {
console.error('Usage: node /tmp/codex-yolo-cold-resume-repro.mjs <codex-binary>');
process.exit(2);
}
const codex = path.resolve(process.argv[2]);
fs.accessSync(codex, fs.constants.X_OK);
const codexVersion = execFileSync(codex, ['--version'], { encoding: 'utf8' }).trim();
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-yolo-cold-resume-'));
const cwd = path.join(home, 'workspace');
fs.mkdirSync(cwd);
fs.mkdirSync(path.join(home, 'tmp'));
let mockResponseCount = 0;
const sse = [
{ type: 'response.created', response: { id: 'mock-response' } },
{
type: 'response.output_item.done',
item: {
type: 'message',
role: 'assistant',
id: 'mock-message',
content: [{ type: 'output_text', text: 'mocked' }],
},
},
{
type: 'response.completed',
response: {
id: 'mock-response',
usage: {
input_tokens: 0,
input_tokens_details: null,
output_tokens: 0,
output_tokens_details: null,
total_tokens: 0,
},
},
},
].map((event) => `data: ${JSON.stringify(event)}\n\n`).join('') + 'data: [DONE]\n\n';
const mock = http.createServer((request, response) => {
if (!request.url?.endsWith('/responses')) {
response.writeHead(200, { 'content-type': 'application/json' });
response.end('[]');
return;
}
mockResponseCount += 1;
request.resume();
request.on('end', () => {
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
response.end(sse);
});
});
function launchAppServer() {
const child = spawn(codex, ['app-server', '--listen', 'stdio://'], {
cwd,
env: {
PATH: process.env.PATH,
HOME: home,
CODEX_HOME: home,
CODEX_APP_SERVER_MANAGED_CONFIG_PATH: path.join(home, 'managed_config.toml'),
TMPDIR: path.join(home, 'tmp'),
RUST_LOG: 'error',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
const lines = readline.createInterface({ input: child.stdout });
const pending = new Map();
const notifications = [];
const notificationWaiters = [];
let stderr = '';
let nextId = 1;
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
lines.on('line', (line) => {
let message;
try { message = JSON.parse(line); } catch { return; }
if (message.id != null && pending.has(message.id)) {
const waiter = pending.get(message.id);
pending.delete(message.id);
waiter.resolve(message);
return;
}
if (!message.method) return;
notifications.push(message);
const index = notificationWaiters.findIndex((waiter) => waiter.method === message.method);
if (index >= 0) notificationWaiters.splice(index, 1)[0].resolve(message);
});
return {
child,
request(method, params) {
const id = nextId++;
const result = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(id);
reject(new Error(`timed out waiting for ${method}; stderr=${stderr}`));
}, 20000);
pending.set(id, {
resolve: (message) => { clearTimeout(timeout); resolve(message); },
});
});
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
return result;
},
notify(method) {
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method })}\n`);
},
waitForNotification(method) {
const index = notifications.findIndex((message) => message.method === method);
if (index >= 0) return Promise.resolve(notifications.splice(index, 1)[0]);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`timed out waiting for ${method}; stderr=${stderr}`)), 20000);
notificationWaiters.push({
method,
resolve: (message) => { clearTimeout(timeout); resolve(message); },
});
});
},
stop() {
return new Promise((resolve) => {
child.once('exit', (code, signal) => resolve({ code, signal, stderr }));
child.kill('SIGTERM');
});
},
};
}
async function rpc(server, method, params) {
const message = await server.request(method, params);
if (message.error) throw new Error(`${method}: ${JSON.stringify(message.error)}`);
return message.result;
}
async function initialize(server) {
await rpc(server, 'initialize', {
clientInfo: { name: 'isolated-yolo-repro', version: '1' },
capabilities: { experimentalApi: true },
});
server.notify('initialized');
}
function rolloutTurnContext() {
const files = [];
const visit = (directory) => {
if (!fs.existsSync(directory)) return;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) visit(entryPath);
else if (entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) files.push(entryPath);
}
};
visit(path.join(home, 'sessions'));
for (const file of files) {
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
if (!line) continue;
const record = JSON.parse(line);
if (record.type === 'turn_context') {
return {
approvalPolicy: record.payload.approval_policy,
sandboxPolicy: record.payload.sandbox_policy,
permissionProfile: record.payload.permission_profile,
activePermissionProfile: record.payload.active_permission_profile ?? null,
};
}
}
}
throw new Error('no persisted turn_context found');
}
let first;
let second;
(async () => {
await new Promise((resolve) => mock.listen(0, '127.0.0.1', resolve));
const providerUrl = `http://127.0.0.1:${mock.address().port}/v1`;
fs.writeFileSync(path.join(home, 'config.toml'), [
'model = "mock-model"',
'model_provider = "mock"',
'approval_policy = "on-request"',
'sandbox_mode = "workspace-write"',
'',
'[model_providers.mock]',
'name = "loopback mock"',
`base_url = "${providerUrl}"`,
'wire_api = "responses"',
'requires_openai_auth = false',
'',
].join('\n'));
fs.writeFileSync(path.join(home, 'managed_config.toml'), '');
first = launchAppServer();
await initialize(first);
const started = await rpc(first, 'thread/start', {
cwd,
model: 'mock-model',
modelProvider: 'mock',
approvalPolicy: 'never',
sandbox: 'danger-full-access',
});
await first.waitForNotification('thread/started');
await rpc(first, 'turn/start', {
threadId: started.thread.id,
input: [{ type: 'text', text: 'reply with mocked output' }],
});
await first.waitForNotification('turn/completed');
const persistedContext = rolloutTurnContext();
const firstExit = await first.stop();
first = null;
second = launchAppServer();
await initialize(second);
const resumed = await rpc(second, 'thread/resume', {
threadId: started.thread.id,
excludeTurns: true,
});
const secondExit = await second.stop();
second = null;
mock.close();
const summary = {
codexVersion,
appServerExitCodes: [firstExit.code, secondExit.code],
localMockResponses: mockResponseCount,
startResponse: {
approvalPolicy: started.approvalPolicy,
sandboxType: started.sandbox.type,
activePermissionProfile: started.activePermissionProfile ?? null,
},
persistedTurnContext: {
approvalPolicy: persistedContext.approvalPolicy,
sandboxType: persistedContext.sandboxPolicy.type,
permissionProfileType: persistedContext.permissionProfile.type,
activePermissionProfile: persistedContext.activePermissionProfile,
},
coldResumeResponse: {
approvalPolicy: resumed.approvalPolicy,
sandboxType: resumed.sandbox.type,
activePermissionProfile: resumed.activePermissionProfile ?? null,
},
};
if (summary.appServerExitCodes.some((code) => code !== 0)) throw new Error(`unclean exit: ${JSON.stringify(summary)}`);
if (summary.localMockResponses !== 1) throw new Error(`expected one loopback response: ${JSON.stringify(summary)}`);
if (summary.startResponse.sandboxType !== 'dangerFullAccess') throw new Error(`unexpected start settings: ${JSON.stringify(summary)}`);
if (summary.persistedTurnContext.sandboxType !== 'danger-full-access') throw new Error(`unexpected persisted settings: ${JSON.stringify(summary)}`);
if (summary.coldResumeResponse.sandboxType !== 'workspaceWrite') throw new Error(`expected reproduced downgrade: ${JSON.stringify(summary)}`);
console.log(JSON.stringify(summary, null, 2));
})().catch(async (error) => {
if (first?.child && first.child.exitCode == null) await first.stop();
if (second?.child && second.child.exitCode == null) await second.stop();
mock.close();
console.error(error.stack || error);
process.exitCode = 1;
});
Verified output on the installed binary:
{
"codexVersion": "codex-cli 0.158.0",
"appServerExitCodes": [
0,
0
],
"localMockResponses": 1,
"startResponse": {
"approvalPolicy": "never",
"sandboxType": "dangerFullAccess",
"activePermissionProfile": null
},
"persistedTurnContext": {
"approvalPolicy": "never",
"sandboxType": "danger-full-access",
"permissionProfileType": "disabled",
"activePermissionProfile": null
},
"coldResumeResponse": {
"approvalPolicy": "never",
"sandboxType": "workspaceWrite",
"activePermissionProfile": null
}
}Source: openai/codex