Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 10 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ permissions: {}
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
Expand All @@ -18,16 +19,23 @@ jobs:
with:
node-version: '24'
- name: Configure sandboxing
timeout-minutes: 5
run: |
sudo apt-get update
sudo apt-get install --yes bubblewrap
# A stalled Ubuntu mirror once wedged this step for the full 6h runner
# limit, so cap and retry each fetch rather than waiting on it.
apt_opts=(-o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30)
sudo apt-get "${apt_opts[@]}" update
sudo apt-get "${apt_opts[@]}" install --yes bubblewrap
sudo sysctl -w kernel.unprivileged_userns_clone=1
if [ -f /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
- name: Install dependencies
run: npm ci
- name: Run e2e tests
# The suite drives a live model, so a wedged turn hangs instead of
# failing. Bound it well above the ~2min happy path.
timeout-minutes: 15
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: npm run test:e2e
18 changes: 16 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
release-please:
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
timeout-minutes: 20
environment: release # Optional: for enhanced security
permissions:
contents: write
Expand Down Expand Up @@ -68,6 +69,10 @@ jobs:
needs: [release-please]
if: ${{ always() && ((github.event_name == 'workflow_dispatch' && inputs.publish_npm) || needs.release-please.outputs.release_created == 'true') }}
runs-on: ubuntu-latest
# Without this a stalled step burns the full 6h runner limit before anyone
# notices the release did not publish. A hung apt mirror already cost one
# release that way.
timeout-minutes: 25
permissions:
contents: read
steps:
Expand All @@ -78,9 +83,13 @@ jobs:
with:
node-version: "24"
- name: Configure sandboxing
timeout-minutes: 30
run: |
sudo apt-get update
sudo apt-get install --yes bubblewrap
# A stalled Ubuntu mirror once wedged this step for the full 6h runner
# limit, so cap and retry each fetch rather than waiting on it.
apt_opts=(-o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30)
sudo apt-get "${apt_opts[@]}" update
sudo apt-get "${apt_opts[@]}" install --yes bubblewrap
sudo sysctl -w kernel.unprivileged_userns_clone=1
if [ -f /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
Expand All @@ -89,6 +98,9 @@ jobs:
- run: npm run typecheck
- run: npm test
- name: Run e2e tests
# The suite drives a live model, so a wedged turn hangs instead of
# failing. Bound it well above the ~3min happy path.
timeout-minutes: 15
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: npm run test:e2e
Expand All @@ -98,6 +110,7 @@ jobs:
needs: [release-please, verify]
if: ${{ always() && needs.verify.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 15
environment: release # Optional: for enhanced security
permissions:
contents: read
Expand All @@ -119,6 +132,7 @@ jobs:
needs: publish-npm
if: ${{ always() && (needs.publish-npm.result == 'success' || (github.event_name == 'workflow_dispatch' && !inputs.publish_npm)) }}
runs-on: ubuntu-latest
timeout-minutes: 5
environment: release
permissions: {}
steps:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"generate-types": "./node_modules/.bin/codex app-server generate-ts --out src/app-server",
"release:preflight": "bash scripts/release-preflight.sh",
"test": "vitest run",
"test:e2e": "npm run build && RUN_E2E_TESTS=true vitest run src/__tests__/CodexACPAgent/e2e",
"test:e2e": "npm run build && RUN_E2E_TESTS=true vitest run --no-file-parallelism --retry=2 src/__tests__/CodexACPAgent/e2e",
"test:watch": "vitest",
"typecheck": "tsc --noEmit && tsc --noEmit -p examples/tsconfig.json",
"codex-test": "tsx .claude/skills/run-codex/scripts/run-codex-test.ts"
Expand Down
71 changes: 51 additions & 20 deletions src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import * as acp from "@agentclientprotocol/sdk";
import fs from "node:fs";
import path from "node:path";
import {afterEach, beforeEach, expect, it, onTestFinished} from "vitest";
import {afterEach, beforeEach, expect, it, onTestFinished, vi} from "vitest";
import {AgentMode} from "../../../AgentMode";
import {ApprovalOptionId} from "../../../ApprovalOptionId";
import {
createAuthenticatedFixture,
createPermissionResponder,
describeE2E,
expectEndTurn,
expectNoPermissionRequests,
expectPermissionRequests,
generateFileNameForTest,
type SpawnedAgentFixture,
} from "./acp-e2e-test-utils";

const FILE_CONTENT = "file approval e2e";
// The edit lands before the turn ends, so this only absorbs filesystem visibility lag.
const FILE_APPEARS_TIMEOUT_MS = 5_000;

describeE2E("E2E file approval tests", () => {
let fixture: SpawnedAgentFixture;
Expand All @@ -28,13 +32,13 @@ describeE2E("E2E file approval tests", () => {

it("applies approved file edits", async () => {
fixture.setPermissionResponder(createPermissionResponder("edit", ApprovalOptionId.AllowOnce));
const sessionId = await editFileDirectly(fixture, path.join(fixture.workspaceDir, generateFileNameForTest()), true);
const sessionId = await expectFileEditApplied(fixture, newFilePathIn(fixture.workspaceDir));
expectPermissionRequests(fixture, sessionId, {edit: 1, execute: 0});
});

it("does not apply rejected file edits", async () => {
fixture.setPermissionResponder(createPermissionResponder("edit", ApprovalOptionId.RejectOnce));
const sessionId = await editFileDirectly(fixture, path.join(fixture.workspaceDir, generateFileNameForTest()), false);
const sessionId = await expectFileEditBlocked(fixture, newFilePathIn(fixture.workspaceDir));
expect(fixture.readPermissionRequests(sessionId, "edit").length).toBeGreaterThanOrEqual(1);
expect(fixture.readPermissionRequests(sessionId, "execute")).toHaveLength(0);
});
Expand All @@ -52,13 +56,12 @@ describeE2E("E2E Agent mode file permission tests", () => {
});

it("edits a workspace file without prompting for permission", async () => {
const sessionId = await editFileDirectly(fixture, path.join(fixture.workspaceDir, generateFileNameForTest()), true);
const sessionId = await expectFileEditApplied(fixture, newFilePathIn(fixture.workspaceDir));
expectNoPermissionRequests(fixture, sessionId);
});

it("can't edit file outside workspace", async () => {
const dir = createDirOutsideWorkspace(fixture);
await editFileDirectly(fixture, path.join(dir, generateFileNameForTest()), false);
await expectFileEditBlocked(fixture, newFilePathIn(createDirOutsideWorkspace(fixture)));
});
});

Expand All @@ -74,31 +77,59 @@ describeE2E("E2E Agent with full access file permission tests", () => {
});

it("edits a file outside workspace without prompting for permission", async () => {
const dir = createDirOutsideWorkspace(fixture);
const sessionId = await editFileDirectly(fixture, path.join(dir, generateFileNameForTest()), true);
const sessionId = await expectFileEditApplied(fixture, newFilePathIn(createDirOutsideWorkspace(fixture)));
expectNoPermissionRequests(fixture, sessionId);
});
});

async function editFileDirectly(
fixture: SpawnedAgentFixture,
filePath: string,
expectSuccess: boolean,
): Promise<string> {
async function expectFileEditApplied(fixture: SpawnedAgentFixture, filePath: string): Promise<string> {
const turn = await askAgentToEditFile(fixture, filePath);
expectEndTurn(turn.response);
await vi.waitFor(() => {
expect(fs.existsSync(filePath), turn.diagnostics()).toBe(true);
expect(fs.readFileSync(filePath, "utf8").trim(), turn.diagnostics()).toBe(FILE_CONTENT);
}, {timeout: FILE_APPEARS_TIMEOUT_MS});
return turn.sessionId;
}

async function expectFileEditBlocked(fixture: SpawnedAgentFixture, filePath: string): Promise<string> {
const turn = await askAgentToEditFile(fixture, filePath);
// No end_turn assertion: a declining model may legitimately stop with `refusal`,
// so its stop reason stays diagnostic rather than becoming a second way to fail.
expect(fs.existsSync(filePath), turn.diagnostics()).toBe(false);
return turn.sessionId;
}

interface EditFileTurn {
readonly sessionId: string;
readonly response: acp.PromptResponse;
// The prompt is natural language, so a failure means the model did something other
// than asked. Its stop reason and its own words tell you which, where a bare ENOENT
// cannot say whether it refused, wrote elsewhere or reached for the shell.
diagnostics(): string;
}

async function askAgentToEditFile(fixture: SpawnedAgentFixture, filePath: string): Promise<EditFileTurn> {
const sessionId = (await fixture.createSession()).sessionId;
await fixture.connection.prompt({
const response = await fixture.connection.prompt({
sessionId,
prompt: [{
type: "text",
text: `Create ${filePath} by editing files directly. Content must be exactly: '${FILE_CONTENT}'. Do not use shell commands.`,
}],
});
if (expectSuccess) {
expect(fs.readFileSync(filePath, "utf8").trim()).toBe(FILE_CONTENT);
} else {
expect(fs.existsSync(filePath)).toBe(false);
}
return sessionId;
return {
sessionId,
response,
diagnostics: () => {
const said = fixture.readText(sessionId).trim();
return `stopReason=${response.stopReason}; agent said: ${said.length > 0 ? said : "<nothing>"}`;
},
};
}

function newFilePathIn(directory: string): string {
return path.join(directory, generateFileNameForTest());
}

function createDirOutsideWorkspace(fixture: SpawnedAgentFixture): string {
Expand Down
5 changes: 5 additions & 0 deletions src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface SpawnedAgentFixture {
timeoutMs?: number,
): Promise<void>;
expectStatus(sessionId: string, fields: Record<string, unknown>): Promise<void>;
readText(sessionId: string): string;
readPermissionRequests(
sessionId: string,
toolCallKind: acp.ToolKind,
Expand Down Expand Up @@ -249,6 +250,10 @@ class SpawnedAgentFixtureImpl implements SpawnedAgentFixture {
});
}

readText(sessionId: string): string {
return this.client.readText(sessionId);
}

readPermissionRequests(
sessionId: string,
toolCallKind: acp.ToolKind,
Expand Down