feat: agent and CLI control plane for sessions, worktrees, and scheduled tasks (#2408)
Add a shared OpenChamber control service with two thin adapters — a native `openchamber` tool injected into managed OpenCode, and new CLI commands — so users can manage parallel sessions, worktrees, and scheduled tasks conversationally through agents or from the terminal. Control plane: - New openchamber-control service owning a fixed action contract: projects.list, models.list, session list/create/send/fork/status/messages, and schedule list/create/run/delete/toggle. Session and worktree deletion and project registration are deliberately not exposed. - New openchamber-sessions module owning create/worktree/prompt orchestration, Goal Mode dispatch, wait semantics (initial idle never counts as completion; timeout and cancellation are failures), and explicit partial-failure results. - Scheduled-task logic extracted into a service shared by routes, CLI, and the agent tool. Agent tool: - Managed OpenCode gets a materialized plugin registering one typed tool with a loopback-only callback, per-child ephemeral bearer (timing-safe, never persisted or logged), and abort propagation into the service. - The ~1.5k-token schema applies progressive disclosure: short descriptions, server-side validation returning actionable usage errors, and intent guardrails — created sessions/tasks are user-facing work (not age self-delegation); worktree/goal/agent/variant/wait are omit-by-default; dispatches produce no completion notification, and later result r to session.messages, which now returns the authoritative sessionStatus. - session.create without a user-named model picks from favorites/re send/fork omit the selection and the service reuses the target session's last user-message model, agent, and variant before falling back t - An "Agent control tool" setting (default on, Save + Reload to apply) disables plugin injection entirely. CLI: - New `openchamber session`, `schedule`, `projects`, and `models` commands with automatic instance targeting, --wait/--timeout/--last-assist worktree flags, and Goal Mode, preserving interactive, non-TTY, --quiet, and --json contracts. The control HTTP timeout derives from the w instead of the 4-second default. UI: - New built-in "Schedule a Task" starter (/schedule-task) running a dialogue that defines a task and offers to create it via the tool after explicit confirmation; Craft a Goal and Feature Planning gain the handoff offer, and guided starters reserve the question tool for concrete option choices. Localized in all 10 locales, migrated into custom starter lists, hidden on VS Code. - Sidebar shows CLI/agent-created sessions live via the control eve - openchamber tool calls render with per-action titles and metadata.
This commit is contained in:
committed by
GitHub
parent
484fe8bc18
commit
e908db637b
@@ -9,7 +9,18 @@ import { pathToFileURL } from 'url';
|
||||
|
||||
import { isModuleCliExecution, normalizeCliEntryPath } from './cli-entry.js';
|
||||
import { requestJson } from './lib/cli-http.js';
|
||||
import { requestControlAction } from './lib/cli-control.js';
|
||||
import { inspectTunnelAttachability } from './lib/cli-lifecycle.js';
|
||||
import { formatGoal } from './lib/commands-schedule.js';
|
||||
import {
|
||||
buildSessionCreatePayload,
|
||||
buildSessionPromptPayload,
|
||||
formatSessionLine,
|
||||
sessionCommand,
|
||||
} from './lib/commands-session.js';
|
||||
import { formatModelsOutput } from './lib/commands-models.js';
|
||||
import { formatProjectLine } from './lib/commands-projects.js';
|
||||
import { resolveTargetPort } from './lib/cli-api-target.js';
|
||||
import { DEFAULT_TUNNEL_PROVIDER_CAPABILITIES } from './lib/cli-tunnel-capabilities.js';
|
||||
import {
|
||||
TUNNEL_PROVIDER_CLOUDFLARE,
|
||||
@@ -229,6 +240,351 @@ describe('cli args', () => {
|
||||
expect(parsed.options.port).toBe(3002);
|
||||
});
|
||||
|
||||
it('parses schedule commands and options', () => {
|
||||
const parsed = parseArgs([
|
||||
'schedule',
|
||||
'create',
|
||||
'--project',
|
||||
'proj_1',
|
||||
'--name',
|
||||
'Daily review',
|
||||
'--prompt',
|
||||
'Review the repo',
|
||||
'--model',
|
||||
'openai/gpt-5.5',
|
||||
'--daily',
|
||||
'09:30',
|
||||
'--timezone',
|
||||
'Europe/Kyiv',
|
||||
]);
|
||||
|
||||
expect(parsed.command).toBe('schedule');
|
||||
expect(parsed.scheduleAction).toBe('create');
|
||||
expect(parsed.options.project).toBe('proj_1');
|
||||
expect(parsed.options.name).toBe('Daily review');
|
||||
expect(parsed.options.prompt).toBe('Review the repo');
|
||||
expect(parsed.options.model).toBe('openai/gpt-5.5');
|
||||
expect(parsed.options.daily).toBe('09:30');
|
||||
expect(parsed.options.timezone).toBe('Europe/Kyiv');
|
||||
});
|
||||
|
||||
it('parses goal-enabled scheduled task options', () => {
|
||||
const parsed = parseArgs([
|
||||
'schedule',
|
||||
'create',
|
||||
'--dir',
|
||||
'/repo',
|
||||
'--name',
|
||||
'Finish migration',
|
||||
'--prompt',
|
||||
'Complete and verify the migration',
|
||||
'--model',
|
||||
'openai/gpt-5.5',
|
||||
'--daily',
|
||||
'09:30',
|
||||
'--goal',
|
||||
'--goal-token-budget',
|
||||
'200000',
|
||||
]);
|
||||
|
||||
expect(parsed.options.goal).toBe(true);
|
||||
expect(parsed.options.goalTokenBudget).toBe('200000');
|
||||
});
|
||||
|
||||
it('formats scheduled goal state compactly', () => {
|
||||
expect(formatGoal({})).toBe('goal:no');
|
||||
expect(formatGoal({ goalEnabled: true })).toBe('goal:yes');
|
||||
expect(formatGoal({ goalEnabled: true, goalTokenBudget: 200000 })).toBe('goal:yes budget:200000');
|
||||
});
|
||||
|
||||
it('parses session create options', () => {
|
||||
const parsed = parseArgs([
|
||||
'session',
|
||||
'create',
|
||||
'--dir',
|
||||
'.',
|
||||
'--name',
|
||||
'Side task',
|
||||
'--prompt',
|
||||
'Investigate cache invalidation',
|
||||
'--model',
|
||||
'openai/gpt-5.5',
|
||||
'--worktree',
|
||||
'side-task',
|
||||
'--branch',
|
||||
'openchamber/side-task',
|
||||
'--base',
|
||||
'main',
|
||||
'--no-upstream',
|
||||
]);
|
||||
|
||||
expect(parsed.command).toBe('session');
|
||||
expect(parsed.sessionAction).toBe('create');
|
||||
expect(parsed.options.directory).toBe('.');
|
||||
expect(parsed.options.name).toBe('Side task');
|
||||
expect(parsed.options.prompt).toBe('Investigate cache invalidation');
|
||||
expect(parsed.options.model).toBe('openai/gpt-5.5');
|
||||
expect(parsed.options.worktree).toBe('side-task');
|
||||
expect(parsed.options.branch).toBe('openchamber/side-task');
|
||||
expect(parsed.options.startRef).toBe('main');
|
||||
expect(parsed.options.setUpstream).toBe(false);
|
||||
});
|
||||
|
||||
it('parses control help command', () => {
|
||||
const parsed = parseArgs(['control', 'help']);
|
||||
expect(parsed.command).toBe('control');
|
||||
expect(parsed.controlAction).toBe('help');
|
||||
});
|
||||
|
||||
it('builds session create payloads from CLI options', () => {
|
||||
expect(buildSessionCreatePayload({
|
||||
directory: '.',
|
||||
name: 'Side task',
|
||||
prompt: 'Investigate cache invalidation',
|
||||
model: 'openai/gpt-5.5',
|
||||
agent: 'build',
|
||||
worktree: 'side-task',
|
||||
branch: 'openchamber/side-task',
|
||||
startRef: 'main',
|
||||
setUpstream: true,
|
||||
})).toEqual({
|
||||
directory: '.',
|
||||
title: 'Side task',
|
||||
worktree: {
|
||||
name: 'side-task',
|
||||
branchName: 'openchamber/side-task',
|
||||
startRef: 'main',
|
||||
},
|
||||
prompt: 'Investigate cache invalidation',
|
||||
model: 'openai/gpt-5.5',
|
||||
agent: 'build',
|
||||
setUpstream: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows session create prompts without an explicit model', () => {
|
||||
expect(buildSessionCreatePayload({
|
||||
directory: '.',
|
||||
prompt: 'Investigate cache invalidation',
|
||||
})).toEqual({
|
||||
directory: '.',
|
||||
prompt: 'Investigate cache invalidation',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds goal-enabled session create payloads', () => {
|
||||
const parsed = parseArgs([
|
||||
'session',
|
||||
'create',
|
||||
'--dir',
|
||||
'/repo',
|
||||
'--prompt',
|
||||
'Finish and verify the migration',
|
||||
'--goal',
|
||||
'--goal-token-budget',
|
||||
'200000',
|
||||
]);
|
||||
|
||||
expect(buildSessionCreatePayload(parsed.options)).toEqual({
|
||||
directory: '/repo',
|
||||
prompt: 'Finish and verify the migration',
|
||||
goal: true,
|
||||
goalTokenBudget: 200000,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates session goal options before HTTP', () => {
|
||||
expect(() => buildSessionCreatePayload({ directory: '/repo', goal: true })).toThrow('--goal requires --prompt.');
|
||||
expect(() => buildSessionCreatePayload({
|
||||
directory: '/repo',
|
||||
prompt: 'Run',
|
||||
goalTokenBudget: '200000',
|
||||
})).toThrow('--goal-token-budget requires --goal.');
|
||||
for (const value of ['999', '1.5', '100000001', 'nope']) {
|
||||
expect(() => buildSessionCreatePayload({
|
||||
directory: '/repo',
|
||||
prompt: 'Run',
|
||||
goal: true,
|
||||
goalTokenBudget: value,
|
||||
})).toThrow('--goal-token-budget must be an integer from 1000 to 100000000.');
|
||||
}
|
||||
});
|
||||
|
||||
it('parses session list filters', () => {
|
||||
const parsed = parseArgs(['session', 'list', '--dir', '/repo', '--limit', '5']);
|
||||
|
||||
expect(parsed.command).toBe('session');
|
||||
expect(parsed.sessionAction).toBe('list');
|
||||
expect(parsed.options.directory).toBe('/repo');
|
||||
expect(parsed.options.limit).toBe(5);
|
||||
});
|
||||
|
||||
it('parses session status and message options', () => {
|
||||
const status = parseArgs(['session', 'status', '--session', 'ses_123', '--dir', '/repo']);
|
||||
expect(status.sessionAction).toBe('status');
|
||||
expect(status.options.session).toBe('ses_123');
|
||||
expect(status.options.directory).toBe('/repo');
|
||||
|
||||
const messages = parseArgs([
|
||||
'session',
|
||||
'messages',
|
||||
'--session',
|
||||
'ses_123',
|
||||
'--dir',
|
||||
'/repo',
|
||||
'--last',
|
||||
'--role',
|
||||
'assistant',
|
||||
]);
|
||||
expect(messages.sessionAction).toBe('messages');
|
||||
expect(messages.options.last).toBe(true);
|
||||
expect(messages.options.role).toBe('assistant');
|
||||
|
||||
const waiting = parseArgs([
|
||||
'session',
|
||||
'messages',
|
||||
'--session',
|
||||
'ses_123',
|
||||
'--dir',
|
||||
'/repo',
|
||||
'--wait',
|
||||
'--timeout',
|
||||
'30',
|
||||
'--last-assistant',
|
||||
]);
|
||||
expect(waiting.options.wait).toBe(true);
|
||||
expect(waiting.options.timeout).toBe('30');
|
||||
expect(waiting.options.lastAssistant).toBe(true);
|
||||
|
||||
const list = parseArgs(['session', 'list', '--dir', '/repo', '--with-status']);
|
||||
expect(list.options.withStatus).toBe(true);
|
||||
});
|
||||
|
||||
it('parses session send and fork actions', () => {
|
||||
const send = parseArgs([
|
||||
'session', 'send', '--session', 'ses_123', '--dir', '/repo', '--prompt', 'Continue',
|
||||
'--goal', '--wait', '--last-assistant',
|
||||
]);
|
||||
expect(send.sessionAction).toBe('send');
|
||||
expect(send.options).toMatchObject({
|
||||
session: 'ses_123',
|
||||
directory: '/repo',
|
||||
prompt: 'Continue',
|
||||
goal: true,
|
||||
wait: true,
|
||||
lastAssistant: true,
|
||||
});
|
||||
|
||||
const fork = parseArgs([
|
||||
'session', 'fork', '--session', 'ses_123', '--dir', '/repo', '--message', 'msg_123',
|
||||
'--prompt', 'Try another approach',
|
||||
]);
|
||||
expect(fork.sessionAction).toBe('fork');
|
||||
expect(fork.options.message).toBe('msg_123');
|
||||
});
|
||||
|
||||
it('builds session send and fork prompt payloads', () => {
|
||||
expect(buildSessionPromptPayload({
|
||||
session: 'ses_123',
|
||||
directory: '/repo',
|
||||
prompt: 'Continue',
|
||||
model: 'openai/gpt-5.5',
|
||||
agent: 'build',
|
||||
goal: true,
|
||||
goalTokenBudget: '200000',
|
||||
}, 'send')).toEqual({
|
||||
directory: '/repo',
|
||||
prompt: 'Continue',
|
||||
model: 'openai/gpt-5.5',
|
||||
agent: 'build',
|
||||
goal: true,
|
||||
goalTokenBudget: 200000,
|
||||
});
|
||||
expect(buildSessionPromptPayload({
|
||||
session: 'ses_123',
|
||||
directory: '/repo',
|
||||
message: 'msg_123',
|
||||
prompt: 'Try another approach',
|
||||
}, 'fork')).toEqual({
|
||||
directory: '/repo',
|
||||
messageId: 'msg_123',
|
||||
prompt: 'Try another approach',
|
||||
});
|
||||
});
|
||||
|
||||
it('validates session message selectors before HTTP', async () => {
|
||||
await expect(sessionCommand({ session: 'ses_123', directory: '/repo', all: true, last: true }, 'messages'))
|
||||
.rejects.toThrow('--all cannot be combined with --last or --limit.');
|
||||
await expect(sessionCommand({ session: 'ses_123', directory: '/repo', role: 'tool' }, 'messages'))
|
||||
.rejects.toThrow('--role must be one of: all, user, assistant.');
|
||||
await expect(sessionCommand({ session: 'ses_123' }, 'status'))
|
||||
.rejects.toThrow('Missing required --dir.');
|
||||
await expect(sessionCommand({ session: 'ses_123', directory: '/repo', timeout: '30' }, 'messages'))
|
||||
.rejects.toThrow('--timeout requires --wait.');
|
||||
await expect(sessionCommand({ directory: '/repo', lastAssistant: true }, 'create'))
|
||||
.rejects.toThrow('--last-assistant requires --wait for session create.');
|
||||
await expect(sessionCommand({ directory: '/repo', timeout: '30' }, 'create'))
|
||||
.rejects.toThrow('--timeout requires --wait.');
|
||||
await expect(sessionCommand({ session: 'ses_123', directory: '/repo' }, 'send'))
|
||||
.rejects.toThrow('Missing required --prompt.');
|
||||
await expect(sessionCommand({ session: 'ses_123', directory: '/repo', prompt: 'Run', message: 'msg_1' }, 'send'))
|
||||
.rejects.toThrow('--message is only valid for session fork.');
|
||||
await expect(sessionCommand({ session: 'ses_123', directory: '/repo', prompt: 'Run', lastAssistant: true }, 'fork'))
|
||||
.rejects.toThrow('--last-assistant requires --wait for session fork.');
|
||||
});
|
||||
|
||||
it('parses models command', () => {
|
||||
const parsed = parseArgs(['models', '--json']);
|
||||
|
||||
expect(parsed.command).toBe('models');
|
||||
expect(parsed.options.json).toBe(true);
|
||||
});
|
||||
|
||||
it('parses projects command', () => {
|
||||
const parsed = parseArgs(['projects', '--json']);
|
||||
|
||||
expect(parsed.command).toBe('projects');
|
||||
expect(parsed.options.json).toBe(true);
|
||||
});
|
||||
|
||||
it('formats projects compactly', () => {
|
||||
expect(formatProjectLine({
|
||||
id: 'path_repo',
|
||||
label: 'Openchamber',
|
||||
path: '/repo/openchamber',
|
||||
})).toBe('- `Openchamber` — `path_repo` — `/repo/openchamber`');
|
||||
});
|
||||
|
||||
it('formats model defaults and favorites compactly', () => {
|
||||
expect(formatModelsOutput({
|
||||
defaultModel: 'opencode-go/deepseek-v4-flash',
|
||||
defaultAgent: 'build',
|
||||
favoriteModels: [
|
||||
{ providerID: 'openai', modelID: 'gpt-5.5' },
|
||||
{ providerID: 'opencode-go', modelID: 'deepseek-v4-pro' },
|
||||
],
|
||||
recentModels: [
|
||||
{ providerID: 'zai-coding-plan', modelID: 'glm-5.2' },
|
||||
],
|
||||
})).toBe('Default: `opencode-go/deepseek-v4-flash` / `build`\n\nFavorites:\n- `openai/gpt-5.5`\n- `opencode-go/deepseek-v4-pro`\n\nRecent:\n- `zai-coding-plan/glm-5.2`\n');
|
||||
});
|
||||
|
||||
it('formats compact session list lines', () => {
|
||||
expect(formatSessionLine({
|
||||
title: 'Default model smoke test',
|
||||
agent: 'build',
|
||||
directory: '/repo',
|
||||
model: { providerID: 'opencode-go', id: 'deepseek-v4-flash', variant: 'default' },
|
||||
})).toBe('- `Default model smoke test` — `opencode-go/deepseek-v4-flash`, `build` — `/repo`');
|
||||
expect(formatSessionLine({
|
||||
title: 'Working session',
|
||||
agent: 'build',
|
||||
directory: '/repo',
|
||||
model: { providerID: 'openai', id: 'gpt-5.4-mini' },
|
||||
status: { type: 'busy' },
|
||||
})).toContain('status:busy');
|
||||
});
|
||||
|
||||
it('parses tunnel auto-start server options', () => {
|
||||
const parsed = parseArgs(['tunnel', 'start', '--port', '3002', '--api-only', '--lan', '--ui-password', 'secret']);
|
||||
|
||||
@@ -261,6 +617,50 @@ describe('cli args', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('cli API target resolution', () => {
|
||||
it('uses an explicit port without discovery', async () => {
|
||||
await expect(resolveTargetPort(
|
||||
{ explicitPort: true, port: 4567 },
|
||||
{
|
||||
discoverDesktopInstance: async () => { throw new Error('should not discover desktop'); },
|
||||
discoverLifecycleInstances: async () => { throw new Error('should not discover lifecycle'); },
|
||||
},
|
||||
)).resolves.toBe(4567);
|
||||
});
|
||||
|
||||
it('prefers a desktop instance when no port is explicit', async () => {
|
||||
await expect(resolveTargetPort({}, {
|
||||
discoverDesktopInstance: async () => ({ port: 4500 }),
|
||||
discoverLifecycleInstances: async () => [{ port: 3001 }],
|
||||
isServerHealthReady: async () => false,
|
||||
})).resolves.toBe(4500);
|
||||
});
|
||||
|
||||
it('uses the only discovered lifecycle instance', async () => {
|
||||
await expect(resolveTargetPort({}, {
|
||||
discoverDesktopInstance: async () => null,
|
||||
discoverLifecycleInstances: async () => [{ port: 3002 }],
|
||||
isServerHealthReady: async () => false,
|
||||
})).resolves.toBe(3002);
|
||||
});
|
||||
|
||||
it('uses healthy default port when discovery finds no instances', async () => {
|
||||
await expect(resolveTargetPort({}, {
|
||||
discoverDesktopInstance: async () => null,
|
||||
discoverLifecycleInstances: async () => [],
|
||||
isServerHealthReady: async (port) => port === 3000,
|
||||
})).resolves.toBe(3000);
|
||||
});
|
||||
|
||||
it('fails when multiple non-default instances are running', async () => {
|
||||
await expect(resolveTargetPort({}, {
|
||||
discoverDesktopInstance: async () => null,
|
||||
discoverLifecycleInstances: async () => [{ port: 3001 }, { port: 3002 }],
|
||||
isServerHealthReady: async () => false,
|
||||
})).rejects.toThrow('Multiple OpenChamber instances are running');
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-exposed auth validation', () => {
|
||||
it('allows loopback without a UI password', () => {
|
||||
expect(() => assertAuthenticatedNetworkExposure({ host: '127.0.0.1' })).not.toThrow();
|
||||
@@ -333,8 +733,9 @@ describe('compatibility exports', () => {
|
||||
|
||||
it('includes ngrok in fallback tunnel providers when no server is reachable', async () => {
|
||||
await withTempOpenChamberDataDir(async () => {
|
||||
const port = await allocateLoopbackPort();
|
||||
const output = await captureStdout(async () => {
|
||||
await commands.tunnel({ json: true }, 'providers');
|
||||
await commands.tunnel({ json: true, explicitPort: true, port }, 'providers');
|
||||
});
|
||||
|
||||
const body = JSON.parse(output);
|
||||
@@ -368,6 +769,27 @@ describe('compatibility exports', () => {
|
||||
});
|
||||
|
||||
describe('CLI HTTP helpers', () => {
|
||||
it('sends one typed request to the shared control endpoint', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
expect(new URL(String(url)).pathname).toBe('/api/openchamber/control');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({
|
||||
action: 'session.status',
|
||||
input: { sessionId: 'ses_1', directory: '/repo' },
|
||||
});
|
||||
return createMockJsonResponse({ status: 'ok', sessionStatus: { type: 'idle' } });
|
||||
};
|
||||
try {
|
||||
await expect(requestControlAction(45677, 'session.status', {
|
||||
sessionId: 'ses_1',
|
||||
directory: '/repo',
|
||||
})).resolves.toEqual({ status: 'ok', sessionStatus: { type: 'idle' } });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('retries UI-authenticated API requests with the stored instance password', async () => {
|
||||
await withTempOpenChamberDataDir(async () => {
|
||||
const port = 45678;
|
||||
@@ -412,6 +834,74 @@ describe('CLI HTTP helpers', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the stored instance password over a non-explicit env password', async () => {
|
||||
await withTempOpenChamberDataDir(async () => {
|
||||
const port = 45679;
|
||||
fs.writeFileSync(await getInstanceFilePath(port), JSON.stringify({ port, uiPassword: 'stored-secret' }, null, 2));
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
if (String(url).endsWith('/auth/session')) {
|
||||
expect(JSON.parse(options.body)).toEqual({ password: 'stored-secret' });
|
||||
return {
|
||||
ok: true,
|
||||
headers: { getSetCookie: () => ['oc_ui_session=session-token; Path=/; HttpOnly'] },
|
||||
json: async () => ({ authenticated: true }),
|
||||
};
|
||||
}
|
||||
if (options.headers?.Cookie === 'oc_ui_session=session-token') {
|
||||
return createMockJsonResponse({ ok: true });
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: 'UI authentication required', locked: true }),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const { response, body } = await requestJson(port, '/api/openchamber/scheduled-tasks/status', {
|
||||
uiPassword: 'stale-env-secret',
|
||||
explicitUiPassword: false,
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(body).toEqual({ ok: true });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('authenticates desktop-local API requests with the stored client token', async () => {
|
||||
await withTempOpenChamberDataDir(async (dir) => {
|
||||
const port = 57123;
|
||||
fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({
|
||||
desktopLocalPort: port,
|
||||
desktopLocalClientToken: 'oc_client_test',
|
||||
}, null, 2));
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (_url, options = {}) => {
|
||||
if (options.headers?.Authorization === 'Bearer oc_client_test') {
|
||||
return createMockJsonResponse({ ok: true });
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: 'Client authentication required', locked: true }),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const { response, body } = await requestJson(port, '/api/openchamber/scheduled-tasks/status');
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(body).toEqual({ ok: true });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cli entry detection', () => {
|
||||
|
||||
Reference in New Issue
Block a user