Merge origin/main into deferred OpenCode restart branch

This commit is contained in:
Bohdan Triapitsyn
2026-08-07 10:08:50 +03:00
218 changed files with 12131 additions and 1293 deletions
+27
View File
@@ -1079,6 +1079,19 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
}
return [...new Set(directories)];
},
// A managed restart can move OpenCode to a NEW port (the old one may stay
// occupied by an orphaned process, e.g. killProcessOnPort is a no-op on
// Windows). Rebind the message-stream upstream readers to the current port
// so the UI keeps receiving events instead of staying pinned to the old
// process (#2638). The runtime is created later by the startup pipeline;
// by the time any restart runs, it is assigned.
onOpenCodeRestarted: () => {
try {
messageStreamRuntime?.rebindUpstream();
} catch (error) {
console.warn('Failed to rebind message stream after OpenCode restart:', error?.message ?? error);
}
},
getManagedOpenCodeEnv: async () => {
const settings = await readSettingsFromDiskMigrated().catch(() => null);
const managedEnv = settings?.agentControlToolEnabled === false
@@ -1474,6 +1487,10 @@ async function main(options = {}) {
// relay candidate lazily at request time, so a late-bound holder is enough.
let relayServiceInstance = null;
// Same pattern for the tunnel runtime: created after the base routes so
// /api/system/info resolves port + tunnel URL lazily at request time.
let tunnelRuntimeContextHolder = null;
const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, {
process,
openchamberVersion: OPENCHAMBER_VERSION,
@@ -1510,6 +1527,15 @@ async function main(options = {}) {
apiOnly,
};
},
// Port this instance serves on and the active tunnel's public URL (if
// any), for /api/system/info. Resolved lazily because the tunnel runtime
// is created after these base routes are registered.
getServerPort: () => {
const activePort = tunnelRuntimeContextHolder?.getActivePort?.();
if (Number.isFinite(activePort) && activePort > 0) return activePort;
return Number.isFinite(port) && port > 0 ? port : null;
},
getTunnelUrl: () => tunnelRuntimeContextHolder?.tunnelService?.getPublicUrl?.() ?? null,
verboseRequestLogs: OPENCHAMBER_VERBOSE_REQUEST_LOGS,
uiPassword,
tunnelAuthController,
@@ -1585,6 +1611,7 @@ async function main(options = {}) {
const tunnelRuntimeContext = tunnelWiringRuntime.initialize(app, port);
const { tunnelService, startTunnelWithNormalizedRequest } = tunnelRuntimeContext;
tunnelRuntimeContextHolder = tunnelRuntimeContext;
// Private relay host service: config + management routes + host client
// lifecycle. Loopback port comes from the same source the tunnel uses so
@@ -0,0 +1,186 @@
import { EventEmitter } from 'node:events';
import { describe, expect, it, vi } from 'vitest';
import { createGlobalMessageStreamHub } from './global-hub.js';
import { createMessageStreamWsRuntime } from './runtime.js';
class FakeSocket extends EventEmitter {
constructor() {
super();
this.readyState = 1;
this.sent = [];
this.closeCalls = [];
}
send(payload) {
this.sent.push(JSON.parse(payload));
}
ping() {
void 0;
}
close(code, reason) {
if (this.readyState === 3) {
return;
}
this.readyState = 3;
this.closeCalls.push({ code, reason });
this.emit('close');
}
}
function createSseResponse({ blocks = [], signal, holdOpen = false }) {
const encoder = new TextEncoder();
let index = 0;
return {
ok: true,
body: {
getReader() {
return {
async read() {
if (index < blocks.length) {
const next = blocks[index++];
return { value: encoder.encode(next), done: false };
}
if (!holdOpen) {
return { value: undefined, done: true };
}
return new Promise((resolve, reject) => {
const onAbort = () => {
signal.removeEventListener('abort', onAbort);
const error = new Error('Aborted');
error.name = 'AbortError';
reject(error);
};
signal.addEventListener('abort', onAbort, { once: true });
});
},
};
},
},
};
}
describe('rebindUpstream (#2638)', () => {
it('restarts the shared hub upstream so a connected client resumes receiving events on the new port', async () => {
const server = new EventEmitter();
const wsClients = new Set();
let port = 4096;
let fetchCalls = 0;
// Port changes after a managed restart: buildOpenCodeUrl resolves the
// CURRENT port on every attempt, exactly like production network-runtime.
const buildOpenCodeUrl = vi.fn(() => `http://127.0.0.1:${port}/global/event`);
const fetchImpl = vi.fn(async (_url, options) => {
fetchCalls += 1;
if (fetchCalls === 1) {
return createSseResponse({
signal: options.signal,
holdOpen: true,
blocks: ['id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n'],
});
}
return createSseResponse({
signal: options.signal,
holdOpen: true,
blocks: ['id: evt-2\ndata: {"type":"session.updated","properties":{"sessionID":"ses_1"}}\n\n'],
});
});
const globalHub = createGlobalMessageStreamHub({
buildOpenCodeUrl,
getOpenCodeAuthHeaders: () => ({}),
fetchImpl,
upstreamReconnectDelayMs: 0,
});
const runtime = createMessageStreamWsRuntime({
server,
uiAuthController: null,
isRequestOriginAllowed: async () => true,
rejectWebSocketUpgrade() {
throw new Error('upgrade should not be used in this test');
},
globalEventHub: globalHub,
buildOpenCodeUrl,
getOpenCodeAuthHeaders: () => ({}),
processForwardedEventPayload() {},
wsClients,
heartbeatIntervalMs: 5000,
upstreamReconnectDelayMs: 0,
fetchImpl,
});
const socket = new FakeSocket();
runtime.wsServer.emit('connection', socket, { url: '/api/global/event/ws' });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fetchCalls).toBe(1);
expect(socket.sent.some((frame) => frame.type === 'event' && frame.eventId === 'evt-1')).toBe(true);
// The managed process was restarted onto a new port while the old
// process's SSE stream stays open (orphaned survivor).
port = 5000;
runtime.rebindUpstream();
await new Promise((resolve) => setTimeout(resolve, 20));
// The hub dialed the new port and the connected client received events
// from the new upstream without reconnecting its own socket.
expect(fetchCalls).toBe(2);
expect(fetchImpl.mock.calls[1][0]).toContain(':5000/global/event');
expect(socket.sent.some((frame) => frame.type === 'event' && frame.eventId === 'evt-2')).toBe(true);
socket.close();
await runtime.close();
});
it('closes directory-scoped sockets so their pinned readers reconnect to the new port', async () => {
const server = new EventEmitter();
const wsClients = new Set();
let port = 4096;
let fetchCalls = 0;
const buildOpenCodeUrl = vi.fn(() => `http://127.0.0.1:${port}/event`);
const fetchImpl = vi.fn(async (_url, options) => {
fetchCalls += 1;
return createSseResponse({
signal: options.signal,
holdOpen: true,
blocks: ['id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n'],
});
});
const runtime = createMessageStreamWsRuntime({
server,
uiAuthController: null,
isRequestOriginAllowed: async () => true,
rejectWebSocketUpgrade() {
throw new Error('upgrade should not be used in this test');
},
buildOpenCodeUrl,
getOpenCodeAuthHeaders: () => ({}),
processForwardedEventPayload() {},
wsClients,
heartbeatIntervalMs: 5000,
upstreamReconnectDelayMs: 0,
fetchImpl,
});
const directorySocket = new FakeSocket();
runtime.wsServer.emit('connection', directorySocket, { url: '/api/event/ws?directory=%2Fproj' });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fetchCalls).toBe(1);
port = 5000;
runtime.rebindUpstream();
expect(directorySocket.readyState).toBe(3);
expect(directorySocket.closeCalls.length).toBeGreaterThan(0);
directorySocket.close();
await runtime.close();
});
});
@@ -70,6 +70,12 @@ export function createMessageStreamWsRuntime({
noServer: true,
});
// Directory-scoped streams create one upstream reader per client
// connection. Track those sockets so a managed OpenCode restart can close
// them: each reader is pinned to the port it connected at and would
// otherwise keep streaming from an orphaned process on the old port (#2638).
const directorySockets = new Set();
const ownsGlobalHub = !globalEventHub;
const globalHub = globalEventHub ?? createGlobalMessageStreamHub({
buildOpenCodeUrl,
@@ -103,6 +109,11 @@ export function createMessageStreamWsRuntime({
return;
}
directorySockets.add(socket);
socket.on('close', () => {
directorySockets.delete(socket);
});
acceptDirectoryMessageStreamWsConnection({
socket,
requestedLastEventId,
@@ -156,6 +167,27 @@ export function createMessageStreamWsRuntime({
return {
wsServer,
/**
* Rebind all upstream readers to the current OpenCode port. Called after
* a managed process restart: the restart can land on a NEW port while
* the old process (or an orphaned survivor of it) still holds the
* previous one, and a healthy-but-pinned SSE connection never notices —
* so the UI would stop receiving events until the app restarts (#2638).
* Restarting the shared hub re-dials `buildOpenCodeUrl` (which reads the
* current port) on its next attempt; directory-scoped readers are
* rebuilt by closing their client sockets, which reconnect with
* `Last-Event-ID` and re-establish the stream against the new port.
*/
rebindUpstream() {
globalHub.stop();
globalHub.start();
for (const socket of Array.from(directorySockets)) {
try {
socket.close(1012, 'OpenCode upstream restarted');
} catch {
}
}
},
async close() {
server.off('upgrade', upgradeHandler);
globalBridge.close();
@@ -34,4 +34,6 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
## Notes for contributors
- Keep filesystem policy (workspace root checks, error mapping, exec timeout behavior) inside this module, not in the composition root.
- Filesystem `EPERM`/`EACCES` failures use the stable `reason: "os-permission"` response marker. Policy denials such as workspace-boundary or missing-grant failures must not use that marker because a native folder picker cannot remediate them.
- If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document.
- `GET /api/fs/list` may resolve symlinks with `realpath` to read directory contents, but the response `path` and each entry `path` must stay in the caller's requested path space (`path.join(requestedPath, name)`). Returning real paths breaks file-tree expansion for directories reached through workspace symlinks.
+78 -31
View File
@@ -16,6 +16,16 @@ const pruneOutsideFileGrants = () => {
}
};
const isOsPermissionError = (error) => (
error
&& typeof error === 'object'
&& (error.code === 'EACCES' || error.code === 'EPERM')
);
const sendOsPermissionDenied = (res, message) => (
res.status(403).json({ error: message, reason: 'os-permission' })
);
export const mintOutsideFileGrant = async (targetPath, {
scopes = ['stat', 'read', 'raw'],
fsPromises = nodeFsPromises,
@@ -382,6 +392,7 @@ export const registerFsRoutes = (app, dependencies) => {
path,
fsPromises,
spawn,
platform = process.platform,
crypto,
normalizeDirectoryPath,
resolveProjectDirectory,
@@ -393,6 +404,27 @@ export const registerFsRoutes = (app, dependencies) => {
realpath: fsPromises.realpath.bind(fsPromises),
});
const spawnDetached = (command, args) => new Promise((resolve, reject) => {
let child;
try {
child = spawn(command, args, { windowsHide: true, stdio: 'ignore', detached: true });
} catch (error) {
reject(new Error('Failed to launch file browser', { cause: error }));
return;
}
const onError = (error) => {
child.removeListener('spawn', onSpawn);
reject(new Error('Failed to launch file browser', { cause: error }));
};
const onSpawn = () => {
child.removeListener('error', onError);
child.unref();
resolve();
};
child.once('error', onError);
child.once('spawn', onSpawn);
});
const execJobs = new Map();
const commandTimeoutMs = createCommandTimeoutMs();
const gitReadCacheTtlMs = createGitReadCacheTtlMs();
@@ -454,7 +486,7 @@ export const registerFsRoutes = (app, dependencies) => {
// Non-cacheable commands always execute and are never stored.
const runCommandWithGitReadCache = async ({ shell, shellFlag, command, resolvedCwd }) => {
const cacheable = gitReadCacheTtlMs > 0 && isCacheableGitReadCommand(command);
const cacheKey = cacheable ? `${resolvedCwd}${normalizeCommand(command)}` : null;
const cacheKey = cacheable ? `${resolvedCwd}${normalizeCommand(command)}` : null;
if (cacheKey) {
const cached = gitReadCache.get(cacheKey);
@@ -583,6 +615,9 @@ export const registerFsRoutes = (app, dependencies) => {
await fsPromises.mkdir(resolvedPath, { recursive: true });
return res.json({ success: true, path: resolvedPath });
} catch (error) {
if (isOsPermissionError(error)) {
return sendOsPermissionDenied(res, 'Access denied');
}
console.error('Failed to create directory:', error);
return res.status(500).json({ error: error.message || 'Failed to create directory' });
}
@@ -746,8 +781,8 @@ export const registerFsRoutes = (app, dependencies) => {
}
return res.status(404).json({ error: 'File not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access to file denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to file denied');
}
console.error('Failed to stat file:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to stat file' });
@@ -818,8 +853,8 @@ export const registerFsRoutes = (app, dependencies) => {
}
return res.status(404).json({ error: 'File not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access to file denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to file denied');
}
console.error('Failed to read file:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
@@ -903,8 +938,8 @@ export const registerFsRoutes = (app, dependencies) => {
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'File not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access to file denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to file denied');
}
console.error('Failed to read raw file:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
@@ -964,8 +999,8 @@ export const registerFsRoutes = (app, dependencies) => {
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'File not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access to file denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to file denied');
}
console.error('Failed to serve file:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to serve file' });
@@ -1026,8 +1061,8 @@ export const registerFsRoutes = (app, dependencies) => {
return res.json({ success: true, path: resolved.resolved });
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access denied');
}
console.error('Failed to write file:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to write file' });
@@ -1061,8 +1096,8 @@ export const registerFsRoutes = (app, dependencies) => {
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'File or directory not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access denied');
}
console.error('Failed to delete path:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to delete path' });
@@ -1116,8 +1151,8 @@ export const registerFsRoutes = (app, dependencies) => {
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'Source path not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access denied');
}
console.error('Failed to rename path:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to rename path' });
@@ -1134,13 +1169,12 @@ export const registerFsRoutes = (app, dependencies) => {
const resolved = path.resolve(targetPath.trim());
await fsPromises.access(resolved);
const platform = process.platform;
if (platform === 'darwin') {
const stat = await fsPromises.stat(resolved);
if (stat.isDirectory()) {
spawn('open', [resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
await spawnDetached('open', [resolved]);
} else {
spawn('open', ['-R', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
await spawnDetached('open', ['-R', resolved]);
}
} else if (platform === 'win32') {
const stat = await fsPromises.stat(resolved);
@@ -1164,7 +1198,7 @@ export const registerFsRoutes = (app, dependencies) => {
} else {
const stat = await fsPromises.stat(resolved);
const dir = stat.isDirectory() ? resolved : path.dirname(resolved);
spawn('xdg-open', [dir], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
await spawnDetached('xdg-open', [dir]);
}
return res.json({ success: true, path: resolved });
@@ -1173,6 +1207,9 @@ export const registerFsRoutes = (app, dependencies) => {
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'Path not found' });
}
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to path denied');
}
console.error('Failed to reveal path:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to reveal path' });
}
@@ -1296,6 +1333,11 @@ export const registerFsRoutes = (app, dependencies) => {
? req.query.path.trim()
: os.homedir();
const respectGitignore = req.query.respectGitignore === 'true';
// Logical (requested) path stays in the caller's path space. Realpath is
// only used to read directory contents — returning real paths for entries
// breaks file-tree expansion when listing through a symlink, because the
// UI rejects expanded paths that fall outside the workspace root.
let requestedPath = '';
let resolvedPath = '';
const isPlansDirectory = (value) => {
@@ -1305,11 +1347,12 @@ export const registerFsRoutes = (app, dependencies) => {
};
try {
resolvedPath = await realpathCache.resolve(path.resolve(normalizeDirectoryPath(rawPath)));
requestedPath = path.resolve(normalizeDirectoryPath(rawPath));
resolvedPath = await realpathCache.resolve(requestedPath);
const stats = await fsPromises.stat(resolvedPath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Specified path is not a directory' });
return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' });
}
const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true });
@@ -1364,8 +1407,8 @@ export const registerFsRoutes = (app, dependencies) => {
const entries = await Promise.all(
dirents.map(async (dirent) => {
const entryPath = path.join(resolvedPath, dirent.name);
if (respectGitignore && ignoredPaths.has(entryPath)) {
const physicalEntryPath = path.join(resolvedPath, dirent.name);
if (respectGitignore && ignoredPaths.has(physicalEntryPath)) {
return null;
}
@@ -1374,7 +1417,7 @@ export const registerFsRoutes = (app, dependencies) => {
if (!isDirectory && isSymbolicLink) {
try {
const linkStats = await fsPromises.stat(entryPath);
const linkStats = await fsPromises.stat(physicalEntryPath);
isDirectory = linkStats.isDirectory();
} catch {
isDirectory = false;
@@ -1383,7 +1426,7 @@ export const registerFsRoutes = (app, dependencies) => {
return {
name: dirent.name,
path: entryPath,
path: path.join(requestedPath, dirent.name),
isDirectory,
isFile: dirent.isFile(),
isSymbolicLink,
@@ -1392,24 +1435,28 @@ export const registerFsRoutes = (app, dependencies) => {
);
return res.json({
path: resolvedPath,
path: requestedPath,
entries: entries.filter(Boolean),
});
} catch (error) {
const err = error;
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
const isPlansPath = code === 'ENOENT' && (isPlansDirectory(resolvedPath) || isPlansDirectory(rawPath));
const isPlansPath = code === 'ENOENT' && (
isPlansDirectory(resolvedPath)
|| isPlansDirectory(requestedPath)
|| isPlansDirectory(rawPath)
);
if (code !== 'ENOENT') {
console.error('Failed to list directory:', error);
}
if (code === 'ENOENT') {
if (isPlansPath) {
return res.json({ path: resolvedPath || rawPath, entries: [] });
return res.json({ path: requestedPath || resolvedPath || rawPath, entries: [] });
}
return res.status(404).json({ error: 'Directory not found' });
return res.status(404).json({ error: 'Directory not found', reason: 'not-found' });
}
if (code === 'EACCES') {
return res.status(403).json({ error: 'Access to directory denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to directory denied');
}
return res.status(500).json({ error: (error && error.message) || 'Failed to list directory' });
}
+188
View File
@@ -200,6 +200,27 @@ const registerMkdir = (fsPromises) => {
return getRoute('POST', '/api/fs/mkdir');
};
const registerReveal = ({ fsPromises, spawn, platform = 'linux' }) => {
const { app, getRoute } = createRouteRegistry();
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
...fsPromises,
},
spawn,
platform,
crypto: { randomUUID: () => 'job-0' },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: async () => ({ directory: '/repo' }),
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
});
return getRoute('POST', '/api/fs/reveal');
};
const callExec = async (handler, body) => {
const res = createMockResponse();
await handler({ body }, res);
@@ -230,6 +251,12 @@ const callMkdir = async (handler, body) => {
return res;
};
const callReveal = async (handler, body) => {
const res = createMockResponse();
await handler({ body }, res);
return res;
};
describe('fs write', () => {
it('does not rewrite a file when content is unchanged', async () => {
const fsPromises = {
@@ -431,6 +458,77 @@ describe('fs read', () => {
});
});
describe('fs reveal', () => {
it.each([
['linux', 'xdg-open', ['/repo']],
['darwin', 'open', ['-R', '/repo/file.txt']],
])('returns a controlled error when the %s launcher is unavailable', async (platform, command, args) => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const child = new EventEmitter();
child.unref = vi.fn();
const spawn = vi.fn(() => {
queueMicrotask(() => child.emit('error', Object.assign(new Error('not found'), { code: 'ENOENT' })));
return child;
});
const handler = registerReveal({
fsPromises: {
access: vi.fn(async () => undefined),
stat: vi.fn(async () => ({ isDirectory: () => false })),
},
spawn,
platform,
});
const res = await callReveal(handler, { path: '/repo/file.txt' });
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: 'Failed to launch file browser' });
expect(spawn).toHaveBeenCalledWith(command, args, { windowsHide: true, stdio: 'ignore', detached: true });
expect(child.unref).not.toHaveBeenCalled();
error.mockRestore();
});
it('unrefs a detached launcher only after it spawns successfully', async () => {
const child = new EventEmitter();
child.unref = vi.fn();
const spawn = vi.fn(() => {
queueMicrotask(() => child.emit('spawn'));
return child;
});
const handler = registerReveal({
fsPromises: {
access: vi.fn(async () => undefined),
stat: vi.fn(async () => ({ isDirectory: () => false })),
},
spawn,
});
const res = await callReveal(handler, { path: '/repo/file.txt' });
expect(res.body).toEqual({ success: true, path: '/repo/file.txt' });
expect(child.unref).toHaveBeenCalledOnce();
});
it('returns a controlled error when the launcher throws synchronously', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const spawnError = Object.assign(new Error('not found'), { code: 'ENOENT' });
const handler = registerReveal({
fsPromises: {
access: vi.fn(async () => undefined),
stat: vi.fn(async () => ({ isDirectory: () => false })),
},
spawn: vi.fn(() => { throw spawnError; }),
});
const res = await callReveal(handler, { path: '/repo/file.txt' });
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: 'Failed to launch file browser' });
expect(error).toHaveBeenCalledWith('Failed to reveal path:', expect.objectContaining({ cause: spawnError }));
error.mockRestore();
});
});
describe('fs exec git-read cache', () => {
beforeEach(() => {
delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS;
@@ -635,3 +733,93 @@ describe('fs raw download Content-Disposition', () => {
expect(cd).toContain("filename*=UTF-8''readme.txt");
});
});
describe('fs list symlink path space (issue 2627)', () => {
const registerList = (fsPromises) => {
const { app, getRoute } = createRouteRegistry();
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
...fsPromises,
},
spawn: vi.fn(),
crypto: { randomUUID: () => 'job-0' },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: async () => ({ directory: '/workspace' }),
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
});
return getRoute('GET', '/api/fs/list');
};
const callList = async (handler, query) => {
const res = createMockResponse();
await handler({ query }, res);
return res;
};
it('keeps entry paths in the requested path space when listing through a symlink', async () => {
const dirents = [
{
name: 'src',
isDirectory: () => true,
isSymbolicLink: () => false,
isFile: () => false,
},
{
name: 'README.md',
isDirectory: () => false,
isSymbolicLink: () => false,
isFile: () => true,
},
];
const fsPromises = {
realpath: vi.fn(async (targetPath) => (
targetPath === '/workspace/pkg' ? '/real/pkg' : targetPath
)),
stat: vi.fn(async () => ({ isDirectory: () => true })),
readdir: vi.fn(async () => dirents),
};
const handler = registerList(fsPromises);
const res = await callList(handler, { path: '/workspace/pkg' });
expect(res.statusCode).toBe(200);
expect(res.body.path).toBe('/workspace/pkg');
expect(res.body.entries).toEqual([
{
name: 'src',
path: '/workspace/pkg/src',
isDirectory: true,
isFile: false,
isSymbolicLink: false,
},
{
name: 'README.md',
path: '/workspace/pkg/README.md',
isDirectory: false,
isFile: true,
isSymbolicLink: false,
},
]);
expect(fsPromises.readdir).toHaveBeenCalledWith('/real/pkg', { withFileTypes: true });
});
for (const code of ['EACCES', 'EPERM']) {
it(`maps ${code} to the os-permission contract`, async () => {
const error = Object.assign(new Error('denied'), { code });
const handler = registerList({
stat: vi.fn(async () => ({ isDirectory: () => true })),
readdir: vi.fn(async () => { throw error; }),
});
const res = await callList(handler, { path: '/workspace/protected' });
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' });
});
}
});
+9 -1
View File
@@ -48,7 +48,7 @@ The following functions are exported and used by the web server:
### Worktree Operations
- `getWorktrees(directory)`: List all git worktrees for a repository.
- `validateWorktreeCreate(directory, input)`: Validate worktree creation parameters (mode, branchName, startRef, upstream config).
- `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup).
- `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup). After populating the worktree, the repository's `post-checkout` hook runs once with git's standard arguments (null ref as previous HEAD, the checked-out HEAD, and flag `1`) from the worktree directory, mirroring `git worktree add` without `--no-checkout`; a missing or non-executable hook is skipped and a failing hook is logged as a warning, never failing worktree creation or the session bootstrap.
- `removeWorktree(directory, input)`: Remove a worktree (optionally delete local branch).
- `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary).
@@ -95,6 +95,7 @@ The following functions are internal helpers used by exported functions:
- `resolveCandidateDirectory(...)`: Generate unique worktree directory candidates.
- `resolveBranchForExistingMode(...)`: Resolve branch for existing-mode worktree creation.
- `applyUpstreamConfiguration(...)`: Set upstream tracking for new branches.
- `runPostCheckoutHook(directory)`: Invoke the worktree's `post-checkout` hook after population, because `git worktree add --no-checkout` and the bootstrap's `git reset --hard` never run git hooks. Runs with git's standard arguments and the worktree as cwd; skips missing/non-executable hooks and never throws on hook failure.
- And various other internal helpers for Git command execution and parsing.
## Response Contracts
@@ -111,6 +112,12 @@ The following functions are internal helpers used by exported functions:
- `mergeInProgress`: Object with `{ head, message }` if merge in progress.
- `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress.
### Branches Response
- `all`: Local branches plus remote-tracking branches that still exist on their remote. A remote that fails to answer keeps its branches in the list: "we could not ask" must not be reported as "these branches are gone", because callers use this list to decide whether a base branch exists at all.
- `current`: Current branch name.
- `branches`: Per-branch detail keyed by branch name, as reported by `git branch`.
- `defaultBranches`: Each remote's default branch, keyed by remote name. Read from the local `remotes/<name>/HEAD` symbolic ref; for a remote that has none — clone writes it, a hand-added remote may not — the remote itself is asked once with `ls-remote --symref`. A remote that answers neither is absent rather than guessed, and consumers fall back to conventional branch names. Omitted entirely by runtimes that do not provide this Git metadata.
### Runtime availability of range diffs
- `GET /api/git/range-diff` is served by the OpenChamber web server, so it is available to web, desktop, and mobile clients. The shared `GitAPI.getGitRangeDiff` is therefore optional: web supplies the HTTP implementation, and VS Code does not implement it because the extension host serves Git through its own bridge rather than these routes. Features built on range diffs (currently the AI diff walkthrough) are not offered in VS Code.
@@ -129,6 +136,7 @@ The following functions are internal helpers used by exported functions:
- Fast-create background failures remove OpenCode sandbox metadata for directories that never became Git worktrees, and remove the pre-created directory only if it is still empty. User-created files are never recursively deleted by this cleanup.
- Worktree removal waits for any active create/bootstrap task for that directory before deleting it, preventing a background Git or setup task from restoring removed state or racing filesystem cleanup.
- Worktree bootstrap retries transient `index.lock` conflicts. If the lock remains byte-for-byte and metadata-identical across the retry window, it is treated as stale, removed, and population continues automatically; changing locks are left untouched and reported as failures.
- Worktree population enables Git `core.longpaths` (local repo config plus `-c core.longpaths=true` on `git reset --hard`) so deeply nested checkouts under the managed data-dir worktree root do not fail on Windows MAX_PATH with "Filename too long". Path-component limits that the filesystem itself rejects still fail bootstrap, with a clearer path-length guidance message.
### Log Response
- `all`: Array of commit objects with hash, date, message, author info, stats.
@@ -0,0 +1,206 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
createWorktree,
ensureWorktreeLongpaths,
getWorktreeBootstrapStatus,
populateWorktreeWithLockRecovery,
} from './service.js';
// ---------------------------------------------------------------------------
// Regression for https://github.com/openchamber/openchamber/issues/2746
//
// "[Bug] new worktree Filename too long"
//
// OpenChamber places worktrees under:
// <XDG_DATA_HOME>/opencode/worktree/<40-char root commit hash>/<worktree name>
// and populates them with `git reset --hard`. On Windows, that deep prefix plus
// a deeply nested repo file (e.g. yudao ~173 chars) exceeds MAX_PATH (260) and
// git aborts with "Filename too long" unless `core.longpaths` is enabled.
// ---------------------------------------------------------------------------
const tempDirs = [];
const createTempDir = () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-issue2746-'));
tempDirs.push(dir);
return dir;
};
const runGit = (cwd, args, input) =>
execFileSync('git', args, {
cwd,
encoding: 'utf8',
input,
stdio: ['pipe', 'pipe', 'pipe'],
});
const canRunGit = () => {
try {
execFileSync('git', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('issue #2746 - worktree long path support', () => {
it('enables core.longpaths and populates a deeply nested worktree checkout', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const dataHome = createTempDir();
process.env.XDG_DATA_HOME = dataHome;
try {
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
// Realistic reporter path: many nested segments, each component well under
// NAME_MAX. On Windows the managed worktree prefix + this relative path
// exceeds MAX_PATH unless core.longpaths is enabled.
const deepRelative = path.join(
'server',
'yudao-framework',
'yudao-spring-boot-starter-biz-data-permission',
'src',
'main',
'java',
'cn',
'iocoder',
'yudao',
'framework',
'datapermission',
'config',
'YudaoDataPermissionAutoConfiguration.java',
);
fs.mkdirSync(path.dirname(path.join(repo, deepRelative)), { recursive: true });
fs.writeFileSync(path.join(repo, deepRelative), '// yudao\n');
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md', deepRelative]);
runGit(repo, ['commit', '-qm', 'init']);
const created = await createWorktree(repo, {
mode: 'new',
worktreeName: 'issue-2746',
branchName: 'openchamber/issue-2746',
});
expect(created.directoryCreated).toBe(true);
await expect.poll(async () => {
const status = await getWorktreeBootstrapStatus(created.path);
return status?.status;
}, { timeout: 10_000 }).toBe('ready');
const longpaths = runGit(created.path, ['config', '--get', 'core.longpaths']).trim();
expect(longpaths).toBe('true');
expect(fs.existsSync(path.join(created.path, deepRelative))).toBe(true);
expect(fs.existsSync(path.join(created.path, 'README.md'))).toBe(true);
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
});
it('ensureWorktreeLongpaths is idempotent when already enabled', async () => {
if (!canRunGit()) return;
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-qm', 'init']);
runGit(repo, ['config', 'core.longpaths', 'true']);
await expect(ensureWorktreeLongpaths(repo)).resolves.toBeUndefined();
expect(runGit(repo, ['config', '--get', 'core.longpaths']).trim()).toBe('true');
});
it('surfaces guided bootstrap failure when a path component exceeds the filesystem name limit', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const dataHome = createTempDir();
process.env.XDG_DATA_HOME = dataHome;
try {
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
// Linux/macOS NAME_MAX equivalent of the Windows failure mode: a single
// path component longer than 255 cannot be materialized. core.longpaths
// cannot fix this; bootstrap must fail clearly instead of leaving a
// silent half-populated worktree.
const longComponent = 'x'.repeat(300);
const longPath = `server/${longComponent}/YudaoDataPermissionAutoConfiguration.java`;
const blobHash = runGit(repo, ['hash-object', '-w', '--stdin'], '// test\n').trim();
runGit(repo, ['update-index', '--add', '--cacheinfo', `100644,${blobHash},${longPath}`]);
runGit(repo, ['commit', '-qm', 'init']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-qm', 'add readme']);
const created = await createWorktree(repo, {
mode: 'new',
worktreeName: 'issue-2746-namemax',
branchName: 'openchamber/issue-2746-namemax',
});
expect(created.directoryCreated).toBe(true);
await expect.poll(async () => {
const status = await getWorktreeBootstrapStatus(created.path);
return status?.status;
}, { timeout: 10_000 }).toBe('failed');
const status = await getWorktreeBootstrapStatus(created.path);
expect(status?.error).toMatch(/file name too long|filename too long/i);
expect(status?.error).toMatch(/path-length limit/i);
expect(runGit(created.path, ['config', '--get', 'core.longpaths']).trim()).toBe('true');
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
});
it('populateWorktreeWithLockRecovery enables longpaths before reset', async () => {
if (!canRunGit()) return;
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-qm', 'init']);
const worktree = createTempDir();
fs.rmSync(worktree, { recursive: true, force: true });
runGit(repo, ['worktree', 'add', '--no-checkout', '-b', 'feature/longpaths-populate', worktree, 'HEAD']);
await expect(populateWorktreeWithLockRecovery(worktree)).resolves.toBeUndefined();
expect(runGit(worktree, ['config', '--get', 'core.longpaths']).trim()).toBe('true');
expect(fs.readFileSync(path.join(worktree, 'README.md'), 'utf8')).toBe('# Test\n');
});
});
+191 -9
View File
@@ -25,6 +25,7 @@ const WORKTREE_BOOTSTRAP_FAILED = 'failed';
const WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED = 'directory-created';
const WORKTREE_BOOTSTRAP_PHASE_GIT_READY = 'git-ready';
const WORKTREE_BOOTSTRAP_PHASE_SETUP_READY = 'setup-ready';
const GIT_NULL_REF = '0'.repeat(40);
const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250;
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
@@ -990,34 +991,70 @@ const getFileIdentity = async (filePath) => {
}
};
// OpenChamber places managed worktrees under a deep data-dir path
// (`<XDG_DATA_HOME>/opencode/worktree/<40-char project id>/<name>/`). On
// Windows that prefix plus a deeply nested repo file routinely exceeds
// MAX_PATH (260). Git can check those paths out when core.longpaths is
// enabled; without it, `git reset --hard` during bootstrap fails with
// "Filename too long" and leaves a half-populated worktree (issue #2746).
const WORKTREE_POPULATE_RESET_ARGS = ['-c', 'core.longpaths=true', 'reset', '--hard'];
const isFilenameTooLongError = (message) => /file ?name too long/i.test(String(message || ''));
const formatWorktreePopulateError = (message) => {
const text = String(message || '').trim() || 'Failed to populate worktree';
if (!isFilenameTooLongError(text)) {
return text;
}
return [
text,
'The worktree checkout path exceeds this system\'s path-length limit.',
'OpenChamber enables Git `core.longpaths` for worktree population; if this still fails on Windows, enable OS long paths (LongPathsEnabled) or open the repository from a shorter absolute path.',
].join('\n');
};
export const ensureWorktreeLongpaths = async (directory) => {
const current = await runGitCommand(directory, ['config', '--get', 'core.longpaths']);
if (String(current.stdout || '').trim().toLowerCase() === 'true') {
return;
}
// Local config is shared across linked worktrees via the common git dir, so
// subsequent OpenChamber and CLI git operations in this repo also get long
// path support. Failures here are non-fatal: populate still passes
// `-c core.longpaths=true` on reset.
await runGitCommand(directory, ['config', 'core.longpaths', 'true']);
};
export const populateWorktreeWithLockRecovery = async (directory) => {
let result = await runGitCommand(directory, ['reset', '--hard']);
await ensureWorktreeLongpaths(directory);
let result = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(result.message || 'Failed to populate worktree');
throw new Error(formatWorktreePopulateError(result.message));
}
await wait(WORKTREE_INDEX_LOCK_RETRY_DELAY_MS);
result = await runGitCommand(directory, ['reset', '--hard']);
result = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(result.message || 'Failed to populate worktree');
throw new Error(formatWorktreePopulateError(result.message));
}
const lockPath = await getWorktreeIndexLockPath(directory);
const identity = lockPath ? await getFileIdentity(lockPath) : null;
await wait(WORKTREE_INDEX_LOCK_STALE_DELAY_MS);
result = await runGitCommand(directory, ['reset', '--hard']);
result = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS);
if (result.success) {
return;
}
if (!isIndexLockError(result) || !lockPath || !identity || await getFileIdentity(lockPath) !== identity) {
throw new Error(result.message || 'Failed to populate worktree');
throw new Error(formatWorktreePopulateError(result.message));
}
await fsp.unlink(lockPath).catch((error) => {
@@ -1025,7 +1062,66 @@ export const populateWorktreeWithLockRecovery = async (directory) => {
throw error;
}
});
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
const finalResult = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS);
if (!finalResult.success) {
throw new Error(formatWorktreePopulateError(finalResult.message || 'Failed to populate worktree'));
}
};
// Worktrees are created with `git worktree add --no-checkout` and populated
// with `git reset --hard`, neither of which runs git's post-checkout hook —
// git only runs it for checkouts, clone, and worktree add *without*
// --no-checkout. Invoke the hook explicitly after population to restore git's
// checkout semantics: git passes the previous HEAD (null ref for a brand-new
// worktree), the new HEAD, and flag 1 for a branch checkout, and runs the hook
// from the worktree top-level.
const runPostCheckoutHook = async (directory) => {
let hookDirectory = null;
try {
const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'hooks']);
if (!result.success) return;
hookDirectory = normalizeDirectoryPath(String(result.stdout || '').trim());
} catch {
return;
}
if (!hookDirectory) return;
const hookPath = path.join(hookDirectory, 'post-checkout');
try {
const stat = await fsp.stat(hookPath);
if (!stat.isFile()) return;
if (process.platform !== 'win32') {
await fsp.access(hookPath, fs.constants.X_OK);
}
} catch {
// Missing or non-executable hooks are skipped, matching git.
return;
}
const [headResult, gitDirResult] = await Promise.all([
runGitCommand(directory, ['rev-parse', 'HEAD']),
runGitCommand(directory, ['rev-parse', '--absolute-git-dir']),
]);
if (!headResult.success || !gitDirResult.success) return;
const head = String(headResult.stdout || '').trim();
const gitDir = String(gitDirResult.stdout || '').trim();
if (!head || !gitDir) return;
try {
await execFileAsync(hookPath, [GIT_NULL_REF, head, '1'], {
cwd: directory,
env: {
...(await buildGitEnv()),
GIT_DIR: gitDir,
GIT_WORK_TREE: path.resolve(directory),
},
windowsHide: true,
});
} catch (error) {
// A failing hook must not fail worktree creation or session bootstrap:
// warn and continue.
console.warn(`[GitService] post-checkout hook failed in worktree ${directory}: ${error instanceof Error ? error.message : String(error)}`);
}
};
const derivePrimaryWorktreeRootFromGitDir = (gitDir) => {
@@ -1719,6 +1815,7 @@ const queueWorktreeBootstrap = (args) => {
const task = new Promise((resolve) => setTimeout(resolve, 0))
.then(async () => {
await populateWorktreeWithLockRecovery(directory);
await runPostCheckoutHook(directory);
if (setUpstream) {
await applyUpstreamConfiguration({
primaryWorktree,
@@ -2446,6 +2543,27 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
// ignore
}
// Not every repository has an `origin`. When the base names a branch that
// exists only on another remote, a bare name does not resolve — git looks in
// refs/heads, not across remotes — and the diff fails with "ambiguous
// argument". Fall back to whichever remote actually carries it.
if (resolvedBase === baseRef && !/[*?[\]^~:\\]/.test(baseRef)) {
const resolvesLocally = await git
.raw(['rev-parse', '--verify', `refs/heads/${baseRef}`])
.then((value) => Boolean(String(value || '').trim()))
.catch(() => false);
if (!resolvesLocally) {
const remoteMatch = await git
.raw(['for-each-ref', '--count=1', '--format=%(refname:short)', `refs/remotes/*/${baseRef}`])
.then((value) => String(value || '').trim())
.catch(() => '');
if (remoteMatch) {
resolvedBase = remoteMatch;
}
}
}
const args = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
args.push(`-U${Math.max(0, contextLines)}`);
@@ -3367,6 +3485,7 @@ export async function getBranches(directory) {
const allBranches = result.all;
const remoteBranches = allBranches.filter(branch => branch.startsWith('remotes/'));
const activeRemoteBranches = await filterActiveRemoteBranches(git, remoteBranches);
const defaultBranches = await getRemoteDefaultBranches(git);
const filteredAll = [
...allBranches.filter(branch => !branch.startsWith('remotes/')),
@@ -3376,7 +3495,8 @@ export async function getBranches(directory) {
return {
all: filteredAll,
current: result.current,
branches: result.branches
branches: result.branches,
defaultBranches,
};
} catch (error) {
console.error('Failed to get branches:', error);
@@ -3384,11 +3504,72 @@ export async function getBranches(directory) {
}
}
async function getRemoteDefaultBranches(git) {
let defaults = {};
try {
const refs = await git.raw([
'for-each-ref',
'--format=%(refname) %(symref)',
'refs/remotes',
]);
defaults = Object.fromEntries(
refs.trim().split('\n').flatMap((line) => {
const [ref, symbolicRef] = line.split(' ');
const match = ref.match(/^refs\/remotes\/([^/]+)\/HEAD$/);
const prefix = match ? `refs/remotes/${match[1]}/` : '';
return match && typeof symbolicRef === 'string' && symbolicRef.startsWith(prefix)
? [[match[1], symbolicRef.slice(prefix.length)]]
: [];
})
);
} catch {
defaults = {};
}
// `remote/HEAD` is written by clone and by `git remote set-head`; a remote
// added by hand may never have one. Without this the caller falls back to
// guessing main/master/develop, which is exactly the guess this data exists
// to replace — so ask the remote itself, but only for the remotes that are
// actually missing an answer.
try {
const remotes = await git.getRemotes();
const missing = remotes.filter((remote) => remote?.name && !defaults[remote.name]);
if (missing.length === 0) return defaults;
const resolved = await Promise.all(missing.map(async (remote) => {
try {
const output = await git.raw(['ls-remote', '--symref', remote.name, 'HEAD']);
const match = String(output || '').match(/^ref:\s+refs\/heads\/(.+?)\s+HEAD$/m);
return match ? [remote.name, match[1]] : null;
} catch {
// Unreachable or refusing: no answer is better than a guessed one.
return null;
}
}));
for (const entry of resolved) {
if (entry) defaults[entry[0]] = entry[1];
}
} catch {
// Remote list unavailable; the local symrefs are still valid.
}
return defaults;
}
async function filterActiveRemoteBranches(git, remoteBranches) {
try {
const remotes = await git.getRemotes();
const branchesByRemote = new Map();
// A remote that did not answer says nothing about its branches. Dropping
// them would turn "we could not ask" into "these branches are gone", and
// callers use this list to decide whether a base branch exists at all — so
// offline would silently remove comparisons that work perfectly well
// against the local remote-tracking refs.
const unreachableRemotes = new Set();
await Promise.all(remotes.map(async (remote) => {
try {
const lsRemoteResult = await git.raw(['ls-remote', '--heads', remote.name]);
@@ -3402,7 +3583,7 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
}
branchesByRemote.set(remote.name, actualRemoteBranches);
} catch {
// Skip remotes that fail (e.g., unreachable)
unreachableRemotes.add(remote.name);
}
}));
@@ -3411,6 +3592,7 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
if (!match) return false;
const remoteName = remoteBranch.split('/')[1];
const branchName = match[1];
if (unreachableRemotes.has(remoteName)) return true;
return branchesByRemote.get(remoteName)?.has(branchName) ?? false;
});
} catch (error) {
+219
View File
@@ -10,6 +10,8 @@ import {
cherryPick,
createWorktree,
getWorktreeBootstrapStatus,
getBranches,
getRangeDiff,
getStatus,
isGitRepository,
populateWorktreeWithLockRecovery,
@@ -47,6 +49,28 @@ const runGit = (cwd, args) =>
stdio: ['ignore', 'pipe', 'pipe'],
});
/**
* A repository on `next` whose only remote publishes `defaultBranch` and has it
* recorded as that remote's HEAD the shape of every repository whose default
* branch is not one of the conventional names.
*/
const createRepositoryWithRemote = ({ remoteName = 'origin', defaultBranch = 'react' } = {}) => {
const remote = createTempDir();
const repository = createTempDir();
runGit(remote, ['init', '--bare', `--initial-branch=${defaultBranch}`]);
runGit(repository, ['init', '-b', 'next']);
runGit(repository, ['config', 'user.email', 'test@example.com']);
runGit(repository, ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(repository, 'README.md'), '# Test\n');
runGit(repository, ['add', 'README.md']);
runGit(repository, ['commit', '-m', 'init']);
runGit(repository, ['remote', 'add', remoteName, remote]);
runGit(repository, ['push', remoteName, `HEAD:${defaultBranch}`]);
runGit(repository, ['fetch', remoteName]);
runGit(repository, ['remote', 'set-head', remoteName, '--auto']);
return { remote, repository };
};
const canRunGit = () => {
try {
execFileSync('git', ['--version'], { stdio: 'ignore' });
@@ -513,6 +537,154 @@ describe('createWorktree', () => {
}
});
const installPostCheckoutHook = (repo, script, executable = true) => {
const hookPath = path.join(repo, '.git', 'hooks', 'post-checkout');
fs.writeFileSync(hookPath, script);
if (executable) {
fs.chmodSync(hookPath, 0o755);
}
return hookPath;
};
it('runs the post-checkout hook after populating a created worktree', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const dataHome = createTempDir();
process.env.XDG_DATA_HOME = dataHome;
try {
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-m', 'Initial commit']);
const head = runGit(repo, ['rev-parse', 'HEAD']).trim();
const hookLog = path.join(dataHome, 'post-checkout.log');
installPostCheckoutHook(
repo,
`#!/bin/sh\nprintf '%s|%s|%s|%s' "$1" "$2" "$3" "$(pwd -P)" > ${JSON.stringify(hookLog)}\n`,
);
const created = await createWorktree(repo, {
mode: 'new',
worktreeName: 'hook-test',
branchName: 'openchamber/hook-test',
returnAfterDirectoryCreated: true,
});
await expect.poll(() => {
try {
return fs.readFileSync(hookLog, 'utf8');
} catch {
return '';
}
}, { timeout: 5_000 }).not.toBe('');
const [previousHead, newHead, flag, cwd] = fs.readFileSync(hookLog, 'utf8').split('|');
expect(previousHead).toBe('0000000000000000000000000000000000000000');
expect(newHead).toBe(head);
expect(flag).toBe('1');
expect(cwd).toBe(fs.realpathSync(created.path));
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
});
it('skips a non-executable post-checkout hook', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const dataHome = createTempDir();
process.env.XDG_DATA_HOME = dataHome;
try {
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-m', 'Initial commit']);
const hookLog = path.join(dataHome, 'post-checkout-skipped.log');
installPostCheckoutHook(
repo,
`#!/bin/sh\nprintf 'ran' > ${JSON.stringify(hookLog)}\n`,
false,
);
const created = await createWorktree(repo, {
mode: 'new',
worktreeName: 'hook-skip-test',
branchName: 'openchamber/hook-skip-test',
returnAfterDirectoryCreated: true,
});
await expect.poll(
async () => (await getWorktreeBootstrapStatus(created.path)).status,
{ timeout: 5_000 },
).toBe('ready');
expect(fs.existsSync(hookLog)).toBe(false);
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
});
it('does not fail worktree bootstrap when the post-checkout hook fails', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const dataHome = createTempDir();
process.env.XDG_DATA_HOME = dataHome;
try {
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-m', 'Initial commit']);
const hookLog = path.join(dataHome, 'post-checkout-failed.log');
installPostCheckoutHook(
repo,
`#!/bin/sh\nprintf 'ran' > ${JSON.stringify(hookLog)}\nexit 1\n`,
);
const created = await createWorktree(repo, {
mode: 'new',
worktreeName: 'hook-fail-test',
branchName: 'openchamber/hook-fail-test',
returnAfterDirectoryCreated: true,
});
await expect.poll(
async () => (await getWorktreeBootstrapStatus(created.path)).status,
{ timeout: 5_000 },
).toBe('ready');
expect(fs.readFileSync(hookLog, 'utf8')).toBe('ran');
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
});
it('waits for active bootstrap work before removing a worktree', async () => {
if (!canRunGit()) return;
@@ -988,3 +1160,50 @@ describe('hash validation', () => {
).rejects.not.toThrow('Invalid commit hash');
});
});
describe.runIf(canRunGit())('getBranches', () => {
it('returns a remote default branch whose name is not a conventional fallback', async () => {
const { repository } = createRepositoryWithRemote({ remoteName: 'origin', defaultBranch: 'react' });
await expect(getBranches(repository)).resolves.toMatchObject({
defaultBranches: { origin: 'react' },
});
});
it('asks the remote when no local remote/HEAD exists', async () => {
const { repository } = createRepositoryWithRemote({ remoteName: 'origin', defaultBranch: 'react' });
// A hand-added remote can end up without this ref; the branch it points at
// is still knowable, and guessing instead is the bug this data replaces.
runGit(repository, ['remote', 'set-head', 'origin', '--delete']);
await expect(getBranches(repository)).resolves.toMatchObject({
defaultBranches: { origin: 'react' },
});
});
it('keeps the branches of a remote that cannot be reached', async () => {
const { repository, remote } = createRepositoryWithRemote({ remoteName: 'origin', defaultBranch: 'react' });
fs.rmSync(remote, { recursive: true, force: true });
const branches = await getBranches(repository);
// "We could not ask" is not "the branch is gone": callers read this list to
// decide whether a base branch exists at all.
expect(branches.all).toContain('remotes/origin/react');
});
});
describe.runIf(canRunGit())('getRangeDiff', () => {
it('resolves a base that exists only on a remote other than origin', async () => {
const { repository } = createRepositoryWithRemote({ remoteName: 'upstream', defaultBranch: 'react' });
// Only refs/remotes/upstream/react carries the base — git cannot resolve the
// bare name, so an unqualified `react...next` fails with "ambiguous argument".
fs.writeFileSync(path.join(repository, 'feature.txt'), 'work\n');
runGit(repository, ['add', 'feature.txt']);
runGit(repository, ['commit', '-m', 'feature']);
const diff = await getRangeDiff(repository, { base: 'react', head: 'next' });
expect(diff).toContain('feature.txt');
});
});
@@ -240,10 +240,32 @@ export const createOpenChamberControlService = (dependencies) => {
}
};
// session.send/fork default the directory to the caller's context directory,
// which is wrong for sessions living in other worktrees: prompt_async then
// targets an instance that does not hold the session and the run dies with
// UnknownError. Resolve the target session's directory from the global
// session list when the caller did not scope explicitly.
const resolveSessionDirectory = async (sessionID) => {
try {
const client = await getClient();
const response = await client.experimental?.session?.list?.({});
const sessions = Array.isArray(response?.data) ? response.data : [];
const session = sessions.find((item) => item?.id === sessionID);
return asNonEmptyString(session?.directory) || null;
} catch {
return null;
}
};
const executeSessionAction = async (action, input, contextDirectory, signal) => {
if (input.timeout !== undefined && input.wait !== true) throw new OpenChamberControlError('timeout requires wait', 400);
if (input.lastAssistant === true && input.wait !== true) throw new OpenChamberControlError('lastAssistant requires wait', 400);
const directory = asNonEmptyString(input.directory) || (!input.projectId ? asNonEmptyString(contextDirectory) : null);
const sessionID = asNonEmptyString(input.sessionId);
let directory = asNonEmptyString(input.directory) || (!input.projectId ? asNonEmptyString(contextDirectory) : null);
if (sessionID && action !== 'session.create' && !asNonEmptyString(input.directory) && !input.projectId) {
const resolvedSessionDirectory = await resolveSessionDirectory(sessionID);
if (resolvedSessionDirectory) directory = resolvedSessionDirectory;
}
const payload = {
...(directory ? { directory } : {}),
...(asNonEmptyString(input.projectId) ? { projectId: input.projectId.trim() } : {}),
@@ -262,7 +284,6 @@ export const createOpenChamberControlService = (dependencies) => {
...(typeof input.setUpstream === 'boolean' ? { setUpstream: input.setUpstream } : {}),
...(asNonEmptyString(input.messageId) ? { messageId: input.messageId.trim() } : {}),
};
const sessionID = asNonEmptyString(input.sessionId);
const startedAt = now();
let result;
if (action === 'session.create') {
@@ -132,6 +132,43 @@ describe('OpenChamber control service', () => {
expect(sessionService[method]).toHaveBeenCalledWith('ses_1', { directory: '/repo', prompt: 'Continue' });
});
it('resolves the target session directory from the global session list when send omits it', async () => {
const { service, sessionService, client } = createService({
createClient: () => ({
...client,
experimental: {
session: {
list: vi.fn(async () => ({
data: [
{ id: 'ses_other', directory: '/repo/worktrees/other' },
{ id: 'ses_target', directory: '/repo/worktrees/target' },
],
})),
},
},
}),
});
sessionService.send.mockResolvedValue({ sessionId: 'ses_target', directory: '/repo/worktrees/target', promptDispatched: true });
await service.execute('session.send', { sessionId: 'ses_target', prompt: 'Continue' }, '/repo');
expect(sessionService.send).toHaveBeenCalledWith('ses_target', { directory: '/repo/worktrees/target', prompt: 'Continue' });
});
it('falls back to the context directory when the session is not in the global list', async () => {
const { service, sessionService, client } = createService({
createClient: () => ({
...client,
experimental: { session: { list: vi.fn(async () => ({ data: [] })) } },
}),
});
sessionService.send.mockResolvedValue({ sessionId: 'ses_unknown', directory: '/repo', promptDispatched: true });
await service.execute('session.send', { sessionId: 'ses_unknown', prompt: 'Continue' }, '/repo');
expect(sessionService.send).toHaveBeenCalledWith('ses_unknown', { directory: '/repo', prompt: 'Continue' });
});
it('waits past initial idle until a completed assistant result appears', async () => {
let timestamp = 1000;
const { service, client, sessionService } = createService({
@@ -1,6 +1,6 @@
import express from 'express';
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { createWorktree } from '../git/index.js';
import { createWorktree, getWorktreeBootstrapStatus } from '../git/index.js';
import { expandSnippets } from '../opencode/snippets.js';
import { expandCommandGoalObjective, parseScheduledCommandPrompt } from '../scheduled-tasks/runtime.js';
import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js';
@@ -275,6 +275,31 @@ const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated
const PROMPT_LANDED_TIMEOUT_MS = 5_000;
const PROMPT_LANDED_POLL_MS = 150;
// createWorktree returns while the worktree is still being populated in the
// background (git reset --hard after a --no-checkout add). Dispatching a
// prompt into a half-populated directory makes opencode's run die with
// UnknownError (agent and config files are not there yet), so wait until the
// bootstrap reaches git-ready (population done) or fails before creating the
// session and dispatching.
const WORKTREE_BOOTSTRAP_TIMEOUT_MS = 60_000;
const WORKTREE_BOOTSTRAP_POLL_MS = 150;
const waitForWorktreeBootstrapReady = async ({ directory }) => {
const deadline = Date.now() + WORKTREE_BOOTSTRAP_TIMEOUT_MS;
for (;;) {
const status = await getWorktreeBootstrapStatus(directory);
if (status?.status === 'failed') {
throw new OpenChamberControlError(`Worktree bootstrap failed: ${status.error || 'unknown error'}`, 500);
}
const phase = status?.phase;
if (status?.status === 'ready' || phase === 'git-ready' || phase === 'setup-ready') return;
if (Date.now() >= deadline) {
throw new OpenChamberControlError('Timed out waiting for the worktree bootstrap', 500);
}
await new Promise((resolve) => setTimeout(resolve, WORKTREE_BOOTSTRAP_POLL_MS));
}
};
const latestUserMessageID = async ({ client, sessionID, directory }) => {
let response;
try {
@@ -579,6 +604,7 @@ export const createOpenChamberSessionService = (dependencies) => {
if (worktreeInput) {
worktree = await createWorktree(resolvedDirectory.directory, worktreeInput);
sessionDirectory = worktree.path;
await waitForWorktreeBootstrapReady({ directory: sessionDirectory });
}
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
@@ -8,6 +8,12 @@ const createWorktreeMock = vi.fn(async () => ({
branch: 'openchamber/side-task',
path: '/repo/worktrees/side-task',
}));
const getWorktreeBootstrapStatusMock = vi.fn(async () => ({
status: 'ready',
phase: 'setup-ready',
error: null,
updatedAt: Date.now(),
}));
const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } }));
const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } }));
const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
@@ -61,6 +67,7 @@ const selectionInputResponse = (url) => {
const sessionCommandMock = vi.fn(async () => ({ data: {} }));
const commandListMock = vi.fn(async () => ({ data: [] }));
globalThis.__openchamberCreateWorktreeMock = createWorktreeMock;
globalThis.__openchamberGetWorktreeBootstrapStatusMock = getWorktreeBootstrapStatusMock;
let registerOpenChamberSessionRoutes;
@@ -80,6 +87,7 @@ vi.mock('@opencode-ai/sdk/v2', () => ({
vi.mock('../git/index.js', () => ({
createWorktree: (...args) => globalThis.__openchamberCreateWorktreeMock(...args),
getWorktreeBootstrapStatus: (...args) => globalThis.__openchamberGetWorktreeBootstrapStatusMock(...args),
}));
const createApp = (overrides = {}, options = {}) => {
@@ -107,6 +115,13 @@ describe('openchamber session routes', () => {
beforeEach(() => {
createWorktreeMock.mockClear();
getWorktreeBootstrapStatusMock.mockClear();
getWorktreeBootstrapStatusMock.mockImplementation(async () => ({
status: 'ready',
phase: 'setup-ready',
error: null,
updatedAt: Date.now(),
}));
sessionCreateMock.mockClear();
sessionForkMock.mockClear();
existingSessionMessages = [];
@@ -366,6 +381,74 @@ describe('openchamber session routes', () => {
}
});
it('waits for the worktree bootstrap to complete before creating the session', async () => {
const statuses = [
{ status: 'pending', phase: 'directory-created', error: null, updatedAt: 1 },
{ status: 'pending', phase: 'git-ready', error: null, updatedAt: 2 },
{ status: 'ready', phase: 'setup-ready', error: null, updatedAt: 3 },
];
getWorktreeBootstrapStatusMock.mockImplementation(async () => statuses.shift() || statuses[statuses.length - 1]);
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async (url) => {
if (String(url).includes('/prompt_async')) {
return { ok: true, text: async () => '' };
}
return { ok: true, json: async () => ({ id: 'ses_123' }) };
});
try {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions')
.send({
directory: '/repo/app',
worktree: { name: 'side-task' },
prompt: 'Run this',
model: 'openai/gpt-5.5',
})
.expect(200);
expect(response.body.promptDispatched).toBe(true);
const sessionCreateCalls = globalThis.fetch.mock.calls.filter(([url]) => String(url).includes('/session?directory'));
const promptCalls = globalThis.fetch.mock.calls.filter(([url]) => String(url).includes('/prompt_async'));
expect(sessionCreateCalls.length).toBeGreaterThanOrEqual(1);
expect(promptCalls.length).toBeGreaterThanOrEqual(1);
const createIndex = globalThis.fetch.mock.calls.indexOf(sessionCreateCalls[0]);
const promptIndex = globalThis.fetch.mock.calls.indexOf(promptCalls[0]);
expect(getWorktreeBootstrapStatusMock).toHaveBeenCalled();
expect(createIndex).toBeGreaterThan(-1);
expect(promptIndex).toBeGreaterThan(createIndex);
} finally {
globalThis.fetch = originalFetch;
}
});
it('fails the create when the worktree bootstrap failed', async () => {
getWorktreeBootstrapStatusMock.mockImplementation(async () => ({
status: 'failed',
phase: 'directory-created',
error: 'branch already exists',
updatedAt: Date.now(),
}));
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async (url) => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions')
.send({
directory: '/repo/app',
worktree: { name: 'side-task' },
prompt: 'Run this',
model: 'openai/gpt-5.5',
})
.expect(500, { error: 'Worktree bootstrap failed: branch already exists' });
const promptCalls = globalThis.fetch.mock.calls.filter(([url]) => String(url).includes('/prompt_async'));
expect(promptCalls.length).toBe(0);
} finally {
globalThis.fetch = originalFetch;
}
});
it('sends a goal prompt to an existing session after creating goal metadata', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
@@ -114,7 +114,7 @@ This module provides OpenCode server integration utilities for the web server ru
The runtime maintains active-session count incrementally from idempotent activity phase transitions. Upstream stall-timeout and lifecycle health checks read it in O(1); the hourly cleanup removes activity phases older than 24 hours without broadcasting synthetic state transitions. Snapshot generation remains reserved for the session-activity API.
## Public exports (lifecycle.js)
- `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration.
- `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. The optional `onOpenCodeRestarted` dependency (default `null`) is fired after a successful managed restart; `index.js` wires it to `messageStreamRuntime.rebindUpstream()` so event-stream readers rebind to the possibly-new port (a restart can land on a new port while an orphaned process keeps the old one, which would otherwise leave the chat UI silent — issue #2638).
- Returned API:
- `startOpenCode()`
- `restartOpenCode()`
@@ -356,6 +356,9 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints:
- `GET /api/openchamber/update-check`
- `POST /api/openchamber/update-install`
- Foreground servers running under a systemd user unit queue installation in
a separate transient unit and restart the configured service afterwards.
`OPENCHAMBER_SYSTEMD_UNIT` overrides the default `openchamber.service`.
- `GET /api/openchamber/models-metadata`
- `GET /api/zen/models`
@@ -388,6 +391,8 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
- SSE forwarders: `GET /api/global/event`, `GET /api/event`
- Downstream heartbeats keep clients and intermediaries alive, while a separate upstream-only stall watchdog closes the downstream response when OpenCode stops producing bytes so clients reconnect instead of trusting synthetic heartbeats indefinitely. Each watchdog reset uses the current load-aware timeout, matching the shared event transport.
- Session message forwarder: `POST /api/session/:sessionId/message`
- Interactive OAuth forwarder: `POST /api/provider/:providerID/oauth/callback`
- Upstream blocks inside this call for the whole browser sign-in (device-code polling or a loopback redirect), so it is exempt from the ordinary request deadline and uses a 15-minute proxy timeout instead of `LONG_REQUEST_TIMEOUT_MS`. All other `/api/provider/*` routes, including `oauth/authorize`, keep the ordinary deadline.
- Generic `/api/*` forwarding with hop-by-hop header filtering
- Windows `/session` merge fallback path behavior
- OpenCode readiness gate for proxied `/api` requests
+4
View File
@@ -19,6 +19,8 @@ export const createBootstrapRuntime = (dependencies) => {
serverStartedAt,
gracefulShutdown,
getHealthSnapshot,
getServerPort,
getTunnelUrl,
verboseRequestLogs,
uiPassword,
tunnelAuthController,
@@ -81,6 +83,8 @@ export const createBootstrapRuntime = (dependencies) => {
gracefulShutdown,
getHealthSnapshot,
getServerId,
getServerPort,
getTunnelUrl,
tunnelAuthController,
uiAuthController,
});
@@ -69,6 +69,12 @@ export const registerServerStatusRoutes = (app, dependencies) => {
serverStartedAt,
gracefulShutdown,
getHealthSnapshot,
// Port this OpenChamber instance serves on and the tunnel public URL (if
// a tunnel is active). Exposed on /api/system/info so the UI can surface
// the active instance's service URLs. Optional: older wiring omits them
// and the endpoint reports null.
getServerPort = () => null,
getTunnelUrl = () => null,
// Stable server identity (hash of the public signing key — not a secret).
// Exposed on /health and /api/version so a client can verify that a
// learned/probed address belongs to the expected server BEFORE sending its
@@ -358,6 +364,8 @@ export const registerServerStatusRoutes = (app, dependencies) => {
runtime: runtimeName,
pid: process.pid,
startedAt: serverStartedAt,
port: getServerPort(),
tunnelUrl: getTunnelUrl(),
});
});
@@ -791,4 +791,46 @@ describe('client auth routes', () => {
socket: { remoteAddress: '203.0.113.10' },
})).toBe('unknown-public');
});
it('reports null port and tunnel URL on /api/system/info when no getters are wired', async () => {
const app = express();
registerServerStatusRoutes(app, {
process,
serverStartedAt: '2026-01-01T00:00:00.000Z',
gracefulShutdown: vi.fn(async () => {}),
getHealthSnapshot: () => ({ status: 'ok' }),
openchamberVersion: '1.0.0',
runtimeName: 'test',
express,
});
const response = await request(app).get('/api/system/info');
expect(response.status).toBe(200);
expect(response.body.openchamberVersion).toBe('1.0.0');
expect(response.body.runtime).toBe('test');
expect(response.body.pid).toBeTypeOf('number');
expect(response.body.startedAt).toBeTypeOf('string');
expect(response.body.port).toBeNull();
expect(response.body.tunnelUrl).toBeNull();
});
it('reports the instance port and tunnel URL on /api/system/info from the wired getters', async () => {
const app = express();
registerServerStatusRoutes(app, {
process,
serverStartedAt: '2026-01-01T00:00:00.000Z',
gracefulShutdown: vi.fn(async () => {}),
getHealthSnapshot: () => ({ status: 'ok' }),
openchamberVersion: '1.0.0',
runtimeName: 'test',
express,
getServerPort: () => 9988,
getTunnelUrl: () => 'https://worktree-a.example.trycloudflare.com',
});
const response = await request(app).get('/api/system/info');
expect(response.status).toBe(200);
expect(response.body.port).toBe(9988);
expect(response.body.tunnelUrl).toBe('https://worktree-a.example.trycloudflare.com');
});
});
@@ -1,3 +1,26 @@
import { isIP } from 'node:net';
const MAX_HOSTNAME_LENGTH = 253;
const HOSTNAME_LABEL_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
// All-numeric dotted values must be a real IPv4 address; otherwise typo'd IPs
// like "0.0.0.0.0" would slip through as (technically valid) hostnames.
const ALL_NUMERIC_DOTTED_RE = /^\d+(?:\.\d+)*$/;
// Valid bind hostnames for the managed OpenCode server: IPv4, IPv6 (with or
// without brackets), or a DNS-style hostname. Everything else (URLs, ports,
// paths, whitespace, underscores) is rejected.
export const isValidOpenCodeHostname = (value) => {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
if (!trimmed || trimmed.length > MAX_HOSTNAME_LENGTH) return false;
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
return isIP(trimmed.slice(1, -1)) === 6;
}
if (isIP(trimmed) !== 0) return true;
if (ALL_NUMERIC_DOTTED_RE.test(trimmed)) return false;
return trimmed.split('.').every((label) => HOSTNAME_LABEL_RE.test(label));
};
export const resolveOpenCodeEnvConfig = (options = {}) => {
const env = options.env && typeof options.env === 'object' ? options.env : {};
const logger = options.logger ?? console;
@@ -60,6 +83,14 @@ export const resolveOpenCodeEnvConfig = (options = {}) => {
);
return '127.0.0.1';
}
if (!isValidOpenCodeHostname(trimmed)) {
logger.error(
`[config] Rejecting OPENCHAMBER_OPENCODE_HOSTNAME=${JSON.stringify(raw)}: `
+ 'must be a valid hostname or IP address (for example 127.0.0.1, 0.0.0.0, localhost, [::1]); '
+ 'falling back to 127.0.0.1 (loopback only)',
);
return '127.0.0.1';
}
return trimmed;
})();
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from 'vitest';
import { isValidOpenCodeHostname, resolveOpenCodeEnvConfig } from './env-config.js';
describe('isValidOpenCodeHostname', () => {
it('accepts IPv4 addresses', () => {
expect(isValidOpenCodeHostname('127.0.0.1')).toBe(true);
expect(isValidOpenCodeHostname('0.0.0.0')).toBe(true);
expect(isValidOpenCodeHostname('192.168.1.10')).toBe(true);
});
it('accepts IPv6 addresses with and without brackets', () => {
expect(isValidOpenCodeHostname('::1')).toBe(true);
expect(isValidOpenCodeHostname('[::1]')).toBe(true);
expect(isValidOpenCodeHostname('::')).toBe(true);
expect(isValidOpenCodeHostname('[::]')).toBe(true);
});
it('accepts DNS-style hostnames', () => {
expect(isValidOpenCodeHostname('localhost')).toBe(true);
expect(isValidOpenCodeHostname('tailscale-host')).toBe(true);
expect(isValidOpenCodeHostname('my.host.example')).toBe(true);
});
it('rejects malformed values', () => {
const invalid = [
'',
' ',
'http://localhost',
'https://host:4096',
'host:4096',
'host/path',
'bad host',
'bad_host',
'0.0.0.0.0',
'999.999.999.999',
'[::1',
'::1]',
'a'.repeat(254),
'1.2.3.4.5.6.7.8.9',
];
for (const value of invalid) {
expect(isValidOpenCodeHostname(value), JSON.stringify(value)).toBe(false);
}
});
it('rejects non-string values', () => {
expect(isValidOpenCodeHostname(undefined)).toBe(false);
expect(isValidOpenCodeHostname(null)).toBe(false);
expect(isValidOpenCodeHostname(42)).toBe(false);
});
});
describe('resolveOpenCodeEnvConfig hostname', () => {
it('defaults to loopback when the env var is absent', () => {
expect(resolveOpenCodeEnvConfig({ env: {} }).configuredOpenCodeHostname).toBe('127.0.0.1');
});
it('reads OPENCHAMBER_OPENCODE_HOSTNAME', () => {
const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: '0.0.0.0' } });
expect(result.configuredOpenCodeHostname).toBe('0.0.0.0');
});
it('trims surrounding whitespace', () => {
const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: ' tailscale-host ' } });
expect(result.configuredOpenCodeHostname).toBe('tailscale-host');
});
it('warns and falls back for an empty value', () => {
const logger = { warn: vi.fn(), error: vi.fn() };
const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: ' ' }, logger });
expect(result.configuredOpenCodeHostname).toBe('127.0.0.1');
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty after trimming'));
expect(logger.error).not.toHaveBeenCalled();
});
it('rejects invalid values with a clear error and falls back to loopback', () => {
const logger = { warn: vi.fn(), error: vi.fn() };
const result = resolveOpenCodeEnvConfig({
env: { OPENCHAMBER_OPENCODE_HOSTNAME: 'http://nope:4096' },
logger,
});
expect(result.configuredOpenCodeHostname).toBe('127.0.0.1');
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Rejecting OPENCHAMBER_OPENCODE_HOSTNAME'),
);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('127.0.0.1'));
});
it('keeps other env config intact when the hostname is validated', () => {
const result = resolveOpenCodeEnvConfig({
env: { OPENCHAMBER_OPENCODE_HOSTNAME: '0.0.0.0', OPENCODE_PORT: '4096' },
});
expect(result.configuredOpenCodeHostname).toBe('0.0.0.0');
expect(result.configuredOpenCodePort).toBe(4096);
expect(result.effectivePort).toBe(4096);
});
});
@@ -49,6 +49,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
getActiveSessionCount = () => 0,
reapManagedOrphanedProcesses = reapOrphanedProcesses,
getWarmupDirectories = async () => [],
onOpenCodeRestarted = null,
now = Date.now,
} = deps;
@@ -695,6 +696,17 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
setupProxy(state.expressApp);
ensureOpenCodeApiPrefix();
}
// The restart may have landed on a NEW port (the old one can remain
// occupied by an orphaned process, e.g. Windows killProcessOnPort is a
// no-op). Upstream event readers pinned to the old process would keep
// the UI silent forever, so rebind them to the current port. Best
// effort: a failure here must not fail the restart itself.
try {
onOpenCodeRestarted?.();
} catch (error) {
console.warn('Failed to rebind event stream after OpenCode restart:', error?.message ?? error);
}
})();
try {
@@ -50,7 +50,7 @@ const createMockChild = () => {
return child;
};
const createRuntime = (overrides = {}, stateOverrides = {}) => {
const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) => {
const state = {
openCodeWorkingDirectory: '/tmp/project',
openCodeProcess: null,
@@ -83,6 +83,7 @@ const createRuntime = (overrides = {}, stateOverrides = {}) => {
ENV_EFFECTIVE_PORT: 3001,
ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
ENV_SKIP_OPENCODE_START: false,
...envOverrides,
},
syncToHmrState: vi.fn(),
syncFromHmrState: vi.fn(),
@@ -286,6 +287,70 @@ describe('OpenCode lifecycle', () => {
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('calls onOpenCodeRestarted after a successful managed restart', async () => {
const close = vi.fn(async () => {});
const replacement = createMockChild();
const onOpenCodeRestarted = vi.fn();
globalThis.fetch = vi.fn(async () => ({
ok: false,
json: async () => null,
}));
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
replacement.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
});
return replacement;
});
const runtime = createRuntime({ onOpenCodeRestarted }, {
openCodePort: 45678,
openCodeProcess: {
pid: null,
exitCode: 1,
signalCode: null,
close,
},
});
await runtime.triggerHealthCheck();
expect(close).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledTimes(1);
// The restart completed on a (possibly new) port — the event-stream
// upstreams must rebind so the UI keeps receiving events (#2638).
expect(onOpenCodeRestarted).toHaveBeenCalledTimes(1);
});
it('does not call onOpenCodeRestarted when a managed restart fails', async () => {
const close = vi.fn(async () => {});
const onOpenCodeRestarted = vi.fn();
globalThis.fetch = vi.fn(async () => ({
ok: false,
json: async () => null,
}));
spawnMock.mockImplementation(() => {
const child = createMockChild();
queueMicrotask(() => {
child.emit('error', new Error('spawn failed'));
});
return child;
});
const runtime = createRuntime({ onOpenCodeRestarted }, {
openCodePort: 45678,
openCodeProcess: {
pid: null,
exitCode: 1,
signalCode: null,
close,
},
});
// triggerHealthCheck logs instead of rethrowing; call restartOpenCode
// directly to observe the failure result.
await expect(runtime.restartOpenCode()).rejects.toThrow();
expect(onOpenCodeRestarted).not.toHaveBeenCalled();
});
it('launches managed OpenCode with the managed PATH', async () => {
delete process.env.OPENCODE_BINARY;
const child = createMockChild();
@@ -312,6 +377,27 @@ describe('OpenCode lifecycle', () => {
expect(server.signalCode).toBe('SIGTERM');
});
it('launches managed OpenCode on the configured bind hostname', async () => {
delete process.env.OPENCODE_BINARY;
const child = createMockChild();
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
child.stdout.emit('data', 'opencode server listening on http://0.0.0.0:45678\n');
});
return child;
});
const runtime = createRuntime({}, {}, { ENV_CONFIGURED_OPENCODE_HOSTNAME: '0.0.0.0' });
const server = await runtime.startOpenCode();
const [binary, args] = spawnMock.mock.calls[0];
expect(binary).toBe('opencode');
expect(args).toEqual(['serve', '--hostname', '0.0.0.0', '--port', '45678']);
await server.close();
expect(server.signalCode).toBe('SIGTERM');
});
it('strips AppImage ARGV0 from managed OpenCode launch env', async () => {
delete process.env.OPENCODE_BINARY;
const previousArgv0 = process.env.ARGV0;
@@ -1,3 +1,21 @@
const SYSTEMD_SERVICE_UNIT_PATTERN = /^[A-Za-z0-9:_.@-]+\.service$/;
function resolveSystemdServiceUnit(environment) {
if (!environment.INVOCATION_ID) {
return null;
}
const configuredUnit = typeof environment.OPENCHAMBER_SYSTEMD_UNIT === 'string'
? environment.OPENCHAMBER_SYSTEMD_UNIT.trim()
: '';
const unit = configuredUnit || 'openchamber.service';
return SYSTEMD_SERVICE_UNIT_PATTERN.test(unit) ? unit : null;
}
function quotePosixShell(value) {
return `'${String(value).replace(/'/g, "'\\''")}'`;
}
export const registerOpenChamberRoutes = (app, dependencies) => {
const {
fs,
@@ -54,7 +72,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
app.post('/api/openchamber/update-install', async (_req, res) => {
try {
const { spawn: spawnChild } = await import('child_process');
const { spawn: spawnChild, spawnSync } = await import('child_process');
const {
checkForUpdates,
getUpdateCommand,
@@ -110,6 +128,55 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
}
const launchMode = storedOptions.launchMode === 'foreground' ? 'foreground' : 'daemon';
const isForegroundService = launchMode === 'foreground';
const systemdServiceUnit = isForegroundService ? resolveSystemdServiceUnit(process.env) : null;
if (isForegroundService) {
if (!systemdServiceUnit) {
return res.status(409).json({
error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.',
});
}
const updateJobName = `openchamber-update-${Date.now()}`;
const updateLogPath = `journalctl --user-unit ${updateJobName}.service`;
const updateScript = [
'set -eu',
updateCmd,
`systemctl --user restart ${quotePosixShell(systemdServiceUnit)}`,
].join('\n');
const systemdRun = spawnSync('systemd-run', [
'--user',
`--unit=${updateJobName}`,
'--collect',
'--service-type=exec',
`--setenv=PATH=${process.env.PATH || ''}`,
'/bin/sh',
'-c',
updateScript,
], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 5000,
});
if (systemdRun.status !== 0) {
const detail = (systemdRun.stderr || systemdRun.stdout || '').trim();
return res.status(409).json({
error: detail || `Could not queue update job for ${systemdServiceUnit}`,
});
}
return res.json({
success: true,
message: 'Update queued; OpenChamber will restart after installation completes',
version: updateInfo.version,
packageManager: pm,
autoRestart: true,
restartManager: 'systemd',
jobId: updateJobName,
logPath: updateLogPath,
});
}
const isWindows = process.platform === 'win32';
const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import express from 'express';
import path from 'node:path';
import request from 'supertest';
vi.mock('child_process', () => ({
spawn: vi.fn(),
spawnSync: vi.fn(),
}));
vi.mock('../package-manager.js', () => ({
checkForUpdates: vi.fn(),
getUpdateCommand: vi.fn(),
detectPackageManagerDetails: vi.fn(),
}));
const childProcess = await import('child_process');
const packageManager = await import('../package-manager.js');
const { registerOpenChamberRoutes } = await import('./openchamber-routes.js');
const createApp = ({ environment = {}, storedOptions = {} } = {}) => {
const app = express();
const dependencies = {
fs: {
existsSync: vi.fn(() => false),
promises: {
readFile: vi.fn(async () => JSON.stringify({
launchMode: 'foreground',
port: 7897,
...storedOptions,
})),
},
},
path,
process: {
env: environment,
platform: 'linux',
execPath: '/usr/bin/node',
},
server: {
address: () => ({ port: 7897 }),
},
__dirname: '/opt/openchamber/server',
openchamberDataDir: '/tmp/openchamber',
modelsDevApiUrl: 'https://models.example.test',
modelsMetadataCacheTtl: 0,
readSettingsFromDiskMigrated: vi.fn(),
fetchFreeZenModels: vi.fn(),
getCachedZenModels: vi.fn(),
};
registerOpenChamberRoutes(app, dependencies);
return { app, dependencies };
};
beforeEach(() => {
packageManager.checkForUpdates.mockResolvedValue({
available: true,
version: '1.17.1',
});
packageManager.detectPackageManagerDetails.mockReturnValue({
packageManager: 'npm',
});
packageManager.getUpdateCommand.mockReturnValue('npm install -g @openchamber/web@latest');
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe('OpenChamber foreground update route', () => {
it('rejects a foreground update when the server is not owned by systemd', async () => {
const { app } = createApp();
await request(app)
.post('/api/openchamber/update-install')
.expect(409, {
error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.',
});
expect(childProcess.spawnSync).not.toHaveBeenCalled();
});
it('rejects an unsafe systemd unit override before starting an update job', async () => {
const { app } = createApp({
environment: {
INVOCATION_ID: 'systemd-invocation',
OPENCHAMBER_SYSTEMD_UNIT: 'openchamber.service; rm -rf /',
},
});
await request(app)
.post('/api/openchamber/update-install')
.expect(409, {
error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.',
});
expect(childProcess.spawnSync).not.toHaveBeenCalled();
});
it('queues the install in a transient systemd unit and returns its job identifier', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
childProcess.spawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' });
const { app } = createApp({
environment: {
INVOCATION_ID: 'systemd-invocation',
OPENCHAMBER_SYSTEMD_UNIT: 'openchamber@wsl.service',
PATH: '/home/syu/.npm-global/bin:/usr/bin:/bin',
},
});
await request(app)
.post('/api/openchamber/update-install')
.expect(200, {
success: true,
message: 'Update queued; OpenChamber will restart after installation completes',
version: '1.17.1',
packageManager: 'npm',
autoRestart: true,
restartManager: 'systemd',
jobId: 'openchamber-update-1700000000000',
logPath: 'journalctl --user-unit openchamber-update-1700000000000.service',
});
expect(childProcess.spawnSync).toHaveBeenCalledWith('systemd-run', [
'--user',
'--unit=openchamber-update-1700000000000',
'--collect',
'--service-type=exec',
'--setenv=PATH=/home/syu/.npm-global/bin:/usr/bin:/bin',
'/bin/sh',
'-c',
"set -eu\nnpm install -g @openchamber/web@latest\nsystemctl --user restart 'openchamber@wsl.service'",
], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 5000,
});
});
});
+21 -3
View File
@@ -309,6 +309,16 @@ export const registerOpenCodeProxy = (app, deps) => {
const PROXY_REQUEST_TIMEOUT_MS = normalizeProxyTimeout(LONG_REQUEST_TIMEOUT_MS);
const PROXY_TIMEOUT_MARKER = Symbol('openchamberProxyTimedOut');
// A provider OAuth callback blocks upstream for as long as the user takes to
// sign in in their browser (device-code polling, or a loopback redirect), so
// it cannot share the ordinary request deadline. Bounded by the shortest
// upstream expiry we know of — GitHub device codes last ~15 minutes.
const INTERACTIVE_OAUTH_TIMEOUT_MS = 15 * 60 * 1000;
const INTERACTIVE_OAUTH_PATH = /^\/provider\/[^/]+\/oauth\/callback\/?$/;
const isInteractiveOAuthCallback = (req) =>
req.method === 'POST' && INTERACTIVE_OAUTH_PATH.test(req.path);
const isProxyTimeoutError = (error) => {
const code = typeof error?.code === 'string' ? error.code : '';
const message = typeof error?.message === 'string' ? error.message.toLowerCase() : '';
@@ -327,6 +337,10 @@ export const registerOpenCodeProxy = (app, deps) => {
};
const applyProxyResponseDeadline = (req, res, next) => {
if (isInteractiveOAuthCallback(req)) {
return next();
}
const timeout = setTimeout(() => {
req[PROXY_TIMEOUT_MARKER] = true;
if (sendProxyErrorResponse(res, 504)) {
@@ -753,12 +767,12 @@ export const registerOpenCodeProxy = (app, deps) => {
});
// Generic proxy for non-SSE OpenCode API routes.
const apiProxy = createProxyMiddleware({
const createApiProxy = (timeoutMs) => createProxyMiddleware({
target: resolveProxyTarget(),
changeOrigin: true,
pathRewrite: { '^/api': '' },
timeout: PROXY_REQUEST_TIMEOUT_MS,
proxyTimeout: PROXY_REQUEST_TIMEOUT_MS,
timeout: timeoutMs,
proxyTimeout: timeoutMs,
// Dynamic target — port can change after restart
router: () => resolveProxyTarget(),
on: {
@@ -805,6 +819,9 @@ export const registerOpenCodeProxy = (app, deps) => {
},
});
const apiProxy = createApiProxy(PROXY_REQUEST_TIMEOUT_MS);
const interactiveOAuthProxy = createApiProxy(INTERACTIVE_OAUTH_TIMEOUT_MS);
// Best-effort fallback for stale clients still sending symlink paths.
// Settings and project selection normalize at source; this cached async path
// avoids blocking the proxy hot path on every directory-scoped request.
@@ -821,5 +838,6 @@ export const registerOpenCodeProxy = (app, deps) => {
});
app.use('/api', applyProxyResponseDeadline);
app.post('/api/provider/:providerID/oauth/callback', interactiveOAuthProxy);
app.use('/api', apiProxy);
};
@@ -565,6 +565,9 @@ export const createSettingsHelpers = (dependencies) => {
result.userMessageRenderingMode = mode;
}
}
if (typeof candidate.collapsibleUserMessages === 'boolean') {
result.collapsibleUserMessages = candidate.collapsibleUserMessages;
}
if (typeof candidate.stickyUserHeader === 'boolean') {
result.stickyUserHeader = candidate.stickyUserHeader;
}
@@ -74,6 +74,14 @@ describe('settings helpers', () => {
expect(helpers.sanitizeSettingsUpdate({ wideChatLayoutEnabled: 'true' })).toEqual({});
});
it('accepts only booleans for collapsible user messages', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: true })).toEqual({ collapsibleUserMessages: true });
expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: false })).toEqual({ collapsibleUserMessages: false });
expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: 'true' })).toEqual({});
});
it('accepts messageStreamTransport as a persisted shared setting', () => {
const helpers = createTestHelpers();
+37 -4
View File
@@ -49,9 +49,36 @@ function ensureDirs() {
// ============== MARKDOWN FILE OPERATIONS ==============
// Mirror of OpenCode's markdown frontmatter sanitizer (packages/opencode/src/
// config/markdown.ts): other coding agents accept unquoted colons in YAML
// values (e.g. `description: Build agent: creates builds`), which strict YAML
// rejects. Rewrite those values as block scalars and retry the parse, so files
// OpenCode accepts are parsed identically here.
function sanitizeFrontmatter(frontmatter) {
return frontmatter
.split(/\r?\n/)
.flatMap((line) => {
if (line.trim().startsWith('#') || line.trim() === '' || /^\s+/.test(line)) return [line];
const entry = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/);
if (!entry) return [line];
const value = entry[2].trim();
if (value === '' || value === '>' || value === '|' || value.startsWith('"') || value.startsWith("'")) return [line];
if (!value.includes(':')) return [line];
return [`${entry[1]}: |-`, ` ${value}`];
})
.join('\n');
}
function parseMdFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
const rawContent = fs.readFileSync(filePath, 'utf8');
// Strip a UTF-8 BOM so frontmatter is recognized regardless of the editor
// that saved the file.
const content = rawContent.charCodeAt(0) === 0xfeff ? rawContent.slice(1) : rawContent;
// The closing `---` may sit at end-of-file without a trailing newline.
// gray-matter (used by OpenCode) accepts that, so we must too: otherwise the
// whole file is treated as the prompt body and a later save rewrites the
// existing YAML block into the body, duplicating the frontmatter.
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/);
if (!match) {
return { frontmatter: {}, body: content.trim() };
@@ -61,8 +88,14 @@ function parseMdFile(filePath) {
try {
frontmatter = yaml.parse(match[1]) || {};
} catch (error) {
console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error);
frontmatter = {};
// Lenient fallback for frontmatter that strict YAML rejects but OpenCode
// still accepts (unquoted colons in scalar values).
try {
frontmatter = yaml.parse(sanitizeFrontmatter(match[1])) || {};
} catch {
console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error);
frontmatter = {};
}
}
const body = match[2].trim();
@@ -0,0 +1,202 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { parseMdFile, writeMdFile } from './shared.js';
import { updateAgent } from './agents.js';
const FIXTURE_DIR = path.join(os.tmpdir(), `openchamber-shared-test-${process.pid}`);
const STANDARD_MD = [
'---',
'description: My build agent',
'model: anthropic/claude-sonnet-4',
'mode: primary',
'---',
'',
'This is the prompt body.',
'',
].join('\n');
const writeFixture = (name, content) => {
const filePath = path.join(FIXTURE_DIR, name);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf8');
return filePath;
};
describe('parseMdFile', () => {
beforeEach(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
});
afterEach(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
});
it('parses standard YAML frontmatter', () => {
const file = writeFixture('standard.md', STANDARD_MD);
const { frontmatter, body } = parseMdFile(file);
expect(frontmatter).toEqual({
description: 'My build agent',
model: 'anthropic/claude-sonnet-4',
mode: 'primary',
});
expect(body).toBe('This is the prompt body.');
});
it('parses frontmatter whose closing --- is at end-of-file without a trailing newline', () => {
// gray-matter (used by OpenCode) accepts this shape; OpenChamber must too,
// otherwise a later save duplicates the YAML block.
const file = writeFixture('eof-close.md', [
'---',
'description: My build agent',
'model: anthropic/claude-sonnet-4',
'---',
].join('\n'));
const { frontmatter, body } = parseMdFile(file);
expect(frontmatter).toEqual({
description: 'My build agent',
model: 'anthropic/claude-sonnet-4',
});
expect(body).toBe('');
});
it('parses frontmatter with CRLF line endings', () => {
const file = writeFixture('crlf.md', STANDARD_MD.replace(/\n/g, '\r\n'));
const { frontmatter, body } = parseMdFile(file);
expect(frontmatter.model).toBe('anthropic/claude-sonnet-4');
expect(body).toBe('This is the prompt body.');
});
it('parses frontmatter preceded by a UTF-8 BOM', () => {
const file = writeFixture('bom.md', `\uFEFF${STANDARD_MD}`);
const { frontmatter, body } = parseMdFile(file);
expect(frontmatter.description).toBe('My build agent');
expect(body).toBe('This is the prompt body.');
});
it('falls back to lenient YAML for unquoted colons in values, matching OpenCode', () => {
const file = writeFixture('colon.md', [
'---',
'description: Build agent: creates builds',
'model: anthropic/claude-sonnet-4',
'---',
'',
'Body',
'',
].join('\n'));
const { frontmatter, body } = parseMdFile(file);
expect(frontmatter).toEqual({
description: 'Build agent: creates builds',
model: 'anthropic/claude-sonnet-4',
});
expect(body).toBe('Body');
});
it('treats files without frontmatter as a plain body', () => {
const file = writeFixture('plain.md', 'Just a prompt body.');
const { frontmatter, body } = parseMdFile(file);
expect(frontmatter).toEqual({});
expect(body).toBe('Just a prompt body.');
});
});
describe('writeMdFile', () => {
beforeEach(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
});
afterEach(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
});
it('round-trips a canonical single frontmatter block', () => {
const file = writeFixture('roundtrip.md', STANDARD_MD);
const parsed = parseMdFile(file);
parsed.frontmatter.model = 'openai/gpt-5';
writeMdFile(file, parsed.frontmatter, parsed.body);
const content = fs.readFileSync(file, 'utf8');
// Exactly one frontmatter block.
expect(content.match(/^---\r?\n/g)).toHaveLength(1);
const reparsed = parseMdFile(file);
expect(reparsed.frontmatter).toEqual({
description: 'My build agent',
model: 'openai/gpt-5',
mode: 'primary',
});
expect(reparsed.body).toBe('This is the prompt body.');
});
});
describe('updateAgent frontmatter preservation', () => {
beforeEach(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
});
afterEach(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
});
it('updates the model in place without duplicating YAML for a file with EOF-closed frontmatter', () => {
// Repro of OPE-178: the file's closing --- sits at EOF (no trailing
// newline). OpenCode parses it; OpenChamber previously treated the whole
// file as the prompt body and prepended a second frontmatter block on save.
const projectDir = path.join(FIXTURE_DIR, 'project');
const agentPath = path.join(projectDir, '.opencode', 'agents', 'strateg.md');
writeFixture(path.join('project', '.opencode', 'agents', 'strateg.md'), [
'---',
'description: Strategy agent',
'model: anthropic/claude-sonnet-4',
'temperature: 0.7',
'---',
].join('\n'));
updateAgent('strateg', { model: 'openai/gpt-5' }, projectDir);
const content = fs.readFileSync(agentPath, 'utf8');
expect(content.match(/^---\r?\n/g)).toHaveLength(1);
const parsed = parseMdFile(agentPath);
expect(parsed.frontmatter).toEqual({
description: 'Strategy agent',
model: 'openai/gpt-5',
temperature: 0.7,
});
expect(parsed.body).toBe('');
});
it('preserves unrelated frontmatter fields when saving one field', () => {
const projectDir = path.join(FIXTURE_DIR, 'project');
const agentPath = path.join(projectDir, '.opencode', 'agents', 'strateg.md');
writeFixture(path.join('project', '.opencode', 'agents', 'strateg.md'), [
'---',
'description: Strategy agent',
'mode: primary',
'temperature: 0.7',
'---',
'',
'Body of strateg.',
'',
].join('\n'));
updateAgent('strateg', { description: 'Updated strategy agent' }, projectDir);
const content = fs.readFileSync(agentPath, 'utf8');
expect(content.match(/^---\r?\n/g)).toHaveLength(1);
const parsed = parseMdFile(agentPath);
expect(parsed.frontmatter).toEqual({
description: 'Updated strategy agent',
mode: 'primary',
temperature: 0.7,
});
expect(parsed.body).toBe('Body of strateg.');
});
});
@@ -2,7 +2,7 @@ import { DateTime, IANAZone } from 'luxon';
import parser from 'cron-parser';
const PROJECT_CONFIG_VERSION = 1;
const MAX_TASK_NAME_LENGTH = 80;
export const MAX_TASK_NAME_LENGTH = 80;
const MAX_TASK_PROMPT_LENGTH = 20_000;
const MAX_CRON_LENGTH = 200;
const MAX_LAST_ERROR_LENGTH = 2_000;
@@ -313,6 +313,11 @@ const normalizeTaskForStorage = (value, options) => {
const schedule = normalizeSchedule(value.schedule, existingTask?.schedule);
const execution = normalizeExecution(value.execution);
// Loop provenance: absolute path of the `.agents/loops/*.md` file driving
// this task, when any. Preserved on every write so the scheduler can detect
// removed loop files across restarts. Unknown to the UI model.
const loopFile = asNonEmptyString(value.loopFile) ?? asNonEmptyString(existingTask?.loopFile);
const nowMs = Math.max(0, Math.round(now));
const baseState = normalizeState(value.state, existingTask?.state);
const state = {
@@ -328,6 +333,7 @@ const normalizeTaskForStorage = (value, options) => {
schedule,
execution,
state,
...(loopFile ? { loopFile } : {}),
};
};
@@ -559,11 +565,141 @@ export const createProjectConfigRuntime = (deps) => {
});
};
/**
* Reconcile discovered `.agents/loops` definitions with the persisted JSON
* task list.
*
* Rules (documented in scheduled-tasks/DOCUMENTATION.md):
* - For loop-owned tasks (carrying the `loopFile` marker) identity is the
* LOOP FILE PATH: a loop takes its task over regardless of the task's
* current name, so renaming the loop (`name` field or a UI edit) renames
* the task in place instead of leaving a stale duplicate behind.
* - A loop whose name matches a JSON task (no `loopFile`) takes that task
* over: its schedule/execution/enabled are overwritten from the file while
* its id and runtime state are preserved (markdown wins on conflict).
* Execution fields the file format does not define (goalEnabled,
* goalTokenBudget, permissionAutoAccept, variant) are preserved.
* - A task whose loopFile no longer matches any discovered loop file is
* unscheduled (removed). JSON-configured tasks (no loopFile) are never
* removed.
* - A task whose loop file still exists but is currently unparseable is
* KEPT with its last good definition: only a genuinely removed file
* unschedules a task, so transiently malformed files (mid-edit, bad
* merge) never delete tasks or their runtime state.
* - Loops with no matching task are created under a deterministic
* `loop:<scope>:<name>` id, so runtime state survives restarts.
* - Malformed definitions are skipped with a warning and never block valid
* loops; the scheduler passes them as `definition: null` entries, and
* normalization failures here are isolated per loop.
*/
const reconcileLoopTasks = async (projectID, loops) => {
return withProjectWriteLock(projectID, async () => {
const now = Date.now();
const current = await readProjectConfigFromDisk(projectID);
const tasks = current.scheduledTasks;
const activeLoopFilePaths = new Set();
const pendingLoops = new Map();
const loopsByPath = new Map();
for (const loop of loops) {
if (!loop || typeof loop.filePath !== 'string' || !loop.filePath) {
continue;
}
activeLoopFilePaths.add(loop.filePath);
if (loop.definition && typeof loop.definition === 'object') {
pendingLoops.set(loop.definition.name, loop);
loopsByPath.set(loop.filePath, loop);
}
}
const consumedLoopPaths = new Set();
const nextTasks = [];
for (const task of tasks) {
if (task.loopFile && !activeLoopFilePaths.has(task.loopFile)) {
// The driving loop file was removed (or renamed) — unschedule.
continue;
}
// Loop-owned tasks adopt by file path (covers renames of the `name`
// field); JSON tasks adopt by name.
const loop = task.loopFile
? loopsByPath.get(task.loopFile) || null
: pendingLoops.get(task.name) || null;
if (loop) {
try {
const adopted = normalizeTaskForStorage(
{
...task,
...loop.definition,
// File-defined execution fields win; UI-only fields the file
// format does not define are preserved from the task.
execution: { ...task.execution, ...loop.definition.execution },
loopFile: loop.filePath,
},
{
now,
createId: taskIDFactory,
existingTask: task,
allowCreate: false,
refreshUpdatedAt: false,
},
);
nextTasks.push(adopted);
pendingLoops.delete(loop.definition.name);
if (task.loopFile) {
consumedLoopPaths.add(task.loopFile);
loopsByPath.delete(task.loopFile);
}
} catch (error) {
console.warn(`[scheduled-tasks] skipped loop ${loop.filePath} for task "${task.name}":`, error?.message ?? error);
nextTasks.push(task);
}
continue;
}
if (task.loopFile && consumedLoopPaths.has(task.loopFile)) {
// Orphan duplicate: another task already adopted this loop file
// (left over from a rename) — unschedule it.
continue;
}
nextTasks.push(task);
}
for (const loop of pendingLoops.values()) {
try {
const id = `loop:${loop.scope}:${loop.definition.name}`;
const created = normalizeTaskForStorage(
{ id, ...loop.definition, loopFile: loop.filePath },
{
now,
createId: taskIDFactory,
existingTask: null,
allowCreate: true,
refreshUpdatedAt: false,
},
);
nextTasks.push(created);
} catch (error) {
console.warn(`[scheduled-tasks] skipped loop ${loop.filePath}:`, error?.message ?? error);
}
}
await writeProjectConfigToDisk(projectID, {
version: PROJECT_CONFIG_VERSION,
scheduledTasks: nextTasks,
});
return nextTasks;
});
};
return {
listScheduledTasks,
upsertScheduledTask,
deleteScheduledTask,
updateScheduledTaskState,
reconcileLoopTasks,
resolveProjectConfigPath,
};
};
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, rm, readFile, writeFile } from 'fs/promises';
@@ -173,3 +173,302 @@ describe('project-config runtime', () => {
}
});
});
describe('project-config loop reconciliation', () => {
const loop = (name, overrides = {}) => ({
scope: 'project',
filePath: `/repo/.agents/loops/${name}.md`,
definition: {
name,
enabled: true,
schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' },
execution: {
prompt: `Loop prompt for ${name}`,
providerID: 'openai',
modelID: 'gpt-4.1',
},
...overrides,
},
});
it('creates tasks for discovered loops with deterministic ids', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const tasks = await runtime.reconcileLoopTasks('project-test', [
loop('daily-digest'),
loop('weekly-report'),
]);
expect(tasks).toHaveLength(2);
const digest = tasks.find((task) => task.name === 'daily-digest');
expect(digest.id).toBe('loop:project:daily-digest');
expect(digest.schedule.cron).toBe('0 9 * * *');
expect(digest.execution.providerID).toBe('openai');
expect(digest.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
const reloaded = await runtime.listScheduledTasks('project-test');
expect(reloaded).toHaveLength(2);
expect(reloaded[0].state.createdAt).toBeGreaterThan(0);
} finally {
await cleanup();
}
});
it('adopts an existing task by name, preserving id and state, and persists state across reconciles', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'daily-digest',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' },
});
const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const adopted = first.find((task) => task.id === created.task.id);
expect(adopted).toBeDefined();
expect(adopted.id).toBe(created.task.id);
expect(adopted.name).toBe('daily-digest');
expect(adopted.schedule.kind).toBe('cron');
expect(adopted.schedule.cron).toBe('0 9 * * *');
expect(adopted.execution.prompt).toBe('Loop prompt for daily-digest');
expect(adopted.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
const state = adopted.state;
await runtime.updateScheduledTaskState('project-test', adopted.id, {
nextRunAt: 123456,
lastRunAt: 111,
lastStatus: 'success',
});
const second = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const again = second.find((task) => task.id === created.task.id);
expect(again.id).toBe(created.task.id);
expect(again.state.nextRunAt).toBe(123456);
expect(again.state.lastRunAt).toBe(111);
expect(again.state.lastStatus).toBe('success');
expect(again.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
} finally {
await cleanup();
}
});
it('unschedules a loop-sourced task when its file is removed', async () => {
const { runtime, cleanup } = await createRuntime();
try {
await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const tasks = await runtime.reconcileLoopTasks('project-test', []);
expect(tasks).toHaveLength(0);
expect(await runtime.listScheduledTasks('project-test')).toHaveLength(0);
} finally {
await cleanup();
}
});
it('leaves JSON-configured tasks untouched when no loop matches', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'json-only',
enabled: true,
schedule: { kind: 'daily', time: '08:00', timezone: 'UTC' },
execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' },
});
const tasks = await runtime.reconcileLoopTasks('project-test', [loop('loop-only')]);
expect(tasks).toHaveLength(2);
expect(tasks.find((task) => task.id === created.task.id)).toBeDefined();
expect(tasks.find((task) => task.name === 'loop-only')).toBeDefined();
} finally {
await cleanup();
}
});
it('does not remove a JSON task that merely shares a loop name after the loop is gone... keeps it when never adopted', async () => {
// A JSON task that was never driven by a loop file (no loopFile marker)
// must survive reconciles even when a loop with the same name existed
// only in a previous reconcile round — but once a loop adopted it, the
// file is authoritative and removing the file unschedules the task.
const { runtime, cleanup } = await createRuntime();
try {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'daily-digest',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' },
});
// First reconcile adopts the task (loopFile marker set).
await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
// Loop file removed -> task unscheduled.
const afterRemoval = await runtime.reconcileLoopTasks('project-test', []);
expect(afterRemoval.find((task) => task.id === created.task.id)).toBeUndefined();
} finally {
await cleanup();
}
});
it('skips invalid loop definitions without blocking valid ones', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
const tasks = await runtime.reconcileLoopTasks('project-test', [
loop('bad-loop', { schedule: { kind: 'cron', cron: 'not a cron', timezone: 'UTC' } }),
loop('good-loop'),
]);
expect(tasks.map((task) => task.name)).toEqual(['good-loop']);
expect(warn).toHaveBeenCalled();
} finally {
warn.mockRestore();
}
} finally {
await cleanup();
}
});
it('renames a loop-sourced task in place when the loop name changes but the file stays', async () => {
// Identity for loop-owned tasks is the loop file path: changing the `name`
// field (or renaming via the UI) must not leave a stale duplicate running.
const { runtime, cleanup } = await createRuntime();
try {
const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const original = first.find((task) => task.name === 'daily-digest');
const renamed = await runtime.reconcileLoopTasks('project-test', [{
scope: 'project',
filePath: '/repo/.agents/loops/daily-digest.md',
definition: {
name: 'digest',
enabled: true,
schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' },
execution: { prompt: 'Loop prompt for digest', providerID: 'openai', modelID: 'gpt-4.1' },
},
}]);
expect(renamed).toHaveLength(1);
const adopted = renamed[0];
expect(adopted.id).toBe(original.id);
expect(adopted.name).toBe('digest');
expect(adopted.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
expect(adopted.execution.prompt).toBe('Loop prompt for digest');
} finally {
await cleanup();
}
});
it('reverts a UI rename of a loop task back to the loop name on reconcile', async () => {
const { runtime, cleanup } = await createRuntime();
try {
await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const created = (await runtime.listScheduledTasks('project-test'))[0];
// The UI editor renamed the task; loopFile survives the write.
await runtime.upsertScheduledTask('project-test', {
id: created.id,
name: 'renamed-by-ui',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'UI prompt', providerID: 'openai', modelID: 'gpt-4.1' },
});
const after = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
expect(after).toHaveLength(1);
expect(after[0].id).toBe(created.id);
expect(after[0].name).toBe('daily-digest');
expect(after[0].execution.prompt).toBe('Loop prompt for daily-digest');
} finally {
await cleanup();
}
});
it('keeps a loop-sourced task while its file exists but is currently unparseable', async () => {
// A transiently malformed file (mid-edit, bad merge) must not delete the
// task or its runtime state — only a genuinely removed file unschedules.
const { runtime, cleanup } = await createRuntime();
try {
const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const original = first[0];
await runtime.updateScheduledTaskState('project-test', original.id, {
nextRunAt: 123456,
lastRunAt: 111,
lastStatus: 'success',
});
const after = await runtime.reconcileLoopTasks('project-test', [{
scope: 'project',
filePath: '/repo/.agents/loops/daily-digest.md',
definition: null,
}]);
expect(after).toHaveLength(1);
expect(after[0].id).toBe(original.id);
expect(after[0].name).toBe('daily-digest');
expect(after[0].loopFile).toBe('/repo/.agents/loops/daily-digest.md');
expect(after[0].schedule.cron).toBe('0 9 * * *');
expect(after[0].state.nextRunAt).toBe(123456);
expect(after[0].state.lastStatus).toBe('success');
} finally {
await cleanup();
}
});
it('unschedules orphan duplicates of the same loop file', async () => {
// Zombie cleanup: two tasks driving one file (e.g. left over from a
// rename under the old name-identity rules) — the later one is removed.
const { runtime, cleanup } = await createRuntime();
try {
const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const original = first[0];
await runtime.upsertScheduledTask('project-test', {
id: 'zombie-copy',
name: 'daily-digest-copy',
enabled: true,
loopFile: '/repo/.agents/loops/daily-digest.md',
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'Stale copy', providerID: 'openai', modelID: 'gpt-4.1' },
});
const after = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
expect(after).toHaveLength(1);
expect(after[0].id).toBe(original.id);
expect(after.find((task) => task.id === 'zombie-copy')).toBeUndefined();
} finally {
await cleanup();
}
});
it('preserves UI-only execution fields when adopting a JSON task', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'daily-digest',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: {
prompt: 'JSON prompt',
providerID: 'openai',
modelID: 'gpt-4.1',
variant: 'fast',
goalEnabled: true,
goalTokenBudget: 20000,
permissionAutoAccept: true,
},
});
const adopted = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const task = adopted.find((entry) => entry.id === created.task.id);
expect(task.execution.prompt).toBe('Loop prompt for daily-digest');
expect(task.execution.variant).toBe('fast');
expect(task.execution.goalEnabled).toBe(true);
expect(task.execution.goalTokenBudget).toBe(20000);
expect(task.execution.permissionAutoAccept).toBe(true);
} finally {
await cleanup();
}
});
});
@@ -5,7 +5,8 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
## Scope
- Per-project scheduled task persistence is owned by `packages/web/server/lib/projects/project-config.js`.
- Runtime orchestration and execution is owned by this module.
- Markdown loop discovery/parsing is owned by `packages/web/server/lib/scheduled-tasks/loops.js`.
- Runtime orchestration and execution is owned by `packages/web/server/lib/scheduled-tasks/runtime.js`.
- This module is OpenChamber feature logic; it is intentionally separate from OpenCode proxy/runtime internals.
## Files
@@ -17,11 +18,90 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
- Session create + prompt_async execution
- Emits OpenChamber task-run events
- `packages/web/server/lib/scheduled-tasks/loops.js`
- Discovery of `.agents/loops/*.md` (project scope, ancestors up to the worktree root) and `~/.agents/loops/*.md` (user scope)
- Frontmatter parsing into scheduled-task definitions
- `syncProject` reconciles discovered loops with the persisted task list on every project sync (startup, task save/delete)
- `packages/web/server/lib/scheduled-tasks/routes.js`
- Scheduled task CRUD endpoints
- Manual run endpoint
- OpenChamber events SSE stream endpoint
## Loop file format
Portable, git-commit-able scheduled-task definitions:
```markdown
---
name: daily-digest
schedule: "0 9 * * *"
enabled: true
model: anthropic/claude-sonnet-4-5
agent: plan
timezone: Europe/Kyiv
---
Summarize repository changes since yesterday.
```
Field mapping (model: `packages/ui/src/lib/scheduledTasksApi.ts`):
| Frontmatter | Task field |
|---|---|
| `name` | `name` (required, max 80 characters — longer names are rejected as malformed) |
| `schedule` | `schedule.kind: "cron"` + `schedule.cron` (required, cron-only in the portable format) |
| `enabled` | `enabled` (default `false` — a loop only runs when the file explicitly enables it; add `enabled: true` to activate) |
| `model` | split on the first `/` into `execution.providerID` / `execution.modelID` (required) |
| `agent` | `execution.agent` (optional) |
| `timezone` | `schedule.timezone` (optional, IANA; defaults to the server zone) |
| body | `execution.prompt` (required) |
`thinking_level` and `goalEnabled`/`goalTokenBudget` are not part of the portable
format (UI/JSON-only today); `daily`/`weekly`/`once` schedules remain UI/JSON-only.
Runtime state (`lastRunAt`, `nextRunAt`, `lastStatus`, `lastError`, `lastSessionId`,
`lastDurationMs`) is never written to the markdown file — it continues to live in
the project config state store.
## Loop reconciliation rules
`projectConfigRuntime.reconcileLoopTasks(projectID, loops)` runs inside the
project write lock on every `syncProject` when the project path is known:
- **Identity.** For loop-owned tasks (carrying the `loopFile` marker) identity
is the loop file path: a loop takes its task over regardless of the task's
current name, so renaming the loop (the `name` field, or a UI rename) renames
the task in place instead of leaving a stale duplicate behind. A loop whose
name matches a JSON task (no `loopFile`) takes that task over instead: its
schedule/execution/enabled are overwritten from the file while the task's
`id` and runtime `state` are preserved (markdown wins on conflict).
- **UI-only fields survive adoption.** Execution fields the file format does
not define (`goalEnabled`, `goalTokenBudget`, `permissionAutoAccept`,
`variant`) are preserved from the task when a loop adopts it; only fields the
file defines are re-applied.
- **Deletion.** A task carrying the `loopFile` marker whose loop file is no
longer discovered (removed or renamed) is unscheduled (removed from the
config). The marker is persisted in the config file, so removal is detected
across restarts. JSON-configured tasks without the marker are never removed.
A task whose loop file still exists but is currently unparseable is KEPT with
its last good definition — a transiently malformed file (mid-edit, bad merge)
never deletes a task or its runtime state.
- **Creation.** Loops without a matching task are created under a deterministic
`loop:<scope>:<name>` id so runtime state survives restarts. At most one task
is driven per loop file; orphan duplicates of the same file are unscheduled.
- **Scope precedence.** Project-scope loops shadow user-scope loops with the
same name; among project files the nearest ancestor wins.
- **Malformed files** (missing `name`/`schedule`/`model`/body, invalid cron,
unreadable) are reported to the scheduler as `definition: null` entries and
warned about; they never block valid loops in the same or other scopes.
- **UI edits** to a loop-sourced task are preserved in the config but the loop
file remains authoritative: the next reconciliation re-applies the file's
definition (including `enabled`). Use `enabled: false` in the file to
disable. Deleting a loop-sourced task through the API is rejected with a 400
while its loop file still exists on disk — the loop file is the removal
surface; once the file is gone, deleting the orphan task is allowed. The
scheduled-tasks UI marks loop tasks as file-managed and disables their
edit/enable/delete actions for the same reason; `run now` remains available.
## Public exports (runtime.js)
- `createScheduledTasksRuntime(dependencies)`
@@ -0,0 +1,209 @@
/**
* Markdown loops portable scheduled-task definitions.
*
* Loops are git-commit-able markdown files with YAML frontmatter, discovered
* from `.agents/loops/*.md` (project scope, including ancestor directories up
* to the worktree root) and `~/.agents/loops/*.md` (user scope), mirroring the
* skills discovery pattern (`packages/web/server/lib/opencode/skills.js`).
*
* File format:
*
* ---
* name: daily-digest
* schedule: "0 9 * * *"
* enabled: true
* model: anthropic/claude-sonnet-4-5
* agent: plan
* timezone: Europe/Kyiv
* ---
* Summarize repository changes since yesterday and post the digest.
*
* Field mapping (see packages/ui/src/lib/scheduledTasksApi.ts):
* name -> task.name
* schedule -> task.schedule.kind "cron" + task.schedule.cron
* enabled -> task.enabled (default false loops only run when the file
* explicitly enables them, so discovery never auto-executes
* repository content)
* model -> split into task.execution.providerID / task.execution.modelID
* agent -> task.execution.agent (optional)
* timezone -> task.schedule.timezone (optional, defaults to the server zone)
* body -> task.execution.prompt
*
* `thinking_level` and `goalEnabled`/`goalTokenBudget` are not part of the
* portable format (they are UI-only today); editing them in the file has no
* effect and they remain JSON/UI-only.
*
* Runtime state (lastRunAt, nextRunAt, lastStatus, ...) is never written to
* the markdown file; it continues to live in the project config/state store.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { parseMdFile, getAncestors, findWorktreeRoot } from '../opencode/shared.js';
import { MAX_TASK_NAME_LENGTH } from '../projects/project-config.js';
const LOOP_DIR_NAME = 'loops';
const USER_LOOP_ROOT = () => path.join(os.homedir(), '.agents', LOOP_DIR_NAME);
const asNonEmptyString = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
/**
* Split a `provider/model` string into its two parts. Splits on the first `/`
* so model ids containing a slash (e.g. `openai/gpt-5`) still resolve.
*/
const splitProviderModel = (value) => {
const raw = asNonEmptyString(value);
if (!raw) {
return null;
}
const separator = raw.indexOf('/');
if (separator <= 0 || separator === raw.length - 1) {
return null;
}
return {
providerId: raw.slice(0, separator).trim(),
modelId: raw.slice(separator + 1).trim(),
};
};
/**
* Parse one loop markdown file into a scheduled-task definition, or return
* null when the file is malformed. Malformed files are skipped with a warning
* and never prevent valid files from loading.
*/
export const parseLoopDefinition = (filePath) => {
let parsed;
try {
parsed = parseMdFile(filePath);
} catch (error) {
console.warn(`[loops] skipped malformed loop file ${filePath}:`, error?.message ?? error);
return null;
}
const frontmatter = parsed.frontmatter && typeof parsed.frontmatter === 'object'
? parsed.frontmatter
: {};
const name = asNonEmptyString(frontmatter.name);
if (!name) {
console.warn(`[loops] skipped ${filePath}: frontmatter "name" is required`);
return null;
}
if (name.length > MAX_TASK_NAME_LENGTH) {
// Reject instead of clamping: task names are clamped to this length at
// storage time, so identity keys must match the stored value exactly.
console.warn(`[loops] skipped ${filePath}: frontmatter "name" exceeds ${MAX_TASK_NAME_LENGTH} characters`);
return null;
}
const cron = asNonEmptyString(frontmatter.schedule);
if (!cron) {
console.warn(`[loops] skipped ${filePath}: frontmatter "schedule" (cron expression) is required`);
return null;
}
const prompt = asNonEmptyString(parsed.body);
if (!prompt) {
console.warn(`[loops] skipped ${filePath}: markdown body (the execution prompt) is required`);
return null;
}
const providerModel = splitProviderModel(frontmatter.model);
if (!providerModel) {
console.warn(`[loops] skipped ${filePath}: frontmatter "model" must be "provider/model"`);
return null;
}
const timezone = asNonEmptyString(frontmatter.timezone);
const agent = asNonEmptyString(frontmatter.agent);
return {
name,
enabled: typeof frontmatter.enabled === 'boolean' ? frontmatter.enabled : false,
schedule: {
kind: 'cron',
cron,
...(timezone ? { timezone } : {}),
},
execution: {
prompt,
providerID: providerModel.providerId,
modelID: providerModel.modelId,
...(agent ? { agent } : {}),
},
};
};
const walkLoopMdFiles = (rootDir) => {
if (!rootDir || !fs.existsSync(rootDir)) {
return [];
}
try {
return fs.readdirSync(rootDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => path.join(rootDir, entry.name))
.sort();
} catch {
return [];
}
};
/**
* Discover loop files for a project: `~/.agents/loops/*.md` (user scope) plus
* `.agents/loops/*.md` in every ancestor of the project path up to the
* worktree root (project scope).
*/
export const discoverLoopFiles = (projectPath) => {
const files = [];
for (const filePath of walkLoopMdFiles(USER_LOOP_ROOT())) {
files.push({ filePath, scope: 'user' });
}
if (projectPath) {
const worktreeRoot = findWorktreeRoot(projectPath) || path.resolve(projectPath);
for (const ancestor of getAncestors(projectPath, worktreeRoot)) {
const root = path.join(ancestor, '.agents', LOOP_DIR_NAME);
for (const filePath of walkLoopMdFiles(root)) {
files.push({ filePath, scope: 'project' });
}
}
}
return files;
};
/**
* Discover and parse all loops for a project. Project-scope loops shadow
* user-scope loops with the same name; among project files the nearest
* ancestor wins.
*
* Unparseable files are reported as `{ scope, filePath, definition: null }`
* entries instead of being dropped: the scheduler must distinguish "file is
* gone" (unschedule its task) from "file exists but is currently malformed"
* (keep its task with the last good definition until the file is fixed).
* Malformed files never block valid ones in the same or other scopes.
*/
export const discoverLoops = (projectPath) => {
const byName = new Map();
const loops = [];
for (const { filePath, scope } of discoverLoopFiles(projectPath)) {
const definition = parseLoopDefinition(filePath);
if (!definition) {
loops.push({ scope, filePath, definition: null });
continue;
}
const existing = byName.get(definition.name);
if (existing && (existing.scope === 'project' || scope === 'user')) {
continue;
}
byName.set(definition.name, { scope, filePath, definition });
}
for (const entry of byName.values()) {
loops.push(entry);
}
return loops;
};
@@ -0,0 +1,389 @@
import { describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { mkdtemp, rm, writeFile, mkdir } from 'fs/promises';
import { parseLoopDefinition, discoverLoops, discoverLoopFiles } from './loops.js';
const createProject = async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-'));
const projectPath = path.join(tempRoot, 'repo');
await mkdir(projectPath, { recursive: true });
await mkdir(path.join(projectPath, '.git'), { recursive: true });
return {
projectPath,
cleanup: async () => {
await rm(tempRoot, { recursive: true, force: true });
},
};
};
const writeLoop = async (projectPath, fileName, content) => {
const dir = path.join(projectPath, '.agents', 'loops');
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, fileName), content, 'utf8');
};
describe('parseLoopDefinition', () => {
it('maps frontmatter and body to the scheduled-task definition shape', async () => {
const { projectPath, cleanup } = await createProject();
try {
await writeLoop(projectPath, 'digest.md', `---
name: daily-digest
schedule: "0 9 * * *"
enabled: true
model: anthropic/claude-sonnet-4-5
agent: plan
timezone: Europe/Kyiv
---
Summarize repository changes since yesterday.
`);
const definition = parseLoopDefinition(path.join(projectPath, '.agents', 'loops', 'digest.md'));
expect(definition).toEqual({
name: 'daily-digest',
enabled: true,
schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'Europe/Kyiv' },
execution: {
prompt: 'Summarize repository changes since yesterday.',
providerID: 'anthropic',
modelID: 'claude-sonnet-4-5',
agent: 'plan',
},
});
} finally {
await cleanup();
}
});
it('splits model ids containing a slash on the first separator', async () => {
const { projectPath, cleanup } = await createProject();
try {
const filePath = path.join(projectPath, 'loop.md');
await writeFile(filePath, `---
name: nested-model
schedule: "0 8 * * 1"
model: openai/gpt-5
---
Run weekly checks.
`, 'utf8');
const definition = parseLoopDefinition(filePath);
expect(definition.execution.providerID).toBe('openai');
expect(definition.execution.modelID).toBe('gpt-5');
expect(definition.enabled).toBe(false);
} finally {
await cleanup();
}
});
it('defaults enabled to false and omits optional fields', async () => {
const { projectPath, cleanup } = await createProject();
try {
const filePath = path.join(projectPath, 'loop.md');
await writeFile(filePath, `---
name: minimal
schedule: "*/30 * * * *"
model: openai/gpt-5
---
Run every half hour.
`, 'utf8');
const definition = parseLoopDefinition(filePath);
// Loops only run when the file explicitly enables them: discovery of
// repository content must never auto-execute scheduled sessions.
expect(definition.enabled).toBe(false);
expect(definition.schedule).toEqual({ kind: 'cron', cron: '*/30 * * * *' });
expect(definition.execution.agent).toBeUndefined();
} finally {
await cleanup();
}
});
it('honors an explicit enabled: true in the frontmatter', async () => {
const { projectPath, cleanup } = await createProject();
try {
const filePath = path.join(projectPath, 'loop.md');
await writeFile(filePath, `---
name: explicit-enabled
schedule: "*/30 * * * *"
model: openai/gpt-5
enabled: true
---
Run every half hour.
`, 'utf8');
expect(parseLoopDefinition(filePath).enabled).toBe(true);
} finally {
await cleanup();
}
});
it('returns null for files missing required frontmatter fields', async () => {
const { projectPath, cleanup } = await createProject();
try {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
const noName = path.join(projectPath, 'noname.md');
await writeFile(noName, `---
schedule: "0 9 * * *"
model: openai/gpt-5
---
Prompt only.
`, 'utf8');
expect(parseLoopDefinition(noName)).toBeNull();
const noSchedule = path.join(projectPath, 'noschedule.md');
await writeFile(noSchedule, `---
name: no-schedule
model: openai/gpt-5
---
Prompt only.
`, 'utf8');
expect(parseLoopDefinition(noSchedule)).toBeNull();
const noModel = path.join(projectPath, 'nomodel.md');
await writeFile(noModel, `---
name: no-model
schedule: "0 9 * * *"
---
Prompt only.
`, 'utf8');
expect(parseLoopDefinition(noModel)).toBeNull();
const malformed = path.join(projectPath, 'malformed.md');
await writeFile(malformed, 'not a markdown frontmatter file at all', 'utf8');
expect(parseLoopDefinition(malformed)).toBeNull();
} finally {
warn.mockRestore();
}
} finally {
await cleanup();
}
});
it('treats a missing body as an invalid loop', async () => {
const { projectPath, cleanup } = await createProject();
try {
const filePath = path.join(projectPath, 'empty-body.md');
await writeFile(filePath, `---
name: empty-body
schedule: "0 9 * * *"
model: openai/gpt-5
---
`, 'utf8');
expect(parseLoopDefinition(filePath)).toBeNull();
} finally {
await cleanup();
}
});
it('rejects names longer than the storage limit', async () => {
const { projectPath, cleanup } = await createProject();
try {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
const filePath = path.join(projectPath, 'long-name.md');
await writeFile(filePath, `---
name: ${'x'.repeat(81)}
schedule: "0 9 * * *"
model: openai/gpt-5
---
Run.
`, 'utf8');
// Task names are clamped to 80 chars at storage time; a raw name that
// exceeds it could never match the stored task, so the file is treated
// as malformed rather than creating an unreachable definition.
expect(parseLoopDefinition(filePath)).toBeNull();
expect(warn).toHaveBeenCalled();
} finally {
warn.mockRestore();
}
} finally {
await cleanup();
}
});
});
describe('discoverLoops', () => {
it('discovers project loops and parses them', async () => {
const { projectPath, cleanup } = await createProject();
try {
await writeLoop(projectPath, 'digest.md', `---
name: daily-digest
schedule: "0 9 * * *"
model: openai/gpt-5
---
Summarize.
`);
const loops = discoverLoops(projectPath);
expect(loops).toHaveLength(1);
expect(loops[0].scope).toBe('project');
expect(loops[0].definition.name).toBe('daily-digest');
expect(loops[0].filePath.endsWith(path.join('.agents', 'loops', 'digest.md'))).toBe(true);
} finally {
await cleanup();
}
});
it('scans ancestor directories up to the worktree root', async () => {
const { projectPath, cleanup } = await createProject();
try {
// Worktree root contains the loop; the project directory is nested.
const nested = path.join(projectPath, 'src', 'nested');
await mkdir(nested, { recursive: true });
await writeLoop(projectPath, 'root-loop.md', `---
name: root-loop
schedule: "0 9 * * *"
model: openai/gpt-5
---
From the root.
`);
const loops = discoverLoops(nested);
expect(loops.map((loop) => loop.definition.name)).toEqual(['root-loop']);
expect(loops[0].scope).toBe('project');
} finally {
await cleanup();
}
});
it('discovers user-scope loops from ~/.agents/loops', async () => {
const { projectPath, cleanup } = await createProject();
const home = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-home-'));
const userDir = path.join(home, '.agents', 'loops');
await mkdir(userDir, { recursive: true });
await writeFile(path.join(userDir, 'user-loop.md'), `---
name: user-loop
schedule: "0 7 * * *"
model: openai/gpt-5
---
User scope.
`, 'utf8');
const originalHome = os.homedir;
vi.spyOn(os, 'homedir').mockReturnValue(home);
try {
const loops = discoverLoops(projectPath);
expect(loops.map((loop) => loop.definition.name)).toEqual(['user-loop']);
expect(loops[0].scope).toBe('user');
} finally {
os.homedir = originalHome;
await rm(home, { recursive: true, force: true });
await cleanup();
}
});
it('lets project scope shadow user scope on name collision', async () => {
const { projectPath, cleanup } = await createProject();
const home = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-home-'));
const userDir = path.join(home, '.agents', 'loops');
await mkdir(userDir, { recursive: true });
await writeFile(path.join(userDir, 'same-name.md'), `---
name: shared
schedule: "0 7 * * *"
model: openai/gpt-5
---
User version.
`, 'utf8');
await writeLoop(projectPath, 'same-name.md', `---
name: shared
schedule: "0 8 * * *"
model: anthropic/claude-sonnet-4-5
---
Project version.
`);
const originalHome = os.homedir;
vi.spyOn(os, 'homedir').mockReturnValue(home);
try {
const loops = discoverLoops(projectPath);
expect(loops).toHaveLength(1);
expect(loops[0].scope).toBe('project');
expect(loops[0].definition.execution.providerID).toBe('anthropic');
expect(loops[0].definition.schedule.cron).toBe('0 8 * * *');
} finally {
os.homedir = originalHome;
await rm(home, { recursive: true, force: true });
await cleanup();
}
});
it('reports malformed files as unparsed entries without blocking valid ones', async () => {
const { projectPath, cleanup } = await createProject();
try {
await writeLoop(projectPath, 'bad.md', `---
name: bad
schedule: "0 9 * * *"
---
No model.
`);
await writeLoop(projectPath, 'good.md', `---
name: good
schedule: "0 9 * * *"
model: openai/gpt-5
---
Valid.
`);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
const loops = discoverLoops(projectPath);
// The malformed file stays visible as a `definition: null` entry so
// the scheduler can keep its task alive while the file is fixed.
const bad = loops.find((loop) => loop.filePath.endsWith(path.join('.agents', 'loops', 'bad.md')));
expect(bad.definition).toBeNull();
expect(bad.scope).toBe('project');
const good = loops.find((loop) => loop.filePath.endsWith(path.join('.agents', 'loops', 'good.md')));
expect(good.definition.name).toBe('good');
expect(warn).toHaveBeenCalled();
} finally {
warn.mockRestore();
}
} finally {
await cleanup();
}
});
it('returns an empty list when nothing exists', async () => {
const { projectPath, cleanup } = await createProject();
try {
expect(discoverLoops(projectPath)).toEqual([]);
} finally {
await cleanup();
}
});
it('lists raw loop files per scope without parsing', async () => {
const { projectPath, cleanup } = await createProject();
try {
await writeLoop(projectPath, 'one.md', `---
name: one
schedule: "0 9 * * *"
model: openai/gpt-5
---
One.
`);
await writeFile(path.join(projectPath, 'not-a-loop.txt'), 'ignore me', 'utf8');
const files = discoverLoopFiles(projectPath);
expect(files).toHaveLength(1);
expect(files[0].scope).toBe('project');
expect(files[0].filePath.endsWith(path.join('.agents', 'loops', 'one.md'))).toBe(true);
} finally {
await cleanup();
}
});
});
@@ -3,6 +3,7 @@ import { DateTime } from 'luxon';
import parser from 'cron-parser';
import { expandSnippets } from '../opencode/snippets.js';
import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js';
import { discoverLoops } from './loops.js';
const DEFAULT_GLOBAL_CONCURRENCY = 4;
const DEFAULT_PROJECT_CONCURRENCY = 2;
@@ -382,8 +383,19 @@ export const createScheduledTasksRuntime = (deps) => {
const syncProject = async (projectID) => {
await ensureProjectPath(projectID);
const projectPath = projectPathByID.get(projectID) || null;
let tasks;
if (projectPath) {
// Reconcile `.agents/loops` definitions with the persisted task list:
// loop files are authoritative while present, removed files unschedule
// their task, and runtime state is preserved (see loops.js).
const loops = await discoverLoops(projectPath);
tasks = await projectConfigRuntime.reconcileLoopTasks(projectID, loops);
} else {
tasks = await projectConfigRuntime.listScheduledTasks(projectID);
}
const tasks = await projectConfigRuntime.listScheduledTasks(projectID);
setProjectTasks(projectID, tasks);
for (const task of tasks) {
@@ -1,5 +1,15 @@
import { describe, expect, it } from 'vitest';
import { computeNextRunAt, expandCommandGoalObjective, formatScheduledSessionTitle, parseScheduledCommandPrompt } from './runtime.js';
import { describe, expect, it, vi } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises';
import {
computeNextRunAt,
expandCommandGoalObjective,
formatScheduledSessionTitle,
parseScheduledCommandPrompt,
createScheduledTasksRuntime,
} from './runtime.js';
import { createProjectConfigRuntime } from '../projects/project-config.js';
describe('scheduled-tasks runtime helpers', () => {
it('computes next daily run in timezone', () => {
@@ -109,3 +119,90 @@ describe('scheduled-tasks runtime helpers', () => {
.toBe('Review the requested scope.\n\nauth module');
});
});
describe('scheduled-tasks runtime syncProject wiring', () => {
const createTempProject = async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-runtime-loop-'));
const repoPath = path.join(tempRoot, 'repo');
await mkdir(path.join(repoPath, '.agents', 'loops'), { recursive: true });
return {
tempRoot,
repoPath,
cleanup: async () => {
await rm(tempRoot, { recursive: true, force: true });
},
};
};
const createProjectConfig = async (tempRoot) => createProjectConfigRuntime({
fsPromises: await import('fs/promises'),
path,
projectsDirPath: path.join(tempRoot, 'config'),
createTaskID: () => 'task-fixed-id',
});
const createRuntimeDeps = (overrides = {}) => ({
buildOpenCodeUrl: () => 'http://localhost',
getOpenCodeAuthHeaders: () => ({}),
waitForOpenCodeReady: async () => {},
...overrides,
});
it('reconciles discovered loops when the project path is known', async () => {
const { tempRoot, repoPath, cleanup } = await createTempProject();
try {
await writeFile(path.join(repoPath, '.agents', 'loops', 'daily.md'), `---
name: daily
schedule: "0 9 * * *"
enabled: true
model: openai/gpt-5
---
Run daily.
`, 'utf8');
const projectConfigRuntime = await createProjectConfig(tempRoot);
const runtime = createScheduledTasksRuntime({
...createRuntimeDeps(),
projectConfigRuntime,
listProjects: async () => [{ id: 'proj', path: repoPath }],
});
await runtime.syncProject('proj');
const tasks = await projectConfigRuntime.listScheduledTasks('proj');
expect(tasks).toHaveLength(1);
expect(tasks[0].id).toBe('loop:project:daily');
expect(tasks[0].loopFile).toBe(path.join(repoPath, '.agents', 'loops', 'daily.md'));
// syncTaskSchedule computed and persisted the next run for the enabled task.
expect(tasks[0].state.nextRunAt).toBeGreaterThan(0);
} finally {
await cleanup();
}
});
it('falls back to plain listing when the project path cannot be resolved', async () => {
const { tempRoot, cleanup } = await createTempProject();
try {
const projectConfigRuntime = await createProjectConfig(tempRoot);
const reconcileSpy = vi.spyOn(projectConfigRuntime, 'reconcileLoopTasks');
const listSpy = vi.spyOn(projectConfigRuntime, 'listScheduledTasks');
const runtime = createScheduledTasksRuntime({
...createRuntimeDeps(),
projectConfigRuntime,
// Project not registered -> ensureProjectPath cannot resolve a path.
listProjects: async () => [],
});
await runtime.syncProject('proj');
expect(reconcileSpy).not.toHaveBeenCalled();
expect(listSpy).toHaveBeenCalledWith('proj');
expect(await projectConfigRuntime.listScheduledTasks('proj')).toEqual([]);
reconcileSpy.mockRestore();
listSpy.mockRestore();
} finally {
await cleanup();
}
});
});
@@ -1,3 +1,4 @@
import fs from 'node:fs';
import path from 'node:path';
import { OpenChamberControlError } from '../openchamber-control/error.js';
@@ -78,6 +79,19 @@ export const createScheduledTaskService = (dependencies) => {
await findProjectByID(projectID);
const normalizedTaskID = asNonEmptyString(taskID);
if (!normalizedTaskID) throw new OpenChamberControlError('taskId is required', 400);
const current = await projectConfigRuntime.listScheduledTasks(projectID);
const existing = current.find((task) => task.id === normalizedTaskID) || null;
if (existing?.loopFile && fs.existsSync(existing.loopFile)) {
// Loop tasks are owned by their `.agents/loops` markdown file: deleting
// the JSON row would be silently undone by the next reconcile while the
// file exists. The file itself is the removal surface. Once the file is
// gone (the task is an orphan that the next sync would remove anyway),
// deleting the row is safe and allowed.
throw new OpenChamberControlError(
'Loop task is managed by its .agents/loops markdown file; delete the file to remove the task',
400,
);
}
const result = await projectConfigRuntime.deleteScheduledTask(projectID, normalizedTaskID);
if (!result.deleted) throw new OpenChamberControlError('Task not found', 404);
await scheduledTasksRuntime.syncProject(projectID);
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { createScheduledTaskService } from './service.js';
const createService = (overrides = {}) => {
const projectConfigRuntime = {
listScheduledTasks: vi.fn(async () => []),
deleteScheduledTask: vi.fn(async () => ({ deleted: true, tasks: [] })),
...(overrides.projectConfigRuntime || {}),
};
const scheduledTasksRuntime = {
syncProject: vi.fn(async () => []),
...(overrides.scheduledTasksRuntime || {}),
};
const service = createScheduledTaskService({
readSettingsFromDiskMigrated: async () => ({
projects: [{ id: 'project-test', path: '/repo' }],
}),
sanitizeProjects: (projects) => projects,
projectConfigRuntime,
scheduledTasksRuntime,
});
return { service, projectConfigRuntime, scheduledTasksRuntime };
};
const loopTask = {
id: 'loop:project:daily-digest',
name: 'daily-digest',
enabled: true,
loopFile: '/repo/.agents/loops/daily-digest.md',
schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' },
execution: { prompt: 'digest', providerID: 'openai', modelID: 'gpt-4.1' },
};
describe('scheduled-task service remove', () => {
it('rejects deleting a loop-sourced task while its loop file still exists', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-delete-'));
try {
const loopFilePath = path.join(tempRoot, 'daily.md');
await writeFile(loopFilePath, '---\nname: daily-digest\n---\nRun.\n', 'utf8');
const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({
projectConfigRuntime: {
listScheduledTasks: vi.fn(async () => [{ ...loopTask, loopFile: loopFilePath }]),
},
});
await expect(service.remove('project-test', loopTask.id)).rejects.toMatchObject({
statusCode: 400,
message: expect.stringContaining('delete the file to remove the task'),
});
expect(projectConfigRuntime.deleteScheduledTask).not.toHaveBeenCalled();
expect(scheduledTasksRuntime.syncProject).not.toHaveBeenCalled();
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
it('allows deleting a loop-sourced task once its loop file is gone', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-delete-'));
try {
// The loop file was removed from disk; the orphan task is allowed to be
// deleted directly instead of waiting for the next reconcile.
const loopFilePath = path.join(tempRoot, 'gone.md');
const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({
projectConfigRuntime: {
listScheduledTasks: vi.fn(async () => [{ ...loopTask, loopFile: loopFilePath }]),
},
});
const tasks = await service.remove('project-test', loopTask.id);
expect(projectConfigRuntime.deleteScheduledTask).toHaveBeenCalledWith('project-test', loopTask.id);
expect(scheduledTasksRuntime.syncProject).toHaveBeenCalled();
expect(Array.isArray(tasks)).toBe(true);
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
it('deletes JSON-configured tasks normally', async () => {
const jsonTask = { ...loopTask, id: 'json-task', loopFile: undefined };
const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({
projectConfigRuntime: {
listScheduledTasks: vi.fn(async () => [jsonTask]),
deleteScheduledTask: vi.fn(async () => ({ deleted: true, tasks: [] })),
},
});
const tasks = await service.remove('project-test', jsonTask.id);
expect(projectConfigRuntime.deleteScheduledTask).toHaveBeenCalledWith('project-test', jsonTask.id);
expect(scheduledTasksRuntime.syncProject).toHaveBeenCalled();
expect(Array.isArray(tasks)).toBe(true);
});
});
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import AdmZip from 'adm-zip';
// Mock the ClawdHub network client so no real HTTP happens. The download
// function is what feeds the ZIP buffer into adm-zip inside install.js.
vi.mock('./api.js', () => ({
downloadClawdHubSkill: vi.fn(),
fetchClawdHubSkillInfo: vi.fn(),
}));
const { downloadClawdHubSkill } = await import('./api.js');
const { installSkillsFromClawdHub } = await import('./install.js');
/**
* Build a real ZIP archive with adm-zip (the dependency under test).
* Returns the raw Buffer, mirroring what downloadClawdHubSkill resolves to.
*/
function buildSkillZip(entries) {
const zip = new AdmZip();
for (const [entryName, content] of Object.entries(entries)) {
zip.addFile(entryName, Buffer.from(content, 'utf8'));
}
return zip.toBuffer();
}
describe('installSkillsFromClawdHub (adm-zip extraction path)', () => {
let userSkillDir;
beforeEach(async () => {
// Keep the target dir under os.tmpdir() so the temp->target rename in
// install.js stays on one filesystem (avoids EXDEV cross-device errors).
userSkillDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'clawdhub-test-skills-'));
vi.clearAllMocks();
});
afterEach(async () => {
await fs.promises.rm(userSkillDir, { recursive: true, force: true }).catch(() => {});
});
it('extracts a real ZIP (incl. nested subdirectories) into the target skill dir', async () => {
const skillMd = 'name: demo-skill\ndescription: adm-zip extraction regression guard\n';
const nested = 'nested file content for subdirectory extraction check\n';
downloadClawdHubSkill.mockResolvedValue(
buildSkillZip({ 'SKILL.md': skillMd, 'nested/data.txt': nested }),
);
const result = await installSkillsFromClawdHub({
scope: 'user',
targetSource: 'opencode',
userSkillDir,
// Non-'latest' version avoids the fetchClawdHubSkillInfo resolve branch.
selections: [{ clawdhub: { slug: 'demo-skill', version: '1.0.0' } }],
});
expect(result.ok).toBe(true);
expect(result.installed).toEqual([
{ skillName: 'demo-skill', scope: 'user', source: 'opencode' },
]);
expect(result.skipped).toEqual([]);
// downloadClawdHubSkill received the resolved (non-latest) version.
expect(downloadClawdHubSkill).toHaveBeenCalledWith('demo-skill', '1.0.0');
// adm-zip actually wrote the files, preserving the nested subdirectory.
const targetDir = path.join(userSkillDir, 'demo-skill');
const skillMdPath = path.join(targetDir, 'SKILL.md');
const nestedPath = path.join(targetDir, 'nested', 'data.txt');
expect(fs.existsSync(skillMdPath)).toBe(true);
expect(fs.existsSync(nestedPath)).toBe(true);
expect(fs.readFileSync(skillMdPath, 'utf8')).toBe(skillMd);
expect(fs.readFileSync(nestedPath, 'utf8')).toBe(nested);
});
it('skips a package whose extracted contents lack SKILL.md', async () => {
// Valid ZIP, but no SKILL.md at the root -> install.js must skip it and
// must NOT create the target dir. This exercises the extractAllTo path
// followed by the post-extraction validation.
downloadClawdHubSkill.mockResolvedValue(
buildSkillZip({ 'README.md': 'no skill manifest here\n' }),
);
const result = await installSkillsFromClawdHub({
scope: 'user',
targetSource: 'opencode',
userSkillDir,
selections: [{ clawdhub: { slug: 'broken-skill', version: '1.0.0' } }],
});
expect(result.ok).toBe(true);
expect(result.installed).toEqual([]);
expect(result.skipped).toEqual([
{ skillName: 'broken-skill', reason: 'SKILL.md not found in downloaded package' },
]);
expect(fs.existsSync(path.join(userSkillDir, 'broken-skill'))).toBe(false);
});
});
@@ -69,10 +69,17 @@ other runtime API.
- `timeoutMs` overrides the 60s default per call; `signal` lets a caller abort
a request that is no longer wanted. Both apply to every wire format.
- `describeSmallModel()` additionally reports `inputCharBudget`,
`contextTokens`, `contextKnown`, and `structuredOutput`. The last is
tri-state: `true`/`false` from the catalog, `null` when the catalog omits the
field — which it does for roughly half of all models, aggregators and proxies
especially. Callers must treat `null` as "try it", not "unsupported".
`contextTokens`, `contextKnown`, `structuredOutput`, and `hasLogin`. The last
is whether the resolved provider has a usable credential (`auth.json` or
config `provider.<id>.options.apiKey`) — settings/config overrides can name a
provider with none, and callers such as the walkthrough refuse before the
request. `structuredOutput` is tri-state: `true`/`false` from the catalog,
`null` when the catalog omits the field — which it does for roughly half of
all models, aggregators and proxies especially. Callers must treat `null` as
"try it", not "unsupported".
- Missing credentials throw with `statusCode: 401` and
`code: 'no-provider-login'` rather than a bare `Error`, so UI callers can show
a blocker instead of a raw 500 message.
- `call.js` — wire formats and per-provider auth, replicating OpenCode's
plugin auth loaders:
- **GitHub Copilot**: fetches the requested model's authenticated `/models`
+19 -3
View File
@@ -566,15 +566,31 @@ const readProviderConfig = (workingDirectory, providerID) => {
// Dispatch
// ---------------------------------------------------------------------------
/**
* Same credential resolution the request path uses: config
* `provider.<id>.options.apiKey` wins, then the auth.json entry.
* Callers that need to refuse before spending a request (walkthrough readiness)
* must use this rather than inventing a second rule.
*/
export function resolveProviderLogin({ auth, workingDirectory, providerID }) {
const providerConfig = readProviderConfig(workingDirectory, providerID);
return providerConfig?.auth || getAuthEntryForProvider(auth, providerID) || null;
}
export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) {
const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
const providerConfig = readProviderConfig(workingDirectory, providerID);
// Match OpenCode's resolveSDK precedence:
// config provider.<id>.options.apiKey (providerConfig.auth) wins; the
// auth.json entry is only a fallback.
// config provider.<id>.options.apiKey wins; the auth.json entry is only a fallback.
const entry = providerConfig?.auth || getAuthEntryForProvider(auth, providerID);
if (!entry) {
throw new Error(`No OpenCode login found for provider "${providerID}"`);
// Structured so the walkthrough (and any other caller) can show a blocker
// instead of a raw 500 banner with this developer-oriented sentence.
throw Object.assign(new Error(`No OpenCode login found for provider "${providerID}"`), {
statusCode: 401,
code: 'no-provider-login',
providerID,
});
}
if (providerID === 'github-copilot') {
@@ -171,14 +171,21 @@ describe('callSmallModel — custom provider config', () => {
provider: { custom: { options: { baseURL: 'https://proxy.example.test/v1' } } },
});
await expect(callSmallModel({
const error = await callSmallModel({
auth: {},
catalog: {},
workingDirectory: '/proj',
providerID: 'custom',
modelID: 'gpt-4o-mini',
prompt: 'hi',
})).rejects.toThrow('No OpenCode login found for provider "custom"');
}).then(() => null, (e) => e);
expect(error).toMatchObject({
message: 'No OpenCode login found for provider "custom"',
code: 'no-provider-login',
statusCode: 401,
providerID: 'custom',
});
// The credential gate fires before any network call.
expect(fetchMock).not.toHaveBeenCalled();
+10 -1
View File
@@ -5,7 +5,7 @@ import { readAuthFile } from '../opencode/auth.js';
import { readConfigLayers } from '../opencode/shared.js';
import { getModelCatalog } from './catalog.js';
import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js';
import { callSmallModel } from './call.js';
import { callSmallModel, resolveProviderLogin } from './call.js';
const OPENCHAMBER_SETTINGS_FILE = path.join(
process.env.OPENCHAMBER_DATA_DIR
@@ -252,8 +252,17 @@ export async function describeSmallModel({ directory, preferredProviderID, prefe
outputReserveTokens: reserveTokens,
});
// Settings/config/request overrides can name a provider with no usable login.
// Report that here so readiness can refuse before the user pays for a 401.
const hasLogin = Boolean(resolveProviderLogin({
auth,
workingDirectory: directory,
providerID: resolved.providerID,
}));
return {
...resolved,
hasLogin,
inputCharBudget: maxChars,
contextTokens,
contextKnown,
@@ -18,7 +18,13 @@ vi.mock('./catalog.js', () => ({
getModelCatalog: vi.fn(),
getCatalogProvider: vi.fn(),
}));
vi.mock('./call.js', () => ({ callSmallModel: vi.fn() }));
vi.mock('./call.js', () => ({
callSmallModel: vi.fn(),
resolveProviderLogin: vi.fn(({ auth, providerID }) => {
const entry = auth?.[providerID];
return entry && typeof entry === 'object' ? entry : null;
}),
}));
const { generateSmallModelText, describeSmallModel } = await import('./index.js');
const { readAuthFile } = await import('../opencode/auth.js');
@@ -126,6 +132,19 @@ describe('describeSmallModel — capability reporting', () => {
contextTokens: 8_000,
contextKnown: true,
structuredOutput: true,
hasLogin: true,
});
});
it('reports hasLogin false when the resolved provider has no usable credential', async () => {
readAuthFile.mockReturnValue({});
const described = await describeSmallModel({ directory: '/proj' });
expect(described).toMatchObject({
providerID: 'anthropic',
modelID: 'claude-haiku-4-5',
hasLogin: false,
});
});
@@ -55,6 +55,18 @@ written against staged code never silently re-anchors onto an unstaged edit.
| `branch` | `branch` | `getRangeDiff` uses three-dot `base...head`, so work merged in from the base branch is excluded |
| `pr` | `pr:<number>` | GitHub returns the merge-base diff, matching the branch semantics |
For the current-branch source, the UI takes the base from the default branch of
the current branch's tracking remote (`defaultBranches` in the branches
response), and only then falls back to the conventional names. It does not offer
the source at all when the chosen base exists neither locally nor on a remote —
a repository whose default is neither `main`, `master` nor `develop` used to be
handed `main...<head>`, which git rejects outright.
A base that exists only on a remote still works: `getRangeDiff` prefers
`origin/<base>` when it exists, and otherwise resolves the base through whichever
remote carries it, because a bare branch name git cannot find in `refs/heads`
fails the same way.
The panel offers the current branch's pull request on its own: it registers with
the shared GitHub PR status store (`useGitHubPrStatusStore`) rather than waiting
for the pull request panel to have been visited. That store already dedupes
@@ -118,6 +130,14 @@ model picker, only shows providers with a usable login. The in-panel picker on a
blocked walkthrough writes this setting too, so recovering from a refusal never
silently changes the model behind commit messages.
A settings or `opencode.json` `small_model` override can still name a provider
with no usable login (neither `auth.json` nor `provider.<id>.options.apiKey`).
`describeSmallModel` reports that as `hasLogin: false`, readiness refuses with
`reason: 'no-provider-login'` and omits the unusable model so the panel cannot
present it as selected, and generation maps the same code to HTTP 401. The UI
disables Generate and keeps the picker on authenticated providers only — it does
not surface a raw auth error or a special login blocker for this case.
## Output language
A walkthrough its reader cannot read is worth nothing, so the prose language is
@@ -369,6 +389,21 @@ endpoint nothing calls is a maintenance surface that rots untested.
Registered lazily from `feature-routes-runtime.js`. `/api/walkthrough` is in the
JSON body-parser allowlist in `core-routes.js`.
## A server that does not have these routes
An `/api/*` path no OpenChamber route claims reaches the OpenCode proxy, and
OpenCode answers any path it does not know with its embedded web UI — HTML, with
status **200**. So a client newer than the server it is connected to is not told
"no such route"; it is handed a web page. Parsing that as JSON is where
`Unexpected token '<', "<!doctype "...` came from, a message that names neither
the cause nor the remedy.
The client therefore checks the content type before parsing. A non-JSON answer
on 2xx or 404 becomes `server-unsupported`, which the panel renders as "this
server is older than the app, update it". A non-JSON **5xx** keeps its own
failure: a server that answered badly is not a server missing the feature, and
telling someone to upgrade would send them after the wrong thing.
## Runtime availability
Web, desktop, and hosted mobile reach these routes normally. VS Code serves Git
@@ -333,6 +333,13 @@ function computeReadiness({ model, digest, files, fileCount, hunkCount, generate
return { ready: false, reason, model, generatedFileCount };
}
// A resolved override/config model can still have no usable login. Refuse up
// front and omit the model — offering an unauthenticated selection in the
// picker is what made the old raw auth error feel like a product bug.
if (model.hasLogin === false) {
return { ready: false, reason: 'no-provider-login' };
}
// Built with the same language the generation would use: the instruction is
// part of the prompt, so a readiness answer computed without it would be
// measuring a request nobody is going to send.
@@ -392,6 +399,13 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit
if (!model) {
throw fail('No model is available — sign in to a provider first', 404, { code: 'no-model' });
}
if (model.hasLogin === false) {
throw fail(
`No OpenCode login found for provider "${model.providerID}" — sign in or choose a different model`,
401,
{ code: 'no-provider-login', model },
);
}
const { digest, files, idByAlias, fileCount, hunkCount, generatedFileCount } = await loadCurrentDiff(directory, source, deps);
setStage(repoRoot, key, 'asking');
@@ -494,6 +508,9 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit
if (error?.code === 'output-exhausted') {
return fail(error.message, 409, { code: 'output-exhausted', model });
}
if (error?.code === 'no-provider-login') {
return fail(error.message, 401, { code: 'no-provider-login', model });
}
return null;
};
@@ -0,0 +1,148 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import express from 'express';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
// ---------------------------------------------------------------------------
// Regression for https://github.com/openchamber/openchamber/issues/2607
// "[Bug] Why say so?" (walkthrough panel)
//
// Before the fix, a walkthrough small model whose provider had no usable login
// reported readiness ready:true, then generation returned HTTP 500 with the raw
// message `No OpenCode login found for provider "deepseek"` — shown in the
// error banner above the "No walkthrough yet" empty state.
//
// After the fix: readiness refuses with `no-provider-login`, and generation
// answers 401 with the same structured code so the UI can show a blocker.
// ---------------------------------------------------------------------------
const TEMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-home-2607-'));
process.env.HOME = TEMP_HOME;
process.env.OPENCHAMBER_DATA_DIR = path.join(TEMP_HOME, '.config', 'openchamber');
const CATALOG = {
deepseek: {
id: 'deepseek',
name: 'DeepSeek',
api: 'https://api.deepseek.com',
models: {
'deepseek-v4-flash': {
id: 'deepseek-v4-flash',
name: 'DeepSeek V4 Flash',
family: 'deepseek-flash',
limit: { context: 128_000 },
},
},
},
};
vi.mock('../../opencode/models-metadata.js', () => ({
getModelsMetadata: vi.fn(async () => ({ metadata: CATALOG, fromCache: false })),
}));
const SOURCE = { kind: 'working-tree', scope: 'all' };
const REPO_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-repo-2607-'));
const setupGitRepo = () => {
const run = (args) => {
try {
return execFileSync('git', args, { cwd: REPO_DIR, encoding: 'utf8' });
} catch (error) {
throw new Error(`git ${args.join(' ')} failed: ${error.stderr?.toString() ?? error.message}`);
}
};
run(['init', '-b', 'main']);
run(['config', 'user.email', 'test@example.com']);
run(['config', 'user.name', 'Test']);
fs.mkdirSync(path.join(REPO_DIR, 'src'), { recursive: true });
fs.writeFileSync(path.join(REPO_DIR, 'src', 'a.ts'), 'export const a = 1;\n', 'utf8');
run(['add', 'src/a.ts']);
run(['commit', '-m', 'init']);
fs.writeFileSync(path.join(REPO_DIR, 'src', 'a.ts'), 'export const a = 1;\nexport const b = 2;\n', 'utf8');
};
let walkthrough;
let callSmallModel;
describe('issue 2607 — walkthrough blocks unauthenticated providers', () => {
beforeAll(async () => {
setupGitRepo();
fs.writeFileSync(
path.join(REPO_DIR, 'opencode.json'),
JSON.stringify({ small_model: 'deepseek/deepseek-v4-flash' }, null, 2),
'utf8',
);
walkthrough = await import('./index.js');
callSmallModel = await import('../small-model/call.js');
});
afterAll(() => {
fs.rmSync(TEMP_HOME, { recursive: true, force: true });
fs.rmSync(REPO_DIR, { recursive: true, force: true });
});
it('resolves the deepseek model but reports not ready without a login', async () => {
const result = await walkthrough.getWalkthrough({ directory: REPO_DIR, source: SOURCE });
expect(result.readiness.ready).toBe(false);
expect(result.readiness.reason).toBe('no-provider-login');
// Unusable models must not be offered as the current selection.
expect(result.readiness.model).toBeUndefined();
});
it('callSmallModel throws a structured no-provider-login error', async () => {
const error = await callSmallModel.callSmallModel({
auth: {},
catalog: CATALOG,
workingDirectory: REPO_DIR,
providerID: 'deepseek',
modelID: 'deepseek-v4-flash',
prompt: 'x',
}).then(() => null, (e) => e);
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('No OpenCode login found for provider "deepseek"');
expect(error.code).toBe('no-provider-login');
expect(error.statusCode).toBe(401);
});
it('generateWalkthrough rejects with structured no-provider-login', async () => {
const error = await walkthrough.generateWalkthrough({ directory: REPO_DIR, source: SOURCE })
.then(() => null, (e) => e);
expect(error).toBeInstanceOf(Error);
expect(error.code).toBe('no-provider-login');
expect(error.statusCode).toBe(401);
expect(error.model).toMatchObject({ providerID: 'deepseek', modelID: 'deepseek-v4-flash' });
});
it('answers the generate route with HTTP 401 and code no-provider-login', async () => {
const service = { ...walkthrough, getPullRequestDiff: async () => { throw new Error('not used'); } };
const app = express();
app.use(express.json());
const { registerWalkthroughRoutes } = await import('./routes.js');
registerWalkthroughRoutes(app, { getWalkthroughService: async () => service });
const server = app.listen(0);
await new Promise((resolve) => server.once('listening', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
try {
const response = await fetch(`${base}/api/walkthrough/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ directory: REPO_DIR, source: SOURCE }),
});
const body = await response.json();
expect(response.status).toBe(401);
expect(body.code).toBe('no-provider-login');
expect(body.model).toMatchObject({ providerID: 'deepseek', modelID: 'deepseek-v4-flash' });
} finally {
await new Promise((resolve) => server.close(resolve));
}
});
});
@@ -623,4 +623,87 @@ describe('OpenCode proxy SSE forwarding', () => {
expect(response.status).toBe(504);
await expect(response.json()).resolves.toMatchObject({ error: 'OpenCode upstream timed out' });
});
it('exempts interactive provider OAuth callbacks from the request deadline', async () => {
const upstream = express();
// Stands in for upstream blocking until the user finishes signing in.
upstream.post('/provider/:providerID/oauth/callback', async (_req, res) => {
await new Promise((resolve) => setTimeout(resolve, 250));
res.json(true);
});
upstreamServer = await listen(upstream);
const upstreamPort = upstreamServer.address().port;
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
const app = express();
registerOpenCodeProxy(app, {
fs: {},
os: {},
path,
OPEN_CODE_READY_GRACE_MS: 0,
LONG_REQUEST_TIMEOUT_MS: 50,
getRuntime: () => ({
openCodePort: upstreamPort,
openCodeBaseUrl: externalBaseUrl,
isOpenCodeReady: true,
openCodeNotReadySince: 0,
isRestartingOpenCode: false,
}),
getOpenCodeAuthHeaders: () => ({}),
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
ensureOpenCodeApiPrefix: () => {},
});
proxyServer = await listen(app);
const proxyPort = proxyServer.address().port;
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/github-copilot/oauth/callback`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ method: 0 }),
signal: AbortSignal.timeout(5000),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toBe(true);
});
it('still applies the request deadline to the OAuth authorize call', async () => {
const upstream = express();
upstream.post('/provider/:providerID/oauth/authorize', (_req, _res) => {
// Leave the response open so the proxy timeout path is exercised.
});
upstreamServer = await listen(upstream);
const upstreamPort = upstreamServer.address().port;
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
const app = express();
registerOpenCodeProxy(app, {
fs: {},
os: {},
path,
OPEN_CODE_READY_GRACE_MS: 0,
LONG_REQUEST_TIMEOUT_MS: 50,
getRuntime: () => ({
openCodePort: upstreamPort,
openCodeBaseUrl: externalBaseUrl,
isOpenCodeReady: true,
openCodeNotReadySince: 0,
isRestartingOpenCode: false,
}),
getOpenCodeAuthHeaders: () => ({}),
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
ensureOpenCodeApiPrefix: () => {},
});
proxyServer = await listen(app);
const proxyPort = proxyServer.address().port;
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/github-copilot/oauth/authorize`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ method: 0 }),
signal: AbortSignal.timeout(2000),
});
expect(response.status).toBe(504);
});
});