merge(main): resolve terminal runtime.test.js ARGV0 vs DA query

Keep ARGV0/env-u assertions from this branch and the DA startup-reply
expectations from main's terminal PTY-before-viewport fix.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-03 09:54:22 +00:00
co-authored by Serhii Dziupin
43 changed files with 1969 additions and 244 deletions
@@ -355,6 +355,10 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
- Skills config CRUD and metadata under `/api/config/skills*`
- Skills catalog listing/source pagination, scan, and install routes
- Supporting skill file read/write/delete routes
- Directory resolution prefers an explicit request directory, then soft-falls
back to the active project / `lastDirectory` so repository-local
`.agents/skills` and `.opencode/skills` remain discoverable when the client
omits `directory`. Requests without any project still list user-scoped skills.
## Public exports (proxy.js)
- `registerOpenCodeProxy(app, dependencies)`: registers OpenCode proxy routes and middleware.
@@ -200,9 +200,33 @@ export const registerSkillRoutes = (app, dependencies) => {
return null;
};
// Prefer an explicit request directory, then soft-fallback to the active
// project / lastDirectory so repository-local skills stay visible when the
// client omits `directory` (create already used resolveProjectDirectory).
const resolveSkillsDirectory = async (req) => {
const optional = await resolveOptionalProjectDirectory(req);
if (optional.error) {
return optional;
}
if (optional.directory) {
return optional;
}
try {
const fallback = await resolveProjectDirectory(req);
if (fallback.directory) {
return { directory: fallback.directory, error: null };
}
} catch {
// ignore — listing user-scoped skills without a project is valid
}
return { directory: null, error: null };
};
app.get('/api/config/skills', async (req, res) => {
try {
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ error });
}
@@ -257,7 +281,7 @@ export const registerSkillRoutes = (app, dependencies) => {
app.get('/api/config/skills/catalog/source', async (req, res) => {
try {
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: error } });
}
@@ -518,7 +542,7 @@ export const registerSkillRoutes = (app, dependencies) => {
app.get('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ error });
}
@@ -546,7 +570,7 @@ export const registerSkillRoutes = (app, dependencies) => {
if (isUnsafeSkillRelativePath(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ error });
}
@@ -579,7 +603,7 @@ export const registerSkillRoutes = (app, dependencies) => {
const { scope, source: skillSource, ...config } = req.body;
const { directory, error } = scope === SKILL_SCOPE.PROJECT
? await resolveProjectDirectory(req)
: await resolveOptionalProjectDirectory(req);
: await resolveSkillsDirectory(req);
if (error || (scope === SKILL_SCOPE.PROJECT && !directory)) {
return res.status(400).json({ error: error || 'Project skill creation requires a directory' });
}
@@ -606,7 +630,7 @@ export const registerSkillRoutes = (app, dependencies) => {
try {
const skillName = req.params.name;
const updates = req.body;
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ error });
}
@@ -637,7 +661,7 @@ export const registerSkillRoutes = (app, dependencies) => {
return res.status(400).json({ error: 'Invalid file path' });
}
const { content } = req.body;
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ error });
}
@@ -671,7 +695,7 @@ export const registerSkillRoutes = (app, dependencies) => {
if (isUnsafeSkillRelativePath(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ error });
}
@@ -701,7 +725,7 @@ export const registerSkillRoutes = (app, dependencies) => {
app.delete('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const { directory, error } = await resolveOptionalProjectDirectory(req);
const { directory, error } = await resolveSkillsDirectory(req);
if (error) {
return res.status(400).json({ error });
}
@@ -0,0 +1,157 @@
import { afterEach, describe, expect, it } from 'vitest';
import express from 'express';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { registerSkillRoutes } from './skill-routes.js';
import {
createSkill,
deleteSkill,
discoverSkills,
getSkillSources,
mergeDiscoveredSkills,
updateSkill,
} from './skills.js';
import {
SKILL_DIR,
SKILL_SCOPE,
deleteSkillSupportingFile,
readSkillSupportingFile,
writeSkillSupportingFile,
} from './shared.js';
const createTempProject = () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-skill-routes-'));
fs.mkdirSync(path.join(projectRoot, '.git'));
return projectRoot;
};
const startSkillsApp = ({ projectRoot }) => {
const app = express();
app.use(express.json());
registerSkillRoutes(app, {
fs,
path,
os,
resolveProjectDirectory: async () => ({ directory: projectRoot, error: null }),
resolveOptionalProjectDirectory: async (req) => {
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
if (!queryDirectory) {
return { directory: null, error: null };
}
return { directory: String(queryDirectory), error: null };
},
readSettingsFromDisk: async () => ({}),
sanitizeSkillCatalogs: (value) => value,
isUnsafeSkillRelativePath: () => false,
refreshOpenCodeAfterConfigChange: async () => {},
clientReloadDelayMs: 0,
buildOpenCodeUrl: () => 'http://127.0.0.1:9/',
getOpenCodeAuthHeaders: () => ({}),
getOpenCodePort: () => 0,
getSkillSources,
discoverSkills,
mergeDiscoveredSkills,
createSkill,
updateSkill,
deleteSkill,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
SKILL_SCOPE,
SKILL_DIR,
getCuratedSkillsSources: () => [],
getCacheKey: () => 'k',
getCachedScan: () => null,
setCachedScan: () => {},
parseSkillRepoSource: () => ({ ok: false }),
scanSkillsRepository: async () => ({ ok: false }),
installSkillsFromRepository: async () => ({ ok: false }),
scanClawdHubPage: async () => ({ ok: false }),
installSkillsFromClawdHub: async () => ({ ok: false }),
isClawdHubSource: () => false,
getProfiles: () => [],
getProfile: () => null,
});
const server = app.listen(0);
const { port } = server.address();
return {
baseUrl: `http://127.0.0.1:${port}`,
close: () => new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
};
describe('skill-routes directory soft fallback', () => {
/** @type {string | null} */
let projectRoot = null;
/** @type {{ close: () => Promise<void> } | null} */
let appHandle = null;
afterEach(async () => {
if (appHandle) {
await appHandle.close();
appHandle = null;
}
if (projectRoot) {
fs.rmSync(projectRoot, { recursive: true, force: true });
projectRoot = null;
}
});
it('lists repository-local .agents skills after create even when list omits directory', async () => {
projectRoot = createTempProject();
appHandle = startSkillsApp({ projectRoot });
const createResponse = await fetch(`${appHandle.baseUrl}/api/config/skills/repo-local-skill`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
description: 'Created without list directory',
instructions: 'Do the thing.',
scope: 'project',
source: 'agents',
}),
});
expect(createResponse.status).toBe(200);
expect(fs.existsSync(path.join(projectRoot, '.agents', 'skills', 'repo-local-skill', 'SKILL.md'))).toBe(true);
const listResponse = await fetch(`${appHandle.baseUrl}/api/config/skills`);
expect(listResponse.status).toBe(200);
const payload = await listResponse.json();
expect(payload.skills.map((skill) => skill.name)).toContain('repo-local-skill');
const skill = payload.skills.find((entry) => entry.name === 'repo-local-skill');
expect(skill.scope).toBe('project');
expect(skill.source).toBe('agents');
});
it('lists manually created repository-local .agents skills via active-project fallback', async () => {
projectRoot = createTempProject();
const skillDir = path.join(projectRoot, '.agents', 'skills', 'manual-repo-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(
path.join(skillDir, 'SKILL.md'),
[
'---',
'name: manual-repo-skill',
'description: Manual repository skill',
'---',
'',
'Instructions',
'',
].join('\n'),
'utf8',
);
appHandle = startSkillsApp({ projectRoot });
const listResponse = await fetch(`${appHandle.baseUrl}/api/config/skills`);
expect(listResponse.status).toBe(200);
const payload = await listResponse.json();
expect(payload.skills.map((skill) => skill.name)).toContain('manual-repo-skill');
});
});
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import fsPromises from 'fs/promises';
import os from 'os';
import path from 'path';
import { getSkillSources, mergeDiscoveredSkills } from './skills.js';
import { discoverSkills, getSkillSources, mergeDiscoveredSkills } from './skills.js';
describe('skills', () => {
it('merges locally discovered skills missing from OpenCode live discovery', () => {
@@ -24,6 +24,43 @@ describe('skills', () => {
]);
});
it('discovers repository-local .agents skills for the project directory', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-project-agents-'));
const skillDir = path.join(tempRoot, '.agents', 'skills', 'repo-local-skill');
const skillPath = path.join(skillDir, 'SKILL.md');
try {
await fsPromises.mkdir(skillDir, { recursive: true });
await fsPromises.mkdir(path.join(tempRoot, '.git'));
await fsPromises.writeFile(
skillPath,
[
'---',
'name: repo-local-skill',
'description: Repository-local agents skill',
'---',
'',
'Use this skill in this repository.',
'',
].join('\n'),
'utf8',
);
const discovered = discoverSkills(tempRoot);
const match = discovered.find((skill) => skill.name === 'repo-local-skill');
expect(match).toEqual({
name: 'repo-local-skill',
path: skillPath,
scope: 'project',
source: 'agents',
description: 'Repository-local agents skill',
});
} finally {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
it('resolves built-in OpenCode skill content without parsing virtual locations as files', () => {
const sources = getSkillSources(
'customize-opencode',
@@ -10,11 +10,12 @@
- `attach` registers a connection for one terminal. One socket may attach to many terminals.
- Every attach and reconnect begins with an authoritative `snapshot` containing bounded history and the current sequence.
- A current socket that closes or errors before its initial `open` invalidates its URL-scoped auth token before retrying, so retries mint a fresh token instead of backing off against a rejected upgrade. Hidden or offline clients wait 60 seconds and wake promptly on visibility/online recovery.
- `output`, `exit`, and `restarted` carry monotonically increasing per-terminal sequences. Output carries raw live bytes plus replay-safe bytes with terminal query exchanges removed.
- Attach registers before capturing the snapshot, buffers concurrent events, drops events represented by the snapshot sequence, then enters live delivery.
- `write` always includes the terminal ID; sockets never have mutable single-terminal binding state.
- `detach` removes only that attachment.
- Creation carries the active UI appearance. The PTY sets `COLORFGBG` and answers OSC 10, OSC 11, and Mode 2031 queries immediately, including queries emitted before a WebSocket attachment exists. Subscribed TUIs receive a Mode 2031 notification when the appearance changes.
- Creation carries the active UI appearance. The PTY sets `COLORFGBG` and answers OSC 10, OSC 11, Mode 2031, and primary-device-attribute queries immediately, including queries emitted before a WebSocket attachment exists. The DA1 fallback prevents Fish from waiting ten seconds for a renderer that cannot observe or answer its startup query. Subscribed TUIs receive a Mode 2031 notification when the appearance changes.
HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path.
@@ -23,6 +24,7 @@ HTTP remains the authenticated command plane for create, resize, appearance upda
- IDs are client-provided or generated with `randomUUID()`.
- Concurrent creates for one ID are single-flight only when working directory and shell preference match. Existing IDs cannot be reused for another working directory.
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
- A client may create before its renderer has mounted. It derives an initial size from the container and font metrics (falling back to 80x24 when unavailable), then sends a resize once Ghostty reports its final dimensions. This allows shell startup and renderer initialization to overlap.
- PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup.
- PTY children also strip AppImage `ARGV0` (and other host-private shell vars such as `ELECTRON_RUN_AS_NODE`, `BASH_ENV`, `ENV`, `BASH_XTRACEFD`). An exported `ARGV0` makes zsh rewrite argv[0] for every external command, which breaks Python venv detection and other argv[0]/$0 consumers while leaving `/proc/self/exe` correct. On Linux, PTY spawn is wrapped with `env -u ARGV0` because `bun-pty` merges the native OS environ and would otherwise reintroduce `ARGV0` after a JS-only delete.
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Preference changes affect new sessions and explicit restarts, not running PTYs.
+2 -2
View File
@@ -150,7 +150,7 @@ export function createTerminalRuntime({
background: session.terminalBackground,
foreground: session.terminalForeground,
modeEnabled: session.themeModeEnabled,
});
}, { respondToPrimaryDeviceAttributes: true });
session.pendingThemeControlSequence = theme.pending;
session.themeModeEnabled = theme.modeEnabled;
for (const response of theme.responses) session.process?.write(response);
@@ -336,7 +336,7 @@ export function createTerminalRuntime({
session.process = spawned.process; session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.cwd = cwd; session.cols = cols; session.rows = rows;
session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; session.status = 'running'; session.exitCode = null; session.signal = null; session.eventQueue.length = 0;
session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground;
wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' });
wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' });
});
pendingSessionRestarts.set(session.id, restart);
try {
@@ -160,8 +160,8 @@ describe('terminal runtime', () => {
expect(harness.processes[0].shell).toMatch(/\/env$/);
expect(harness.processes[0].args.slice(0, 3)).toEqual(['-u', 'ARGV0', expect.any(String)]);
}
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007');
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\']);
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007\u001b[0c');
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\', '\u001b[?1;2c']);
const appearance = createResponse();
harness.routes.post.get('/api/terminal/:sessionId/appearance')({ params: { sessionId: 'term-1' }, body: { themeMode: 'dark' } }, appearance);
@@ -2,11 +2,21 @@ const MODE_SET = '\u001b[?2031h';
const MODE_RESET = '\u001b[?2031l';
const CAPABILITY_QUERY = '\u001b[?2031$p';
const MODE_QUERIES = ['\u001b[?996n', '\u001b[?997n'];
// Fish asks this before an unattached browser terminal can reply.
const PRIMARY_DEVICE_ATTRIBUTE_QUERIES = ['\u001b[c', '\u001b[0c'];
const PRIMARY_DEVICE_ATTRIBUTE_RESPONSE = '\u001b[?1;2c';
const OSC_QUERIES = [10, 11].flatMap((code) => [
{ sequence: `\u001b]${code};?\u0007`, code },
{ sequence: `\u001b]${code};?\u001b\\`, code },
]);
const CONTROL_SEQUENCES = [MODE_SET, MODE_RESET, CAPABILITY_QUERY, ...MODE_QUERIES, ...OSC_QUERIES.map(({ sequence }) => sequence)];
const CONTROL_SEQUENCES = [
MODE_SET,
MODE_RESET,
CAPABILITY_QUERY,
...MODE_QUERIES,
...PRIMARY_DEVICE_ATTRIBUTE_QUERIES,
...OSC_QUERIES.map(({ sequence }) => sequence),
];
const parseColor = (value) => {
if (typeof value !== 'string') return null;
@@ -28,7 +38,12 @@ const colorReport = (code, color) => {
export const terminalThemeModeReport = (themeMode) => `\u001b[?997;${themeMode === 'light' ? 2 : 1}n`;
export const consumeTerminalThemeQueries = (pending, data, appearance) => {
export const consumeTerminalThemeQueries = (
pending,
data,
appearance,
{ respondToPrimaryDeviceAttributes = false } = {},
) => {
if (!pending && !data.includes('\u001b')) return { pending: '', responses: [], modeEnabled: appearance.modeEnabled === true };
const input = `${pending}${data}`;
const responses = [];
@@ -56,6 +71,15 @@ export const consumeTerminalThemeQueries = (pending, data, appearance) => {
index += modeQuery.length - 1;
continue;
}
const primaryDeviceAttributeQuery = PRIMARY_DEVICE_ATTRIBUTE_QUERIES.find((query) => input.startsWith(query, index));
if (primaryDeviceAttributeQuery && respondToPrimaryDeviceAttributes) {
// A shell can ask before any browser terminal is attached. Answer with a
// conservative VT100 DA1 response so Fish does not block startup for its
// ten-second query timeout while waiting for a renderer that cannot see it.
responses.push(PRIMARY_DEVICE_ATTRIBUTE_RESPONSE);
index += primaryDeviceAttributeQuery.length - 1;
continue;
}
const oscQuery = OSC_QUERIES.find(({ sequence }) => input.startsWith(sequence, index));
if (oscQuery) {
const response = colorReport(oscQuery.code, oscQuery.code === 10 ? appearance.foreground : appearance.background);
@@ -44,4 +44,27 @@ describe('terminal theme responses', () => {
'\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\',
]);
});
test('answers a primary device attribute query when the fallback is enabled', () => {
const attached = consumeTerminalThemeQueries('', '\u001b[0c', lightAppearance);
const unattached = consumeTerminalThemeQueries('', '\u001b[0c', lightAppearance, {
respondToPrimaryDeviceAttributes: true,
});
expect(attached.responses).toEqual([]);
expect(unattached.responses).toEqual(['\u001b[?1;2c']);
});
test('answers a primary device attribute query split across PTY chunks', () => {
const first = consumeTerminalThemeQueries('', '\u001b[0', lightAppearance, {
respondToPrimaryDeviceAttributes: true,
});
const second = consumeTerminalThemeQueries(first.pending, 'c', {
...lightAppearance,
modeEnabled: first.modeEnabled,
}, { respondToPrimaryDeviceAttributes: true });
expect(first.pending).toBe('\u001b[0');
expect(second.responses).toEqual(['\u001b[?1;2c']);
});
});