diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aeaf03c..0805138 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -54,3 +54,39 @@ jobs:
- name: Build
run: npm run build
+
+ release-gate:
+ name: MVP release gate (macOS)
+ runs-on: macos-15
+ timeout-minutes: 20
+ env:
+ CODEX_DESKTOP_VERSION: 26.901.41600 (build 7982)
+ CODEX_GIT_REFERENCE_PROFILE: github-actions-macos-15
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Set up Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .node-version
+ cache: npm
+ cache-dependency-path: package-lock.json
+
+ - name: Install repository npm version
+ run: npm install --global npm@11.17.0
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run MVP release gate
+ run: npm run release:gate
+
+ - name: Archive MVP release evidence
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: mvp-release-gate-${{ runner.os }}-${{ github.sha }}
+ path: artifacts/release-gate
+ if-no-files-found: error
+ retention-days: 14
diff --git a/.gitignore b/.gitignore
index c6138ac..145a8ca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
node_modules/
dist/
coverage/
+artifacts/
.DS_Store
*.log
.env
diff --git a/apps/launcher/src/codex-runtime.ts b/apps/launcher/src/codex-runtime.ts
index beb3539..8bd1e06 100644
--- a/apps/launcher/src/codex-runtime.ts
+++ b/apps/launcher/src/codex-runtime.ts
@@ -45,7 +45,12 @@ export async function startCodexRuntime(
options.dedicatedInstance,
);
const result = await new DedicatedCodexHostAdapter({
- connectRenderer: options.connectRenderer ?? connectDedicatedCodexRenderer,
+ connectRenderer:
+ options.connectRenderer ??
+ ((request) =>
+ connectDedicatedCodexRenderer(request, {
+ loadDocument: () => standalone.loadEmbeddedDocument(),
+ })),
instance,
projectPath: options.projectPath,
}).attach({
@@ -66,11 +71,13 @@ export async function startCodexRuntime(
const attachedConnection = connection;
const dedicatedInstance = instance;
connection = null;
- instance = null;
- await Promise.allSettled([
- attachedConnection?.close(),
- dedicatedInstance?.close(),
- ]);
+ try {
+ await attachedConnection?.close();
+ } catch {
+ // Terminate only when native state/CSP could not be restored safely.
+ instance = null;
+ await dedicatedInstance?.close();
+ }
});
}
} catch {
@@ -82,18 +89,21 @@ export async function startCodexRuntime(
healthUrl: standalone.healthUrl,
sessionUrl: standalone.sessionUrl,
surfaceUrl: standalone.surfaceUrl,
+ loadEmbeddedDocument: () => standalone.loadEmbeddedDocument(),
currentHost: () => host,
async close() {
if (closing) {
return;
}
closing = true;
- const results = await Promise.allSettled([
- connection?.close(),
- instance?.close(),
- monitor,
- standalone.close(),
- ]);
+ const results = await Promise.allSettled([connection?.close()]);
+ results.push(
+ ...(await Promise.allSettled([
+ instance?.close(),
+ monitor,
+ standalone.close(),
+ ])),
+ );
const failure = results.find(
(result): result is PromiseRejectedResult =>
result.status === 'rejected',
diff --git a/apps/launcher/src/embedded-assets.test.ts b/apps/launcher/src/embedded-assets.test.ts
new file mode 100644
index 0000000..59563b0
--- /dev/null
+++ b/apps/launcher/src/embedded-assets.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+import { startStandaloneRuntime } from './standalone-runtime.js';
+
+describe('embedded asset CORS boundary', () => {
+ it('allows opaque-origin modules but never exposes bootstrap HTML or fallback HTML', async () => {
+ const runtime = await startStandaloneRuntime({ surfacePort: 0 });
+ try {
+ for (const path of ['/', '/index.html', '/unknown-route']) {
+ const response = await fetch(new URL(path, runtime.surfaceUrl), {
+ headers: { origin: 'null', accept: 'text/html' },
+ });
+ expect(response.headers.get('access-control-allow-origin')).toBeNull();
+ }
+ const module = await fetch(new URL('/src/main.tsx', runtime.surfaceUrl), {
+ headers: { origin: 'null' },
+ });
+ expect(module.headers.get('access-control-allow-origin')).toBe('null');
+ expect(await module.text()).not.toContain(runtime.sessionUrl.pathname);
+ const html = await runtime.loadEmbeddedDocument();
+ expect(html).toContain(runtime.sessionUrl.href);
+ expect(html).toContain(' {
+ if (request.headers.origin === 'null') {
+ const original = response.writeHead;
+ response.writeHead = function (
+ statusCode: number,
+ statusMessageOrHeaders?:
+ string | OutgoingHttpHeaders | OutgoingHttpHeader[],
+ extraHeaders?: OutgoingHttpHeaders | OutgoingHttpHeader[],
+ ) {
+ const headers =
+ typeof statusMessageOrHeaders === 'string'
+ ? extraHeaders
+ : statusMessageOrHeaders;
+ const args =
+ typeof statusMessageOrHeaders === 'string'
+ ? [statusCode, statusMessageOrHeaders, extraHeaders]
+ : [statusCode, headers];
+ const explicitType =
+ headers !== undefined &&
+ typeof headers === 'object' &&
+ !Array.isArray(headers)
+ ? (headers['content-type'] ?? headers['Content-Type'])
+ : undefined;
+ const type = Array.isArray(headers)
+ ? ''
+ : String(
+ explicitType ?? response.getHeader('content-type') ?? '',
+ ).split(';')[0];
+ if (
+ type === 'text/javascript' ||
+ type === 'application/javascript' ||
+ type === 'text/css'
+ ) {
+ response.setHeader('access-control-allow-origin', 'null');
+ response.setHeader('vary', 'Origin');
+ }
+ return Reflect.apply(original, response, args);
+ };
+ }
+ next();
+ });
+ },
+ };
+}
diff --git a/apps/launcher/src/standalone-runtime.ts b/apps/launcher/src/standalone-runtime.ts
index ff4c1eb..a042ec4 100644
--- a/apps/launcher/src/standalone-runtime.ts
+++ b/apps/launcher/src/standalone-runtime.ts
@@ -1,5 +1,5 @@
import { execFile } from 'node:child_process';
-import { lstat, realpath } from 'node:fs/promises';
+import { lstat, realpath, readFile } from 'node:fs/promises';
import type { Server } from 'node:http';
import { isAbsolute, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -23,6 +23,7 @@ import { startLoopbackServer, type LoopbackServer } from '@codex-git/server';
import { StandaloneHostAdapter } from '@codex-git/host-adapter-standalone';
import { createServer as createViteServer, type ViteDevServer } from 'vite';
+import { embeddedAssetsPlugin } from './embedded-assets.js';
import { protocolBootstrapPlugin } from './protocol-bootstrap.js';
import { toProtocolRepositorySnapshot } from './repository-protocol-adapter.js';
@@ -43,6 +44,7 @@ export interface StandaloneRuntime {
readonly healthUrl: URL;
readonly sessionUrl: URL;
readonly surfaceUrl: URL;
+ loadEmbeddedDocument(): Promise;
close(): Promise;
}
@@ -119,12 +121,14 @@ export async function startStandaloneRuntime(
surfaceServer = await createViteServer({
configFile: uiConfigPath,
plugins: [
+ embeddedAssetsPlugin(),
protocolBootstrapPlugin(protocolServer.sessionUrl, options.projectPath),
],
server: {
host: loopbackHost,
port: options.surfacePort ?? 5173,
strictPort: true,
+ cors: false,
},
});
await surfaceServer.listen();
@@ -143,6 +147,18 @@ export async function startStandaloneRuntime(
healthUrl: protocolServer.healthUrl,
sessionUrl: protocolServer.sessionUrl,
surfaceUrl,
+ async loadEmbeddedDocument() {
+ if (closed) throw new Error('The surface is closed.');
+ const source = await readFile(
+ new URL('../../ui/index.html', import.meta.url),
+ 'utf8',
+ );
+ const html = await surfaceServer!.transformIndexHtml(
+ surfaceUrl.href,
+ source,
+ );
+ return html.replace('', ``);
+ },
async close() {
if (closed) {
return;
diff --git a/apps/ui/src/RepositoryOverview.interactions.test.tsx b/apps/ui/src/RepositoryOverview.interactions.test.tsx
index 7cb5b51..18af0b0 100644
--- a/apps/ui/src/RepositoryOverview.interactions.test.tsx
+++ b/apps/ui/src/RepositoryOverview.interactions.test.tsx
@@ -3,7 +3,12 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { operationIdSchema, refIdSchema } from '@codex-git/protocol';
+import {
+ operationIdSchema,
+ refIdSchema,
+ worktreeIdSchema,
+ worktreeGenerationSchema,
+} from '@codex-git/protocol';
import { App } from './overview.js';
import { createOverviewFixture } from './overview-fixtures.js';
@@ -25,6 +30,65 @@ describe('Repository overview interactions', () => {
container.remove();
});
+ it('preserves row and search focus across renewed unavailable identities', () => {
+ const fixture = createOverviewFixture('unavailable-worktree');
+ const store = createRepositoryStore(fixture.source);
+ act(() => root.render());
+ const missing = button(
+ 'Select missing-worktree Worktree at /private/tmp/missing-worktree',
+ );
+ act(() => missing.click());
+ missing.focus();
+ const source = fixture.source.getSnapshot();
+ if (source.kind !== 'repository') throw new Error('Expected Repository');
+ const renew = (digit: string) =>
+ act(() =>
+ fixture.publish({
+ ...source,
+ snapshot: {
+ ...source.snapshot,
+ worktrees: source.snapshot.worktrees.map((w) =>
+ w.status.kind !== 'unavailable'
+ ? w
+ : {
+ ...w,
+ worktreeId: worktreeIdSchema.parse(
+ `worktree_${digit.repeat(32)}`,
+ ),
+ generation: worktreeGenerationSchema.parse(
+ `generation_${digit.repeat(32)}`,
+ ),
+ },
+ ),
+ },
+ }),
+ );
+ renew('a');
+ expect(document.activeElement).toBe(
+ button(
+ 'Select missing-worktree Worktree at /private/tmp/missing-worktree',
+ ),
+ );
+ act(() =>
+ document.activeElement!.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
+ ),
+ );
+ expect(store.getSnapshot().selectedWorktreeId).toBe(
+ source.snapshot.worktrees[0]!.worktreeId,
+ );
+ act(() => missing.click());
+ const search = container.querySelector(
+ 'input[type="search"]',
+ )!;
+ search.focus();
+ renew('b');
+ expect(document.activeElement).toBe(search);
+ expect(store.getSnapshot().selectedWorktreeId).toBe(
+ worktreeIdSchema.parse(`worktree_${'b'.repeat(32)}`),
+ );
+ });
+
it('confirms the exact Remote and same-name target before Publish', async () => {
const fixture = createOverviewFixture('one-worktree');
const current = fixture.source.getSnapshot();
diff --git a/apps/ui/src/RepositoryOverview.tsx b/apps/ui/src/RepositoryOverview.tsx
index 8ee7c89..17377f9 100644
--- a/apps/ui/src/RepositoryOverview.tsx
+++ b/apps/ui/src/RepositoryOverview.tsx
@@ -284,7 +284,22 @@ export function RepositoryOverview({
{visibleWorktrees.map((worktree) => (
- -
+
-