Skip to content
Draft
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
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
node_modules/
dist/
coverage/
artifacts/
.DS_Store
*.log
.env
Expand Down
34 changes: 22 additions & 12 deletions apps/launcher/src/codex-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 {
Expand All @@ -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',
Expand Down
26 changes: 26 additions & 0 deletions apps/launcher/src/embedded-assets.test.ts
Original file line number Diff line number Diff line change
@@ -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('<base href=');
} finally {
await runtime.close();
}
});
});
53 changes: 53 additions & 0 deletions apps/launcher/src/embedded-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { OutgoingHttpHeaders, OutgoingHttpHeader } from 'node:http';
import type { Plugin } from 'vite';

// Only public JavaScript/CSS may be read by sandboxed frames. HTML contains
// the protocol capability and must never receive opaque-origin CORS headers.
export function embeddedAssetsPlugin(): Plugin {
return {
name: 'codex-git-embedded-assets',
configureServer(server) {
server.middlewares.use((request, response, next) => {
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();
});
},
};
}
18 changes: 17 additions & 1 deletion apps/launcher/src/standalone-runtime.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand All @@ -43,6 +44,7 @@ export interface StandaloneRuntime {
readonly healthUrl: URL;
readonly sessionUrl: URL;
readonly surfaceUrl: URL;
loadEmbeddedDocument(): Promise<string>;
close(): Promise<void>;
}

Expand Down Expand Up @@ -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();
Expand All @@ -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('<head>', `<head><base href="${surfaceUrl.href}">`);
},
async close() {
if (closed) {
return;
Expand Down
66 changes: 65 additions & 1 deletion apps/ui/src/RepositoryOverview.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(<App store={store} />));
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<HTMLInputElement>(
'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();
Expand Down
17 changes: 16 additions & 1 deletion apps/ui/src/RepositoryOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,22 @@ export function RepositoryOverview({
</label>
<ul>
{visibleWorktrees.map((worktree) => (
<li key={worktree.worktreeId}>
<li
key={
(
worktree.availability === undefined
? worktree.status.kind === 'unavailable'
: worktree.availability.kind === 'unavailable'
)
? JSON.stringify([
snapshot.repositoryId,
worktree.role,
worktree.path,
'unavailable',
])
: worktree.worktreeId
}
>
<button
aria-current={
worktree.worktreeId === state.selectedWorktreeId
Expand Down
Loading
Loading