Merge origin/main into deferred OpenCode restart branch.
Resolve ProvidersPage and lifecycle conflicts with custom providers and AppImage ARGV0 stripping. Address review follow-ups: OAuth index helper + tests, single auth-methods load trigger, shared Google env-alias module with VS Code parity coverage, and deferred restart for custom provider upsert. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Sanitize environment objects inherited by user-facing child processes.
|
||||
*
|
||||
* Linux AppImage runtimes export `ARGV0` as the AppImage path before launching
|
||||
* the packaged app. zsh treats an exported `ARGV0` as the argv[0] for every
|
||||
* external command it spawns, which corrupts Python venv detection and any
|
||||
* other program that reads argv[0]/$0 while leaving `/proc/self/exe` correct.
|
||||
*
|
||||
* See openchamber/openchamber#2588 and pingdotgg/t3code#2509.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const LINUX_ENV_BINARIES = ['/usr/bin/env', '/bin/env'];
|
||||
|
||||
/**
|
||||
* Remove AppImage `ARGV0` from a mutable env object (or `process.env`).
|
||||
* @param {NodeJS.ProcessEnv | Record<string, string | undefined> | null | undefined} env
|
||||
* @returns {typeof env}
|
||||
*/
|
||||
export function stripAppImageArgv0Leak(env) {
|
||||
if (!env || typeof env !== 'object') return env;
|
||||
if (Object.prototype.hasOwnProperty.call(env, 'ARGV0')) {
|
||||
delete env.ARGV0;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear AppImage `ARGV0` from this process.
|
||||
*
|
||||
* Bun keeps a native environ that `bun-pty` inherits even after
|
||||
* `delete process.env.ARGV0`. On Linux under Bun we also call libc `unsetenv`.
|
||||
*/
|
||||
export function clearAppImageArgv0FromProcessEnv() {
|
||||
delete process.env.ARGV0;
|
||||
if (process.platform !== 'linux' || typeof Bun === 'undefined') return;
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const { dlopen } = require('bun:ffi');
|
||||
const libc = dlopen('libc.so.6', {
|
||||
unsetenv: { args: ['cstring'], returns: 'i32' },
|
||||
});
|
||||
libc.symbols.unsetenv(Buffer.from('ARGV0\0'));
|
||||
} catch {
|
||||
// Node/Electron and environments without bun:ffi rely on explicit child envs.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Linux PTY launch that drops native `ARGV0` before the shell starts.
|
||||
*
|
||||
* `bun-pty` merges the OS environ into the child, so deleting `ARGV0` from the
|
||||
* JS env object alone is not enough. Wrapping with `env -u ARGV0` unsets it
|
||||
* before execing the real shell. No-op on non-Linux platforms.
|
||||
*
|
||||
* @param {string} executable
|
||||
* @param {string[]} args
|
||||
* @returns {{ executable: string, args: string[] }}
|
||||
*/
|
||||
export function resolveLinuxPtyLaunch(executable, args = []) {
|
||||
if (process.platform !== 'linux') {
|
||||
return { executable, args };
|
||||
}
|
||||
const envBinary = LINUX_ENV_BINARIES.find((candidate) => existsSync(candidate));
|
||||
if (!envBinary) {
|
||||
return { executable, args };
|
||||
}
|
||||
return {
|
||||
executable: envBinary,
|
||||
args: ['-u', 'ARGV0', executable, ...args],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
clearAppImageArgv0FromProcessEnv,
|
||||
resolveLinuxPtyLaunch,
|
||||
stripAppImageArgv0Leak,
|
||||
} from './inherited-env.js';
|
||||
|
||||
describe('stripAppImageArgv0Leak', () => {
|
||||
it('removes ARGV0 from a child env object', () => {
|
||||
const env = {
|
||||
PATH: '/usr/bin',
|
||||
ARGV0: '/path/to/OpenChamber-1.17.2-linux-x86_64.AppImage',
|
||||
SHELL: '/bin/zsh',
|
||||
};
|
||||
|
||||
expect(stripAppImageArgv0Leak(env)).toBe(env);
|
||||
expect(env).toEqual({
|
||||
PATH: '/usr/bin',
|
||||
SHELL: '/bin/zsh',
|
||||
});
|
||||
});
|
||||
|
||||
it('is a no-op when ARGV0 is absent', () => {
|
||||
const env = { PATH: '/usr/bin', SHELL: '/bin/bash' };
|
||||
stripAppImageArgv0Leak(env);
|
||||
expect(env).toEqual({ PATH: '/usr/bin', SHELL: '/bin/bash' });
|
||||
});
|
||||
|
||||
it('tolerates nullish env values', () => {
|
||||
expect(stripAppImageArgv0Leak(null)).toBeNull();
|
||||
expect(stripAppImageArgv0Leak(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAppImageArgv0FromProcessEnv', () => {
|
||||
it('removes ARGV0 from process.env', () => {
|
||||
const previous = process.env.ARGV0;
|
||||
process.env.ARGV0 = '/path/to/OpenChamber.AppImage';
|
||||
try {
|
||||
clearAppImageArgv0FromProcessEnv();
|
||||
expect(process.env.ARGV0).toBeUndefined();
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.ARGV0;
|
||||
else process.env.ARGV0 = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLinuxPtyLaunch', () => {
|
||||
it('wraps the shell with env -u ARGV0 on Linux', () => {
|
||||
if (process.platform !== 'linux') return;
|
||||
expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({
|
||||
executable: expect.stringMatching(/\/env$/),
|
||||
args: ['-u', 'ARGV0', '/bin/zsh', '-l'],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves non-Linux launches unchanged', () => {
|
||||
if (process.platform === 'linux') return;
|
||||
expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({
|
||||
executable: '/bin/zsh',
|
||||
args: ['-l'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, parses CLI options, and starts server bootstrap.
|
||||
- `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration.
|
||||
- `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). After readiness it warms the most recently used directories (`getWarmupDirectories` dep, sequential and best-effort) because OpenCode initializes each directory lazily on first request and that cost would otherwise be paid by the user's first interactive session open.
|
||||
- `packages/web/server/lib/opencode/provider-env-aliases.js`: mirrors known provider credential env aliases into the managed OpenCode process environment (for example `GEMINI_API_KEY` → `GOOGLE_GENERATIVE_AI_API_KEY`) so OpenCode connection detection and the upstream AI SDK agree on the same key names.
|
||||
- `packages/web/server/lib/opencode/provider-env-aliases.js`: mirrors known provider credential env aliases into the managed OpenCode process environment (for example `GEMINI_API_KEY` → `GOOGLE_GENERATIVE_AI_API_KEY`) so OpenCode connection detection and the upstream AI SDK agree on the same key names. Canonical implementation shared by web lifecycle and the VS Code managed spawn path (`packages/vscode/src/provider-env-aliases.ts` re-exports this module).
|
||||
- `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime.
|
||||
- `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname).
|
||||
- `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers.
|
||||
@@ -59,8 +59,14 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `AUTH_FILE`: Auth file path constant.
|
||||
- `OPENCODE_DATA_DIR`: OpenCode data directory path constant.
|
||||
|
||||
## Public exports (providers.js)
|
||||
- `getProviderSources(providerId, workingDirectory)`: Resolves which OpenCode config layers define a provider.
|
||||
- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom OpenAI-compatible provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`). Edit flows must pass the provider's effective existing layer (`custom` > `project` > `user`) so updates do not create a global user override.
|
||||
- `validateCustomProviderConfig(providerId, config, options?)`: Structural validation for custom provider payloads (id format, http(s) base URL, models, credentials via `env` or `hasStoredAuth`).
|
||||
- `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer.
|
||||
|
||||
## Public exports (shared.js)
|
||||
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`, `CUSTOM_CONFIG_FILE`: Path constants.
|
||||
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants. `OPENCODE_CONFIG` is resolved at call time for the custom config layer path.
|
||||
- `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values.
|
||||
- `ensureDirs()`: Creates required OpenCode directories.
|
||||
- `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter.
|
||||
@@ -84,6 +90,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `GET /api/opencode/upgrade-status` (returns version availability plus the authoritative `upgrade.supported`, `upgrade.manager`, and `upgrade.reason` capability)
|
||||
- `POST /api/opencode/directory`
|
||||
- `GET /api/provider/:providerId/source`
|
||||
- `PUT /api/provider` (create/update custom OpenAI-compatible provider config in OpenCode user/project/custom layers via `scope`; secrets stay in auth via the OpenCode auth API)
|
||||
- `DELETE /api/provider/:providerId/auth`
|
||||
- Owns lazy auth library loading for provider auth checks/removal.
|
||||
- Keeps route behavior independent from composition root; `index.js` now supplies dependencies only.
|
||||
@@ -122,7 +129,9 @@ The runtime maintains active-session count incrementally from idempotent activit
|
||||
Managed OpenCode launch also merges the environment returned by the agent-tool
|
||||
runtime. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
|
||||
be replaced by injected values. External OpenCode processes receive no
|
||||
OpenChamber tool injection.
|
||||
OpenChamber tool injection. Managed launch env strips AppImage `ARGV0` before
|
||||
spawn so zsh-backed OpenCode tools do not rewrite child argv[0] to the AppImage
|
||||
path (#2588).
|
||||
|
||||
Before spawn, `applyProviderEnvAliases` fills unset Google credential aliases
|
||||
from any present sibling (`GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_API_KEY`,
|
||||
@@ -364,6 +373,8 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
|
||||
## Public exports (skill-routes.js)
|
||||
- `registerSkillRoutes(app, dependencies)`: registers skills-related routes:
|
||||
- Skills config CRUD and metadata under `/api/config/skills*`
|
||||
- Skill rename via `PATCH /api/config/skills/:name` with `{ renameTo }` (directory rename preserves `SKILL.md` body and supporting files; restricted to managed skill roots under `.opencode/skills|skill`, `.claude/skills`, and `.agents/skills`)
|
||||
- Skill list responses include authoritative `renamable` derived from the same managed-root policy used by rename
|
||||
- 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
|
||||
|
||||
@@ -1078,6 +1078,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
|
||||
req.path.startsWith('/api/push') ||
|
||||
req.path.startsWith('/api/notifications') ||
|
||||
req.path.startsWith('/api/permission-auto-accept') ||
|
||||
req.path.startsWith('/api/provider') ||
|
||||
req.path.startsWith('/api/session-folders') ||
|
||||
req.path.startsWith('/api/small-model') ||
|
||||
req.path.startsWith('/api/walkthrough') ||
|
||||
|
||||
@@ -127,6 +127,37 @@ describe('core-routes', () => {
|
||||
expect(response.body).toEqual({ body: { content: 'Snippet body' } });
|
||||
});
|
||||
|
||||
it('should parse JSON bodies for custom provider upsert routes', async () => {
|
||||
const app = express();
|
||||
registerCommonRequestMiddleware(app, { express });
|
||||
app.put('/api/provider', (req, res) => {
|
||||
res.json({ body: req.body });
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/api/provider')
|
||||
.send({
|
||||
providerID: 'campus-llm',
|
||||
config: {
|
||||
name: 'Campus LLM',
|
||||
options: { baseURL: 'https://llm.example.edu/v1' },
|
||||
models: { fast: { name: 'Fast' } },
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
body: {
|
||||
providerID: 'campus-llm',
|
||||
config: {
|
||||
name: 'Campus LLM',
|
||||
options: { baseURL: 'https://llm.example.edu/v1' },
|
||||
models: { fast: { name: 'Fast' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should require API auth before probing loopback preview URLs', async () => {
|
||||
const app = express();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { clearAppImageArgv0FromProcessEnv } from '../inherited-env.js';
|
||||
import { mergePathValues } from './path-utils.js';
|
||||
|
||||
export const createOpenCodeEnvRuntime = (deps) => {
|
||||
@@ -227,12 +228,16 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
||||
};
|
||||
|
||||
const applyLoginShellEnvSnapshot = () => {
|
||||
// Always clear AppImage ARGV0, even when no login-shell snapshot is available.
|
||||
// Otherwise a leaked process.env.ARGV0 survives into later child spawns (#2588).
|
||||
clearAppImageArgv0FromProcessEnv();
|
||||
|
||||
const snapshot = getLoginShellEnvSnapshot();
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']);
|
||||
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_', 'ARGV0']);
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (skipKeys.has(key)) {
|
||||
continue;
|
||||
|
||||
@@ -131,6 +131,43 @@ describe('OpenCode env runtime', () => {
|
||||
expect(process.env.PATH).toBe(defaultDir);
|
||||
});
|
||||
|
||||
it('clears AppImage ARGV0 when applying a login-shell env snapshot', () => {
|
||||
const previousArgv0 = process.env.ARGV0;
|
||||
process.env.ARGV0 = '/path/to/OpenChamber.AppImage';
|
||||
delete process.env.OPENCHAMBER_ARGV0_TEST_MARKER;
|
||||
const { runtime, state } = createRuntime({});
|
||||
state.cachedLoginShellEnvSnapshot = {
|
||||
PATH: '/usr/bin',
|
||||
ARGV0: '/leaked/from/shell.AppImage',
|
||||
OPENCHAMBER_ARGV0_TEST_MARKER: '1',
|
||||
};
|
||||
|
||||
try {
|
||||
runtime.applyLoginShellEnvSnapshot();
|
||||
expect(process.env.ARGV0).toBeUndefined();
|
||||
expect(process.env.OPENCHAMBER_ARGV0_TEST_MARKER).toBe('1');
|
||||
} finally {
|
||||
delete process.env.OPENCHAMBER_ARGV0_TEST_MARKER;
|
||||
if (previousArgv0 === undefined) delete process.env.ARGV0;
|
||||
else process.env.ARGV0 = previousArgv0;
|
||||
}
|
||||
});
|
||||
|
||||
it('clears AppImage ARGV0 even when no login-shell snapshot is available', () => {
|
||||
const previousArgv0 = process.env.ARGV0;
|
||||
process.env.ARGV0 = '/path/to/OpenChamber.AppImage';
|
||||
const { runtime, state } = createRuntime({});
|
||||
state.cachedLoginShellEnvSnapshot = null;
|
||||
|
||||
try {
|
||||
runtime.applyLoginShellEnvSnapshot();
|
||||
expect(process.env.ARGV0).toBeUndefined();
|
||||
} finally {
|
||||
if (previousArgv0 === undefined) delete process.env.ARGV0;
|
||||
else process.env.ARGV0 = previousArgv0;
|
||||
}
|
||||
});
|
||||
|
||||
it('throws a specific error for a missing configured OpenCode binary in strict mode', async () => {
|
||||
const { runtime } = createRuntime({ opencodeBinary: '/missing/opencode' });
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { registerPluginRoutes } from './plugin-routes.js';
|
||||
import { getNpmInfo, clearCache as clearNpmCache } from './npm-registry.js';
|
||||
import { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
|
||||
import { registerOpenCodeRoutes } from './routes.js';
|
||||
import { getProviderSources, removeProviderConfig } from './providers.js';
|
||||
import { getProviderSources, removeProviderConfig, upsertProviderConfig } from './providers.js';
|
||||
import { getAgentSources, getAgentConfig, createAgent, updateAgent, deleteAgent } from './agents.js';
|
||||
import { getCommandSources, createCommand, updateCommand, deleteCommand } from './commands.js';
|
||||
import { listMcpConfigs, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig } from './mcp.js';
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
decodePluginId,
|
||||
} from './plugins.js';
|
||||
import { SKILL_DIR, SKILL_SCOPE, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile } from './shared.js';
|
||||
import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill } from './skills.js';
|
||||
import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill, renameSkill, isManagedSkillPath } from './skills.js';
|
||||
import { getCuratedSkillsSources } from '../skills-catalog/curated-sources.js';
|
||||
import { getCacheKey, getCachedScan, setCachedScan } from '../skills-catalog/cache.js';
|
||||
import { isClawdHubSource, parseSkillRepoSource } from '../skills-catalog/source.js';
|
||||
@@ -145,6 +145,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
resolveProjectDirectory,
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
upsertProviderConfig,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
@@ -256,6 +257,8 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
renameSkill,
|
||||
isManagedSkillPath,
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import net from 'node:net';
|
||||
import { stripAppImageArgv0Leak } from '../inherited-env.js';
|
||||
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js';
|
||||
import { applyProviderEnvAliases } from './provider-env-aliases.js';
|
||||
import { recordStartupPerformance } from './startup-performance.js';
|
||||
@@ -70,7 +71,9 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasChildProcessExited = (child) => !child || child.exitCode !== null || child.signalCode !== null;
|
||||
const hasChildProcessExited = (child) => !child
|
||||
|| (child.exitCode !== null && child.exitCode !== undefined)
|
||||
|| (child.signalCode !== null && child.signalCode !== undefined);
|
||||
|
||||
const isManagedOpenCodeProcessAlive = () => {
|
||||
const child = state.openCodeProcess;
|
||||
@@ -366,6 +369,12 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
return {
|
||||
url,
|
||||
pid: child.pid || null,
|
||||
get exitCode() {
|
||||
return child.exitCode;
|
||||
},
|
||||
get signalCode() {
|
||||
return child.signalCode;
|
||||
},
|
||||
async close() {
|
||||
await closeManagedOpenCodeChild(child);
|
||||
},
|
||||
@@ -519,13 +528,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
timeout: 30000,
|
||||
cwd: state.openCodeWorkingDirectory,
|
||||
shellEnvKeysCount: Object.keys(shellEnv).length,
|
||||
env: applyProviderEnvAliases({
|
||||
env: stripAppImageArgv0Leak(applyProviderEnvAliases({
|
||||
...shellEnv,
|
||||
...process.env,
|
||||
...managedOpenCodeEnv,
|
||||
PATH: envPath,
|
||||
OPENCODE_SERVER_PASSWORD: openCodePassword,
|
||||
}),
|
||||
})),
|
||||
});
|
||||
|
||||
if (!serverInstance || !serverInstance.url) {
|
||||
|
||||
@@ -233,6 +233,30 @@ describe('OpenCode lifecycle', () => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('does not mistake a live managed process wrapper for an exited child', async () => {
|
||||
const close = vi.fn(async () => {});
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: false,
|
||||
json: async () => null,
|
||||
}));
|
||||
const runtime = createRuntime({}, {
|
||||
openCodePort: 45678,
|
||||
openCodeProcess: {
|
||||
pid: process.pid,
|
||||
close,
|
||||
},
|
||||
isOpenCodeReady: true,
|
||||
});
|
||||
|
||||
await runtime.triggerHealthCheck();
|
||||
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('(1/20)'));
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('restarts an exited managed process without waiting for the failure interval', async () => {
|
||||
const close = vi.fn(async () => {});
|
||||
const replacement = createMockChild();
|
||||
@@ -281,8 +305,45 @@ describe('OpenCode lifecycle', () => {
|
||||
expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin');
|
||||
expect(options.env.SHELL_ONLY).toBe('yes');
|
||||
expect(options.env.OPENCODE_SERVER_PASSWORD).toBe('password');
|
||||
expect(server.exitCode).toBeNull();
|
||||
expect(server.signalCode).toBeNull();
|
||||
|
||||
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;
|
||||
process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage';
|
||||
const child = createMockChild();
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
|
||||
});
|
||||
return child;
|
||||
});
|
||||
|
||||
try {
|
||||
const runtime = createRuntime({
|
||||
getManagedOpenCodeShellEnvSnapshot: vi.fn(() => ({
|
||||
PATH: '/home/user/.bun/bin:/usr/local/bin:/usr/bin',
|
||||
ARGV0: '/leaked/from/shell/snapshot.AppImage',
|
||||
SHELL_ONLY: 'yes',
|
||||
})),
|
||||
});
|
||||
const server = await runtime.startOpenCode();
|
||||
const [, , options] = spawnMock.mock.calls[0];
|
||||
|
||||
expect(options.env).not.toHaveProperty('ARGV0');
|
||||
expect(options.env.SHELL_ONLY).toBe('yes');
|
||||
expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin');
|
||||
|
||||
await server.close();
|
||||
} finally {
|
||||
if (previousArgv0 === undefined) delete process.env.ARGV0;
|
||||
else process.env.ARGV0 = previousArgv0;
|
||||
}
|
||||
});
|
||||
|
||||
it('adds managed OpenChamber tool environment without allowing it to replace launch invariants', async () => {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function applyProviderEnvAliases(
|
||||
env: Record<string, string | undefined> | null | undefined,
|
||||
): Record<string, string | undefined>;
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
writeConfig,
|
||||
} from './shared.js';
|
||||
|
||||
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
|
||||
const BASE_URL_PATTERN = /^https?:\/\//;
|
||||
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
|
||||
|
||||
function getProviderSources(providerId, workingDirectory) {
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const { userConfig, projectConfig, customConfig, paths } = layers;
|
||||
@@ -37,6 +41,162 @@ function getProviderSources(providerId, workingDirectory) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a custom OpenAI-compatible provider config payload before persistence.
|
||||
* Returns { ok: true, value } or { ok: false, error }.
|
||||
*
|
||||
* Credentials: either config.env contains a variable name, or hasStoredAuth is true
|
||||
* (auth.json already has a key — typically after auth.set, or when editing).
|
||||
*/
|
||||
function validateCustomProviderConfig(providerId, config, options = {}) {
|
||||
if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) {
|
||||
return { ok: false, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' };
|
||||
}
|
||||
|
||||
if (!isPlainObject(config)) {
|
||||
return { ok: false, error: 'Provider config must be an object' };
|
||||
}
|
||||
|
||||
const name = typeof config.name === 'string' ? config.name.trim() : '';
|
||||
if (!name) {
|
||||
return { ok: false, error: 'Provider name is required' };
|
||||
}
|
||||
|
||||
const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM;
|
||||
if (npm !== OPENAI_COMPATIBLE_NPM) {
|
||||
return { ok: false, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
|
||||
}
|
||||
|
||||
const optionsBlock = isPlainObject(config.options) ? config.options : null;
|
||||
if (!optionsBlock) {
|
||||
return { ok: false, error: 'Provider options are required' };
|
||||
}
|
||||
|
||||
const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.baseURL.trim() : '';
|
||||
if (!baseURL) {
|
||||
return { ok: false, error: 'Base URL is required' };
|
||||
}
|
||||
if (!BASE_URL_PATTERN.test(baseURL)) {
|
||||
return { ok: false, error: 'Base URL must start with http:// or https://' };
|
||||
}
|
||||
|
||||
const models = isPlainObject(config.models) ? config.models : null;
|
||||
if (!models || Object.keys(models).length === 0) {
|
||||
return { ok: false, error: 'At least one model is required' };
|
||||
}
|
||||
|
||||
const normalizedModels = {};
|
||||
for (const [modelId, modelValue] of Object.entries(models)) {
|
||||
const trimmedId = typeof modelId === 'string' ? modelId.trim() : '';
|
||||
if (!trimmedId) {
|
||||
return { ok: false, error: 'Model id is required' };
|
||||
}
|
||||
if (!isPlainObject(modelValue)) {
|
||||
return { ok: false, error: `Model "${trimmedId}" must be an object` };
|
||||
}
|
||||
const modelName = typeof modelValue.name === 'string' ? modelValue.name.trim() : '';
|
||||
if (!modelName) {
|
||||
return { ok: false, error: `Model "${trimmedId}" requires a name` };
|
||||
}
|
||||
normalizedModels[trimmedId] = { name: modelName };
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
npm: OPENAI_COMPATIBLE_NPM,
|
||||
name,
|
||||
options: {
|
||||
baseURL,
|
||||
},
|
||||
models: normalizedModels,
|
||||
};
|
||||
|
||||
let env = [];
|
||||
if (Array.isArray(config.env)) {
|
||||
env = config.env
|
||||
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
||||
.map((entry) => entry.trim());
|
||||
if (env.length > 0) {
|
||||
normalized.env = env;
|
||||
}
|
||||
}
|
||||
|
||||
const hasStoredAuth = Boolean(options.hasStoredAuth);
|
||||
if (env.length === 0 && !hasStoredAuth) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'API key or {env:VAR} credentials are required',
|
||||
};
|
||||
}
|
||||
|
||||
if (isPlainObject(optionsBlock.headers)) {
|
||||
const headers = {};
|
||||
for (const [headerKey, headerValue] of Object.entries(optionsBlock.headers)) {
|
||||
if (typeof headerKey !== 'string' || !headerKey.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (typeof headerValue !== 'string' || !headerValue.trim()) {
|
||||
return { ok: false, error: `Header "${headerKey}" requires a non-empty value` };
|
||||
}
|
||||
headers[headerKey.trim()] = headerValue.trim();
|
||||
}
|
||||
if (Object.keys(headers).length > 0) {
|
||||
normalized.options.headers = headers;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, value: { providerId, config: normalized } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist (create or update) a custom provider block in OpenCode user/project/custom config.
|
||||
* Does not write secrets — API keys remain in auth.json via the OpenCode auth API.
|
||||
*/
|
||||
function upsertProviderConfig(providerId, config, workingDirectory, scope = 'user', options = {}) {
|
||||
const validated = validateCustomProviderConfig(providerId, config, options);
|
||||
if (!validated.ok) {
|
||||
const error = new Error(validated.error);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
let targetPath = layers.paths.userPath;
|
||||
|
||||
if (scope === 'project') {
|
||||
if (!workingDirectory) {
|
||||
throw new Error('Working directory is required for project scope');
|
||||
}
|
||||
targetPath = layers.paths.projectPath || targetPath;
|
||||
} else if (scope === 'custom') {
|
||||
if (!layers.paths.customPath) {
|
||||
throw new Error('Custom config path (OPENCODE_CONFIG) is not set');
|
||||
}
|
||||
targetPath = layers.paths.customPath;
|
||||
} else if (scope !== 'user') {
|
||||
throw new Error('Invalid scope');
|
||||
}
|
||||
|
||||
const targetConfig = getConfigForPath(layers, targetPath);
|
||||
const providerConfig = isPlainObject(targetConfig.provider) ? { ...targetConfig.provider } : {};
|
||||
providerConfig[validated.value.providerId] = validated.value.config;
|
||||
targetConfig.provider = providerConfig;
|
||||
|
||||
if (Array.isArray(targetConfig.disabled_providers)) {
|
||||
targetConfig.disabled_providers = targetConfig.disabled_providers.filter(
|
||||
(entry) => entry !== validated.value.providerId,
|
||||
);
|
||||
}
|
||||
|
||||
const writePath = targetPath || CONFIG_FILE;
|
||||
writeConfig(targetConfig, writePath);
|
||||
|
||||
return {
|
||||
providerId: validated.value.providerId,
|
||||
path: writePath,
|
||||
config: validated.value.config,
|
||||
};
|
||||
}
|
||||
|
||||
function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
|
||||
if (!providerId || typeof providerId !== 'string') {
|
||||
throw new Error('Provider ID is required');
|
||||
@@ -93,4 +253,6 @@ function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
|
||||
export {
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
upsertProviderConfig,
|
||||
validateCustomProviderConfig,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
upsertProviderConfig,
|
||||
validateCustomProviderConfig,
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
} from './providers.js';
|
||||
|
||||
let projectDir;
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
describe('custom provider config persistence', () => {
|
||||
beforeEach(() => {
|
||||
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-provider-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('validateCustomProviderConfig rejects invalid endpoint and credentials shape', () => {
|
||||
expect(validateCustomProviderConfig('Bad Id', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok).toBe(false);
|
||||
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'ftp://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).error).toContain('http://');
|
||||
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: {},
|
||||
}).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('validateCustomProviderConfig rejects missing credentials', () => {
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok).toBe(false);
|
||||
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, { hasStoredAuth: true }).ok).toBe(true);
|
||||
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
env: ['MY_KEY'],
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('upsertProviderConfig writes and round-trips project config', () => {
|
||||
const result = upsertProviderConfig('campus-llm', {
|
||||
name: 'Campus LLM',
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
options: {
|
||||
baseURL: 'https://llm.example.edu/v1',
|
||||
headers: { 'X-Campus': '1' },
|
||||
},
|
||||
models: {
|
||||
'fast-model': { name: 'Fast' },
|
||||
},
|
||||
env: ['CAMPUS_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
expect(result.providerId).toBe('campus-llm');
|
||||
expect(fs.existsSync(result.path)).toBe(true);
|
||||
expect(result.path.startsWith(projectDir)).toBe(true);
|
||||
|
||||
const written = readJson(result.path);
|
||||
expect(written.provider['campus-llm']).toEqual({
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Campus LLM',
|
||||
env: ['CAMPUS_KEY'],
|
||||
options: {
|
||||
baseURL: 'https://llm.example.edu/v1',
|
||||
headers: { 'X-Campus': '1' },
|
||||
},
|
||||
models: {
|
||||
'fast-model': { name: 'Fast' },
|
||||
},
|
||||
});
|
||||
|
||||
const sources = getProviderSources('campus-llm', projectDir);
|
||||
expect(sources.sources.project.exists).toBe(true);
|
||||
expect(sources.sources.project.path).toBe(result.path);
|
||||
});
|
||||
|
||||
test('upsertProviderConfig updates existing entry and clears disabled_providers', () => {
|
||||
const configPath = path.join(projectDir, 'opencode.json');
|
||||
writeJson(configPath, {
|
||||
provider: {
|
||||
'campus-llm': {
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Old',
|
||||
options: { baseURL: 'https://old.example.edu/v1' },
|
||||
models: { a: { name: 'A' } },
|
||||
},
|
||||
},
|
||||
disabled_providers: ['campus-llm', 'other'],
|
||||
});
|
||||
|
||||
upsertProviderConfig('campus-llm', {
|
||||
name: 'Campus LLM',
|
||||
options: { baseURL: 'https://llm.example.edu/v1' },
|
||||
models: { b: { name: 'B' } },
|
||||
env: ['CAMPUS_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
const written = readJson(configPath);
|
||||
expect(written.provider['campus-llm'].name).toBe('Campus LLM');
|
||||
expect(written.provider['campus-llm'].models).toEqual({ b: { name: 'B' } });
|
||||
expect(written.disabled_providers).toEqual(['other']);
|
||||
});
|
||||
|
||||
test('upsert then remove restores absence', () => {
|
||||
upsertProviderConfig('temp-provider', {
|
||||
name: 'Temp',
|
||||
options: { baseURL: 'https://api.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
env: ['TEMP_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(true);
|
||||
expect(removeProviderConfig('temp-provider', projectDir, 'project')).toBe(true);
|
||||
expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(false);
|
||||
});
|
||||
|
||||
test('failed validation does not write config', () => {
|
||||
const configPath = path.join(projectDir, 'opencode.json');
|
||||
expect(() => upsertProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'not-a-url' },
|
||||
models: { m: { name: 'M' } },
|
||||
env: ['X'],
|
||||
}, projectDir, 'project')).toThrow(/Base URL/);
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
});
|
||||
|
||||
test('upsert with hasStoredAuth allows config without env', () => {
|
||||
const result = upsertProviderConfig('keyed-provider', {
|
||||
name: 'Keyed',
|
||||
options: { baseURL: 'https://api.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'project', { hasStoredAuth: true });
|
||||
|
||||
expect(result.providerId).toBe('keyed-provider');
|
||||
expect(result.config.env).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('project-scope edit updates project layer without creating a user entry', () => {
|
||||
const providerId = `proj-scope-${Date.now()}`;
|
||||
const configPath = path.join(projectDir, 'opencode.json');
|
||||
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Project Scoped',
|
||||
options: { baseURL: 'https://project.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'project', { hasStoredAuth: true });
|
||||
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Project Scoped Updated',
|
||||
options: { baseURL: 'https://project.example.com/v2', headers: { 'X-Project': '1' } },
|
||||
models: { m: { name: 'M2' } },
|
||||
}, projectDir, 'project', { hasStoredAuth: true });
|
||||
|
||||
const written = readJson(configPath);
|
||||
expect(written.provider[providerId]).toEqual({
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Project Scoped Updated',
|
||||
options: {
|
||||
baseURL: 'https://project.example.com/v2',
|
||||
headers: { 'X-Project': '1' },
|
||||
},
|
||||
models: { m: { name: 'M2' } },
|
||||
});
|
||||
|
||||
const sources = getProviderSources(providerId, projectDir);
|
||||
expect(sources.sources.project.exists).toBe(true);
|
||||
expect(sources.sources.user.exists).toBe(false);
|
||||
expect(sources.sources.custom.exists).toBe(false);
|
||||
|
||||
for (const userPath of [
|
||||
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
|
||||
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
|
||||
]) {
|
||||
if (!fs.existsSync(userPath)) continue;
|
||||
const userConfig = readJson(userPath);
|
||||
expect(userConfig.provider?.[providerId]).toBeUndefined();
|
||||
expect(userConfig.providers?.[providerId]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('custom-scope edit updates custom layer without creating a user entry', () => {
|
||||
const providerId = `custom-scope-${Date.now()}`;
|
||||
const customPath = path.join(projectDir, 'custom-opencode.json');
|
||||
const previousEnv = process.env.OPENCODE_CONFIG;
|
||||
process.env.OPENCODE_CONFIG = customPath;
|
||||
|
||||
try {
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Custom Scoped',
|
||||
options: { baseURL: 'https://custom.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'custom', { hasStoredAuth: true });
|
||||
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Custom Scoped Updated',
|
||||
options: { baseURL: 'https://custom.example.com/v2' },
|
||||
models: { n: { name: 'N' } },
|
||||
}, projectDir, 'custom', { hasStoredAuth: true });
|
||||
|
||||
const written = readJson(customPath);
|
||||
expect(written.provider[providerId].name).toBe('Custom Scoped Updated');
|
||||
expect(written.provider[providerId].options.baseURL).toBe('https://custom.example.com/v2');
|
||||
|
||||
const sources = getProviderSources(providerId, projectDir);
|
||||
expect(sources.sources.custom.exists).toBe(true);
|
||||
expect(sources.sources.user.exists).toBe(false);
|
||||
expect(sources.sources.project.exists).toBe(false);
|
||||
|
||||
for (const userPath of [
|
||||
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
|
||||
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
|
||||
]) {
|
||||
if (!fs.existsSync(userPath)) continue;
|
||||
const userConfig = readJson(userPath);
|
||||
expect(userConfig.provider?.[providerId]).toBeUndefined();
|
||||
expect(userConfig.providers?.[providerId]).toBeUndefined();
|
||||
}
|
||||
} finally {
|
||||
if (previousEnv === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG;
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG = previousEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
resolveProjectDirectory,
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
upsertProviderConfig,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
@@ -445,6 +446,63 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/provider', async (req, res) => {
|
||||
try {
|
||||
const providerID = typeof req.body?.providerID === 'string'
|
||||
? req.body.providerID.trim()
|
||||
: (typeof req.body?.providerId === 'string' ? req.body.providerId.trim() : '');
|
||||
const config = req.body?.config;
|
||||
const scope = typeof req.body?.scope === 'string' ? req.body.scope : 'user';
|
||||
|
||||
if (!providerID) {
|
||||
return res.status(400).json({ error: 'Provider ID is required' });
|
||||
}
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
return res.status(400).json({ error: 'Provider config is required' });
|
||||
}
|
||||
if (scope !== 'user' && scope !== 'project' && scope !== 'custom') {
|
||||
return res.status(400).json({ error: 'Invalid scope' });
|
||||
}
|
||||
|
||||
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||
const queryDirectory = Array.isArray(req.query?.directory)
|
||||
? req.query.directory[0]
|
||||
: req.query?.directory;
|
||||
const requestedDirectory = headerDirectory || queryDirectory || null;
|
||||
|
||||
let directory = null;
|
||||
if (scope === 'project' || requestedDirectory) {
|
||||
const resolved = await resolveProjectDirectory(req);
|
||||
if (!resolved.directory) {
|
||||
return res.status(400).json({ error: resolved.error || 'Working directory is required' });
|
||||
}
|
||||
directory = resolved.directory;
|
||||
} else {
|
||||
const resolved = await resolveProjectDirectory(req);
|
||||
if (resolved.directory) {
|
||||
directory = resolved.directory;
|
||||
}
|
||||
}
|
||||
|
||||
const { getProviderAuth } = await getAuthLibrary();
|
||||
const hasStoredAuth = Boolean(getProviderAuth(providerID));
|
||||
const upsertResult = upsertProviderConfig(providerID, config, directory, scope, { hasStoredAuth });
|
||||
|
||||
return res.json({
|
||||
...buildDeferredRestartResponse(
|
||||
`Provider ${providerID} saved. Restart OpenCode to apply.`,
|
||||
),
|
||||
providerId: upsertResult.providerId,
|
||||
path: upsertResult.path,
|
||||
config: upsertResult.config,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = typeof error?.statusCode === 'number' ? error.statusCode : 500;
|
||||
console.error('Failed to upsert provider config:', error);
|
||||
return res.status(status).json({ error: error.message || 'Failed to save provider config' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/provider/:providerId/auth', async (req, res) => {
|
||||
try {
|
||||
const { providerId } = req.params;
|
||||
|
||||
@@ -11,9 +11,6 @@ const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
|
||||
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
|
||||
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
|
||||
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
|
||||
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
|
||||
? path.resolve(process.env.OPENCODE_CONFIG)
|
||||
: null;
|
||||
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
|
||||
|
||||
// ============== SCOPE TYPE CONSTANTS ==============
|
||||
@@ -121,7 +118,10 @@ function getConfigPaths(workingDirectory) {
|
||||
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
|
||||
],
|
||||
projectPath: getProjectConfigPath(workingDirectory),
|
||||
customPath: CUSTOM_CONFIG_FILE
|
||||
// Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect.
|
||||
customPath: process.env.OPENCODE_CONFIG
|
||||
? path.resolve(process.env.OPENCODE_CONFIG)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
renameSkill,
|
||||
isManagedSkillPath,
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
@@ -236,9 +238,15 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
|
||||
const enrichedSkills = skills.map((skill) => {
|
||||
const sources = getSkillSources(skill.name, directory, skill);
|
||||
const skillPath = typeof skill.path === 'string' ? skill.path : null;
|
||||
return {
|
||||
...skill,
|
||||
sources
|
||||
sources,
|
||||
renamable: Boolean(
|
||||
skillPath
|
||||
&& skillPath !== '<built-in>'
|
||||
&& isManagedSkillPath(skillPath, directory)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -628,6 +636,22 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
if (typeof updates?.renameTo === 'string') {
|
||||
const newName = updates.renameTo.trim();
|
||||
console.log(`[Server] Renaming skill: ${skillName} -> ${newName}`);
|
||||
console.log('[Server] Working directory:', directory);
|
||||
renameSkill(skillName, newName, directory);
|
||||
await refreshOpenCodeAfterConfigChange('skill rename');
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
name: newName,
|
||||
requiresReload: true,
|
||||
message: `Skill renamed to ${newName} successfully. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[Server] Updating skill: ${skillName}`);
|
||||
console.log('[Server] Working directory:', directory);
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
deleteSkill,
|
||||
discoverSkills,
|
||||
getSkillSources,
|
||||
isManagedSkillPath,
|
||||
mergeDiscoveredSkills,
|
||||
renameSkill,
|
||||
updateSkill,
|
||||
} from './skills.js';
|
||||
import {
|
||||
@@ -58,6 +60,8 @@ const startSkillsApp = ({ projectRoot }) => {
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
renameSkill,
|
||||
isManagedSkillPath,
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
@@ -154,4 +158,62 @@ describe('skill-routes directory soft fallback', () => {
|
||||
const payload = await listResponse.json();
|
||||
expect(payload.skills.map((skill) => skill.name)).toContain('manual-repo-skill');
|
||||
});
|
||||
|
||||
it('marks managed-root skills renamable and cache skills not renamable', async () => {
|
||||
projectRoot = createTempProject();
|
||||
const managedDir = path.join(projectRoot, '.opencode', 'skills', 'managed-list-skill');
|
||||
fs.mkdirSync(managedDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(managedDir, 'SKILL.md'),
|
||||
[
|
||||
'---',
|
||||
'name: managed-list-skill',
|
||||
'description: Managed list skill',
|
||||
'---',
|
||||
'',
|
||||
'Managed body',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const cacheStamp = `oc-skill-routes-${Date.now()}`;
|
||||
const cacheDir = path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp, 'cache-list-skill');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(cacheDir, 'SKILL.md'),
|
||||
[
|
||||
'---',
|
||||
'name: cache-list-skill',
|
||||
'description: Cache list skill',
|
||||
'---',
|
||||
'',
|
||||
'Cache body',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
appHandle = startSkillsApp({ projectRoot });
|
||||
const listResponse = await fetch(
|
||||
`${appHandle.baseUrl}/api/config/skills?directory=${encodeURIComponent(projectRoot)}`,
|
||||
);
|
||||
expect(listResponse.status).toBe(200);
|
||||
const payload = await listResponse.json();
|
||||
|
||||
const managed = payload.skills.find((entry) => entry.name === 'managed-list-skill');
|
||||
const cached = payload.skills.find((entry) => entry.name === 'cache-list-skill');
|
||||
|
||||
expect(managed).toBeTruthy();
|
||||
expect(managed.renamable).toBe(true);
|
||||
expect(cached).toBeTruthy();
|
||||
expect(cached.renamable).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -412,12 +412,22 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
||||
return sources;
|
||||
}
|
||||
|
||||
function createSkill(skillName, config, workingDirectory, scope) {
|
||||
ensureDirs();
|
||||
function isValidSkillName(skillName) {
|
||||
return typeof skillName === 'string'
|
||||
&& skillName.length > 0
|
||||
&& skillName.length <= 64
|
||||
&& /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName);
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
|
||||
function assertValidSkillName(skillName) {
|
||||
if (!isValidSkillName(skillName)) {
|
||||
throw new Error(`Invalid skill name "${skillName}". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.`);
|
||||
}
|
||||
}
|
||||
|
||||
function createSkill(skillName, config, workingDirectory, scope) {
|
||||
ensureDirs();
|
||||
assertValidSkillName(skillName);
|
||||
|
||||
const existing = getSkillScope(skillName, workingDirectory);
|
||||
if (existing.path) {
|
||||
@@ -505,7 +515,7 @@ function updateSkill(skillName, updates, workingDirectory, targetPath = null) {
|
||||
let mdModified = false;
|
||||
|
||||
for (const [field, value] of Object.entries(updates)) {
|
||||
if (field === 'scope' || field === 'source' || field === 'targetPath') {
|
||||
if (field === 'scope' || field === 'source' || field === 'targetPath' || field === 'renameTo') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -592,6 +602,130 @@ function deleteSkill(skillName, workingDirectory) {
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInside(candidatePath, parentPath) {
|
||||
if (!candidatePath || !parentPath) return false;
|
||||
const resolvedCandidate = path.resolve(candidatePath);
|
||||
const resolvedParent = path.resolve(parentPath);
|
||||
return resolvedCandidate === resolvedParent
|
||||
|| resolvedCandidate.startsWith(`${resolvedParent}${path.sep}`);
|
||||
}
|
||||
|
||||
function getManagedSkillRoots(workingDirectory) {
|
||||
const roots = [];
|
||||
const pushRoot = (dir) => {
|
||||
if (!dir) return;
|
||||
const resolved = path.resolve(dir);
|
||||
if (!roots.includes(resolved)) {
|
||||
roots.push(resolved);
|
||||
}
|
||||
};
|
||||
|
||||
pushRoot(SKILL_DIR);
|
||||
pushRoot(path.join(OPENCODE_CONFIG_DIR, 'skill'));
|
||||
pushRoot(path.join(os.homedir(), '.opencode', 'skills'));
|
||||
pushRoot(path.join(os.homedir(), '.opencode', 'skill'));
|
||||
pushRoot(path.join(os.homedir(), '.claude', 'skills'));
|
||||
pushRoot(path.join(os.homedir(), '.agents', 'skills'));
|
||||
|
||||
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
|
||||
: null;
|
||||
if (customConfigDir) {
|
||||
pushRoot(path.join(customConfigDir, 'skills'));
|
||||
pushRoot(path.join(customConfigDir, 'skill'));
|
||||
}
|
||||
|
||||
if (workingDirectory) {
|
||||
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
|
||||
for (const ancestor of getAncestors(workingDirectory, worktreeRoot)) {
|
||||
pushRoot(path.join(ancestor, '.opencode', 'skills'));
|
||||
pushRoot(path.join(ancestor, '.opencode', 'skill'));
|
||||
pushRoot(path.join(ancestor, '.claude', 'skills'));
|
||||
pushRoot(path.join(ancestor, '.agents', 'skills'));
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
function isManagedSkillPath(skillMdPath, workingDirectory) {
|
||||
if (!skillMdPath || skillMdPath === BUILT_IN_SKILL_LOCATION) {
|
||||
return false;
|
||||
}
|
||||
const skillDir = path.dirname(path.resolve(skillMdPath));
|
||||
return getManagedSkillRoots(workingDirectory).some((root) => isPathInside(skillDir, root));
|
||||
}
|
||||
|
||||
function renameSkill(oldName, newName, workingDirectory) {
|
||||
ensureDirs();
|
||||
assertValidSkillName(newName);
|
||||
|
||||
if (oldName === newName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = getSkillScope(oldName, workingDirectory);
|
||||
if (!existing.path) {
|
||||
throw new Error(`Skill "${oldName}" not found`);
|
||||
}
|
||||
if (existing.path === BUILT_IN_SKILL_LOCATION || !fs.existsSync(existing.path)) {
|
||||
throw new Error(`Skill "${oldName}" cannot be renamed`);
|
||||
}
|
||||
if (path.basename(existing.path) !== 'SKILL.md') {
|
||||
throw new Error(`Skill "${oldName}" target must be a SKILL.md file`);
|
||||
}
|
||||
if (!isManagedSkillPath(existing.path, workingDirectory)) {
|
||||
throw new Error(`Skill "${oldName}" is outside managed skill directories and cannot be renamed`);
|
||||
}
|
||||
|
||||
const mdDataBeforeMove = parseMdFile(existing.path);
|
||||
const frontmatterName = typeof mdDataBeforeMove.frontmatter?.name === 'string'
|
||||
? mdDataBeforeMove.frontmatter.name
|
||||
: oldName;
|
||||
if (frontmatterName !== oldName) {
|
||||
throw new Error(`Skill "${oldName}" does not match ${existing.path}`);
|
||||
}
|
||||
|
||||
const conflict = getSkillScope(newName, workingDirectory);
|
||||
if (conflict.path) {
|
||||
throw new Error(`Skill ${newName} already exists at ${conflict.path}`);
|
||||
}
|
||||
|
||||
const oldDir = path.dirname(existing.path);
|
||||
const newDir = path.join(path.dirname(oldDir), newName);
|
||||
const directoriesDiffer = path.resolve(oldDir) !== path.resolve(newDir);
|
||||
|
||||
if (directoriesDiffer && fs.existsSync(newDir)) {
|
||||
throw new Error(`Skill directory already exists at ${newDir}`);
|
||||
}
|
||||
|
||||
// Rename the skill directory in place so supporting files and SKILL.md body are preserved.
|
||||
if (directoriesDiffer) {
|
||||
fs.renameSync(oldDir, newDir);
|
||||
}
|
||||
|
||||
const newPath = path.join(newDir, 'SKILL.md');
|
||||
try {
|
||||
const mdData = parseMdFile(newPath);
|
||||
mdData.frontmatter = {
|
||||
...mdData.frontmatter,
|
||||
name: newName,
|
||||
};
|
||||
writeMdFile(newPath, mdData.frontmatter, mdData.body);
|
||||
} catch (error) {
|
||||
if (directoriesDiffer && fs.existsSync(newDir) && !fs.existsSync(oldDir)) {
|
||||
try {
|
||||
fs.renameSync(newDir, oldDir);
|
||||
} catch (rollbackError) {
|
||||
console.error(`Failed to rollback skill rename from ${newDir} to ${oldDir}:`, rollbackError);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Renamed skill: ${oldName} -> ${newName} (path: ${newPath})`);
|
||||
}
|
||||
|
||||
export {
|
||||
getSkillSources,
|
||||
discoverSkills,
|
||||
@@ -599,4 +733,6 @@ export {
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
renameSkill,
|
||||
isManagedSkillPath,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import fsPromises from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { discoverSkills, getSkillSources, mergeDiscoveredSkills } from './skills.js';
|
||||
import { discoverSkills, getSkillSources, mergeDiscoveredSkills, renameSkill } from './skills.js';
|
||||
|
||||
describe('skills', () => {
|
||||
it('merges locally discovered skills missing from OpenCode live discovery', () => {
|
||||
@@ -147,4 +148,198 @@ describe('skills', () => {
|
||||
await fsPromises.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('renames a skill directory while preserving SKILL.md body and supporting files', async () => {
|
||||
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-'));
|
||||
const projectRoot = path.join(tempRoot, 'project');
|
||||
const skillDir = path.join(projectRoot, '.opencode', 'skills', 'original-skill');
|
||||
const skillPath = path.join(skillDir, 'SKILL.md');
|
||||
const supportPath = path.join(skillDir, 'notes.md');
|
||||
const body = [
|
||||
'# Original Skill',
|
||||
'',
|
||||
'Preserve this non-trivial body across rename.',
|
||||
'',
|
||||
'## Details',
|
||||
'',
|
||||
'- step one',
|
||||
'- step two',
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
await fsPromises.mkdir(skillDir, { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
skillPath,
|
||||
[
|
||||
'---',
|
||||
'name: original-skill',
|
||||
'description: Original skill description',
|
||||
'license: MIT',
|
||||
'---',
|
||||
'',
|
||||
body,
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
await fsPromises.writeFile(supportPath, 'supporting file contents\n', 'utf8');
|
||||
|
||||
renameSkill('original-skill', 'renamed-skill', projectRoot);
|
||||
|
||||
const renamedDir = path.join(projectRoot, '.opencode', 'skills', 'renamed-skill');
|
||||
const renamedPath = path.join(renamedDir, 'SKILL.md');
|
||||
const renamedSupportPath = path.join(renamedDir, 'notes.md');
|
||||
|
||||
expect(fs.existsSync(skillDir)).toBe(false);
|
||||
expect(fs.existsSync(renamedPath)).toBe(true);
|
||||
expect(fs.existsSync(renamedSupportPath)).toBe(true);
|
||||
|
||||
const sources = getSkillSources('renamed-skill', projectRoot, {
|
||||
name: 'renamed-skill',
|
||||
path: renamedPath,
|
||||
scope: 'project',
|
||||
source: 'opencode',
|
||||
description: 'fallback',
|
||||
});
|
||||
|
||||
expect(sources.md.exists).toBe(true);
|
||||
expect(sources.md.name).toBe('renamed-skill');
|
||||
expect(sources.md.description).toBe('Original skill description');
|
||||
expect(sources.md.instructions).toBe(body);
|
||||
expect(await fsPromises.readFile(renamedSupportPath, 'utf8')).toBe('supporting file contents\n');
|
||||
|
||||
const raw = await fsPromises.readFile(renamedPath, 'utf8');
|
||||
expect(raw).toContain('license: MIT');
|
||||
expect(raw).not.toContain('Renamed skill');
|
||||
} finally {
|
||||
await fsPromises.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rolls back the directory rename when frontmatter write fails', async () => {
|
||||
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-rollback-'));
|
||||
const projectRoot = path.join(tempRoot, 'project');
|
||||
const skillDir = path.join(projectRoot, '.opencode', 'skills', 'rollback-skill');
|
||||
const skillPath = path.join(skillDir, 'SKILL.md');
|
||||
const body = '# Rollback body\n\nMust remain in the original directory.';
|
||||
|
||||
try {
|
||||
await fsPromises.mkdir(skillDir, { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
skillPath,
|
||||
[
|
||||
'---',
|
||||
'name: rollback-skill',
|
||||
'description: Rollback skill',
|
||||
'---',
|
||||
'',
|
||||
body,
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
await fsPromises.chmod(skillPath, 0o444);
|
||||
|
||||
expect(() => renameSkill('rollback-skill', 'rollback-skill-renamed', projectRoot)).toThrow();
|
||||
|
||||
expect(fs.existsSync(skillDir)).toBe(true);
|
||||
expect(fs.existsSync(path.join(projectRoot, '.opencode', 'skills', 'rollback-skill-renamed'))).toBe(false);
|
||||
expect(await fsPromises.readFile(skillPath, 'utf8')).toContain(body);
|
||||
} finally {
|
||||
try {
|
||||
await fsPromises.chmod(skillPath, 0o644);
|
||||
} catch {
|
||||
// Best-effort cleanup when the file was rolled back under a different mode.
|
||||
}
|
||||
await fsPromises.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid names, missing skills, conflicts, unmanaged paths, and frontmatter mismatches', async () => {
|
||||
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-reject-'));
|
||||
const projectRoot = path.join(tempRoot, 'project');
|
||||
const managedDir = path.join(projectRoot, '.opencode', 'skills', 'managed-skill');
|
||||
const conflictDir = path.join(projectRoot, '.opencode', 'skills', 'taken-name');
|
||||
const mismatchDir = path.join(projectRoot, '.opencode', 'skills', 'folder-name');
|
||||
const cacheStamp = `oc-rename-${Date.now()}`;
|
||||
const cacheDir = path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp, 'cache-skill');
|
||||
|
||||
try {
|
||||
await fsPromises.mkdir(managedDir, { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
path.join(managedDir, 'SKILL.md'),
|
||||
[
|
||||
'---',
|
||||
'name: managed-skill',
|
||||
'description: Managed',
|
||||
'---',
|
||||
'',
|
||||
'Managed body',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await fsPromises.mkdir(conflictDir, { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
path.join(conflictDir, 'SKILL.md'),
|
||||
[
|
||||
'---',
|
||||
'name: taken-name',
|
||||
'description: Taken',
|
||||
'---',
|
||||
'',
|
||||
'Taken body',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await fsPromises.mkdir(mismatchDir, { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
path.join(mismatchDir, 'SKILL.md'),
|
||||
[
|
||||
'---',
|
||||
'name: frontmatter-name',
|
||||
'description: Mismatch',
|
||||
'---',
|
||||
'',
|
||||
'Mismatch body',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await fsPromises.mkdir(cacheDir, { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
path.join(cacheDir, 'SKILL.md'),
|
||||
[
|
||||
'---',
|
||||
'name: cache-skill',
|
||||
'description: Cache skill',
|
||||
'---',
|
||||
'',
|
||||
'Cache body',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(() => renameSkill('managed-skill', 'Invalid_Name', projectRoot)).toThrow(/Invalid skill name/);
|
||||
expect(() => renameSkill('missing-skill', 'new-skill', projectRoot)).toThrow(/not found/);
|
||||
expect(() => renameSkill('managed-skill', 'taken-name', projectRoot)).toThrow(/already exists/);
|
||||
expect(() => renameSkill('folder-name', 'renamed-mismatch', projectRoot)).toThrow(/does not match/);
|
||||
expect(() => renameSkill('cache-skill', 'cache-skill-renamed', projectRoot)).toThrow(/managed skill directories/);
|
||||
|
||||
expect(fs.existsSync(managedDir)).toBe(true);
|
||||
expect(fs.existsSync(cacheDir)).toBe(true);
|
||||
expect(fs.existsSync(path.join(projectRoot, '.opencode', 'skills', 'renamed-mismatch'))).toBe(false);
|
||||
} finally {
|
||||
await fsPromises.rm(tempRoot, { recursive: true, force: true });
|
||||
await fsPromises.rm(path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,7 +24,9 @@ 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.
|
||||
- PTY data and exit callbacks enter one FIFO queue. Stale callbacks from replaced processes are ignored.
|
||||
- Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { sanitizeTerminalHistoryChunk } from './history.js';
|
||||
import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js';
|
||||
import { createTerminalShellResolver, getTerminalShellLoginArgs, normalizeTerminalShell } from './shells.js';
|
||||
import { stripAppImageArgv0Leak, resolveLinuxPtyLaunch } from '../inherited-env.js';
|
||||
|
||||
const MAX_SESSIONS = 20;
|
||||
const MAX_HISTORY_BYTES = 512 * 1024;
|
||||
@@ -66,8 +67,12 @@ export function createTerminalRuntime({
|
||||
// required because bun-pty also inherits Bun's native process environment.
|
||||
env.NODE_CHANNEL_FD = '';
|
||||
delete env.BASH_XTRACEFD; delete env.BASH_ENV; delete env.ENV; delete env.ELECTRON_RUN_AS_NODE;
|
||||
// AppImage exports ARGV0; zsh would otherwise rewrite argv[0] for every command (#2588).
|
||||
// bun-pty also merges the native OS environ, so wrap with `env -u ARGV0` on Linux.
|
||||
stripAppImageArgv0Leak(env);
|
||||
const launch = resolveLinuxPtyLaunch(executable, args);
|
||||
const options = { name: 'xterm-256color', cwd, cols, rows, env, ...(process.platform === 'win32' ? { useConpty: true } : {}) };
|
||||
return { process: provider.spawn(executable, args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
|
||||
return { process: provider.spawn(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
|
||||
} catch (error) { lastError = error; }
|
||||
}
|
||||
throw lastError ?? new Error('No executable shell found');
|
||||
@@ -145,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);
|
||||
@@ -331,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 {
|
||||
|
||||
@@ -154,8 +154,14 @@ describe('terminal runtime', () => {
|
||||
expect(harness.processes[0].options.cwd).toBe('/repo');
|
||||
expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
|
||||
expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe('');
|
||||
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\\']);
|
||||
expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
|
||||
expect(harness.processes[0].options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE');
|
||||
if (process.platform === 'linux') {
|
||||
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\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);
|
||||
@@ -173,6 +179,27 @@ describe('terminal runtime', () => {
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('strips AppImage ARGV0 from PTY child environments', async () => {
|
||||
const previousArgv0 = process.env.ARGV0;
|
||||
process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage';
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const response = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-argv0', cwd: '/repo', cols: 80, rows: 24 } }, response);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
|
||||
if (process.platform === 'linux') {
|
||||
expect(harness.processes[0].shell).toMatch(/\/env$/);
|
||||
expect(harness.processes[0].args[0]).toBe('-u');
|
||||
expect(harness.processes[0].args[1]).toBe('ARGV0');
|
||||
}
|
||||
} finally {
|
||||
if (previousArgv0 === undefined) delete process.env.ARGV0;
|
||||
else process.env.ARGV0 = previousArgv0;
|
||||
await harness.runtime.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('lists available shells and uses the selected shell for create and restart', async () => {
|
||||
const executables = new Set(['/bin/zsh', '/bin/bash', '/bin/sh']);
|
||||
const harness = createHarness({
|
||||
@@ -198,14 +225,24 @@ describe('terminal runtime', () => {
|
||||
const created = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-shell', cwd: '/repo', shell: 'zsh', loginShell: true } }, created);
|
||||
expect(created.statusCode).toBe(200);
|
||||
expect(harness.processes[0].shell).toBe('/bin/zsh');
|
||||
expect(harness.processes[0].args).toEqual(['-l']);
|
||||
if (process.platform === 'linux') {
|
||||
expect(harness.processes[0].shell).toMatch(/\/env$/);
|
||||
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/zsh', '-l']);
|
||||
} else {
|
||||
expect(harness.processes[0].shell).toBe('/bin/zsh');
|
||||
expect(harness.processes[0].args).toEqual(['-l']);
|
||||
}
|
||||
|
||||
const restarted = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-shell' }, body: { shell: 'bash', loginShell: true } }, restarted);
|
||||
expect(restarted.statusCode).toBe(200);
|
||||
expect(harness.processes[1].shell).toBe('/bin/bash');
|
||||
expect(harness.processes[1].args).toEqual(['-l']);
|
||||
if (process.platform === 'linux') {
|
||||
expect(harness.processes[1].shell).toMatch(/\/env$/);
|
||||
expect(harness.processes[1].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l']);
|
||||
} else {
|
||||
expect(harness.processes[1].shell).toBe('/bin/bash');
|
||||
expect(harness.processes[1].args).toEqual(['-l']);
|
||||
}
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,7 +136,12 @@ silently. The prompt says so explicitly.
|
||||
|
||||
`languages.js` owns the accepted tags; they match the UI's `Locale` union, and
|
||||
anything else — unknown, malformed, absent — resolves to English rather than
|
||||
failing the request. The default language adds no instruction at all, since the
|
||||
failing the request. The two lists cannot be one, because the server cannot
|
||||
import from `packages/ui`, so `languages.test.js` reads `i18n/runtime.ts` and
|
||||
compares them. That test exists because a locale added to the interface alone
|
||||
fails silently in the worst way: the picker offers the language, the tag
|
||||
resolves to English, and the reader pays for a walkthrough written in the wrong
|
||||
one while the picker still names theirs. The default language adds no instruction at all, since the
|
||||
system prompt is already English.
|
||||
|
||||
The language is part of the cache key. Without that, asking for a translation
|
||||
|
||||
@@ -17,6 +17,7 @@ export const DEFAULT_LANGUAGE = 'en';
|
||||
// which every model handles more reliably than a switch mid-sentence.
|
||||
const LANGUAGE_NAMES = {
|
||||
en: 'English',
|
||||
de: 'German',
|
||||
fr: 'French',
|
||||
'zh-CN': 'Simplified Chinese',
|
||||
'zh-TW': 'Traditional Chinese',
|
||||
@@ -57,3 +58,9 @@ export function normalizeLanguage(value) {
|
||||
export function languageName(language) {
|
||||
return LANGUAGE_NAMES[language] ?? LANGUAGE_NAMES[DEFAULT_LANGUAGE];
|
||||
}
|
||||
|
||||
// The tags this list must agree with live in `packages/ui/src/lib/i18n`, which
|
||||
// the server cannot import. `languages.test.js` compares the two by reading
|
||||
// that file, because a locale added on one side only fails silently: the picker
|
||||
// offers the language and the walkthrough comes back in English.
|
||||
export const __testing = { LANGUAGE_NAMES };
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeLanguage, __testing } from './languages.js';
|
||||
|
||||
// The languages a walkthrough may be written in have to agree with the locales
|
||||
// the interface ships, because the picker offers exactly those and the server
|
||||
// decides what the prompt asks for. The two lists cannot be one list — the
|
||||
// server cannot import from `packages/ui` — so they are compared here instead.
|
||||
//
|
||||
// This exists because German was added to the interface and not here. Nothing
|
||||
// broke loudly: the picker offered Deutsch, `normalizeLanguage` quietly resolved
|
||||
// it to English, and a German user paid for a walkthrough written in English
|
||||
// while the picker still said Deutsch. A drift this quiet needs a test, not
|
||||
// vigilance.
|
||||
const RUNTIME_TS = fileURLToPath(new URL('../../../../ui/src/lib/i18n/runtime.ts', import.meta.url));
|
||||
|
||||
const interfaceLocales = () => {
|
||||
const source = fs.readFileSync(RUNTIME_TS, 'utf8');
|
||||
const match = source.match(/export const LOCALES = \[([^\]]*)\]/);
|
||||
if (!match) throw new Error(`Could not find LOCALES in ${RUNTIME_TS}`);
|
||||
return match[1]
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().replace(/^['"]|['"]$/g, ''))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
describe('supported languages', () => {
|
||||
it('covers every locale the interface offers', () => {
|
||||
const missing = interfaceLocales().filter((locale) => !Object.hasOwn(__testing.LANGUAGE_NAMES, locale));
|
||||
|
||||
expect(missing, `add these to LANGUAGE_NAMES in languages.js: ${missing.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('offers nothing the interface cannot label', () => {
|
||||
const locales = new Set(interfaceLocales());
|
||||
const extra = Object.keys(__testing.LANGUAGE_NAMES).filter((tag) => !locales.has(tag));
|
||||
|
||||
// A language here that the interface does not know is not harmful, but it
|
||||
// is unreachable: the picker is built from the interface list.
|
||||
expect(extra, `unreachable from the picker: ${extra.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves every interface locale to itself rather than to the default', () => {
|
||||
for (const locale of interfaceLocales()) {
|
||||
expect(normalizeLanguage(locale)).toBe(locale);
|
||||
}
|
||||
});
|
||||
|
||||
it('names every supported language in English, for the prompt', () => {
|
||||
for (const [tag, name] of Object.entries(__testing.LANGUAGE_NAMES)) {
|
||||
expect(name, tag).toMatch(/^[A-Z][A-Za-z ]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user