Merge branch 'main' into reproduce/issue-1720
Signed-off-by: Mayuresh K <23300+mskadu@users.noreply.github.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import net from 'net';
|
||||
import { fileURLToPath } from 'url';
|
||||
import os from 'os';
|
||||
import crypto from 'crypto';
|
||||
import http2 from 'node:http2';
|
||||
import { createUiAuth } from './lib/ui-auth/ui-auth.js';
|
||||
import { createTunnelAuth } from './lib/opencode/tunnel-auth.js';
|
||||
import { createManagedTunnelConfigRuntime } from './lib/tunnels/managed-config.js';
|
||||
@@ -79,11 +80,13 @@ import { registerNotificationRoutes } from './lib/notifications/routes.js';
|
||||
import { createNotificationEmitterRuntime } from './lib/notifications/emitter-runtime.js';
|
||||
import { createNotificationTriggerRuntime } from './lib/notifications/runtime.js';
|
||||
import { createPushRuntime } from './lib/notifications/push-runtime.js';
|
||||
import { createApnsRuntime } from './lib/notifications/apns-runtime.js';
|
||||
import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js';
|
||||
import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js';
|
||||
import { createProjectConfigRuntime } from './lib/projects/project-config.js';
|
||||
import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js';
|
||||
import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js';
|
||||
import { attachRealtimeProxy } from './lib/realtime-proxy.js';
|
||||
import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
|
||||
import webPush from 'web-push';
|
||||
|
||||
@@ -135,9 +138,14 @@ const SSE_PATH_PREFIXES = [
|
||||
'/api/global/event',
|
||||
'/api/notifications/stream',
|
||||
'/api/openchamber/events',
|
||||
'/api/openchamber/realtime-proxy/sse',
|
||||
];
|
||||
|
||||
function shouldSkipCompression(req, res) {
|
||||
if (process.env.OPENCHAMBER_RUNTIME === 'desktop') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (headerIncludesEventStream(req.headers.accept)) {
|
||||
return true;
|
||||
}
|
||||
@@ -269,6 +277,7 @@ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
||||
: path.join(os.homedir(), '.config', 'openchamber');
|
||||
const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
|
||||
const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json');
|
||||
const APNS_TOKENS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'apns-tokens.json');
|
||||
const REMOTE_CLIENTS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'remote-clients.json');
|
||||
const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-managed-remote-tunnels.json');
|
||||
const CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-named-tunnels.json');
|
||||
@@ -371,12 +380,34 @@ const getOrCreateVapidKeys = (...args) => pushRuntime.getOrCreateVapidKeys(...ar
|
||||
const addOrUpdatePushSubscription = (...args) => pushRuntime.addOrUpdatePushSubscription(...args);
|
||||
const removePushSubscription = (...args) => pushRuntime.removePushSubscription(...args);
|
||||
const sendPushToAllUiSessions = (...args) => pushRuntime.sendPushToAllUiSessions(...args);
|
||||
const updateUiVisibility = (...args) => pushRuntime.updateUiVisibility(...args);
|
||||
// Set once the notification trigger runtime exists (declared later). When a UI
|
||||
// client reports it became visible, reset the native push badge set — the same
|
||||
// moment the device zeroes its icon badge on becomeActive, keeping them in sync.
|
||||
let clearPendingPushBadge = () => {};
|
||||
const updateUiVisibility = (token, visible, platform) => {
|
||||
if (visible === true) clearPendingPushBadge();
|
||||
return pushRuntime.updateUiVisibility(token, visible, platform);
|
||||
};
|
||||
const isAnyUiVisible = (...args) => pushRuntime.isAnyUiVisible(...args);
|
||||
const isAnyInteractiveClientVisible = (...args) => pushRuntime.isAnyInteractiveClientVisible(...args);
|
||||
const isUiVisible = (...args) => pushRuntime.isUiVisible(...args);
|
||||
const ensurePushInitialized = (...args) => pushRuntime.ensurePushInitialized(...args);
|
||||
const setPushInitialized = (...args) => pushRuntime.setPushInitialized(...args);
|
||||
|
||||
const apnsRuntime = createApnsRuntime({
|
||||
fsPromises,
|
||||
path,
|
||||
crypto,
|
||||
http2,
|
||||
APNS_TOKENS_FILE_PATH,
|
||||
readSettingsFromDiskMigrated,
|
||||
writeSettingsToDisk,
|
||||
});
|
||||
|
||||
const addOrUpdateApnsToken = (...args) => apnsRuntime.addOrUpdateApnsToken(...args);
|
||||
const removeApnsToken = (...args) => apnsRuntime.removeApnsToken(...args);
|
||||
const sendApnsToAllUiSessions = (...args) => apnsRuntime.sendApnsToAllUiSessions(...args);
|
||||
|
||||
const TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW = 128;
|
||||
const TERMINAL_INPUT_WS_REBIND_WINDOW_MS = 60 * 1000;
|
||||
const TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS = 15 * 1000;
|
||||
@@ -670,12 +701,15 @@ const notificationTriggerRuntime = createNotificationTriggerRuntime({
|
||||
emitDesktopNotification,
|
||||
broadcastUiNotification,
|
||||
sendPushToAllUiSessions,
|
||||
sendApnsToAllUiSessions,
|
||||
isAnyInteractiveClientVisible,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
});
|
||||
|
||||
const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args);
|
||||
const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args);
|
||||
clearPendingPushBadge = () => notificationTriggerRuntime.clearPendingPushBadge();
|
||||
|
||||
const globalMessageStreamHub = createGlobalMessageStreamHub({
|
||||
buildOpenCodeUrl,
|
||||
@@ -1087,6 +1121,9 @@ async function main(options = {}) {
|
||||
if (typeof options.getIsWindowFocused === 'function') {
|
||||
notificationTriggerRuntime.setGetIsWindowFocused(options.getIsWindowFocused);
|
||||
}
|
||||
const getDesktopRuntimeConfig = typeof options.getDesktopRuntimeConfig === 'function'
|
||||
? options.getDesktopRuntimeConfig
|
||||
: null;
|
||||
|
||||
console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`);
|
||||
|
||||
@@ -1094,7 +1131,13 @@ async function main(options = {}) {
|
||||
|
||||
const app = express();
|
||||
const serverStartedAt = new Date().toISOString();
|
||||
const packagedClientOrigins = new Set(['openchamber-ui://app']);
|
||||
const packagedClientOrigins = new Set([
|
||||
'openchamber-ui://app',
|
||||
'capacitor://localhost',
|
||||
'http://localhost',
|
||||
'https://localhost',
|
||||
]);
|
||||
const isLocalDevClientOrigin = (origin) => /^https?:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin);
|
||||
app.set('trust proxy', true);
|
||||
// Keep self-hosted instances out of search engines. The app shell is served
|
||||
// publicly (it loads before prompting for the UI password), so without this
|
||||
@@ -1109,7 +1152,7 @@ async function main(options = {}) {
|
||||
});
|
||||
app.use((req, res, next) => {
|
||||
const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
|
||||
if (packagedClientOrigins.has(origin)) {
|
||||
if (packagedClientOrigins.has(origin) || isLocalDevClientOrigin(origin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
||||
@@ -1132,6 +1175,7 @@ async function main(options = {}) {
|
||||
}));
|
||||
expressApp = app;
|
||||
server = http.createServer(app);
|
||||
let realtimeProxyRuntime = { stop: () => {} };
|
||||
|
||||
const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, {
|
||||
process,
|
||||
@@ -1183,7 +1227,10 @@ async function main(options = {}) {
|
||||
writeSettingsToDisk,
|
||||
addOrUpdatePushSubscription,
|
||||
removePushSubscription,
|
||||
addOrUpdateApnsToken,
|
||||
removeApnsToken,
|
||||
updateUiVisibility,
|
||||
clearPendingPushBadge: () => clearPendingPushBadge(),
|
||||
isUiVisible,
|
||||
getUiNotificationClients: () => uiNotificationClients,
|
||||
writeSseEvent,
|
||||
@@ -1202,6 +1249,13 @@ async function main(options = {}) {
|
||||
setAutoAcceptSession,
|
||||
});
|
||||
uiAuthController = bootstrapResult.uiAuthController;
|
||||
realtimeProxyRuntime = attachRealtimeProxy({
|
||||
app,
|
||||
server,
|
||||
getDesktopRuntimeConfig,
|
||||
getUiAuthController: () => uiAuthController,
|
||||
isRequestOriginAllowed,
|
||||
});
|
||||
|
||||
const tunnelRuntimeContext = tunnelWiringRuntime.initialize(app, port);
|
||||
const { tunnelService, startTunnelWithNormalizedRequest } = tunnelRuntimeContext;
|
||||
@@ -1327,13 +1381,24 @@ async function main(options = {}) {
|
||||
}),
|
||||
isReady: () => isOpenCodeReady,
|
||||
restartOpenCode: () => restartOpenCode(),
|
||||
getOpenCodeProcessInfo: () => ({
|
||||
managed: Boolean((openCodeProcess || openCodePort) && !ENV_SKIP_OPENCODE_START && !isExternalOpenCode),
|
||||
pid: typeof openCodeProcess?.pid === 'number' ? openCodeProcess.pid : null,
|
||||
port: openCodePort,
|
||||
}),
|
||||
stop: (shutdownOptions = {}) =>
|
||||
gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false })
|
||||
getOpenCodeProcessInfo: () => {
|
||||
const managed = Boolean((openCodeProcess || openCodePort) && !ENV_SKIP_OPENCODE_START && !isExternalOpenCode);
|
||||
// Only ever expose pid/port for a server WE manage. The Electron-side
|
||||
// killer kills by port (lsof + kill -KILL), so returning a port we don't
|
||||
// own — e.g. an external/desktop OpenCode on 4096 we attached to — would
|
||||
// let a single miscomputed `managed` flag take down the user's separate
|
||||
// server. Structurally withhold what isn't ours so the killer has no
|
||||
// target, instead of relying on the flag check alone.
|
||||
return {
|
||||
managed,
|
||||
pid: managed && typeof openCodeProcess?.pid === 'number' ? openCodeProcess.pid : null,
|
||||
port: managed ? openCodePort : null,
|
||||
};
|
||||
},
|
||||
stop: (shutdownOptions = {}) => {
|
||||
realtimeProxyRuntime.stop();
|
||||
return gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function checkCloudflaredAvailable() {
|
||||
return { available: false, path: null, version: null };
|
||||
}
|
||||
|
||||
export function printCloudflareTunnelInstallHelp() {
|
||||
function printCloudflareTunnelInstallHelp() {
|
||||
const platform = process.platform;
|
||||
let installCmd = '';
|
||||
|
||||
@@ -600,7 +600,7 @@ export async function startCloudflareManagedLocalTunnel({ configPath, hostname }
|
||||
};
|
||||
}
|
||||
|
||||
export async function startCloudflareTunnel({ originUrl, port }) {
|
||||
async function startCloudflareTunnel({ originUrl, port }) {
|
||||
void port;
|
||||
return startCloudflareQuickTunnel({ originUrl });
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
|
||||
- The global hub keeps a bounded replay buffer keyed by SSE `eventId` so reconnecting browser clients can receive buffered events after their requested `Last-Event-ID`.
|
||||
- Directory WS clients still attach one upstream `/event?directory=...` SSE reader per connection because directory streams are scoped.
|
||||
- If an upstream SSE stream stalls after the browser WS is already ready, the reader aborts that upstream fetch and reconnects upstream with `Last-Event-ID`, keeping the browser WS alive when recovery is fast.
|
||||
- When the shared global upstream reconnects after it was previously ready, the global WS bridge sends a fresh `ready` frame to already-ready browser clients. The browser treats this as a reconnect edge and can run scoped state repair without requiring the browser WS to close.
|
||||
- Health checks are reserved for initial upstream connect failures and explicit upstream-unavailable responses, not for ordinary stall recovery on an already-established stream.
|
||||
- Global synthetic events such as `openchamber:session-status`, `openchamber:session-activity`, `openchamber:notification`, and `openchamber:heartbeat` are preserved on the WS path, but heartbeat frames are emitted only while an upstream SSE stream is actively attached.
|
||||
- Global UI broadcasts are fan-out capable across both SSE and WS clients.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createUpstreamSseReader } from './upstream-reader.js';
|
||||
|
||||
// Raised from 512 → 2048 to improve recovery after brief disconnects during
|
||||
// long-running agent sessions where many events accumulate quickly.
|
||||
export const MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT = 2048;
|
||||
const MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT = 2048;
|
||||
|
||||
export function createGlobalMessageStreamHub({
|
||||
buildOpenCodeUrl,
|
||||
|
||||
@@ -120,6 +120,17 @@ export function createGlobalMessageStreamWsBridge({
|
||||
for (const socket of Array.from(clients)) {
|
||||
if (!readyClients.has(socket)) {
|
||||
markReady(socket, clientLastEventIds.get(socket) ?? '');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status.wasReady) {
|
||||
const sent = sendMessageStreamWsFrame(socket, {
|
||||
type: 'ready',
|
||||
scope: 'global',
|
||||
});
|
||||
if (!sent) {
|
||||
removeClient(socket);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
export {
|
||||
MESSAGE_STREAM_GLOBAL_WS_PATH,
|
||||
MESSAGE_STREAM_DIRECTORY_WS_PATH,
|
||||
MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
|
||||
parseSseEventEnvelope,
|
||||
sendMessageStreamWsFrame,
|
||||
sendMessageStreamWsEvent,
|
||||
} from './protocol.js';
|
||||
|
||||
export {
|
||||
createGlobalUiEventBroadcaster,
|
||||
createMessageStreamWsRuntime,
|
||||
} from './runtime.js';
|
||||
|
||||
export {
|
||||
MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT,
|
||||
createGlobalMessageStreamHub,
|
||||
} from './global-hub.js';
|
||||
|
||||
export {
|
||||
DEFAULT_UPSTREAM_RECONNECT_DELAY_MS,
|
||||
DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
|
||||
UPSTREAM_STALL_TIMEOUT_CONCURRENT_MS,
|
||||
createUpstreamSseReader,
|
||||
} from './upstream-reader.js';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { parseRequestPathname } from '../terminal/index.js';
|
||||
import { parseRequestPathname } from '../terminal/terminal-ws-protocol.js';
|
||||
import {
|
||||
MESSAGE_STREAM_DIRECTORY_WS_PATH,
|
||||
MESSAGE_STREAM_GLOBAL_WS_PATH,
|
||||
|
||||
@@ -435,7 +435,7 @@ describe('message stream websocket runtime', () => {
|
||||
|
||||
return createSseResponse({
|
||||
signal: options.signal,
|
||||
holdOpen: false,
|
||||
holdOpen: true,
|
||||
blocks: [
|
||||
'id: evt-2\ndata: {"type":"server.connected","properties":{}}\n\n',
|
||||
],
|
||||
@@ -451,7 +451,7 @@ describe('message stream websocket runtime', () => {
|
||||
const readyFrames = socket.sent.filter((frame) => frame.type === 'ready');
|
||||
const eventFrames = socket.sent.filter((frame) => frame.type === 'event' && frame.payload?.type === 'server.connected');
|
||||
|
||||
expect(readyFrames).toHaveLength(1);
|
||||
expect(readyFrames.length).toBeGreaterThanOrEqual(2);
|
||||
expect(eventFrames.length).toBeGreaterThanOrEqual(2);
|
||||
expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']);
|
||||
expect(triggerHealthCheckCalls).toBe(0);
|
||||
|
||||
@@ -883,7 +883,13 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
const download = req.query.download === 'true';
|
||||
if (download) {
|
||||
const fileName = path.basename(canonicalPath);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||
// RFC 5987: use filename*= for non-ASCII filenames, with ASCII-only
|
||||
// filename= as fallback for older clients.
|
||||
const asciiOnly = fileName.replace(/[^\u0000-\u007F]/g, '');
|
||||
const fallback = asciiOnly || 'file';
|
||||
// Percent-encode the raw UTF-8 bytes for filename*=
|
||||
const encoded = encodeURIComponent(fileName);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`);
|
||||
}
|
||||
|
||||
const content = await fsPromises.readFile(canonicalPath);
|
||||
|
||||
@@ -596,3 +596,42 @@ describe('fs exec git-read cache', () => {
|
||||
expect(calls.length).toBe(afterFill + 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fs raw download Content-Disposition', () => {
|
||||
it('uses RFC 5987 filename*= encoding for non-ASCII filenames on download', async () => {
|
||||
const fsPromises = {
|
||||
realpath: vi.fn(async (targetPath) => targetPath),
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
||||
readFile: vi.fn(async () => Buffer.from('content')),
|
||||
};
|
||||
const handler = registerRaw(fsPromises);
|
||||
|
||||
const res = await callRaw(handler, {
|
||||
path: '/repo/文件.txt',
|
||||
download: 'true',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const cd = res.getHeader('content-disposition');
|
||||
expect(cd).toContain("filename*=UTF-8''");
|
||||
expect(cd).toContain(encodeURIComponent('文件.txt'));
|
||||
// ASCII fallback strips non-ASCII chars, leaving extension
|
||||
expect(cd).toContain('filename=".txt"');
|
||||
});
|
||||
|
||||
it('uses plain filename for ASCII-only filenames on download', async () => {
|
||||
const fsPromises = {
|
||||
realpath: vi.fn(async (targetPath) => targetPath),
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
||||
readFile: vi.fn(async () => Buffer.from('content')),
|
||||
};
|
||||
const handler = registerRaw(fsPromises);
|
||||
|
||||
const res = await callRaw(handler, { path: '/repo/readme.txt', download: 'true' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const cd = res.getHeader('content-disposition');
|
||||
expect(cd).toContain('filename="readme.txt"');
|
||||
expect(cd).toContain("filename*=UTF-8''readme.txt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,8 @@ export function createProfile(profileData) {
|
||||
userEmail: profileData.userEmail,
|
||||
authType: profileData.authType || 'ssh',
|
||||
sshKey: profileData.sshKey || null,
|
||||
signCommits: profileData.signCommits,
|
||||
signingKey: profileData.signingKey || null,
|
||||
host: profileData.host || null,
|
||||
color: profileData.color || 'keyword',
|
||||
icon: profileData.icon || 'branch'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const gitLibraries = {
|
||||
stageFiles: mock(),
|
||||
unstageFiles: mock(),
|
||||
stageFiles: vi.fn(),
|
||||
unstageFiles: vi.fn(),
|
||||
};
|
||||
|
||||
mock.module('./index.js', () => ({
|
||||
vi.mock('./index.js', () => ({
|
||||
stageFiles: gitLibraries.stageFiles,
|
||||
unstageFiles: gitLibraries.unstageFiles,
|
||||
}));
|
||||
|
||||
@@ -824,6 +824,19 @@ const isNotGitRepositoryError = (error) => {
|
||||
return /not a git repository/i.test(text);
|
||||
};
|
||||
|
||||
// A directory that no longer exists (e.g. a worktree deleted while something
|
||||
// was still polling its status) is an expected, benign condition — not a fault
|
||||
// to scream about. simple-git throws "Cannot use simple-git on a directory that
|
||||
// does not exist"; the underlying fs errors are ENOENT/ENOTDIR.
|
||||
const isMissingDirectoryError = (error) => {
|
||||
const code = error?.code;
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
return true;
|
||||
}
|
||||
const text = parseGitErrorText(error);
|
||||
return /directory that does not exist|does not exist|no such file or directory/i.test(text);
|
||||
};
|
||||
|
||||
const runGitCommand = async (cwd, args) => {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(getGitBinary(), args, {
|
||||
@@ -1913,6 +1926,12 @@ export async function setLocalIdentity(directory, profile) {
|
||||
await git.raw(['config', '--local', '--unset', 'core.sshCommand']).catch(() => {});
|
||||
}
|
||||
|
||||
if (profile.signCommits === true && typeof profile.signingKey === 'string' && profile.signingKey.trim()) {
|
||||
await git.addConfig('gpg.format', 'ssh', false, 'local');
|
||||
await git.addConfig('user.signingkey', profile.signingKey.trim(), false, 'local');
|
||||
await git.addConfig('commit.gpgsign', 'true', false, 'local');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to set Git identity:', error);
|
||||
@@ -2178,7 +2197,7 @@ export async function getStatus(directory, options = {}) {
|
||||
rebaseInProgress,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isNotGitRepositoryError(error)) {
|
||||
if (!isNotGitRepositoryError(error) && !isMissingDirectoryError(error)) {
|
||||
console.error('Failed to get Git status:', error);
|
||||
}
|
||||
throw error;
|
||||
@@ -3544,6 +3563,19 @@ export async function validateWorktreeCreate(directory, input = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const assertWorktreeCreatePreflight = async (directory, input = {}) => {
|
||||
const validation = await validateWorktreeCreate(directory, input);
|
||||
if (validation?.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = validation?.errors
|
||||
?.map((error) => error?.message)
|
||||
.filter(Boolean)
|
||||
.join('\n') || 'Failed to validate worktree creation';
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
export async function previewWorktreeCreate(directory, input = {}) {
|
||||
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
||||
const context = await resolveWorktreeProjectContext(directory);
|
||||
@@ -3692,6 +3724,11 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
|
||||
export async function createWorktree(directory, input = {}) {
|
||||
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
||||
const context = await resolveWorktreeProjectContext(directory);
|
||||
|
||||
if (input?.returnAfterDirectoryCreated === true) {
|
||||
await assertWorktreeCreatePreflight(directory, input);
|
||||
}
|
||||
|
||||
await fsp.mkdir(context.worktreeRoot, { recursive: true });
|
||||
|
||||
const preferredName = String(input?.worktreeName || input?.name || '').trim();
|
||||
|
||||
@@ -8,6 +8,7 @@ import simpleGit from 'simple-git';
|
||||
import {
|
||||
checkoutCommit,
|
||||
cherryPick,
|
||||
createWorktree,
|
||||
getStatus,
|
||||
removeWorktree,
|
||||
resolvePrimaryWorktreeRoot,
|
||||
@@ -315,6 +316,53 @@ describe('worktree root resolution', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createWorktree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createWorktree', () => {
|
||||
it('preflights fast create branch-in-use failures before creating the candidate directory', async () => {
|
||||
if (!canRunGit()) return;
|
||||
|
||||
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||
const dataHome = createTempDir();
|
||||
process.env.XDG_DATA_HOME = dataHome;
|
||||
|
||||
try {
|
||||
const repo = createTempDir();
|
||||
const worktree = createTempDir();
|
||||
runGit(repo, ['init', '-b', 'main']);
|
||||
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
||||
runGit(repo, ['config', 'user.name', 'Test User']);
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
|
||||
runGit(repo, ['add', 'README.md']);
|
||||
runGit(repo, ['commit', '-m', 'Initial commit']);
|
||||
const projectID = runGit(repo, ['rev-list', '--max-parents=0', '--all']).trim();
|
||||
|
||||
fs.rmSync(worktree, { recursive: true, force: true });
|
||||
runGit(repo, ['worktree', 'add', '-b', 'feature/in-use', worktree, 'HEAD']);
|
||||
const canonicalWorktree = fs.realpathSync(worktree);
|
||||
|
||||
await expect(createWorktree(repo, {
|
||||
mode: 'existing',
|
||||
existingBranch: 'feature/in-use',
|
||||
branchName: 'feature/in-use',
|
||||
worktreeName: 'feature-in-use',
|
||||
returnAfterDirectoryCreated: true,
|
||||
})).rejects.toThrow(`Branch is already checked out in ${canonicalWorktree}`);
|
||||
|
||||
const candidateDirectory = path.join(dataHome, 'opencode', 'worktree', projectID, 'feature-in-use');
|
||||
expect(fs.existsSync(candidateDirectory)).toBe(false);
|
||||
} finally {
|
||||
if (previousXdgDataHome === undefined) {
|
||||
delete process.env.XDG_DATA_HOME;
|
||||
} else {
|
||||
process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// removeWorktree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
|
||||
export {
|
||||
getOctokitOrNull,
|
||||
createOctokit,
|
||||
} from './octokit.js';
|
||||
|
||||
export {
|
||||
|
||||
@@ -2,6 +2,26 @@ import { Octokit } from '@octokit/rest';
|
||||
import { getGitHubAuth, isGhCliActive, isGhCliDisabled } from './auth.js';
|
||||
import { getGhCliToken } from './gh-cli-credential.js';
|
||||
|
||||
// Per-request timeout for every GitHub call. Octokit v22 uses native fetch,
|
||||
// which has no built-in timeout — without this, a stuck connection hangs until
|
||||
// some outer bound (the PR-status route's 12s overall budget) fires, and a
|
||||
// single slow request can eat the whole budget. Bounding each request lets the
|
||||
// caller fail fast and fall back to cached state instead.
|
||||
const OCTOKIT_REQUEST_TIMEOUT_MS = 8000;
|
||||
|
||||
const timeoutFetch = (url, options = {}) => {
|
||||
// Respect a caller-provided signal if present; otherwise attach our timeout.
|
||||
if (options.signal) {
|
||||
return fetch(url, options);
|
||||
}
|
||||
return fetch(url, { ...options, signal: AbortSignal.timeout(OCTOKIT_REQUEST_TIMEOUT_MS) });
|
||||
};
|
||||
|
||||
/** Create an Octokit instance with a per-request timeout applied. */
|
||||
export function createOctokit(token) {
|
||||
return new Octokit({ auth: token, request: { fetch: timeoutFetch } });
|
||||
}
|
||||
|
||||
export function getOctokitOrNull() {
|
||||
const auth = getGitHubAuth();
|
||||
const ghToken = !isGhCliDisabled() ? getGhCliToken() : null;
|
||||
@@ -9,5 +29,5 @@ export function getOctokitOrNull() {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return new Octokit({ auth: token });
|
||||
return createOctokit(token);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { getRemotes, getStatus } from '../git/index.js';
|
||||
import { resolveGitHubRepoFromDirectory } from './repo/index.js';
|
||||
import { noteIfGitHubRateLimit } from './rate-limit.js';
|
||||
|
||||
const directoryExists = async (dir) => {
|
||||
if (!dir) return false;
|
||||
try {
|
||||
await stat(dir);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const REPO_DEFAULT_BRANCH_TTL_MS = 5 * 60_000;
|
||||
const defaultBranchCache = new Map();
|
||||
@@ -160,6 +172,17 @@ const getRepoDefaultBranch = async (octokit, repo) => {
|
||||
return cached.defaultBranch;
|
||||
}
|
||||
|
||||
// Reuse the full repo metadata if it was already fetched (expandRepoNetwork
|
||||
// calls getRepoMetadata for every candidate before the default-branch loop).
|
||||
// This avoids a redundant repos.get per repo — fewer serial GitHub calls means
|
||||
// less exposure to secondary-rate-limiting that makes PR status slow.
|
||||
const metaCached = repoMetadataCache.get(repoKey);
|
||||
if (metaCached && Date.now() - metaCached.fetchedAt < REPO_DEFAULT_BRANCH_TTL_MS) {
|
||||
const defaultBranch = normalizeText(metaCached.data?.default_branch) || null;
|
||||
defaultBranchCache.set(repoKey, { defaultBranch, fetchedAt: Date.now() });
|
||||
return defaultBranch;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await octokit.rest.repos.get({
|
||||
owner: repo.owner,
|
||||
@@ -171,7 +194,8 @@ const getRepoDefaultBranch = async (octokit, repo) => {
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
return defaultBranch;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
noteIfGitHubRateLimit(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -199,6 +223,7 @@ const getRepoMetadata = async (octokit, repo) => {
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
noteIfGitHubRateLimit(error);
|
||||
if (error?.status === 403 || error?.status === 404) {
|
||||
repoMetadataCache.set(repoKey, {
|
||||
data: null,
|
||||
@@ -211,21 +236,26 @@ const getRepoMetadata = async (octokit, repo) => {
|
||||
};
|
||||
|
||||
const resolveRemoteCandidates = async (directory, rankedRemoteNames) => {
|
||||
// Resolve every ranked remote concurrently — they're independent git lookups.
|
||||
// Dedup afterwards in rank order so the result is identical to the previous
|
||||
// sequential pass, just without paying each lookup's latency back-to-back.
|
||||
const resolvedRemotes = await Promise.all(
|
||||
rankedRemoteNames.map((remoteName) =>
|
||||
resolveGitHubRepoFromDirectory(directory, remoteName)
|
||||
.then((resolved) => ({ remoteName, repo: resolved?.repo || null }))
|
||||
.catch(() => ({ remoteName, repo: null })),
|
||||
),
|
||||
);
|
||||
|
||||
const results = [];
|
||||
const seenRepoKeys = new Set();
|
||||
|
||||
for (const remoteName of rankedRemoteNames) {
|
||||
const resolved = await resolveGitHubRepoFromDirectory(directory, remoteName).catch(() => ({ repo: null }));
|
||||
const repo = resolved?.repo || null;
|
||||
for (const { remoteName, repo } of resolvedRemotes) {
|
||||
const repoKey = normalizeRepoKey(repo?.owner, repo?.repo);
|
||||
if (!repo || !repoKey || seenRepoKeys.has(repoKey)) {
|
||||
continue;
|
||||
}
|
||||
seenRepoKeys.add(repoKey);
|
||||
results.push({
|
||||
remoteName,
|
||||
repo,
|
||||
});
|
||||
results.push({ remoteName, repo });
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -244,8 +274,16 @@ const expandRepoNetwork = async (octokit, candidates) => {
|
||||
expanded.push({ repo, remoteName, priority });
|
||||
};
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const metadata = await getRepoMetadata(octokit, candidate.repo);
|
||||
// Fetch repo metadata for all candidates concurrently (independent GET
|
||||
// /repos calls), then fold them in candidate order so dedup/priority is
|
||||
// unchanged from the sequential version.
|
||||
const metadatas = await Promise.all(
|
||||
candidates.map((candidate) =>
|
||||
getRepoMetadata(octokit, candidate.repo).then((metadata) => ({ candidate, metadata })),
|
||||
),
|
||||
);
|
||||
|
||||
for (const { candidate, metadata } of metadatas) {
|
||||
if (!metadata) {
|
||||
continue;
|
||||
}
|
||||
@@ -279,6 +317,7 @@ const safeListPulls = async (octokit, options) => {
|
||||
const response = await octokit.rest.pulls.list(options);
|
||||
return Array.isArray(response?.data) ? response.data : [];
|
||||
} catch (error) {
|
||||
noteIfGitHubRateLimit(error);
|
||||
if (error?.status === 404 || error?.status === 403) {
|
||||
return [];
|
||||
}
|
||||
@@ -334,6 +373,7 @@ const searchFallbackPr = async ({ octokit, branch, repoNames }) => {
|
||||
// If we get here, search API works for this repo — clear the disabled flag
|
||||
_searchApiDisabledRepos.delete(repoKey);
|
||||
} catch (error) {
|
||||
noteIfGitHubRateLimit(error);
|
||||
if (error?.status === 403) {
|
||||
_searchApiDisabledRepos.set(repoKey, Date.now());
|
||||
return null;
|
||||
@@ -424,6 +464,14 @@ const findFirstMatchingPr = async ({ octokit, target, branch, sourceCandidates }
|
||||
};
|
||||
|
||||
export async function resolveGitHubPrStatus({ octokit, directory, branch, remoteName }) {
|
||||
// A deleted worktree can still have a session in the sidebar that keeps
|
||||
// requesting its PR status. Bail before touching git or GitHub for a
|
||||
// directory that no longer exists — otherwise every poll spends a git call
|
||||
// (and the remote/repo resolution that follows) on a path that's gone.
|
||||
if (!(await directoryExists(directory))) {
|
||||
return { repo: null, pr: null, defaultBranch: null, resolvedRemoteName: null };
|
||||
}
|
||||
|
||||
const normalizedBranch = normalizeText(branch);
|
||||
const normalizedRemoteName = normalizeText(remoteName) || 'origin';
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Lightweight, process-global GitHub rate-limit gate.
|
||||
//
|
||||
// Octokit is configured without the throttling plugin, so a primary or
|
||||
// secondary rate limit surfaces as a thrown 403/429. Resolving PR status for
|
||||
// many worktrees fans out dozens of calls; once GitHub starts limiting, every
|
||||
// further call wastes a round-trip and the cache masks the failure. When we
|
||||
// detect a rate-limit response we record a cooldown and skip GitHub work until
|
||||
// it passes, so the burst stops and the reason is visible in the logs.
|
||||
|
||||
const MAX_COOLDOWN_MS = 15 * 60 * 1000;
|
||||
const DEFAULT_COOLDOWN_MS = 60 * 1000;
|
||||
|
||||
let rateLimitedUntil = 0;
|
||||
|
||||
const headerValue = (headers, name) => {
|
||||
if (!headers) return undefined;
|
||||
// Octokit/fetch headers can be a plain object or a Headers instance.
|
||||
if (typeof headers.get === 'function') return headers.get(name);
|
||||
return headers[name];
|
||||
};
|
||||
|
||||
const parseRetryAfterMs = (error) => {
|
||||
const headers = error?.response?.headers;
|
||||
const retryAfter = headerValue(headers, 'retry-after');
|
||||
if (retryAfter !== undefined && retryAfter !== null) {
|
||||
const secs = Number(retryAfter);
|
||||
if (Number.isFinite(secs) && secs > 0) return secs * 1000;
|
||||
}
|
||||
const reset = headerValue(headers, 'x-ratelimit-reset');
|
||||
if (reset !== undefined && reset !== null) {
|
||||
const delta = Number(reset) * 1000 - Date.now();
|
||||
if (Number.isFinite(delta) && delta > 0) return delta;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** True when an Octokit error represents a primary or secondary rate limit. */
|
||||
export const isGitHubRateLimitError = (error) => {
|
||||
const status = error?.status ?? error?.response?.status;
|
||||
if (status === 429) return true;
|
||||
if (status !== 403) return false;
|
||||
const remaining = headerValue(error?.response?.headers, 'x-ratelimit-remaining');
|
||||
if (remaining === '0' || remaining === 0) return true;
|
||||
if (headerValue(error?.response?.headers, 'retry-after') != null) return true;
|
||||
const message = String(error?.message ?? '').toLowerCase();
|
||||
return message.includes('rate limit');
|
||||
};
|
||||
|
||||
/** Record a cooldown after a detected rate-limit response. */
|
||||
export const noteGitHubRateLimit = (error) => {
|
||||
const retryMs = Math.min(parseRetryAfterMs(error) ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS);
|
||||
const until = Date.now() + retryMs;
|
||||
if (until > rateLimitedUntil) {
|
||||
rateLimitedUntil = until;
|
||||
console.warn(`[github] rate limited — pausing GitHub PR status calls for ~${Math.round(retryMs / 1000)}s`);
|
||||
}
|
||||
};
|
||||
|
||||
/** Convenience: note the error if it is a rate-limit error. Returns whether it was. */
|
||||
export const noteIfGitHubRateLimit = (error) => {
|
||||
if (!isGitHubRateLimitError(error)) return false;
|
||||
noteGitHubRateLimit(error);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const isGitHubRateLimited = () => Date.now() < rateLimitedUntil;
|
||||
@@ -1,7 +1,26 @@
|
||||
const PR_STATUS_CACHE_TTL_MS = 90_000;
|
||||
const PR_STATUS_CACHE_MAX_ENTRIES = 200;
|
||||
// Upper bound for resolving a single PR status. resolveGitHubPrStatus makes many
|
||||
// serial GitHub API calls; under GitHub secondary-rate-limiting a single request
|
||||
// can otherwise hang 20s+. We bound it so the route fails fast instead of holding
|
||||
// the response (and a client socket) open — the client keeps its last-known
|
||||
// status on error, and a later poll fills it in.
|
||||
const PR_STATUS_RESOLVE_TIMEOUT_MS = 12_000;
|
||||
const prStatusCache = new Map();
|
||||
|
||||
function withTimeout(promise, timeoutMs, label) {
|
||||
let timer;
|
||||
const timeout = new Promise((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
|
||||
error.code = 'ETIMEDOUT';
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
if (typeof timer.unref === 'function') timer.unref();
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
function getRequestedRepo(req) {
|
||||
const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : '';
|
||||
const repo = typeof req.query?.repo === 'string' ? req.query.repo.trim() : '';
|
||||
@@ -89,8 +108,8 @@ export function registerGitHubRoutes(app) {
|
||||
|
||||
if (ghToken !== null && !ghCliDisabled) {
|
||||
try {
|
||||
const { Octokit } = await import('@octokit/rest');
|
||||
ghCliUser = await getGitHubUserSummary(new Octokit({ auth: ghToken }));
|
||||
const { createOctokit } = await import('./octokit.js');
|
||||
ghCliUser = await getGitHubUserSummary(createOctokit(ghToken));
|
||||
} catch {
|
||||
ghCliUser = null;
|
||||
}
|
||||
@@ -227,8 +246,8 @@ export function registerGitHubRoutes(app) {
|
||||
return res.status(500).json({ error: 'Missing access_token from GitHub' });
|
||||
}
|
||||
|
||||
const { Octokit } = await import('@octokit/rest');
|
||||
const octokit = new Octokit({ auth: accessToken });
|
||||
const { createOctokit } = await import('./octokit.js');
|
||||
const octokit = createOctokit(accessToken);
|
||||
const user = await getGitHubUserSummary(octokit);
|
||||
|
||||
setGitHubAuth({
|
||||
@@ -264,8 +283,8 @@ export function registerGitHubRoutes(app) {
|
||||
return res.status(404).json({ error: 'GitHub CLI account not found' });
|
||||
}
|
||||
|
||||
const { Octokit } = await import('@octokit/rest');
|
||||
const user = await getGitHubUserSummary(new Octokit({ auth: ghToken }));
|
||||
const { createOctokit } = await import('./octokit.js');
|
||||
const user = await getGitHubUserSummary(createOctokit(ghToken));
|
||||
setGhCliActive(true);
|
||||
const accounts = getGitHubAuthAccounts()
|
||||
.map((account) => ({ ...account, current: false }))
|
||||
@@ -300,8 +319,8 @@ export function registerGitHubRoutes(app) {
|
||||
let ghCliUser = null;
|
||||
if (ghToken) {
|
||||
try {
|
||||
const { Octokit } = await import('@octokit/rest');
|
||||
ghCliUser = await getGitHubUserSummary(new Octokit({ auth: ghToken }));
|
||||
const { createOctokit } = await import('./octokit.js');
|
||||
ghCliUser = await getGitHubUserSummary(createOctokit(ghToken));
|
||||
accounts = accounts.concat({
|
||||
id: GH_CLI_ACCOUNT_ID,
|
||||
user: ghCliUser,
|
||||
@@ -400,6 +419,17 @@ export function registerGitHubRoutes(app) {
|
||||
return res.json(cached.data);
|
||||
}
|
||||
|
||||
// If GitHub recently rate-limited us, don't pile on more calls that will
|
||||
// also fail. Serve whatever we last cached (even if stale); otherwise
|
||||
// report a transient failure so the client keeps its last-known status.
|
||||
const { isGitHubRateLimited } = await import('./rate-limit.js');
|
||||
if (isGitHubRateLimited()) {
|
||||
if (cached) {
|
||||
return res.json(cached.data);
|
||||
}
|
||||
return res.status(503).json({ error: 'GitHub rate limited' });
|
||||
}
|
||||
|
||||
// Intercept res.json to cache successful responses before sending
|
||||
// Only caches responses with connected:true — error/edge-case responses are not cached
|
||||
const originalJson = res.json.bind(res);
|
||||
@@ -417,12 +447,16 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { resolveGitHubPrStatus } = await import('./pr-status.js');
|
||||
const resolvedStatus = await resolveGitHubPrStatus({
|
||||
octokit,
|
||||
directory,
|
||||
branch,
|
||||
remoteName: remote,
|
||||
});
|
||||
const resolvedStatus = await withTimeout(
|
||||
resolveGitHubPrStatus({
|
||||
octokit,
|
||||
directory,
|
||||
branch,
|
||||
remoteName: remote,
|
||||
}),
|
||||
PR_STATUS_RESOLVE_TIMEOUT_MS,
|
||||
'resolveGitHubPrStatus',
|
||||
);
|
||||
const searchRepo = resolvedStatus.repo;
|
||||
const first = resolvedStatus.pr;
|
||||
if (!searchRepo) {
|
||||
@@ -554,6 +588,24 @@ export function registerGitHubRoutes(app) {
|
||||
clearGitHubAuth();
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
// Transient failures — a rate limit, or the overall resolve timeout
|
||||
// firing — are expected under heavy load and should not be logged as hard
|
||||
// errors. Record a rate-limit cooldown when applicable, then serve the
|
||||
// last cached status (even if stale) or a 503 so the client keeps its
|
||||
// last-known value instead of clearing the badge.
|
||||
const { noteIfGitHubRateLimit } = await import('./rate-limit.js');
|
||||
const wasRateLimited = noteIfGitHubRateLimit(error);
|
||||
const wasTimeout = error?.code === 'ETIMEDOUT';
|
||||
if (wasRateLimited || wasTimeout) {
|
||||
const dir = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
||||
const br = typeof req.query?.branch === 'string' ? req.query.branch.trim() : '';
|
||||
const rem = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin';
|
||||
const cached = prStatusCache.get(`${dir}::${br}::${rem}`);
|
||||
if (cached) {
|
||||
return res.json(cached.data);
|
||||
}
|
||||
return res.status(503).json({ error: wasRateLimited ? 'GitHub rate limited' : 'GitHub request timed out' });
|
||||
}
|
||||
if (isGitHubResourceUnavailable(error)) {
|
||||
return res.json({
|
||||
connected: true,
|
||||
@@ -982,6 +1034,7 @@ export function registerGitHubRoutes(app) {
|
||||
if (upstream) {
|
||||
try {
|
||||
const { getRemotes } = await import('../git/index.js');
|
||||
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
||||
const remotes = await getRemotes(directory);
|
||||
for (const r of remotes) {
|
||||
if (r?.name) {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# APNs remote push — signed relay mode
|
||||
|
||||
Native iOS background push (notifications even when the app is **suspended or killed**) is
|
||||
delivered via APNs through a **central relay**, so no user configures an Apple key. Each server
|
||||
signs its relay requests with an auto-generated keypair, and tokens are bound to the server that
|
||||
registered them — so a leaked device token alone can't be used to push.
|
||||
|
||||
## How it works
|
||||
|
||||
1. The app registers its APNs device token with **its own server** (`POST /api/push/apns-token`,
|
||||
`useNativePushRegistration`). PWA/desktop never register — only the native Capacitor app.
|
||||
2. The server **binds the token on the relay**: it POSTs `{ token, publicKeyJwk, ts, sig }` to
|
||||
`POST /v1/push/register-token`, signed with its auto-generated ECDSA P-256 key
|
||||
(`getOrCreateRelayKeypair`, persisted in settings like the VAPID keys). The relay records
|
||||
`token → serverId` where `serverId = SHA-256(publicKey)`.
|
||||
3. On a trigger (ready/error/question/permission), the server composes **generic, content-free**
|
||||
text — a fixed scenario title ("Agent response is ready" / "Agent needs your input" / "Agent
|
||||
needs permission" / "Agent hit an error") + the **session name** as the body, no model/project/
|
||||
message content — plus a **`badge`** count (see below) — and POSTs `{ tokens, title, body,
|
||||
badge, env, data:{sessionId}, publicKeyJwk, ts, sig }` to `POST /v1/push/send`
|
||||
(`apns-runtime.js` → `sendViaRelay`). It does **not** gate on UI visibility (see below).
|
||||
4. The **relay** (`openchamber-website/apps/api`, Cloudflare Worker) verifies the signature +
|
||||
`ts` freshness, derives `serverId`, and only delivers to tokens bound to that server. It holds
|
||||
the single project APNs `.p8` key, signs an ES256 JWT with `crypto.subtle`, and sends each
|
||||
token to APNs over HTTP/2, returning per-token results; the server drops tokens flagged `drop`
|
||||
(410 / BadDeviceToken). The relay stores no secret — only `token → serverId` hashes.
|
||||
5. Tapping a push deep-links to its session via the forwarded `sessionId`.
|
||||
|
||||
## Foreground suppression
|
||||
|
||||
APNs is **not** gated on UI visibility. A backgrounded WKWebView can't reliably report "hidden"
|
||||
before iOS suspends it, so a server-side visibility gate dropped background push for short
|
||||
responses. Instead the server always sends, and **iOS** suppresses the foreground banner
|
||||
(`PushNotifications.presentationOptions: []` in `capacitor.config`) — so there is no notification
|
||||
while the app is active, with no race. APNs is the native app's **only** channel; local
|
||||
notifications were removed (a WKWebView can't tell foreground from background — `document.hasFocus()`
|
||||
is unreliable — so they leaked while the app was open). Cloudflare is touched only when a native
|
||||
app with notifications on has a registered token and a trigger fires.
|
||||
|
||||
## App-icon badge
|
||||
|
||||
Each push carries an **absolute** `aps.badge` = the number of **distinct collapse-ids (`tag`)
|
||||
pushed since the app was last foregrounded**. It mirrors the lock-screen banner stack.
|
||||
|
||||
The count is a `Set<tag>` (`pendingPushTags`) in the trigger runtime (`runtime.js`):
|
||||
`toApnsGenericPayload` adds the push `tag` and returns the set size as the badge. We key by **`tag`,
|
||||
not sessionId**, because the tag *is* the banner identity — iOS uses it as `apns-collapse-id`, so
|
||||
same-tag pushes replace one banner while different tags are distinct banners. One session can raise
|
||||
several banners (`ready-<id>`, `question-<id>`, `permission-<requestKey>` are different tags), so
|
||||
counting sessionIds both over- and under-counts the stack; counting tags matches it.
|
||||
|
||||
It is deliberately **not** derived from the live attention snapshot (`needsAttention`/`isViewed`):
|
||||
that machinery drives in-app indicators on *connected* clients, where a backgrounded client stays
|
||||
"viewing" and `needsAttention` is set by a separate `session.status` event that races the push
|
||||
trigger. The set self-clears via `clearPendingPushBadge` on any signal that the user is engaging
|
||||
with the app: the visibility beacon (`updateUiVisibility` wrapper, `visible:true`), **plus** opening
|
||||
a session (`POST /api/sessions/:id/view`) and sending a message (`POST /api/sessions/:id/
|
||||
message-sent`). The latter two need no auth and fire reliably on the native app when it foregrounds,
|
||||
so they are the dependable reset — the visibility beacon alone proved unreliable in WKWebView. This
|
||||
mirrors the device zeroing its icon badge on `sceneDidBecomeActive` (`AppDelegate.swift`), keeping
|
||||
server and device in sync.
|
||||
|
||||
The value flows `runtime.js` (`toApnsGenericPayload`) → `apns-runtime.js` (`sendViaRelay` body /
|
||||
direct-mode `aps.badge`) → relay (`pushSendSchema.badge` → `aps.badge`). It is **not** signed (like
|
||||
`body`/`data`); the relay still only delivers to bound tokens. The set is server-global, so every
|
||||
device token of a server sees the same badge.
|
||||
|
||||
## Modes
|
||||
|
||||
- **Relay (default):** server has no Apple key; `OPENCHAMBER_PUSH_RELAY_URL` defaults to
|
||||
`https://api.openchamber.dev/v1/push/send` (register URL is derived as `…/register-token`).
|
||||
- **Direct (fallback):** set `OPENCHAMBER_PUSH_RELAY_DISABLED=true` + `OPENCHAMBER_APNS_KEY_ID/
|
||||
TEAM_ID/P8` to sign+send from the server itself (HTTP/2 + ES256 JWT); no relay binding needed.
|
||||
|
||||
## Config
|
||||
|
||||
Server (`apns-runtime.js`):
|
||||
- `OPENCHAMBER_PUSH_RELAY_URL` (default the public relay), `OPENCHAMBER_APNS_ENVIRONMENT`
|
||||
(`sandbox` default / `production`). The signing keypair is auto-generated — nothing to set.
|
||||
- Direct fallback: `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8`
|
||||
(or `_P8_PATH`), `OPENCHAMBER_APNS_BUNDLE_ID`, `OPENCHAMBER_PUSH_RELAY_DISABLED=true`.
|
||||
|
||||
Relay (Cloudflare Worker secrets via `wrangler secret put` / GitHub Actions): `APNS_P8`,
|
||||
`APNS_KEY_ID`, `APNS_TEAM_ID`, optional `APNS_BUNDLE_ID` / `APNS_DEFAULT_ENV`. The `push_tokens`
|
||||
binding table is created by `migrations/0002_push_tokens.sql` (applied on deploy).
|
||||
|
||||
## Apple setup (one-time)
|
||||
|
||||
1. Apple **Keys** (not Certificates) → create an **APNs Auth Key** (`.p8`) → Key ID + Team ID;
|
||||
enable **Push Notifications** on App ID `com.openchamber.app`.
|
||||
2. In the **openchamber-website** repo → Actions secrets: `APNS_P8` (PEM), `APNS_KEY_ID`,
|
||||
`APNS_TEAM_ID`. Push to `main` → relay deploys, secrets sync, D1 migrations apply.
|
||||
3. Xcode: confirm the Push Notifications capability; Clean Build Folder; run on device.
|
||||
|
||||
## Security posture
|
||||
|
||||
- The device token is a per-install secret, but no longer the *only* defence: every relay request
|
||||
is signed by the server's private key, and the relay only delivers to a token from its bound
|
||||
`serverId`. A leaked token alone is useless — an attacker has neither the private key nor a
|
||||
matching binding.
|
||||
- `serverId` self-certifies (`SHA-256(publicKey)`), so the relay holds no secret; a D1 leak
|
||||
exposes only `token → serverId` hashes. The signed `ts` (±5 min window) blocks replay.
|
||||
- Residual: trust-on-first-bind (whoever registers a token first owns it) — acceptable, since
|
||||
registering already requires possessing the token. Cloudflare rate limiting is defence-in-depth.
|
||||
|
||||
## Data confidentiality (what the relay / Apple can see)
|
||||
|
||||
The push payload is **not** application-encrypted, so there is no decryption step. The text is
|
||||
sent in plaintext, protected only by **TLS in transit** (HTTPS to the relay, TLS from the relay
|
||||
to APNs). The request **signature is authentication, not encryption** — the relay *verifies* it
|
||||
(valid / invalid), it does not hide anything.
|
||||
|
||||
Who can read the alert text:
|
||||
|
||||
- **Network hops:** nothing (TLS).
|
||||
- **The relay (Cloudflare):** the generic title + body (session name), the device token, and
|
||||
`sessionId`. It stores only `token → serverId` hashes (no text, no payload).
|
||||
- **Apple APNs:** the alert text too — APNs always reads the alert payload of an `alert` push.
|
||||
- **The device:** displays it.
|
||||
|
||||
This is acceptable **because the text is deliberately content-free**: a fixed scenario title +
|
||||
the session name only — no model, project, or message content (`runtime.js` →
|
||||
`toApnsGenericPayload`). The session name is the single semi-personal field that crosses the
|
||||
relay/Apple. To hide even that from Apple would require an end-to-end **encrypted payload**
|
||||
(`mutable-content` + a Notification Service Extension that decrypts on-device with a key never
|
||||
sent to the relay) — not implemented, and unnecessary for generic text.
|
||||
|
||||
## Android (FCM) note
|
||||
|
||||
The Android equivalent is **FCM** (not implemented): the same relay would forward to FCM with a
|
||||
server key, and the client would register an FCM token (same store/routes + signing).
|
||||
@@ -7,6 +7,7 @@ This module provides notification message preparation utilities for the web serv
|
||||
- `packages/web/server/lib/notifications/index.js`: public entrypoint imported by `packages/web/server/index.js`.
|
||||
- `packages/web/server/lib/notifications/routes.js`: route registration for push, visibility, and session status/attention endpoints.
|
||||
- `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime.
|
||||
- `packages/web/server/lib/notifications/apns-runtime.js`: native iOS APNs device-token persistence + delivery. Two modes: **relay** (default — sign + POST tokens + generic text to the central Cloudflare relay `https://api.openchamber.dev/v1/push/send`, which holds the single project APNs key) and **direct** (fallback — sign ES256 JWT with Node crypto + HTTP/2, when `OPENCHAMBER_PUSH_RELAY_DISABLED=true`). Each server has an auto-generated ECDSA P-256 keypair (`getOrCreateRelayKeypair`, persisted in settings); it binds tokens on the relay (`/v1/push/register-token`) and signs every relay request, so the relay only delivers to tokens bound to that server. APNs is the native app's sole notification channel (no local notifications) and is NOT gated on UI visibility — iOS suppresses the foreground banner instead. Mobile push carries only generic text (scenario title + session name) — see `APNS.md`.
|
||||
- `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime.
|
||||
- `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout.
|
||||
- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only.
|
||||
@@ -24,6 +25,8 @@ This module provides notification message preparation utilities for the web serv
|
||||
- `GET /api/push/vapid-public-key`
|
||||
- `POST /api/push/subscribe`
|
||||
- `DELETE /api/push/subscribe`
|
||||
- `POST /api/push/apns-token` (native iOS APNs device-token registration)
|
||||
- `DELETE /api/push/apns-token`
|
||||
- `POST /api/push/visibility`
|
||||
- `GET /api/push/visibility`
|
||||
- `GET /api/notifications/stream`
|
||||
@@ -61,6 +64,16 @@ This module provides notification message preparation utilities for the web serv
|
||||
- `isAnyUiVisible()`
|
||||
- `isUiVisible(token)`
|
||||
|
||||
### APNs runtime API (apns-runtime.js)
|
||||
- `createApnsRuntime(dependencies)`: creates runtime for native iOS APNs push and device-token state. Dependencies: `fsPromises`, `path`, `crypto`, `http2`, `APNS_TOKENS_FILE_PATH`, `readSettingsFromDiskMigrated`, `writeSettingsToDisk` (persists the auto-generated relay signing keypair).
|
||||
- Returned API:
|
||||
- `addOrUpdateApnsToken(uiSessionToken, deviceToken, userAgent)` — also binds a newly-seen token on the relay (signed `/v1/push/register-token`).
|
||||
- `removeApnsToken(uiSessionToken, deviceToken)`
|
||||
- `removeApnsTokenFromAllSessions(deviceToken)`
|
||||
- `sendApnsToAllUiSessions(payload)` — signs + sends to all registered tokens (no UI-visibility gate; iOS suppresses the foreground banner). No-ops with a single warning when APNs is unconfigured. Drops tokens on `410` / `BadDeviceToken` / `Unregistered`.
|
||||
- `resolveApnsConfig()`
|
||||
- Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.openchamber.app`), `OPENCHAMBER_APNS_ENVIRONMENT` (`sandbox` default, or `production`).
|
||||
|
||||
### Emitter runtime API (emitter-runtime.js)
|
||||
- `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels.
|
||||
- Returned API:
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
// APNs (Apple Push Notification service) runtime for the native iOS mobile app.
|
||||
//
|
||||
// Device tokens are persisted per UI session (mirrors push-runtime.js). Delivery has two
|
||||
// modes, chosen at send time:
|
||||
// - Relay (default): POST tokens + generic text to the central Cloudflare relay, which
|
||||
// holds the single project APNs key and signs+sends — so users configure nothing.
|
||||
// - Direct (fallback): sign an ES256 JWT with Node crypto and send over HTTP/2 ourselves,
|
||||
// for self-hosters who set OPENCHAMBER_APNS_* and OPENCHAMBER_PUSH_RELAY_DISABLED=true.
|
||||
// Wired into the same trigger fanout as web push (see runtime.js); the relay carries only
|
||||
// generic, model-based text (no session content) — see APNS.md.
|
||||
|
||||
const APNS_TOKENS_VERSION = 1;
|
||||
const APNS_HOST_PRODUCTION = 'https://api.push.apple.com';
|
||||
const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com';
|
||||
// APNs rejects auth tokens older than 1h; refresh well inside that window.
|
||||
const JWT_TTL_MS = 50 * 60 * 1000;
|
||||
const DEFAULT_BUNDLE_ID = 'com.openchamber.app';
|
||||
const DEFAULT_RELAY_URL = 'https://api.openchamber.dev/v1/push/send';
|
||||
const MAX_TOKENS_PER_SESSION = 10;
|
||||
// APNs reasons that mean the token is permanently invalid → drop it.
|
||||
const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
|
||||
|
||||
const trimmedEnv = (name) => {
|
||||
const value = process.env[name];
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
};
|
||||
|
||||
// Env vars commonly store the .p8 with literal "\n" sequences; restore real newlines.
|
||||
const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/g, '\n').trim() : '');
|
||||
|
||||
export const createApnsRuntime = (deps) => {
|
||||
const {
|
||||
fsPromises,
|
||||
path,
|
||||
crypto,
|
||||
http2,
|
||||
APNS_TOKENS_FILE_PATH,
|
||||
readSettingsFromDiskMigrated,
|
||||
writeSettingsToDisk,
|
||||
} = deps;
|
||||
|
||||
let persistLock = Promise.resolve();
|
||||
let cachedJwt = null; // { token, issuedAtMs, keyId }
|
||||
let cachedRelayKey = null; // { privateKey, publicJwk }
|
||||
let warnedUnconfigured = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-server relay signing identity (ECDSA P-256). Auto-generated + persisted in settings
|
||||
// (mirrors getOrCreateVapidKeys). The relay derives serverId = SHA-256(publicKey), verifies
|
||||
// each request's signature, and only delivers to tokens this server registered — so a leaked
|
||||
// device token alone can't be used to push. Zero-config: the keypair generates on first use.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getOrCreateRelayKeypair = async () => {
|
||||
if (cachedRelayKey) return cachedRelayKey;
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const existing = settings?.relaySigningKey;
|
||||
if (existing && existing.privateJwk && existing.publicJwk) {
|
||||
cachedRelayKey = {
|
||||
privateKey: crypto.createPrivateKey({ key: existing.privateJwk, format: 'jwk' }),
|
||||
publicJwk: existing.publicJwk,
|
||||
};
|
||||
return cachedRelayKey;
|
||||
}
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const privateJwk = privateKey.export({ format: 'jwk' });
|
||||
const publicJwk = publicKey.export({ format: 'jwk' });
|
||||
await writeSettingsToDisk({ ...settings, relaySigningKey: { privateJwk, publicJwk } });
|
||||
cachedRelayKey = { privateKey, publicJwk };
|
||||
return cachedRelayKey;
|
||||
};
|
||||
|
||||
const signRelayMessage = (privateKey, message) =>
|
||||
crypto.sign('SHA256', Buffer.from(message), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url');
|
||||
|
||||
// Trim to the 4 fields the relay's schema accepts (and that feed the serverId hash).
|
||||
const relayPublicJwk = (publicJwk) => ({
|
||||
kty: publicJwk.kty,
|
||||
crv: publicJwk.crv,
|
||||
x: publicJwk.x,
|
||||
y: publicJwk.y,
|
||||
});
|
||||
|
||||
const registerTokenWithRelay = async (token, platform = 'ios') => {
|
||||
const relay = resolveRelayConfig();
|
||||
if (!relay) return; // direct mode — no relay binding needed
|
||||
try {
|
||||
const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
|
||||
const ts = Date.now();
|
||||
// platform is part of the signed message so it can't be tampered en route.
|
||||
const sig = signRelayMessage(privateKey, `${ts}.${token}.${platform}`);
|
||||
const res = await fetch(relay.registerUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ token, platform, publicKeyJwk: relayPublicJwk(publicJwk), ts, sig }),
|
||||
});
|
||||
if (!res.ok) console.warn(`[Push relay] register-token failed status=${res.status}`);
|
||||
} catch (error) {
|
||||
console.warn('[Push relay] register-token request failed:', error?.message ?? error);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token persistence (same shape + write-lock pattern as push-runtime.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const emptyStore = () => ({ version: APNS_TOKENS_VERSION, tokensBySession: {} });
|
||||
|
||||
const readTokensFromDisk = async () => {
|
||||
try {
|
||||
const raw = await fsPromises.readFile(APNS_TOKENS_FILE_PATH, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || parsed.version !== APNS_TOKENS_VERSION) {
|
||||
return emptyStore();
|
||||
}
|
||||
const tokensBySession =
|
||||
parsed.tokensBySession && typeof parsed.tokensBySession === 'object' ? parsed.tokensBySession : {};
|
||||
return { version: APNS_TOKENS_VERSION, tokensBySession };
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return emptyStore();
|
||||
}
|
||||
console.warn('Failed to read APNs tokens file:', error);
|
||||
return emptyStore();
|
||||
}
|
||||
};
|
||||
|
||||
const writeTokensToDisk = async (data) => {
|
||||
await fsPromises.mkdir(path.dirname(APNS_TOKENS_FILE_PATH), { recursive: true });
|
||||
await fsPromises.writeFile(APNS_TOKENS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
const persistTokenUpdate = async (mutate) => {
|
||||
persistLock = persistLock.then(async () => {
|
||||
const current = await readTokensFromDisk();
|
||||
const next = mutate({ version: APNS_TOKENS_VERSION, tokensBySession: current.tokensBySession || {} });
|
||||
await writeTokensToDisk(next);
|
||||
return next;
|
||||
});
|
||||
return persistLock;
|
||||
};
|
||||
|
||||
const normalizeTokens = (record) => {
|
||||
if (!Array.isArray(record)) return [];
|
||||
return record
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const deviceToken = entry.deviceToken;
|
||||
if (typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return null;
|
||||
return {
|
||||
deviceToken: deviceToken.trim(),
|
||||
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
|
||||
lastSeenAt: typeof entry.lastSeenAt === 'number' ? entry.lastSeenAt : null,
|
||||
userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined,
|
||||
// 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default.
|
||||
platform: entry.platform === 'android' ? 'android' : 'ios',
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
// Normalize an incoming platform hint to the two we support; default to APNs/iOS since that
|
||||
// was the only registrant before Android/FCM existed.
|
||||
const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios');
|
||||
|
||||
const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform) => {
|
||||
if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return;
|
||||
const token = deviceToken.trim();
|
||||
const tokenPlatform = normalizePlatform(platform);
|
||||
const now = Date.now();
|
||||
|
||||
await persistTokenUpdate((current) => {
|
||||
const tokensBySession = { ...(current.tokensBySession || {}) };
|
||||
const existing = normalizeTokens(tokensBySession[uiSessionToken]);
|
||||
const filtered = existing.filter((entry) => entry.deviceToken !== token);
|
||||
filtered.unshift({
|
||||
deviceToken: token,
|
||||
createdAt: now,
|
||||
lastSeenAt: now,
|
||||
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
|
||||
platform: tokenPlatform,
|
||||
});
|
||||
tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION);
|
||||
return { version: APNS_TOKENS_VERSION, tokensBySession };
|
||||
});
|
||||
|
||||
// (Re)bind this token to our server on the relay so only we can push to it. The device
|
||||
// re-sends its token on each launch; this is an idempotent upsert relay-side, and binding
|
||||
// every time (not just for new tokens) keeps existing tokens bound after a relay/server
|
||||
// upgrade rather than silently going unbound. Platform is bound too so the relay routes
|
||||
// it to APNs vs FCM.
|
||||
await registerTokenWithRelay(token, tokenPlatform);
|
||||
};
|
||||
|
||||
const removeApnsToken = async (uiSessionToken, deviceToken) => {
|
||||
if (!uiSessionToken || !deviceToken) return;
|
||||
await persistTokenUpdate((current) => {
|
||||
const tokensBySession = { ...(current.tokensBySession || {}) };
|
||||
const filtered = normalizeTokens(tokensBySession[uiSessionToken]).filter(
|
||||
(entry) => entry.deviceToken !== deviceToken,
|
||||
);
|
||||
if (filtered.length === 0) delete tokensBySession[uiSessionToken];
|
||||
else tokensBySession[uiSessionToken] = filtered;
|
||||
return { version: APNS_TOKENS_VERSION, tokensBySession };
|
||||
});
|
||||
};
|
||||
|
||||
const removeApnsTokenFromAllSessions = async (deviceToken) => {
|
||||
if (!deviceToken) return;
|
||||
await persistTokenUpdate((current) => {
|
||||
const tokensBySession = { ...(current.tokensBySession || {}) };
|
||||
for (const [session, entries] of Object.entries(tokensBySession)) {
|
||||
const filtered = normalizeTokens(entries).filter((entry) => entry.deviceToken !== deviceToken);
|
||||
if (filtered.length === 0) delete tokensBySession[session];
|
||||
else tokensBySession[session] = filtered;
|
||||
}
|
||||
return { version: APNS_TOKENS_VERSION, tokensBySession };
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config (env first, then settings.apnsConfig) — mirrors resolveVapidSubject
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resolveApnsConfig = async () => {
|
||||
let keyId = trimmedEnv('OPENCHAMBER_APNS_KEY_ID');
|
||||
let teamId = trimmedEnv('OPENCHAMBER_APNS_TEAM_ID');
|
||||
let bundleId = trimmedEnv('OPENCHAMBER_APNS_BUNDLE_ID');
|
||||
let environment = (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || '').toLowerCase();
|
||||
let p8 = normalizePem(process.env.OPENCHAMBER_APNS_P8 || '');
|
||||
|
||||
const p8Path = trimmedEnv('OPENCHAMBER_APNS_P8_PATH');
|
||||
if (!p8 && p8Path) {
|
||||
try {
|
||||
p8 = (await fsPromises.readFile(p8Path, 'utf8')).trim();
|
||||
} catch (error) {
|
||||
console.warn('[APNs] Failed to read OPENCHAMBER_APNS_P8_PATH:', error?.message ?? error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!keyId || !teamId || !p8) {
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const stored = settings?.apnsConfig;
|
||||
if (stored && typeof stored === 'object') {
|
||||
keyId = keyId || (typeof stored.keyId === 'string' ? stored.keyId.trim() : null);
|
||||
teamId = teamId || (typeof stored.teamId === 'string' ? stored.teamId.trim() : null);
|
||||
bundleId = bundleId || (typeof stored.bundleId === 'string' ? stored.bundleId.trim() : null);
|
||||
environment = environment || (typeof stored.environment === 'string' ? stored.environment.toLowerCase() : '');
|
||||
if (!p8 && typeof stored.p8 === 'string') p8 = normalizePem(stored.p8);
|
||||
}
|
||||
} catch {
|
||||
// settings unavailable — fall through to the unconfigured result
|
||||
}
|
||||
}
|
||||
|
||||
if (!keyId || !teamId || !p8) return null;
|
||||
|
||||
return {
|
||||
keyId,
|
||||
teamId,
|
||||
p8,
|
||||
bundleId: bundleId || DEFAULT_BUNDLE_ID,
|
||||
environment: environment === 'production' ? 'production' : 'sandbox',
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWT (ES256, JOSE/raw signature) + HTTP/2 send
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const signApnsJwt = (config) => {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: config.keyId })).toString('base64url');
|
||||
const claims = Buffer.from(
|
||||
JSON.stringify({ iss: config.teamId, iat: Math.floor(Date.now() / 1000) }),
|
||||
).toString('base64url');
|
||||
const signingInput = `${header}.${claims}`;
|
||||
const signature = crypto
|
||||
.sign('sha256', Buffer.from(signingInput), { key: config.p8, dsaEncoding: 'ieee-p1363' })
|
||||
.toString('base64url');
|
||||
return `${signingInput}.${signature}`;
|
||||
};
|
||||
|
||||
const getJwt = (config) => {
|
||||
const now = Date.now();
|
||||
if (cachedJwt && cachedJwt.keyId === config.keyId && now - cachedJwt.issuedAtMs < JWT_TTL_MS) {
|
||||
return cachedJwt.token;
|
||||
}
|
||||
const token = signApnsJwt(config);
|
||||
cachedJwt = { token, issuedAtMs: now, keyId: config.keyId };
|
||||
return token;
|
||||
};
|
||||
|
||||
const buildBody = (payload) => {
|
||||
const data = payload && typeof payload.data === 'object' && payload.data ? payload.data : {};
|
||||
return JSON.stringify({
|
||||
aps: {
|
||||
alert: {
|
||||
title: typeof payload?.title === 'string' ? payload.title : undefined,
|
||||
body: typeof payload?.body === 'string' ? payload.body : undefined,
|
||||
},
|
||||
badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
|
||||
sound: 'default',
|
||||
'thread-id': typeof payload?.tag === 'string' ? payload.tag : undefined,
|
||||
// Wakes the Notification Service Extension so it can refresh the home/lock-screen
|
||||
// widgets (attention count + unread dot) from the push, even when the app is closed.
|
||||
// No extra network call — just an extra key on the push we already send.
|
||||
'mutable-content': 1,
|
||||
},
|
||||
...data,
|
||||
});
|
||||
};
|
||||
|
||||
const sendOne = (client, deviceToken, body, jwt, config) =>
|
||||
new Promise((resolve) => {
|
||||
const headers = {
|
||||
':method': 'POST',
|
||||
':path': `/3/device/${deviceToken}`,
|
||||
authorization: `bearer ${jwt}`,
|
||||
'apns-topic': config.bundleId,
|
||||
'apns-push-type': 'alert',
|
||||
'apns-priority': '10',
|
||||
};
|
||||
// collapse-id dedups like web-push tags; APNs caps it at 64 bytes.
|
||||
const collapseId = typeof config.tag === 'string' ? config.tag.slice(0, 64) : undefined;
|
||||
if (collapseId) headers['apns-collapse-id'] = collapseId;
|
||||
|
||||
let req;
|
||||
try {
|
||||
req = client.request(headers);
|
||||
} catch (error) {
|
||||
console.warn('[APNs] request open failed:', error?.message ?? error);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let status = 0;
|
||||
let responseBody = '';
|
||||
req.on('response', (resHeaders) => {
|
||||
status = Number(resHeaders[':status']) || 0;
|
||||
});
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => {
|
||||
responseBody += chunk;
|
||||
});
|
||||
req.on('end', async () => {
|
||||
if (status === 200) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
let reason = '';
|
||||
try {
|
||||
reason = JSON.parse(responseBody)?.reason || '';
|
||||
} catch {
|
||||
// non-JSON error body
|
||||
}
|
||||
if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {
|
||||
await removeApnsTokenFromAllSessions(deviceToken);
|
||||
} else {
|
||||
console.warn(`[APNs] push failed status=${status} reason=${reason || 'unknown'}`);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
req.on('error', (error) => {
|
||||
console.warn('[APNs] request error:', error?.message ?? error);
|
||||
resolve();
|
||||
});
|
||||
req.end(body);
|
||||
});
|
||||
|
||||
// Relay mode (default): the single APNs key lives in the central Cloudflare relay, not on
|
||||
// each user's server — so users configure nothing. The server just POSTs device tokens +
|
||||
// generic text; the relay signs + sends and reports which tokens to drop. Direct mode (below)
|
||||
// is the fallback for self-hosters who set OPENCHAMBER_APNS_* and disable the relay.
|
||||
const resolveRelayConfig = () => {
|
||||
if (trimmedEnv('OPENCHAMBER_PUSH_RELAY_DISABLED') === 'true') return null;
|
||||
const url = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL') || DEFAULT_RELAY_URL;
|
||||
return {
|
||||
url,
|
||||
registerUrl: url.replace(/\/send$/, '/register-token'),
|
||||
environment:
|
||||
(trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production'
|
||||
? 'production'
|
||||
: 'sandbox',
|
||||
};
|
||||
};
|
||||
|
||||
const sendViaRelay = async (deviceTokens, payload, relay) => {
|
||||
const tokens = deviceTokens.slice(0, 100);
|
||||
const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber';
|
||||
const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
|
||||
const ts = Date.now();
|
||||
// Sign over the same canonical form the relay verifies: ts.sortedTokens.title.
|
||||
const sig = signRelayMessage(privateKey, `${ts}.${[...tokens].sort().join(',')}.${title}`);
|
||||
const requestBody = JSON.stringify({
|
||||
tokens,
|
||||
title,
|
||||
body: typeof payload?.body === 'string' ? payload.body : '',
|
||||
badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
|
||||
collapseId: typeof payload?.tag === 'string' ? payload.tag.slice(0, 64) : undefined,
|
||||
env: relay.environment,
|
||||
data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined,
|
||||
publicKeyJwk: relayPublicJwk(publicJwk),
|
||||
ts,
|
||||
sig,
|
||||
});
|
||||
try {
|
||||
const res = await fetch(relay.url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: requestBody,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[APNs relay] send failed status=${res.status}`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json().catch(() => null);
|
||||
const results = Array.isArray(data?.results) ? data.results : [];
|
||||
for (const result of results) {
|
||||
if (result && result.drop === true && typeof result.token === 'string') {
|
||||
await removeApnsTokenFromAllSessions(result.token);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[APNs relay] request failed:', error?.message ?? error);
|
||||
}
|
||||
};
|
||||
|
||||
const sendViaDirectApns = async (deviceTokens, payload) => {
|
||||
const config = await resolveApnsConfig();
|
||||
if (!config) {
|
||||
if (!warnedUnconfigured) {
|
||||
warnedUnconfigured = true;
|
||||
console.warn(
|
||||
'[APNs] Relay disabled and no direct config; set OPENCHAMBER_APNS_KEY_ID / OPENCHAMBER_APNS_TEAM_ID / OPENCHAMBER_APNS_P8 for direct send.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const host = config.environment === 'production' ? APNS_HOST_PRODUCTION : APNS_HOST_SANDBOX;
|
||||
const jwt = getJwt(config);
|
||||
const body = buildBody(payload);
|
||||
const sendConfig = { ...config, tag: typeof payload?.tag === 'string' ? payload.tag : undefined };
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = http2.connect(host);
|
||||
} catch (error) {
|
||||
console.warn('[APNs] connect failed:', error?.message ?? error);
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
client.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
client.on('error', (error) => {
|
||||
console.warn('[APNs] session error:', error?.message ?? error);
|
||||
finish();
|
||||
});
|
||||
Promise.all(
|
||||
deviceTokens.map((token) => sendOne(client, token, body, jwt, sendConfig)),
|
||||
).finally(finish);
|
||||
});
|
||||
};
|
||||
|
||||
// NOT gated on UI visibility (unlike web push). A backgrounded WKWebView can't reliably
|
||||
// report "hidden" before iOS suspends it, so a visibility gate wrongly suppressed
|
||||
// background push for short responses. Instead we always send, and rely on iOS to NOT
|
||||
// display the alert while the app is foreground (presentationOptions: [] in
|
||||
// capacitor.config) — so there is no notification when the app is active, with no race.
|
||||
const sendApnsToAllUiSessions = async (payload, _options = {}) => {
|
||||
const store = await readTokensFromDisk();
|
||||
const deviceTokens = [];
|
||||
const seen = new Set();
|
||||
for (const record of Object.values(store.tokensBySession || {})) {
|
||||
for (const entry of normalizeTokens(record)) {
|
||||
if (!seen.has(entry.deviceToken)) {
|
||||
seen.add(entry.deviceToken);
|
||||
deviceTokens.push(entry.deviceToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (deviceTokens.length === 0) return;
|
||||
|
||||
const relay = resolveRelayConfig();
|
||||
if (relay) {
|
||||
await sendViaRelay(deviceTokens, payload, relay);
|
||||
return;
|
||||
}
|
||||
await sendViaDirectApns(deviceTokens, payload);
|
||||
};
|
||||
|
||||
return {
|
||||
addOrUpdateApnsToken,
|
||||
removeApnsToken,
|
||||
removeApnsTokenFromAllSessions,
|
||||
sendApnsToAllUiSessions,
|
||||
resolveApnsConfig,
|
||||
// exposed for tests
|
||||
signApnsJwt,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createApnsRuntime } from './apns-runtime.js';
|
||||
|
||||
// A real P-256 key so the ES256 signing path (direct mode) runs for real.
|
||||
const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
||||
const APNS_CONFIG = { keyId: 'KEY123', teamId: 'TEAM123', p8: P8, bundleId: 'com.openchamber.app', environment: 'sandbox' };
|
||||
|
||||
// In-memory fs so add-then-read reflects within a test.
|
||||
const createMemoryFs = () => {
|
||||
let content = null;
|
||||
return {
|
||||
mkdir: vi.fn(async () => {}),
|
||||
readFile: vi.fn(async () => {
|
||||
if (content == null) {
|
||||
const err = new Error('ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
return content;
|
||||
}),
|
||||
writeFile: vi.fn(async (_path, data) => {
|
||||
content = data;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const makeDeps = (overrides = {}) => {
|
||||
// Stateful settings so the auto-generated relay signing keypair persists + reads back.
|
||||
let settings = {};
|
||||
return {
|
||||
fsPromises: createMemoryFs(),
|
||||
path: { dirname: () => '/tmp' },
|
||||
crypto,
|
||||
http2: { connect: vi.fn(() => { throw new Error('http2 must not be used in relay mode'); }) },
|
||||
APNS_TOKENS_FILE_PATH: '/tmp/apns-tokens.json',
|
||||
readSettingsFromDiskMigrated: vi.fn(async () => settings),
|
||||
writeSettingsToDisk: vi.fn(async (next) => { settings = next; }),
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
const jsonResponse = (data, status = 200) =>
|
||||
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
// Mirror of the relay's verifier (crypto.subtle), to prove the server's signatures are valid.
|
||||
const verifyRelaySignature = async (publicKeyJwk, message, sigB64Url) => {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'jwk',
|
||||
{ kty: publicKeyJwk.kty, crv: publicKeyJwk.crv, x: publicKeyJwk.x, y: publicKeyJwk.y },
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['verify'],
|
||||
);
|
||||
return crypto.subtle.verify(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
key,
|
||||
new Uint8Array(Buffer.from(sigB64Url, 'base64url')),
|
||||
new TextEncoder().encode(message),
|
||||
);
|
||||
};
|
||||
|
||||
const isRegister = ([url]) => String(url).endsWith('/register-token');
|
||||
const isSend = ([url]) => String(url) === 'https://relay.test/v1/push/send';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.OPENCHAMBER_PUSH_RELAY_URL;
|
||||
delete process.env.OPENCHAMBER_PUSH_RELAY_DISABLED;
|
||||
});
|
||||
|
||||
describe('apns runtime relay mode (default)', () => {
|
||||
it('registers tokens (signed) and posts signed generic text, dropping dead tokens', async () => {
|
||||
const fetchMock = vi.fn(async (url) =>
|
||||
isRegister([url])
|
||||
? jsonResponse({ ok: true })
|
||||
: jsonResponse({
|
||||
results: [
|
||||
{ token: 'tokenA', ok: true, drop: false },
|
||||
{ token: 'tokenDead', ok: false, drop: true },
|
||||
],
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
||||
|
||||
const runtime = createApnsRuntime(makeDeps());
|
||||
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
|
||||
await runtime.addOrUpdateApnsToken('s2', 'tokenDead');
|
||||
|
||||
// Each new token is bound on the relay with a signed register-token call.
|
||||
const registerCalls = fetchMock.mock.calls.filter(isRegister);
|
||||
expect(registerCalls).toHaveLength(2);
|
||||
for (const [url, init] of registerCalls) {
|
||||
expect(url).toBe('https://relay.test/v1/push/register-token');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.publicKeyJwk).toMatchObject({ kty: 'EC', crv: 'P-256' });
|
||||
expect(typeof body.ts).toBe('number');
|
||||
expect(body.platform).toBe('ios');
|
||||
expect(await verifyRelaySignature(body.publicKeyJwk, `${body.ts}.${body.token}.${body.platform}`, body.sig)).toBe(true);
|
||||
}
|
||||
|
||||
fetchMock.mockClear();
|
||||
await runtime.sendApnsToAllUiSessions(
|
||||
{ title: 'Agent response is ready', body: 'My session', badge: 3, tag: 'ready-x', data: { sessionId: 'sess1' } },
|
||||
{},
|
||||
);
|
||||
|
||||
const sendCall = fetchMock.mock.calls.find(isSend);
|
||||
expect(sendCall).toBeTruthy();
|
||||
const sent = JSON.parse(sendCall[1].body);
|
||||
expect(sendCall[1].headers.authorization).toBeUndefined();
|
||||
expect(new Set(sent.tokens)).toEqual(new Set(['tokenA', 'tokenDead']));
|
||||
expect(sent.title).toBe('Agent response is ready');
|
||||
expect(sent.body).toBe('My session');
|
||||
expect(sent.badge).toBe(3);
|
||||
expect(sent.data).toEqual({ sessionId: 'sess1' });
|
||||
expect(sent.publicKeyJwk).toMatchObject({ kty: 'EC', crv: 'P-256' });
|
||||
const sendMessage = `${sent.ts}.${[...sent.tokens].sort().join(',')}.${sent.title}`;
|
||||
expect(await verifyRelaySignature(sent.publicKeyJwk, sendMessage, sent.sig)).toBe(true);
|
||||
|
||||
// tokenDead should have been dropped → next send targets only tokenA.
|
||||
fetchMock.mockClear();
|
||||
await runtime.sendApnsToAllUiSessions({ title: 'x', body: 'y', tag: 't' }, {});
|
||||
expect(JSON.parse(fetchMock.mock.calls.find(isSend)[1].body).tokens).toEqual(['tokenA']);
|
||||
});
|
||||
|
||||
it('reuses one persisted keypair (same serverId) across register + send', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
||||
|
||||
const deps = makeDeps();
|
||||
const runtime = createApnsRuntime(deps);
|
||||
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
|
||||
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'x' }, {});
|
||||
|
||||
const keys = fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body).publicKeyJwk);
|
||||
expect(keys.length).toBeGreaterThanOrEqual(2);
|
||||
expect(keys.every((k) => k.x === keys[0].x && k.y === keys[0].y)).toBe(true);
|
||||
// Keypair was generated + persisted exactly once.
|
||||
expect(deps.writeSettingsToDisk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('no-ops (no relay call) when no tokens are registered', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const runtime = createApnsRuntime(makeDeps());
|
||||
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apns runtime direct fallback (relay disabled)', () => {
|
||||
it('signs an ES256 JWT and sends over http2 when relay is disabled', async () => {
|
||||
process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true';
|
||||
const targeted = [];
|
||||
const http2 = {
|
||||
connect: () => ({
|
||||
on: () => {},
|
||||
close: () => {},
|
||||
request: (headers) => {
|
||||
targeted.push(String(headers[':path']).replace('/3/device/', ''));
|
||||
const listeners = {};
|
||||
const req = {
|
||||
on: (event, cb) => { listeners[event] = cb; return req; },
|
||||
setEncoding: () => req,
|
||||
end: () => {
|
||||
queueMicrotask(() => {
|
||||
listeners.response?.({ ':status': '200' });
|
||||
listeners.end?.();
|
||||
});
|
||||
},
|
||||
};
|
||||
return req;
|
||||
},
|
||||
}),
|
||||
};
|
||||
const runtime = createApnsRuntime(
|
||||
makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: APNS_CONFIG })) }),
|
||||
);
|
||||
await runtime.addOrUpdateApnsToken('s', 'tokenDirect');
|
||||
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'ready-x' });
|
||||
expect(targeted).toEqual(['tokenDirect']);
|
||||
});
|
||||
|
||||
it('signApnsJwt produces a 3-part ES256 token with the expected header/claims', () => {
|
||||
const runtime = createApnsRuntime(makeDeps());
|
||||
const parts = runtime.signApnsJwt(APNS_CONFIG).split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
expect(JSON.parse(Buffer.from(parts[0], 'base64url').toString())).toEqual({ alg: 'ES256', kid: 'KEY123' });
|
||||
expect(JSON.parse(Buffer.from(parts[1], 'base64url').toString()).iss).toBe('TEAM123');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1 @@
|
||||
export { truncateNotificationText, prepareNotificationLastMessage } from './message.js';
|
||||
export { createNotificationTriggerRuntime } from './runtime.js';
|
||||
export { createPushRuntime } from './push-runtime.js';
|
||||
export { createNotificationTemplateRuntime } from './template-runtime.js';
|
||||
export { prepareNotificationLastMessage } from './message.js';
|
||||
|
||||
@@ -115,12 +115,13 @@ export const createPushRuntime = (deps) => {
|
||||
p256dh,
|
||||
auth,
|
||||
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
|
||||
platform: typeof entry.platform === 'string' ? entry.platform : undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => {
|
||||
const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent, platform) => {
|
||||
if (!uiSessionToken) {
|
||||
return;
|
||||
}
|
||||
@@ -135,6 +136,7 @@ export const createPushRuntime = (deps) => {
|
||||
|
||||
const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== subscription.endpoint);
|
||||
|
||||
const previous = existing.find((entry) => entry && entry.endpoint === subscription.endpoint);
|
||||
filtered.unshift({
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.p256dh,
|
||||
@@ -142,6 +144,13 @@ export const createPushRuntime = (deps) => {
|
||||
createdAt: now,
|
||||
lastSeenAt: now,
|
||||
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
|
||||
// Platform lets the sender route mobile PWA push through the same presence gate as APNs.
|
||||
platform:
|
||||
typeof platform === 'string' && platform
|
||||
? platform
|
||||
: typeof previous?.platform === 'string'
|
||||
? previous.platform
|
||||
: undefined,
|
||||
});
|
||||
|
||||
subsBySession[uiSessionToken] = filtered.slice(0, 10);
|
||||
@@ -230,18 +239,32 @@ export const createPushRuntime = (deps) => {
|
||||
}
|
||||
|
||||
await Promise.all(Array.from(subscriptionsByEndpoint.values()).map(async (sub) => {
|
||||
if (requireNoSse && isAnyUiVisible()) {
|
||||
return;
|
||||
if (requireNoSse) {
|
||||
// Mobile PWA subscriptions follow the same presence model as native push: suppress only
|
||||
// when an interactive (desktop/web) client is visible. The phone PWA's own foreground is
|
||||
// handled in the service worker (focused-client check), so it won't double-notify.
|
||||
// Non-mobile (desktop/web) subscriptions keep the existing any-visible gate.
|
||||
const suppressed = isMobilePlatform(sub.platform) ? isAnyInteractiveClientVisible() : isAnyUiVisible();
|
||||
if (suppressed) return;
|
||||
}
|
||||
await sendPushToSubscription(sub, payload);
|
||||
}));
|
||||
};
|
||||
|
||||
const updateUiVisibility = (token, visible) => {
|
||||
// A client is "mobile" if it reports a native mobile platform. Anything else (web, desktop,
|
||||
// vscode, or an older client that doesn't report a platform) is treated as interactive — i.e.
|
||||
// a surface where the user would actually see the in-app notification.
|
||||
const MOBILE_PLATFORMS = new Set(['ios', 'android']);
|
||||
const isMobilePlatform = (platform) => typeof platform === 'string' && MOBILE_PLATFORMS.has(platform);
|
||||
|
||||
const updateUiVisibility = (token, visible, platform) => {
|
||||
if (!token) return;
|
||||
const now = Date.now();
|
||||
const nextVisible = Boolean(visible);
|
||||
uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now });
|
||||
const existing = uiVisibilityByToken.get(token);
|
||||
// Keep the last known platform if this beacon didn't carry one (e.g. a heartbeat).
|
||||
const nextPlatform = typeof platform === 'string' && platform ? platform : existing?.platform;
|
||||
uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now, platform: nextPlatform });
|
||||
};
|
||||
|
||||
const isAnyUiVisible = () => {
|
||||
@@ -255,6 +278,25 @@ export const createPushRuntime = (deps) => {
|
||||
return false;
|
||||
};
|
||||
|
||||
// True when at least one NON-mobile client (desktop/web/vscode) is currently visible. Used to
|
||||
// suppress native push to the phone: an active desktop already shows the notification, so the
|
||||
// phone doesn't need it. Deliberately based on the desktop's visibility (reliable), never the
|
||||
// phone's own (a backgrounded WKWebView can't report "hidden" before iOS suspends it).
|
||||
const isAnyInteractiveClientVisible = () => {
|
||||
const now = Date.now();
|
||||
pruneUiVisibility(now);
|
||||
for (const state of uiVisibilityByToken.values()) {
|
||||
if (
|
||||
state.visible === true &&
|
||||
now - state.updatedAt <= UI_VISIBILITY_TTL_MS &&
|
||||
!isMobilePlatform(state.platform)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isUiVisible = (token) => {
|
||||
const now = Date.now();
|
||||
pruneUiVisibility(now);
|
||||
@@ -317,6 +359,7 @@ export const createPushRuntime = (deps) => {
|
||||
sendPushToAllUiSessions,
|
||||
updateUiVisibility,
|
||||
isAnyUiVisible,
|
||||
isAnyInteractiveClientVisible,
|
||||
isUiVisible,
|
||||
ensurePushInitialized,
|
||||
setPushInitialized,
|
||||
|
||||
@@ -42,4 +42,38 @@ describe('push runtime visibility tracking', () => {
|
||||
expect(runtime.isAnyUiVisible()).toBe(false);
|
||||
expect(runtime.isUiVisible('visible-client')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats only mobile platforms as non-interactive for isAnyInteractiveClientVisible', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
||||
|
||||
const runtime = createRuntime();
|
||||
|
||||
// Only the phone (foreground) is connected → no interactive client to absorb the notification.
|
||||
runtime.updateUiVisibility('phone', true, 'ios');
|
||||
expect(runtime.isAnyUiVisible()).toBe(true);
|
||||
expect(runtime.isAnyInteractiveClientVisible()).toBe(false);
|
||||
|
||||
// A visible desktop counts as interactive → suppress mobile push.
|
||||
runtime.updateUiVisibility('desktop', true, 'desktop');
|
||||
expect(runtime.isAnyInteractiveClientVisible()).toBe(true);
|
||||
|
||||
// Desktop hidden again → back to mobile-only, push should flow to the phone.
|
||||
runtime.updateUiVisibility('desktop', false, 'desktop');
|
||||
expect(runtime.isAnyInteractiveClientVisible()).toBe(false);
|
||||
|
||||
// A client that never reported a platform is treated as interactive (conservative).
|
||||
runtime.updateUiVisibility('legacy', true);
|
||||
expect(runtime.isAnyInteractiveClientVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('remembers the last platform when a heartbeat omits it', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
||||
|
||||
const runtime = createRuntime();
|
||||
runtime.updateUiVisibility('phone', true, 'android');
|
||||
runtime.updateUiVisibility('phone', true); // heartbeat without platform
|
||||
expect(runtime.isAnyInteractiveClientVisible()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,10 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
writeSettingsToDisk,
|
||||
addOrUpdatePushSubscription,
|
||||
removePushSubscription,
|
||||
addOrUpdateApnsToken,
|
||||
removeApnsToken,
|
||||
updateUiVisibility,
|
||||
clearPendingPushBadge,
|
||||
isUiVisible,
|
||||
getUiNotificationClients,
|
||||
writeSseEvent,
|
||||
@@ -106,6 +109,7 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
}
|
||||
}
|
||||
|
||||
const platform = typeof req.body?.platform === 'string' ? req.body.platform : undefined;
|
||||
await addOrUpdatePushSubscription(
|
||||
uiToken,
|
||||
{
|
||||
@@ -113,7 +117,8 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
},
|
||||
req.headers['user-agent']
|
||||
req.headers['user-agent'],
|
||||
platform
|
||||
);
|
||||
|
||||
return res.json({ ok: true });
|
||||
@@ -138,6 +143,50 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Native iOS APNs device token registration (mirrors /api/push/subscribe). The token
|
||||
// is a hex APNs device token from @capacitor/push-notifications, scoped to the UI
|
||||
// session like web-push subscriptions.
|
||||
app.post('/api/push/apns-token', async (req, res) => {
|
||||
await ensureSessionWatcher();
|
||||
|
||||
const uiToken = uiAuthController?.ensureSessionToken
|
||||
? await uiAuthController.ensureSessionToken(req, res)
|
||||
: getUiSessionTokenFromRequest(req);
|
||||
if (!uiToken) {
|
||||
return res.status(401).json({ error: 'UI session missing' });
|
||||
}
|
||||
|
||||
const deviceToken = typeof req.body?.token === 'string' ? req.body.token.trim() : '';
|
||||
if (!deviceToken) {
|
||||
return res.status(400).json({ error: 'Invalid body' });
|
||||
}
|
||||
|
||||
const platform = req.body?.platform === 'android' ? 'android' : 'ios';
|
||||
if (typeof addOrUpdateApnsToken === 'function') {
|
||||
await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform);
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/push/apns-token', async (req, res) => {
|
||||
const uiToken = uiAuthController?.ensureSessionToken
|
||||
? await uiAuthController.ensureSessionToken(req, res)
|
||||
: getUiSessionTokenFromRequest(req);
|
||||
if (!uiToken) {
|
||||
return res.status(401).json({ error: 'UI session missing' });
|
||||
}
|
||||
|
||||
const deviceToken = typeof req.body?.token === 'string' ? req.body.token.trim() : '';
|
||||
if (!deviceToken) {
|
||||
return res.status(400).json({ error: 'Invalid body' });
|
||||
}
|
||||
|
||||
if (typeof removeApnsToken === 'function') {
|
||||
await removeApnsToken(uiToken, deviceToken);
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/push/visibility', async (req, res) => {
|
||||
const uiToken = uiAuthController?.ensureSessionToken
|
||||
? await uiAuthController.ensureSessionToken(req, res)
|
||||
@@ -146,8 +195,9 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
return res.status(401).json({ error: 'UI session missing' });
|
||||
}
|
||||
|
||||
const visible = req.body && typeof req.body === 'object' ? req.body.visible : null;
|
||||
updateUiVisibility(uiToken, visible === true);
|
||||
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const platform = typeof body.platform === 'string' ? body.platform : undefined;
|
||||
updateUiVisibility(uiToken, body.visible === true, platform);
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -301,6 +351,10 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
const clientId = req.headers['x-client-id'] || req.ip || 'anonymous';
|
||||
|
||||
markSessionViewed(sessionId, clientId);
|
||||
// The user is engaging with the app, so the native push badge no longer
|
||||
// applies — reset it here too (not only on the visibility beacon), since
|
||||
// opening the app reliably marks the opened session viewed.
|
||||
if (typeof clearPendingPushBadge === 'function') clearPendingPushBadge();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -326,6 +380,9 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
const sessionId = req.params.id;
|
||||
|
||||
markUserMessageSent(sessionId);
|
||||
// Sending a message means the user is active in the app; reset the native
|
||||
// push badge so it counts only notifications since this engagement.
|
||||
if (typeof clearPendingPushBadge === 'function') clearPendingPushBadge();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
||||
@@ -10,10 +10,84 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
emitDesktopNotification,
|
||||
broadcastUiNotification,
|
||||
sendPushToAllUiSessions,
|
||||
sendApnsToAllUiSessions,
|
||||
isAnyInteractiveClientVisible,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
} = deps;
|
||||
|
||||
// App-icon badge for native push: the set of DISTINCT collapse-ids (the push
|
||||
// `tag`, e.g. `ready-<sessionId>` / `permission-<requestKey>`) we've sent since
|
||||
// the app was last foregrounded. The badge is the absolute APNs `aps.badge`.
|
||||
//
|
||||
// We key by `tag`, not sessionId, because the tag IS the banner identity: iOS
|
||||
// uses it as `apns-collapse-id`, so same-tag pushes REPLACE one banner while
|
||||
// different tags are distinct banners. One session can raise several banners
|
||||
// (ready + question + permission are different tags), so counting sessionIds
|
||||
// both over- and under-counts the lock-screen stack; counting tags mirrors it.
|
||||
//
|
||||
// We deliberately do NOT derive this from the live attention snapshot
|
||||
// (needsAttention/isViewed): that machinery is for in-app indicators on
|
||||
// connected clients — a backgrounded client stays "viewing", and needsAttention
|
||||
// is set by a separate session.status event that races the push trigger. The
|
||||
// set is cleared when a UI client reports visible (`clearPendingPushBadge`),
|
||||
// the same moment the device zeroes its icon badge on becomeActive.
|
||||
const pendingPushTags = new Set();
|
||||
const clearPendingPushBadge = () => {
|
||||
pendingPushTags.clear();
|
||||
};
|
||||
const trackPushAndCountBadge = (tag) => {
|
||||
if (typeof tag === 'string' && tag.length > 0) {
|
||||
pendingPushTags.add(tag);
|
||||
}
|
||||
return pendingPushTags.size;
|
||||
};
|
||||
|
||||
// Generic notification for native push (per the mobile design): a fixed, scenario-based
|
||||
// title + the session name as the body. No model/project/message content crosses the relay.
|
||||
const APNS_TITLE_BY_TYPE = {
|
||||
ready: 'Agent response is ready',
|
||||
error: 'Agent hit an error',
|
||||
question: 'Agent needs your input',
|
||||
permission: 'Agent needs permission',
|
||||
};
|
||||
|
||||
const toApnsGenericPayload = (payload) => {
|
||||
const data = payload?.data && typeof payload.data === 'object' ? payload.data : {};
|
||||
const sessionName = typeof data.sessionName === 'string' && data.sessionName.trim().length > 0
|
||||
? data.sessionName.trim()
|
||||
: 'Session';
|
||||
return {
|
||||
title: APNS_TITLE_BY_TYPE[data.type] || 'Agent update',
|
||||
body: sessionName,
|
||||
badge: trackPushAndCountBadge(typeof payload?.tag === 'string' ? payload.tag : undefined),
|
||||
tag: payload?.tag,
|
||||
// sessionId is forwarded so a tapped push can deep-link; it is an opaque id, not content.
|
||||
data: typeof data.sessionId === 'string' ? { sessionId: data.sessionId } : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// Fan a notification out to every delivery channel: browser web-push (full templated
|
||||
// payload) and native iOS APNs (generic model-based text). Both share the dedup tag and
|
||||
// `requireNoSse` focus gate; a failure in one channel must not block the other.
|
||||
const fanoutPush = (payload, options) => {
|
||||
// Presence-aware routing: if any interactive (non-mobile) client — desktop/web/vscode — is
|
||||
// currently visible, it already shows the in-app notification, so skip the native push to the
|
||||
// phone. Gated on the desktop's visibility (reliable), never the phone's own. When we skip we
|
||||
// also skip toApnsGenericPayload, so the badge isn't incremented for an undelivered push.
|
||||
const interactiveVisible = isAnyInteractiveClientVisible?.() === true;
|
||||
return Promise.all([
|
||||
Promise.resolve(sendPushToAllUiSessions?.(payload, options)).catch((error) => {
|
||||
console.warn('[Push] web-push fanout failed:', error?.message ?? error);
|
||||
}),
|
||||
interactiveVisible
|
||||
? Promise.resolve()
|
||||
: Promise.resolve(sendApnsToAllUiSessions?.(toApnsGenericPayload(payload), options)).catch((error) => {
|
||||
console.warn('[APNs] fanout failed:', error?.message ?? error);
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
let getIsWindowFocused = typeof deps.getIsWindowFocused === 'function'
|
||||
? deps.getIsWindowFocused
|
||||
: null;
|
||||
@@ -240,6 +314,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
|
||||
let title = `${formatMode(info?.mode)} agent is ready`;
|
||||
let body = `${formatModelId(info?.modelID)} completed the task`;
|
||||
let sessionName = '';
|
||||
|
||||
try {
|
||||
const templates = settings.notificationTemplates || {};
|
||||
@@ -249,6 +324,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
: (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' });
|
||||
|
||||
const variables = await buildTemplateVariables(payload, sessionId);
|
||||
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
|
||||
|
||||
const messageId = info?.id;
|
||||
let lastMessage = extractLastMessageText(payload);
|
||||
@@ -283,7 +359,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
|
||||
}
|
||||
|
||||
await sendPushToAllUiSessions(
|
||||
await fanoutPush(
|
||||
{
|
||||
title,
|
||||
body,
|
||||
@@ -291,6 +367,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
data: {
|
||||
url: buildSessionDeepLinkUrl(sessionId),
|
||||
sessionId,
|
||||
sessionName,
|
||||
type: 'ready',
|
||||
},
|
||||
},
|
||||
@@ -308,9 +385,11 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
|
||||
let title = 'Tool error';
|
||||
let body = 'An error occurred';
|
||||
let sessionName = '';
|
||||
|
||||
try {
|
||||
const variables = await buildTemplateVariables(payload, sessionId);
|
||||
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
|
||||
const errorMessageId = info?.id;
|
||||
let lastMessage = extractLastMessageText(payload);
|
||||
if (!lastMessage) {
|
||||
@@ -345,7 +424,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
|
||||
}
|
||||
|
||||
await sendPushToAllUiSessions(
|
||||
await fanoutPush(
|
||||
{
|
||||
title,
|
||||
body,
|
||||
@@ -353,6 +432,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
data: {
|
||||
url: buildSessionDeepLinkUrl(sessionId),
|
||||
sessionId,
|
||||
sessionName,
|
||||
type: 'error',
|
||||
},
|
||||
},
|
||||
@@ -391,9 +471,11 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
? 'Switch to build mode'
|
||||
: header || 'Input needed';
|
||||
let body = questionText || 'Agent is waiting for your response';
|
||||
let sessionName = '';
|
||||
|
||||
try {
|
||||
const variables = await buildTemplateVariables(payload, sessionId);
|
||||
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
|
||||
variables.last_message = questionText || header || '';
|
||||
|
||||
const templates = settings.notificationTemplates || {};
|
||||
@@ -421,7 +503,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
|
||||
}
|
||||
|
||||
void sendPushToAllUiSessions(
|
||||
void fanoutPush(
|
||||
{
|
||||
title,
|
||||
body,
|
||||
@@ -429,6 +511,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
data: {
|
||||
url: buildSessionDeepLinkUrl(sessionId),
|
||||
sessionId,
|
||||
sessionName,
|
||||
type: 'question',
|
||||
},
|
||||
},
|
||||
@@ -505,9 +588,11 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
|
||||
let title = 'Permission required';
|
||||
let body = fallbackMessage;
|
||||
let sessionName = '';
|
||||
|
||||
try {
|
||||
const variables = await buildTemplateVariables(payload, sessionId);
|
||||
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
|
||||
variables.last_message = fallbackMessage;
|
||||
|
||||
const templates = settings.notificationTemplates || {};
|
||||
@@ -539,7 +624,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
notifiedPermissionRequests.add(requestKey);
|
||||
}
|
||||
|
||||
void sendPushToAllUiSessions(
|
||||
void fanoutPush(
|
||||
{
|
||||
title,
|
||||
body,
|
||||
@@ -547,6 +632,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
data: {
|
||||
url: buildSessionDeepLinkUrl(sessionId),
|
||||
sessionId,
|
||||
sessionName,
|
||||
type: 'permission',
|
||||
},
|
||||
},
|
||||
@@ -562,5 +648,6 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
maybeSendPushForTrigger,
|
||||
setAutoAcceptSession,
|
||||
setGetIsWindowFocused,
|
||||
clearPendingPushBadge,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -409,7 +409,10 @@ function createAgent(agentName, config, workingDirectory, scope) {
|
||||
targetScope = AGENT_SCOPE.USER;
|
||||
}
|
||||
|
||||
const { prompt, scope: _scopeFromConfig, ...frontmatter } = config;
|
||||
const { prompt, scope: _scopeFromConfig, ...rawFrontmatter } = config;
|
||||
const frontmatter = Object.fromEntries(
|
||||
Object.entries(rawFrontmatter).filter(([, value]) => value !== null && value !== undefined)
|
||||
);
|
||||
|
||||
writeMdFile(targetPath, frontmatter, prompt || '');
|
||||
console.log(`Created new agent: ${agentName} (scope: ${targetScope}, path: ${targetPath})`);
|
||||
@@ -685,12 +688,6 @@ function deleteAgent(agentName, workingDirectory, scope) {
|
||||
}
|
||||
|
||||
export {
|
||||
ensureProjectAgentDir,
|
||||
getProjectAgentPath,
|
||||
getUserAgentPath,
|
||||
getAgentScope,
|
||||
getAgentWritePath,
|
||||
getAgentPermissionSource,
|
||||
getAgentSources,
|
||||
getAgentConfig,
|
||||
createAgent,
|
||||
|
||||
@@ -51,7 +51,8 @@ export const createOpenCodeAuthStateRuntime = (dependencies) => {
|
||||
return {};
|
||||
}
|
||||
|
||||
const credentials = Buffer.from(`opencode:${password}`).toString('base64');
|
||||
const username = process.env.OPENCODE_SERVER_USERNAME?.trim() || 'opencode';
|
||||
const credentials = Buffer.from(`${username}:${password}`).toString('base64');
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
};
|
||||
|
||||
|
||||
@@ -32,7 +32,10 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
writeSettingsToDisk,
|
||||
addOrUpdatePushSubscription,
|
||||
removePushSubscription,
|
||||
addOrUpdateApnsToken,
|
||||
removeApnsToken,
|
||||
updateUiVisibility,
|
||||
clearPendingPushBadge,
|
||||
isUiVisible,
|
||||
getUiNotificationClients,
|
||||
writeSseEvent,
|
||||
@@ -95,7 +98,10 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
writeSettingsToDisk,
|
||||
addOrUpdatePushSubscription,
|
||||
removePushSubscription,
|
||||
addOrUpdateApnsToken,
|
||||
removeApnsToken,
|
||||
updateUiVisibility,
|
||||
clearPendingPushBadge,
|
||||
isUiVisible,
|
||||
getUiNotificationClients,
|
||||
writeSseEvent,
|
||||
|
||||
@@ -327,11 +327,6 @@ function deleteCommand(commandName, workingDirectory) {
|
||||
}
|
||||
|
||||
export {
|
||||
ensureProjectCommandDir,
|
||||
getProjectCommandPath,
|
||||
getUserCommandPath,
|
||||
getCommandScope,
|
||||
getCommandWritePath,
|
||||
getCommandSources,
|
||||
createCommand,
|
||||
updateCommand,
|
||||
|
||||
@@ -26,6 +26,30 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
||||
expandSnippets,
|
||||
} = dependencies;
|
||||
|
||||
// Build the response for a config mutation based on whether OpenCode actually
|
||||
// reloaded the change. When connected to an external OpenCode server that
|
||||
// OpenChamber cannot restart, the change is persisted to disk but the running
|
||||
// server will not serve it until the user restarts that server. We must not
|
||||
// report a clean "reloading" success in that case, otherwise the UI silently
|
||||
// reverts the edit to the stale value on the next refresh.
|
||||
const buildConfigMutationResponse = (refreshResult, { liveMessage, manualRestartMessage }) => {
|
||||
if (refreshResult && refreshResult.external) {
|
||||
return {
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
requiresManualRestart: true,
|
||||
message: manualRestartMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: liveMessage,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
};
|
||||
};
|
||||
|
||||
const completeMcpMutation = async (res, action, name, applyChange) => {
|
||||
applyChange();
|
||||
|
||||
@@ -104,16 +128,14 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
||||
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
||||
|
||||
createAgent(agentName, config, directory, scope);
|
||||
await refreshOpenCodeAfterConfigChange('agent creation', {
|
||||
const refreshResult = await refreshOpenCodeAfterConfigChange('agent creation', {
|
||||
agentName
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
res.json(buildConfigMutationResponse(refreshResult, {
|
||||
liveMessage: `Agent ${agentName} created successfully. Reloading interface…`,
|
||||
manualRestartMessage: `Agent ${agentName} saved. Restart your connected OpenCode server to apply the change.`,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to create agent:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to create agent' });
|
||||
@@ -134,16 +156,14 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
||||
console.log('[Server] Working directory:', directory);
|
||||
|
||||
updateAgent(agentName, updates, directory);
|
||||
await refreshOpenCodeAfterConfigChange('agent update');
|
||||
const refreshResult = await refreshOpenCodeAfterConfigChange('agent update');
|
||||
|
||||
console.log(`[Server] Agent ${agentName} updated successfully`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
res.json(buildConfigMutationResponse(refreshResult, {
|
||||
liveMessage: `Agent ${agentName} updated successfully. Reloading interface…`,
|
||||
manualRestartMessage: `Agent ${agentName} saved. Restart your connected OpenCode server to apply the change.`,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[Server] Failed to update agent:', error);
|
||||
console.error('[Server] Error stack:', error.stack);
|
||||
@@ -161,14 +181,12 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
||||
|
||||
const scope = req.body?.scope;
|
||||
deleteAgent(agentName, directory, scope);
|
||||
await refreshOpenCodeAfterConfigChange('agent deletion');
|
||||
const refreshResult = await refreshOpenCodeAfterConfigChange('agent deletion');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
res.json(buildConfigMutationResponse(refreshResult, {
|
||||
liveMessage: `Agent ${agentName} deleted successfully. Reloading interface…`,
|
||||
manualRestartMessage: `Agent ${agentName} deleted. Restart your connected OpenCode server to apply the change.`,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete agent:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to delete agent' });
|
||||
|
||||
@@ -396,6 +396,35 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
}
|
||||
};
|
||||
|
||||
const runWithClientCreateAuth = async (req, res, next, handler) => {
|
||||
try {
|
||||
if (typeof uiAuthController.resolveAuthContext === 'function') {
|
||||
const context = await uiAuthController.resolveAuthContext(req, res, {
|
||||
allowClientAuth: true,
|
||||
allowUrlToken: false,
|
||||
});
|
||||
if (context?.type === 'session') {
|
||||
await handler(context);
|
||||
return;
|
||||
}
|
||||
if (context?.type === 'client') {
|
||||
const client = await clientRecordFromAuthContext(context);
|
||||
if (client?.clientKind === 'desktop-local') {
|
||||
await handler({ ...context, client });
|
||||
return;
|
||||
}
|
||||
return res.status(403).json({ error: 'Client tokens cannot create remote clients' });
|
||||
}
|
||||
}
|
||||
|
||||
await runWithUiAuth(req, res, next, async () => {
|
||||
await handler({ type: 'session' });
|
||||
}, { sessionOnly: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const clientIdFromAuthContext = (context) => {
|
||||
const raw = context?.client?.id || context?.clientId;
|
||||
return typeof raw === 'string' && raw.length > 0 ? raw : null;
|
||||
@@ -567,7 +596,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
});
|
||||
|
||||
app.post('/api/client-auth/clients', express.json({ limit: '64kb' }), async (req, res, next) => {
|
||||
await runWithUiAuth(req, res, next, async () => {
|
||||
await runWithClientCreateAuth(req, res, next, async () => {
|
||||
const result = await remoteClientAuthRuntime.createClient({
|
||||
label: req.body?.label,
|
||||
clientKind: req.body?.clientKind,
|
||||
@@ -575,7 +604,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
});
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.status(201).json(result);
|
||||
}, { sessionOnly: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/client-auth/clients/:id', async (req, res, next) => {
|
||||
|
||||
@@ -399,6 +399,36 @@ describe('client auth routes', () => {
|
||||
expect(revoked.body.client.id).toBe(current.body.client.id);
|
||||
});
|
||||
|
||||
it('allows only the local desktop client token to create remote client tokens', async () => {
|
||||
const app = express();
|
||||
let authContext = { type: 'session' };
|
||||
const dependencies = createDependencies({
|
||||
resolveAuthContext: async () => authContext,
|
||||
});
|
||||
registerAuthAndAccessRoutes(app, dependencies);
|
||||
|
||||
const desktop = await request(app)
|
||||
.post('/api/client-auth/clients')
|
||||
.send({ label: 'OpenChamber Desktop', clientKind: 'desktop-local' });
|
||||
const remote = await request(app)
|
||||
.post('/api/client-auth/clients')
|
||||
.send({ label: 'Phone' });
|
||||
|
||||
authContext = { type: 'client', clientId: remote.body.client.id, client: remote.body.client };
|
||||
const denied = await request(app)
|
||||
.post('/api/client-auth/clients')
|
||||
.send({ label: 'Another phone' });
|
||||
expect(denied.status).toBe(403);
|
||||
expect(denied.body.error).toBe('Client tokens cannot create remote clients');
|
||||
|
||||
authContext = { type: 'client', clientId: desktop.body.client.id, client: desktop.body.client };
|
||||
const created = await request(app)
|
||||
.post('/api/client-auth/clients')
|
||||
.send({ label: 'Mobile' });
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body.client.label).toBe('Mobile');
|
||||
});
|
||||
|
||||
it('requires UI-session auth for passkey registration management routes', async () => {
|
||||
const app = express();
|
||||
const dependencies = createDependencies();
|
||||
|
||||
@@ -13,6 +13,33 @@ 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 { 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';
|
||||
import { listSnippets, getSnippet, createSnippet, updateSnippet, deleteSnippet, expandSnippets } from './snippets.js';
|
||||
import {
|
||||
listPluginEntries,
|
||||
getPluginEntry,
|
||||
createPluginEntry,
|
||||
updatePluginEntry,
|
||||
deletePluginEntry,
|
||||
listPluginDirFiles,
|
||||
readPluginDirFile,
|
||||
writePluginDirFile,
|
||||
deletePluginDirFile,
|
||||
encodePluginId,
|
||||
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 { getCuratedSkillsSources } from '../skills-catalog/curated-sources.js';
|
||||
import { getCacheKey, getCachedScan, setCachedScan } from '../skills-catalog/cache.js';
|
||||
import { isClawdHubSource, parseSkillRepoSource } from '../skills-catalog/source.js';
|
||||
import { scanSkillsRepository } from '../skills-catalog/scan.js';
|
||||
import { installSkillsFromRepository } from '../skills-catalog/install.js';
|
||||
import { scanClawdHubPage } from '../skills-catalog/clawdhub/scan.js';
|
||||
import { installSkillsFromClawdHub } from '../skills-catalog/clawdhub/install.js';
|
||||
|
||||
export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
const {
|
||||
@@ -63,8 +90,6 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
writeSseEvent,
|
||||
} = routeDependencies;
|
||||
|
||||
const { getProviderSources, removeProviderConfig } = await import('./index.js');
|
||||
|
||||
registerSettingsUtilityRoutes(app, {
|
||||
readCustomThemesFromDisk,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
@@ -111,40 +136,6 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
writeSseEvent,
|
||||
});
|
||||
|
||||
const {
|
||||
getAgentSources,
|
||||
getAgentConfig,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
getCommandSources,
|
||||
createCommand,
|
||||
updateCommand,
|
||||
deleteCommand,
|
||||
listMcpConfigs,
|
||||
getMcpConfig,
|
||||
createMcpConfig,
|
||||
updateMcpConfig,
|
||||
deleteMcpConfig,
|
||||
listSnippets,
|
||||
getSnippet,
|
||||
createSnippet,
|
||||
updateSnippet,
|
||||
deleteSnippet,
|
||||
expandSnippets,
|
||||
listPluginEntries,
|
||||
getPluginEntry,
|
||||
createPluginEntry,
|
||||
updatePluginEntry,
|
||||
deletePluginEntry,
|
||||
listPluginDirFiles,
|
||||
readPluginDirFile,
|
||||
writePluginDirFile,
|
||||
deletePluginDirFile,
|
||||
encodePluginId,
|
||||
decodePluginId,
|
||||
} = await import('./index.js');
|
||||
|
||||
registerConfigEntityRoutes(app, {
|
||||
resolveProjectDirectory,
|
||||
resolveOptionalProjectDirectory,
|
||||
@@ -193,32 +184,6 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
isExactSemver,
|
||||
});
|
||||
|
||||
const {
|
||||
getSkillSources,
|
||||
discoverSkills,
|
||||
mergeDiscoveredSkills,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
SKILL_SCOPE,
|
||||
SKILL_DIR,
|
||||
} = await import('./index.js');
|
||||
|
||||
const {
|
||||
getCuratedSkillsSources,
|
||||
getCacheKey,
|
||||
getCachedScan,
|
||||
setCachedScan,
|
||||
parseSkillRepoSource,
|
||||
scanSkillsRepository,
|
||||
installSkillsFromRepository,
|
||||
scanClawdHubPage,
|
||||
installSkillsFromClawdHub,
|
||||
isClawdHubSource,
|
||||
} = await import('../skills-catalog/index.js');
|
||||
const { getProfiles, getProfile } = await import('../git/index.js');
|
||||
|
||||
registerSkillRoutes(app, {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
export {
|
||||
AGENT_DIR,
|
||||
COMMAND_DIR,
|
||||
SKILL_DIR,
|
||||
CONFIG_FILE,
|
||||
AGENT_SCOPE,
|
||||
COMMAND_SCOPE,
|
||||
SKILL_SCOPE,
|
||||
readConfig,
|
||||
writeConfig,
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
} from './shared.js';
|
||||
|
||||
export {
|
||||
getAgentScope,
|
||||
getAgentPermissionSource,
|
||||
getAgentSources,
|
||||
getAgentConfig,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
} from './agents.js';
|
||||
|
||||
export {
|
||||
getCommandScope,
|
||||
getCommandSources,
|
||||
createCommand,
|
||||
updateCommand,
|
||||
deleteCommand,
|
||||
} from './commands.js';
|
||||
|
||||
export {
|
||||
getSkillSources,
|
||||
getSkillScope,
|
||||
discoverSkills,
|
||||
mergeDiscoveredSkills,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
} from './skills.js';
|
||||
|
||||
export {
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
} from './providers.js';
|
||||
|
||||
export {
|
||||
readAuthFile,
|
||||
writeAuthFile,
|
||||
removeProviderAuth,
|
||||
getProviderAuth,
|
||||
listProviderAuths,
|
||||
AUTH_FILE,
|
||||
OPENCODE_DATA_DIR,
|
||||
} from './auth.js';
|
||||
|
||||
export { createUiAuth } from '../ui-auth/ui-auth.js';
|
||||
|
||||
export {
|
||||
listMcpConfigs,
|
||||
getMcpConfig,
|
||||
createMcpConfig,
|
||||
updateMcpConfig,
|
||||
deleteMcpConfig,
|
||||
} from './mcp.js';
|
||||
|
||||
export {
|
||||
listPluginEntries,
|
||||
getPluginEntry,
|
||||
createPluginEntry,
|
||||
updatePluginEntry,
|
||||
deletePluginEntry,
|
||||
listPluginDirFiles,
|
||||
readPluginDirFile,
|
||||
writePluginDirFile,
|
||||
deletePluginDirFile,
|
||||
encodePluginId,
|
||||
decodePluginId,
|
||||
parsePluginRaw,
|
||||
serializePluginEntry,
|
||||
} from './plugins.js';
|
||||
|
||||
export {
|
||||
listSnippets,
|
||||
getSnippet,
|
||||
createSnippet,
|
||||
updateSnippet,
|
||||
deleteSnippet,
|
||||
expandSnippets,
|
||||
} from './snippets.js';
|
||||
|
||||
export { getNpmInfo, lookupNpmPackage, clearCache as clearNpmCache } from './npm-registry.js';
|
||||
export { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import net from 'node:net';
|
||||
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js';
|
||||
|
||||
const parsePositiveInt = (value, fallback) => {
|
||||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||
@@ -140,7 +141,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
});
|
||||
};
|
||||
|
||||
const closeManagedOpenCodeChild = async (child) => {
|
||||
const terminateChildProcess = async (child) => {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
@@ -212,6 +213,19 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
await waitForChildProcessClose(child, 1000);
|
||||
};
|
||||
|
||||
const closeManagedOpenCodeChild = async (child) => {
|
||||
const pid = child?.pid;
|
||||
try {
|
||||
await terminateChildProcess(child);
|
||||
} finally {
|
||||
// Drop it from the registry only once it has actually exited, so a child
|
||||
// that survived teardown stays eligible for the next run's reaper.
|
||||
if (Number.isInteger(pid) && hasChildProcessExited(child)) {
|
||||
unregisterManagedProcess(pid);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatCapturedOutput = ({ stdout, stderr }) => {
|
||||
const parts = [];
|
||||
if (stdout.trim()) {
|
||||
@@ -324,6 +338,19 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
child.on('error', onError);
|
||||
});
|
||||
|
||||
// Record this child so a future run can reap it if we crash before teardown.
|
||||
// The web-server lifecycle runs in-process inside multiple hosts, so tag the
|
||||
// actual host (Electron sets OPENCHAMBER_RUNTIME='desktop'; the standalone
|
||||
// web CLI leaves it unset → 'web'; SSH remote → 'ssh-remote') rather than a
|
||||
// hardcoded label, matching the server's existing runtimeName convention.
|
||||
registerManagedProcess({
|
||||
pid: child.pid,
|
||||
ownerPid: process.pid,
|
||||
port,
|
||||
binary,
|
||||
runtime: process.env.OPENCHAMBER_RUNTIME || 'web',
|
||||
});
|
||||
|
||||
return {
|
||||
url,
|
||||
pid: child.pid || null,
|
||||
@@ -726,12 +753,22 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
|
||||
await restartOpenCode();
|
||||
|
||||
// A managed OpenCode process is restarted (and thus re-reads config from
|
||||
// disk) by restartOpenCode(). An external OpenCode server is NOT owned by
|
||||
// OpenChamber: restartOpenCode() only re-probes its health, so the freshly
|
||||
// written config is on disk but the running server keeps serving its old,
|
||||
// startup-cached config until the user restarts it themselves. Report this
|
||||
// honestly so callers don't claim the change is live.
|
||||
const external = state.isExternalOpenCode === true;
|
||||
|
||||
try {
|
||||
await waitForOpenCodeReady();
|
||||
state.isOpenCodeReady = true;
|
||||
state.openCodeNotReadySince = 0;
|
||||
|
||||
if (agentName) {
|
||||
// Waiting for the agent to appear only makes sense when we actually
|
||||
// reloaded config. An external server will never surface it here.
|
||||
if (agentName && !external) {
|
||||
await waitForAgentPresence(agentName);
|
||||
}
|
||||
|
||||
@@ -743,10 +780,22 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
console.error(`Failed to refresh OpenCode after ${reason}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { reloaded: !external, external };
|
||||
};
|
||||
|
||||
const bootstrapOpenCodeAtStartup = async () => {
|
||||
try {
|
||||
// Before doing anything, reap any OpenCode process WE spawned in a prior
|
||||
// run that was orphaned by a crash/hard-exit. Verified + scoped to our own
|
||||
// pids, so it never touches a live instance's or the user's own server.
|
||||
try {
|
||||
const { reaped } = await reapOrphanedProcesses({ log: (msg) => console.log(msg) });
|
||||
if (reaped > 0) console.log(`[lifecycle] startup reaped ${reaped} orphaned OpenCode process(es)`);
|
||||
} catch (error) {
|
||||
console.warn('[lifecycle] orphan reap failed:', error?.message ?? error);
|
||||
}
|
||||
|
||||
syncFromHmrState();
|
||||
if (await isOpenCodeProcessHealthy()) {
|
||||
console.log(`[HMR] Reusing existing OpenCode process on port ${state.openCodePort}`);
|
||||
@@ -770,15 +819,15 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
state.lastOpenCodeError = null;
|
||||
state.openCodeNotReadySince = 0;
|
||||
syncToHmrState();
|
||||
} else if (!env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) {
|
||||
console.log('Auto-detected existing OpenCode server on default port 4096');
|
||||
setOpenCodePort(4096);
|
||||
state.isOpenCodeReady = true;
|
||||
state.isExternalOpenCode = true;
|
||||
state.lastOpenCodeError = null;
|
||||
state.openCodeNotReadySince = 0;
|
||||
syncToHmrState();
|
||||
} else {
|
||||
// We never auto-attach to an arbitrary pre-existing OpenCode instance.
|
||||
// Attaching to an external server requires explicit opt-in via env
|
||||
// (OPENCODE_HOST / OPENCODE_PORT / OPENCODE_SKIP_START), handled by the
|
||||
// branches above. Without that opt-in we always start our OWN managed
|
||||
// instance on a freshly-allocated port. A blind probe of the default
|
||||
// port 4096 used to hijack a user's separately-running OpenCode (e.g.
|
||||
// the OpenCode desktop app), coupling our lifecycle to theirs and
|
||||
// breaking init against an unexpected server version/config.
|
||||
if (env.ENV_EFFECTIVE_PORT) {
|
||||
console.log(`Using OpenCode port from environment: ${env.ENV_EFFECTIVE_PORT}`);
|
||||
setOpenCodePort(env.ENV_EFFECTIVE_PORT);
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// Managed OpenCode process registry + orphan reaper.
|
||||
//
|
||||
// OpenChamber spawns the OpenCode server as an EXTERNAL child binary (on Unix
|
||||
// with `detached: true`, so it leads its own process group). That binary can
|
||||
// therefore outlive its parent if the parent is hard-killed/crashes/`Ctrl+C`ed
|
||||
// before graceful teardown runs — leaving an orphaned `opencode serve` that
|
||||
// then contends on the shared SQLite DB and slows everything down.
|
||||
//
|
||||
// We cannot tie an arbitrary external binary to the parent's death portably
|
||||
// (Electron's `utilityProcess` would, but it only runs JS entrypoints, not a
|
||||
// standalone binary). So we use the same pattern OpenCode's own CLI daemon uses
|
||||
// for its detached server: an on-disk record of the pids WE spawned, plus a
|
||||
// startup reaper that kills ONLY our own, verified, genuinely-orphaned
|
||||
// processes — never a process a live instance (another desktop window, a VS
|
||||
// Code host, the user's standalone `opencode`) is actively using.
|
||||
//
|
||||
// Storage: ONE FILE PER SPAWNED PROCESS in a registry directory, named
|
||||
// `<childPid>.json`. Multiple runtimes (web/desktop/VS Code) and multiple
|
||||
// windows all run concurrently; a single shared JSON file would be corrupted by
|
||||
// the read-modify-write race (last writer wins, clobbering another instance's
|
||||
// entry). Per-process files mean every instance only ever writes/deletes its
|
||||
// OWN file, so there is no write contention at all.
|
||||
//
|
||||
// Safety model (why this never kills the wrong thing):
|
||||
// 1. The reaper only ever considers pids THIS product recorded. The user's
|
||||
// standalone CLI server, the official desktop app, and the TUI are never
|
||||
// recorded, so they are never even candidates.
|
||||
// 2. Before killing, it re-verifies the live pid is still an `opencode serve`
|
||||
// matching the recorded port (guards against the OS recycling a dead pid
|
||||
// onto an unrelated process).
|
||||
// 3. It kills only when the spawning owner is provably gone — the child has
|
||||
// been reparented to init/pid 1, or the recorded owner pid is dead. A
|
||||
// child still owned by a live instance is left untouched.
|
||||
//
|
||||
// The VS Code extension cannot import this module (it does not bundle the web
|
||||
// package); it carries a parity implementation that reads/writes the SAME dir.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const resolveRegistryDir = () => {
|
||||
const override = process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY;
|
||||
if (override && override.trim()) return override.trim();
|
||||
return path.join(os.homedir(), '.config', 'openchamber', 'managed-opencode');
|
||||
};
|
||||
|
||||
const entryFilePath = (pid) => path.join(resolveRegistryDir(), `${pid}.json`);
|
||||
|
||||
const writeEntryFile = (entry) => {
|
||||
const dir = resolveRegistryDir();
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const filePath = path.join(dir, `${entry.pid}.json`);
|
||||
const tmp = `${filePath}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(entry, null, 2));
|
||||
fs.renameSync(tmp, filePath);
|
||||
} catch {
|
||||
// Best-effort: a failed registry write must never break spawn/shutdown.
|
||||
}
|
||||
};
|
||||
|
||||
const readAllEntries = () => {
|
||||
const dir = resolveRegistryDir();
|
||||
let names = [];
|
||||
try {
|
||||
names = fs.readdirSync(dir).filter((name) => name.endsWith('.json'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out = [];
|
||||
for (const name of names) {
|
||||
const filePath = path.join(dir, name);
|
||||
try {
|
||||
const entry = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
if (entry && Number.isInteger(entry.pid)) {
|
||||
out.push({ entry, filePath });
|
||||
} else {
|
||||
fs.rmSync(filePath, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// Corrupt/partial file — drop it.
|
||||
try { fs.rmSync(filePath, { force: true }); } catch {}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Record an OpenCode process WE spawned so a future run can reap it if orphaned. */
|
||||
export const registerManagedProcess = ({ pid, ownerPid, port, binary, runtime } = {}) => {
|
||||
if (!Number.isInteger(pid)) return;
|
||||
writeEntryFile({
|
||||
pid,
|
||||
ownerPid: Number.isInteger(ownerPid) ? ownerPid : process.pid,
|
||||
port: Number.isInteger(port) ? port : null,
|
||||
binary: typeof binary === 'string' ? binary : null,
|
||||
runtime: typeof runtime === 'string' ? runtime : 'web',
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
/** Drop a pid from the registry (after we have killed/closed it ourselves). */
|
||||
export const unregisterManagedProcess = (pid) => {
|
||||
if (!Number.isInteger(pid)) return;
|
||||
try {
|
||||
fs.rmSync(entryFilePath(pid), { force: true });
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
const isPidAlive = (pid) => {
|
||||
if (!Number.isInteger(pid)) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// EPERM = process exists but we lack permission to signal it → still alive.
|
||||
return error?.code === 'EPERM';
|
||||
}
|
||||
};
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Returns { ppid, command } for a live pid on Unix, or null if it can't be read.
|
||||
const readUnixProcInfo = (pid) => {
|
||||
try {
|
||||
const result = spawnSync('ps', ['-p', String(pid), '-o', 'ppid=,command='], {
|
||||
encoding: 'utf8',
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const line = (result.stdout || '').trim();
|
||||
if (!line) return null;
|
||||
const match = line.match(/^\s*(\d+)\s+(.*)$/);
|
||||
if (!match) return null;
|
||||
return { ppid: Number.parseInt(match[1], 10), command: match[2] };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Windows image name for a pid (e.g. "opencode.exe"), or null.
|
||||
const readWindowsImageName = (pid) => {
|
||||
try {
|
||||
const result = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
return (result.stdout || '').trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const commandIdentifiesOurServer = (command, entry) => {
|
||||
if (typeof command !== 'string') return false;
|
||||
const lower = command.toLowerCase();
|
||||
if (!lower.includes('opencode') || !lower.includes('serve')) return false;
|
||||
// Tie to the exact server we registered when we know its port, so a recycled
|
||||
// pid running a *different* opencode server is never mistaken for ours.
|
||||
if (Number.isInteger(entry.port) && !command.includes(String(entry.port))) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const killOrphan = async (pid) => {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000, windowsHide: true });
|
||||
} catch {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const signalTree = (signal) => {
|
||||
try { process.kill(-pid, signal); } catch {}
|
||||
try { process.kill(pid, signal); } catch {}
|
||||
};
|
||||
|
||||
signalTree('SIGTERM');
|
||||
for (let waited = 0; waited < 1500 && isPidAlive(pid); waited += 150) {
|
||||
await sleep(150);
|
||||
}
|
||||
if (isPidAlive(pid)) {
|
||||
signalTree('SIGKILL');
|
||||
await sleep(300);
|
||||
}
|
||||
};
|
||||
|
||||
// Decide+act on a single registry entry. Returns true if it was reaped.
|
||||
const processEntry = async (entry, { log }) => {
|
||||
// Dead pid → nothing to do (caller drops the file).
|
||||
if (!isPidAlive(entry.pid)) return false;
|
||||
|
||||
const ownerGone = Number.isInteger(entry.ownerPid) && !isPidAlive(entry.ownerPid);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const image = readWindowsImageName(entry.pid);
|
||||
const looksLikeOpencode = typeof image === 'string' && image.toLowerCase().includes('opencode');
|
||||
// Windows lacks reliable reparent-to-1 semantics (job objects usually kill
|
||||
// children with the parent), so we reap only when the owner is provably dead
|
||||
// AND the image still looks like opencode.
|
||||
if (looksLikeOpencode && ownerGone) {
|
||||
await killOrphan(entry.pid);
|
||||
log?.(`[lifecycle] reaped orphaned OpenCode pid ${entry.pid} (owner ${entry.ownerPid} gone)`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const info = readUnixProcInfo(entry.pid);
|
||||
// Can't verify identity (or it's not our server) → leave it alone.
|
||||
if (!info || !commandIdentifiesOurServer(info.command, entry)) return false;
|
||||
|
||||
const orphaned = info.ppid === 1 || ownerGone;
|
||||
if (!orphaned) return false; // still owned by a live instance
|
||||
|
||||
await killOrphan(entry.pid);
|
||||
log?.(`[lifecycle] reaped orphaned OpenCode pid ${entry.pid} (reparented/owner gone)`);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Kill any genuinely-orphaned OpenCode processes WE previously spawned, and
|
||||
* prune their registry files. Safe to call at startup before spawning a new
|
||||
* server. Returns { inspected, reaped }.
|
||||
*/
|
||||
export const reapOrphanedProcesses = async ({ log } = {}) => {
|
||||
const records = readAllEntries();
|
||||
if (records.length === 0) return { inspected: 0, reaped: 0 };
|
||||
|
||||
let reaped = 0;
|
||||
for (const { entry, filePath } of records) {
|
||||
let drop = false;
|
||||
try {
|
||||
const wasReaped = await processEntry(entry, { log });
|
||||
if (wasReaped) reaped += 1;
|
||||
// Drop the file when the process is gone (reaped now, or already dead);
|
||||
// keep it only while the process is still alive and owned by a live owner.
|
||||
drop = wasReaped || !isPidAlive(entry.pid);
|
||||
} catch (error) {
|
||||
log?.(`[lifecycle] reap check failed for pid ${entry.pid}: ${error?.message ?? error}`);
|
||||
}
|
||||
if (drop) {
|
||||
try { fs.rmSync(filePath, { force: true }); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return { inspected: records.length, reaped };
|
||||
};
|
||||
@@ -2,9 +2,9 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
export const NPM_CACHE_TTL_MS = 3_600_000;
|
||||
export const NPM_FETCH_TIMEOUT_MS = 5_000;
|
||||
export const NPM_REGISTRY_BASE = 'https://registry.npmjs.org';
|
||||
const NPM_CACHE_TTL_MS = 3_600_000;
|
||||
const NPM_FETCH_TIMEOUT_MS = 5_000;
|
||||
const NPM_REGISTRY_BASE = 'https://registry.npmjs.org';
|
||||
|
||||
/**
|
||||
* @typedef {Object} NpmPackagePayload
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { createRealpathCache } from '../path-realpath-cache.js';
|
||||
|
||||
// Browser transport percent-encodes directory hints and marks them explicitly.
|
||||
// Only marked values are decoded so literal percent sequences from direct API
|
||||
// clients are preserved.
|
||||
const safeDecodeMarkedURIComponent = (value, encoding) => {
|
||||
if (encoding !== 'uri') return value;
|
||||
try { return decodeURIComponent(value); } catch { return value; }
|
||||
};
|
||||
|
||||
export const createProjectDirectoryRuntime = (dependencies) => {
|
||||
const {
|
||||
fsPromises,
|
||||
@@ -50,18 +58,24 @@ export const createProjectDirectoryRuntime = (dependencies) => {
|
||||
};
|
||||
|
||||
const resolveProjectDirectory = async (req) => {
|
||||
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||
const rawHeaderDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||
const headerEncoding = typeof req.get === 'function' ? req.get('x-opencode-directory-encoding') : null;
|
||||
const headerDirectory = rawHeaderDirectory ? safeDecodeMarkedURIComponent(rawHeaderDirectory, headerEncoding) : null;
|
||||
const queryDirectory = Array.isArray(req.query?.directory)
|
||||
? req.query.directory[0]
|
||||
: req.query?.directory;
|
||||
const requested = headerDirectory || queryDirectory || null;
|
||||
const requested = [headerDirectory, queryDirectory].filter(Boolean);
|
||||
|
||||
if (requested) {
|
||||
const validated = await validateDirectoryPath(requested);
|
||||
if (!validated.ok) {
|
||||
return { directory: null, error: validated.error };
|
||||
if (requested.length > 0) {
|
||||
let lastError = null;
|
||||
for (const candidate of requested) {
|
||||
const validated = await validateDirectoryPath(candidate);
|
||||
if (validated.ok) {
|
||||
return { directory: validated.directory, error: null };
|
||||
}
|
||||
lastError = validated.error;
|
||||
}
|
||||
return { directory: validated.directory, error: null };
|
||||
return { directory: null, error: lastError };
|
||||
}
|
||||
|
||||
const readSettings = typeof getReadSettingsFromDiskMigrated === 'function'
|
||||
@@ -103,22 +117,27 @@ export const createProjectDirectoryRuntime = (dependencies) => {
|
||||
};
|
||||
|
||||
const resolveOptionalProjectDirectory = async (req) => {
|
||||
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||
const rawHeaderDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||
const headerEncoding = typeof req.get === 'function' ? req.get('x-opencode-directory-encoding') : null;
|
||||
const headerDirectory = rawHeaderDirectory ? safeDecodeMarkedURIComponent(rawHeaderDirectory, headerEncoding) : null;
|
||||
const queryDirectory = Array.isArray(req.query?.directory)
|
||||
? req.query.directory[0]
|
||||
: req.query?.directory;
|
||||
const requested = headerDirectory || queryDirectory || null;
|
||||
const requested = [headerDirectory, queryDirectory].filter(Boolean);
|
||||
|
||||
if (!requested) {
|
||||
if (requested.length === 0) {
|
||||
return { directory: null, error: null };
|
||||
}
|
||||
|
||||
const validated = await validateDirectoryPath(requested);
|
||||
if (!validated.ok) {
|
||||
return { directory: null, error: validated.error };
|
||||
let lastError = null;
|
||||
for (const candidate of requested) {
|
||||
const validated = await validateDirectoryPath(candidate);
|
||||
if (validated.ok) {
|
||||
return { directory: validated.directory, error: null };
|
||||
}
|
||||
lastError = validated.error;
|
||||
}
|
||||
|
||||
return { directory: validated.directory, error: null };
|
||||
return { directory: null, error: lastError };
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -128,6 +128,80 @@ describe('project directory runtime', () => {
|
||||
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
|
||||
});
|
||||
|
||||
it('decodes marked x-opencode-directory header values', async () => {
|
||||
const pathWithUnicode = '/home/user/测试项目';
|
||||
let validatedPath = null;
|
||||
const runtime = createTestRuntime({
|
||||
fsPromises: {
|
||||
stat: async (p) => {
|
||||
validatedPath = p;
|
||||
return { isDirectory: () => true };
|
||||
},
|
||||
realpath: async (p) => p,
|
||||
},
|
||||
});
|
||||
|
||||
const req = {
|
||||
get: (header) => {
|
||||
if (header === 'x-opencode-directory') return encodeURIComponent(pathWithUnicode);
|
||||
if (header === 'x-opencode-directory-encoding') return 'uri';
|
||||
return null;
|
||||
},
|
||||
query: {},
|
||||
};
|
||||
|
||||
const result = await runtime.resolveProjectDirectory(req);
|
||||
|
||||
expect(validatedPath).toBe(pathWithUnicode);
|
||||
expect(result).toEqual({ directory: pathWithUnicode, error: null });
|
||||
});
|
||||
|
||||
it('preserves raw percent sequences without directory encoding marker', async () => {
|
||||
const rawPath = '/home/user/foo%20bar';
|
||||
let validatedPath = null;
|
||||
const runtime = createTestRuntime({
|
||||
fsPromises: {
|
||||
stat: async (p) => {
|
||||
validatedPath = p;
|
||||
return { isDirectory: () => true };
|
||||
},
|
||||
realpath: async (p) => p,
|
||||
},
|
||||
});
|
||||
|
||||
const req = {
|
||||
get: (header) => header === 'x-opencode-directory' ? rawPath : null,
|
||||
query: {},
|
||||
};
|
||||
|
||||
const result = await runtime.resolveProjectDirectory(req);
|
||||
|
||||
expect(validatedPath).toBe(rawPath);
|
||||
expect(result).toEqual({ directory: rawPath, error: null });
|
||||
});
|
||||
|
||||
it('falls back to query directory when an unmarked encoded header is invalid', async () => {
|
||||
const validPath = '/home/user/workspace/project';
|
||||
const runtime = createTestRuntime({
|
||||
fsPromises: {
|
||||
stat: async (p) => {
|
||||
if (p === validPath) return { isDirectory: () => true };
|
||||
throw { code: 'ENOENT' };
|
||||
},
|
||||
realpath: async (p) => p,
|
||||
},
|
||||
});
|
||||
|
||||
const req = {
|
||||
get: (header) => header === 'x-opencode-directory' ? encodeURIComponent(validPath) : null,
|
||||
query: { directory: validPath },
|
||||
};
|
||||
|
||||
const result = await runtime.resolveProjectDirectory(req);
|
||||
|
||||
expect(result).toEqual({ directory: validPath, error: null });
|
||||
});
|
||||
|
||||
it('resolves symlinks in query directory parameter', async () => {
|
||||
const runtime = createTestRuntime({
|
||||
fsPromises: {
|
||||
@@ -222,5 +296,29 @@ describe('project directory runtime', () => {
|
||||
|
||||
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
|
||||
});
|
||||
|
||||
it('preserves raw percent sequences without directory encoding marker', async () => {
|
||||
const rawPath = '/optional/foo%25bar';
|
||||
let validatedPath = null;
|
||||
const runtime = createTestRuntime({
|
||||
fsPromises: {
|
||||
stat: async (p) => {
|
||||
validatedPath = p;
|
||||
return { isDirectory: () => true };
|
||||
},
|
||||
realpath: async (p) => p,
|
||||
},
|
||||
});
|
||||
|
||||
const req = {
|
||||
get: (header) => header === 'x-opencode-directory' ? rawPath : null,
|
||||
query: {},
|
||||
};
|
||||
|
||||
const result = await runtime.resolveOptionalProjectDirectory(req);
|
||||
|
||||
expect(validatedPath).toBe(rawPath);
|
||||
expect(result).toEqual({ directory: rawPath, error: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,26 @@ export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions }
|
||||
};
|
||||
};
|
||||
|
||||
export const waitForSseDrain = (res, signal) => new Promise((resolve) => {
|
||||
export const normalizeForwardedDirectoryHeaders = (headers) => {
|
||||
const rawDirectory = headers?.['x-opencode-directory'];
|
||||
if (typeof rawDirectory !== 'string') {
|
||||
return headers;
|
||||
}
|
||||
|
||||
if (headers['x-opencode-directory-encoding'] !== 'uri') {
|
||||
return headers;
|
||||
}
|
||||
|
||||
try {
|
||||
headers['x-opencode-directory'] = decodeURIComponent(rawDirectory);
|
||||
} catch {
|
||||
// Leave malformed values untouched; upstream will reject invalid paths.
|
||||
}
|
||||
delete headers['x-opencode-directory-encoding'];
|
||||
return headers;
|
||||
};
|
||||
|
||||
const waitForSseDrain = (res, signal) => new Promise((resolve) => {
|
||||
if (signal?.aborted || res.writableEnded || res.destroyed) {
|
||||
resolve();
|
||||
return;
|
||||
@@ -113,7 +132,7 @@ const SESSION_LIST_ALLOWED_FIELDS = [
|
||||
'project',
|
||||
];
|
||||
|
||||
export const sanitizeSessionListItem = (session) => {
|
||||
const sanitizeSessionListItem = (session) => {
|
||||
if (!session || typeof session !== 'object' || Array.isArray(session)) {
|
||||
return session;
|
||||
}
|
||||
@@ -149,7 +168,7 @@ export const sanitizeSessionListItem = (session) => {
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
export const sanitizeSessionListPayload = (payload) => {
|
||||
const sanitizeSessionListPayload = (payload) => {
|
||||
if (!Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
@@ -295,7 +314,9 @@ export const registerOpenCodeProxy = (app, deps) => {
|
||||
? req.originalUrl
|
||||
: (typeof req.url === 'string' ? req.url : '');
|
||||
const upstreamPath = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl;
|
||||
const headers = collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders());
|
||||
const headers = normalizeForwardedDirectoryHeaders(
|
||||
collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())
|
||||
);
|
||||
headers.accept ??= 'text/event-stream';
|
||||
headers['cache-control'] ??= 'no-cache';
|
||||
|
||||
@@ -414,7 +435,7 @@ export const registerOpenCodeProxy = (app, deps) => {
|
||||
const fetchSessionListPayload = async (upstreamPath, { req = null, timeoutMs = null } = {}) => {
|
||||
const headers = req
|
||||
? {
|
||||
...collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders()),
|
||||
...normalizeForwardedDirectoryHeaders(collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())),
|
||||
accept: 'application/json',
|
||||
'accept-encoding': 'identity',
|
||||
}
|
||||
@@ -654,6 +675,18 @@ export const registerOpenCodeProxy = (app, deps) => {
|
||||
proxyReq.setHeader('Authorization', authHeaders.Authorization);
|
||||
}
|
||||
|
||||
if (req.headers?.['x-opencode-directory-encoding'] === 'uri') {
|
||||
const rawDirectory = req.headers['x-opencode-directory'];
|
||||
if (typeof rawDirectory === 'string') {
|
||||
try {
|
||||
proxyReq.setHeader('x-opencode-directory', decodeURIComponent(rawDirectory));
|
||||
} catch {
|
||||
proxyReq.setHeader('x-opencode-directory', rawDirectory);
|
||||
}
|
||||
}
|
||||
proxyReq.removeHeader?.('x-opencode-directory-encoding');
|
||||
}
|
||||
|
||||
// Defensive: request identity encoding from upstream OpenCode.
|
||||
// This avoids compressed-body/header mismatches in multi-proxy setups.
|
||||
proxyReq.setHeader('accept-encoding', 'identity');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createDirectoryQueryCanonicalizer } from './proxy.js';
|
||||
import { createDirectoryQueryCanonicalizer, normalizeForwardedDirectoryHeaders } from './proxy.js';
|
||||
|
||||
describe('createDirectoryQueryCanonicalizer', () => {
|
||||
it('canonicalizes directory query params and preserves other params', async () => {
|
||||
@@ -70,3 +70,26 @@ describe('createDirectoryQueryCanonicalizer', () => {
|
||||
await expect(canonicalize('/session?foo=1')).resolves.toBe('/session?foo=1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeForwardedDirectoryHeaders', () => {
|
||||
it('decodes marked directory headers before forwarding to OpenCode', () => {
|
||||
const headers = normalizeForwardedDirectoryHeaders({
|
||||
'x-opencode-directory': encodeURIComponent('/Users/example/project'),
|
||||
'x-opencode-directory-encoding': 'uri',
|
||||
});
|
||||
|
||||
expect(headers).toEqual({
|
||||
'x-opencode-directory': '/Users/example/project',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves unmarked percent sequences from direct clients', () => {
|
||||
const headers = normalizeForwardedDirectoryHeaders({
|
||||
'x-opencode-directory': '/Users/example/project%20literal',
|
||||
});
|
||||
|
||||
expect(headers).toEqual({
|
||||
'x-opencode-directory': '/Users/example/project%20literal',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,9 +131,15 @@ export const createServerStartupRuntime = (dependencies) => {
|
||||
const handleSignal = async () => {
|
||||
await gracefulShutdown();
|
||||
};
|
||||
// Cover every signal a shell or dev harness may use to stop/restart us, so
|
||||
// the managed OpenCode child is always torn down gracefully instead of
|
||||
// orphaned: SIGINT/SIGQUIT (Ctrl+C/Ctrl+\), SIGTERM (kill/default), SIGHUP
|
||||
// (terminal close), SIGUSR2 (nodemon restart for `dev:server:watch`).
|
||||
process.on('SIGTERM', handleSignal);
|
||||
process.on('SIGINT', handleSignal);
|
||||
process.on('SIGQUIT', handleSignal);
|
||||
process.on('SIGHUP', handleSignal);
|
||||
process.on('SIGUSR2', handleSignal);
|
||||
setSignalsAttached(true);
|
||||
syncToHmrState();
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
const SHORTCUT_OVERRIDE_VALUE_MAX_LENGTH = 128;
|
||||
const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']);
|
||||
const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']);
|
||||
const HIDDEN_MODELS_MAX = 1024;
|
||||
const RECENT_EFFORTS_MAX_KEYS = 128;
|
||||
const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5;
|
||||
|
||||
const sanitizeShortcutOverrides = (value) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
@@ -41,6 +44,35 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const sanitizeRecentEfforts = (value) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const result = {};
|
||||
const seenKeys = new Set();
|
||||
let count = 0;
|
||||
for (const [rawKey, rawVariants] of Object.entries(value)) {
|
||||
const key = typeof rawKey === 'string' ? rawKey.trim() : '';
|
||||
if (!key || seenKeys.has(key)) continue;
|
||||
if (!Array.isArray(rawVariants)) continue;
|
||||
const variants = [];
|
||||
const seenVariants = new Set();
|
||||
for (const rawVariant of rawVariants) {
|
||||
const variant = typeof rawVariant === 'string' ? rawVariant.trim() : '';
|
||||
if (!variant || seenVariants.has(variant)) continue;
|
||||
seenVariants.add(variant);
|
||||
variants.push(variant);
|
||||
if (variants.length >= RECENT_EFFORTS_MAX_VARIANTS_PER_KEY) break;
|
||||
}
|
||||
if (variants.length === 0) continue;
|
||||
seenKeys.add(key);
|
||||
result[key] = variants;
|
||||
count += 1;
|
||||
if (count >= RECENT_EFFORTS_MAX_KEYS) break;
|
||||
}
|
||||
return count > 0 ? result : null;
|
||||
};
|
||||
|
||||
const normalizePwaAppName = (value, fallback = '') => {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
@@ -74,6 +106,20 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const normalizeFollowUpBehavior = (value, legacyQueueModeEnabled = null) => {
|
||||
// "immediate" was removed (it was wire-identical to "steer"); collapse it.
|
||||
if (value === 'immediate') {
|
||||
return 'steer';
|
||||
}
|
||||
if (value === 'steer' || value === 'queue') {
|
||||
return value;
|
||||
}
|
||||
if (legacyQueueModeEnabled === false) {
|
||||
return 'steer';
|
||||
}
|
||||
return 'queue';
|
||||
};
|
||||
|
||||
const sanitizeSettingsUpdate = (payload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return {};
|
||||
@@ -132,6 +178,9 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
if (typeof candidate.desktopLanAccessEnabled === 'boolean') {
|
||||
result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled;
|
||||
}
|
||||
if (typeof candidate.desktopKeepAwakeEnabled === 'boolean') {
|
||||
result.desktopKeepAwakeEnabled = candidate.desktopKeepAwakeEnabled;
|
||||
}
|
||||
if (typeof candidate.desktopUiPassword === 'string') {
|
||||
result.desktopUiPassword = candidate.desktopUiPassword.trim();
|
||||
}
|
||||
@@ -329,8 +378,10 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
const trimmed = candidate.defaultGitIdentityId.trim();
|
||||
result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof candidate.queueModeEnabled === 'boolean') {
|
||||
result.queueModeEnabled = candidate.queueModeEnabled;
|
||||
if (typeof candidate.followUpBehavior === 'string') {
|
||||
result.followUpBehavior = normalizeFollowUpBehavior(candidate.followUpBehavior);
|
||||
} else if (typeof candidate.queueModeEnabled === 'boolean') {
|
||||
result.followUpBehavior = normalizeFollowUpBehavior(undefined, candidate.queueModeEnabled);
|
||||
}
|
||||
if (typeof candidate.autoCreateWorktree === 'boolean') {
|
||||
result.autoCreateWorktree = candidate.autoCreateWorktree;
|
||||
@@ -474,6 +525,28 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
if (recentModels) {
|
||||
result.recentModels = recentModels;
|
||||
}
|
||||
|
||||
// Cap at 1024: users with several providers (anthropic, openai, google,
|
||||
// bedrock, azure, etc.) each exposing dozens-to-hundreds of models can
|
||||
// exceed 256 hidden entries quickly. 1024 covers dense multi-provider
|
||||
// setups while still bounding persistence/memory.
|
||||
const hiddenModels = sanitizeModelRefs(candidate.hiddenModels, HIDDEN_MODELS_MAX);
|
||||
if (hiddenModels) {
|
||||
result.hiddenModels = hiddenModels;
|
||||
}
|
||||
|
||||
if (Array.isArray(candidate.collapsedModelProviders)) {
|
||||
result.collapsedModelProviders = normalizeStringArray(candidate.collapsedModelProviders);
|
||||
}
|
||||
|
||||
if (Array.isArray(candidate.recentAgents)) {
|
||||
result.recentAgents = normalizeStringArray(candidate.recentAgents);
|
||||
}
|
||||
|
||||
const recentEfforts = sanitizeRecentEfforts(candidate.recentEfforts);
|
||||
if (recentEfforts) {
|
||||
result.recentEfforts = recentEfforts;
|
||||
}
|
||||
if (typeof candidate.diffLayoutPreference === 'string') {
|
||||
const mode = candidate.diffLayoutPreference.trim();
|
||||
if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createSettingsHelpers } from './settings-helpers.js';
|
||||
import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js';
|
||||
|
||||
const createTestHelpers = () => createSettingsHelpers({
|
||||
normalizePathForPersistence: (value) => value,
|
||||
@@ -20,6 +21,42 @@ const createTestHelpers = () => createSettingsHelpers({
|
||||
sanitizeProjects: () => undefined,
|
||||
});
|
||||
|
||||
const createTestHelpersWithRealSanitizers = () => {
|
||||
const runtime = createSettingsNormalizationRuntime({
|
||||
os: { homedir: () => '/home/testuser' },
|
||||
path: {
|
||||
resolve: (...args) => args[args.length - 1],
|
||||
sep: '/',
|
||||
dirname: (p) => p.split('/').slice(0, -1).join('/') || '/',
|
||||
},
|
||||
processLike: { platform: 'linux', env: {} },
|
||||
realpathSync: (p) => p,
|
||||
tunnelBootstrapTtlDefaultMs: 600000,
|
||||
tunnelBootstrapTtlMinMs: 60000,
|
||||
tunnelBootstrapTtlMaxMs: 3600000,
|
||||
tunnelSessionTtlDefaultMs: 86400000,
|
||||
tunnelSessionTtlMinMs: 3600000,
|
||||
tunnelSessionTtlMaxMs: 604800000,
|
||||
});
|
||||
return createSettingsHelpers({
|
||||
normalizePathForPersistence: (value) => value,
|
||||
normalizeDirectoryPath: (value) => value,
|
||||
normalizeTunnelBootstrapTtlMs: (value) => value,
|
||||
normalizeTunnelSessionTtlMs: (value) => value,
|
||||
normalizeTunnelProvider: (value) => value,
|
||||
normalizeTunnelMode: (value) => value,
|
||||
normalizeOptionalPath: (value) => value,
|
||||
normalizeManagedRemoteTunnelHostname: (value) => value,
|
||||
normalizeManagedRemoteTunnelPresets: () => undefined,
|
||||
normalizeManagedRemoteTunnelPresetTokens: () => undefined,
|
||||
sanitizeTypographySizesPartial: () => undefined,
|
||||
normalizeStringArray: runtime.normalizeStringArray,
|
||||
sanitizeModelRefs: runtime.sanitizeModelRefs,
|
||||
sanitizeSkillCatalogs: () => undefined,
|
||||
sanitizeProjects: () => undefined,
|
||||
});
|
||||
};
|
||||
|
||||
describe('settings helpers', () => {
|
||||
it('accepts messageStreamTransport as a persisted shared setting', () => {
|
||||
const helpers = createTestHelpers();
|
||||
@@ -52,6 +89,17 @@ describe('settings helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts desktopKeepAwakeEnabled as a persisted shared setting', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ desktopKeepAwakeEnabled: true })).toEqual({
|
||||
desktopKeepAwakeEnabled: true,
|
||||
});
|
||||
expect(helpers.sanitizeSettingsUpdate({ desktopKeepAwakeEnabled: false })).toEqual({
|
||||
desktopKeepAwakeEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts desktopUiPassword as a persisted shared setting', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
@@ -188,4 +236,121 @@ describe('settings helpers', () => {
|
||||
else delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON;
|
||||
}
|
||||
});
|
||||
|
||||
describe('previously-dropped model selector persistence fields', () => {
|
||||
it('round-trips hiddenModels through the sanitizer', () => {
|
||||
const helpers = createTestHelpersWithRealSanitizers();
|
||||
const input = [
|
||||
{ providerID: 'anthropic', modelID: 'claude-opus-4' },
|
||||
{ providerID: 'openai', modelID: 'gpt-5' },
|
||||
];
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: input })).toEqual({
|
||||
hiddenModels: input,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty hiddenModels the same way as empty favoriteModels', () => {
|
||||
const helpers = createTestHelpersWithRealSanitizers();
|
||||
|
||||
const hiddenResult = helpers.sanitizeSettingsUpdate({ hiddenModels: [] });
|
||||
const favoriteResult = helpers.sanitizeSettingsUpdate({ favoriteModels: [] });
|
||||
|
||||
expect(hiddenResult.hiddenModels).toEqual([]);
|
||||
expect(favoriteResult.favoriteModels).toEqual([]);
|
||||
expect(hiddenResult.hiddenModels).toEqual(favoriteResult.favoriteModels);
|
||||
});
|
||||
|
||||
it('round-trips collapsedModelProviders and recentAgents as string arrays', () => {
|
||||
const helpers = createTestHelpersWithRealSanitizers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: ['anthropic', 'openai'] })).toEqual({
|
||||
collapsedModelProviders: ['anthropic', 'openai'],
|
||||
});
|
||||
expect(helpers.sanitizeSettingsUpdate({ recentAgents: ['build', 'plan'] })).toEqual({
|
||||
recentAgents: ['build', 'plan'],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips recentEfforts as a Record<string, string[]>', () => {
|
||||
const helpers = createTestHelpersWithRealSanitizers();
|
||||
const input = {
|
||||
'anthropic/claude-opus-4': ['high', 'default'],
|
||||
'openai/gpt-5': ['low'],
|
||||
};
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: input })).toEqual({
|
||||
recentEfforts: input,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects garbage hiddenModels input the same way sanitizeModelRefs rejects bad refs', () => {
|
||||
const helpers = createTestHelpersWithRealSanitizers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: 'not-an-array' })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: null })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: 123 })).toEqual({});
|
||||
expect(
|
||||
helpers.sanitizeSettingsUpdate({
|
||||
hiddenModels: [
|
||||
{ providerID: 'anthropic' },
|
||||
{ modelID: 'gpt-5' },
|
||||
'not-an-object',
|
||||
null,
|
||||
{ providerID: ' ', modelID: 'x' },
|
||||
{ providerID: 'openai', modelID: '' },
|
||||
],
|
||||
})
|
||||
).toEqual({ hiddenModels: [] });
|
||||
});
|
||||
|
||||
it('rejects garbage collapsedModelProviders and recentAgents input', () => {
|
||||
const helpers = createTestHelpersWithRealSanitizers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: 'anthropic' })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: null })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ recentAgents: 42 })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ recentAgents: { build: 1 } })).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects garbage recentEfforts input', () => {
|
||||
const helpers = createTestHelpersWithRealSanitizers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: 'not-an-object' })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: [] })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: null })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': 'high' } })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { '': ['high'] } })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [] } })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [123, ''] } })).toEqual({});
|
||||
});
|
||||
|
||||
it('survives a full settings.json payload containing all four previously-dropped fields (regression)', () => {
|
||||
const helpers = createTestHelpersWithRealSanitizers();
|
||||
const payload = {
|
||||
themeId: 'default',
|
||||
hiddenModels: [
|
||||
{ providerID: 'anthropic', modelID: 'claude-opus-4' },
|
||||
{ providerID: 'openai', modelID: 'gpt-5' },
|
||||
],
|
||||
collapsedModelProviders: ['anthropic', 'openai'],
|
||||
recentAgents: ['build', 'plan'],
|
||||
recentEfforts: {
|
||||
'anthropic/claude-opus-4': ['high', 'default'],
|
||||
'openai/gpt-5': ['low'],
|
||||
},
|
||||
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }],
|
||||
recentModels: [{ providerID: 'openai', modelID: 'gpt-5' }],
|
||||
};
|
||||
|
||||
const sanitized = helpers.sanitizeSettingsUpdate(payload);
|
||||
|
||||
expect(sanitized.hiddenModels).toEqual(payload.hiddenModels);
|
||||
expect(sanitized.collapsedModelProviders).toEqual(payload.collapsedModelProviders);
|
||||
expect(sanitized.recentAgents).toEqual(payload.recentAgents);
|
||||
expect(sanitized.recentEfforts).toEqual(payload.recentEfforts);
|
||||
expect(sanitized.favoriteModels).toEqual(payload.favoriteModels);
|
||||
expect(sanitized.recentModels).toEqual(payload.recentModels);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -507,20 +507,14 @@ export {
|
||||
COMMAND_DIR,
|
||||
SKILL_DIR,
|
||||
CONFIG_FILE,
|
||||
CUSTOM_CONFIG_FILE,
|
||||
PROMPT_FILE_PATTERN,
|
||||
AGENT_SCOPE,
|
||||
COMMAND_SCOPE,
|
||||
SKILL_SCOPE,
|
||||
ensureDirs,
|
||||
parseMdFile,
|
||||
writeMdFile,
|
||||
getProjectConfigCandidates,
|
||||
getProjectConfigPath,
|
||||
getConfigPaths,
|
||||
readConfigFile,
|
||||
isPlainObject,
|
||||
mergeConfigs,
|
||||
readConfigLayers,
|
||||
readConfig,
|
||||
getConfigForPath,
|
||||
|
||||
@@ -594,8 +594,6 @@ function deleteSkill(skillName, workingDirectory) {
|
||||
|
||||
export {
|
||||
getSkillSources,
|
||||
getSkillScope,
|
||||
getSkillWritePath,
|
||||
discoverSkills,
|
||||
mergeDiscoveredSkills,
|
||||
createSkill,
|
||||
|
||||
@@ -240,5 +240,3 @@ export function expandSnippets(text, workingDirectory) {
|
||||
const expanded = expandText(text || '', registry, new Map(), collector).trim();
|
||||
return [...collector.prepend, expanded, ...collector.append].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export { assertValidSnippetName };
|
||||
|
||||
@@ -634,7 +634,7 @@ export function getCurrentVersion() {
|
||||
/**
|
||||
* Fetch latest version from npm registry
|
||||
*/
|
||||
export async function getLatestVersion() {
|
||||
async function getLatestVersion() {
|
||||
try {
|
||||
const response = await fetch(NPM_REGISTRY_URL, {
|
||||
headers: { Accept: 'application/json' },
|
||||
@@ -690,7 +690,7 @@ function compareVersions(left, right) {
|
||||
/**
|
||||
* Fetch changelog notes between versions
|
||||
*/
|
||||
export async function fetchChangelogNotes(fromVersion, toVersion) {
|
||||
async function fetchChangelogNotes(fromVersion, toVersion) {
|
||||
try {
|
||||
const response = await fetch(CHANGELOG_URL, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
|
||||
@@ -6,7 +6,12 @@ vi.mock('node:child_process', () => ({
|
||||
spawnSync: vi.fn(() => ({ status: 0, stdout: '/usr/local/bin', stderr: '' })),
|
||||
}));
|
||||
|
||||
const { checkForUpdates } = await import('./package-manager.js');
|
||||
const {
|
||||
checkForUpdates,
|
||||
detectPackageManager,
|
||||
executeUpdate,
|
||||
getCurrentVersion,
|
||||
} = await import('./package-manager.js');
|
||||
|
||||
/** Helper: create a fetch mock that routes by URL pattern */
|
||||
function createFetchMock() {
|
||||
@@ -244,3 +249,17 @@ describe('checkForUpdates', () => {
|
||||
expect(result.available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentVersion', () => {
|
||||
it('is exported for the CLI update command', () => {
|
||||
expect(typeof getCurrentVersion).toBe('function');
|
||||
expect(getCurrentVersion()).toMatch(/^\d+\.\d+\.\d+|unknown$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI update exports', () => {
|
||||
it('exports package-manager helpers used by the update command', () => {
|
||||
expect(typeof detectPackageManager).toBe('function');
|
||||
expect(typeof executeUpdate).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -557,11 +557,3 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
resolveProjectConfigPath,
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
MAX_TASK_NAME_LENGTH,
|
||||
MAX_TASK_PROMPT_LENGTH,
|
||||
MAX_CRON_LENGTH,
|
||||
MAX_LAST_ERROR_LENGTH,
|
||||
normalizeTaskForStorage,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ This module fetches quota and usage signals for supported providers in the web s
|
||||
- `packages/web/server/lib/quota/index.js`: public entrypoint imported by `packages/web/server/index.js`.
|
||||
- `packages/web/server/lib/quota/routes.js`: Express route registration for quota endpoints.
|
||||
- `packages/web/server/lib/quota/providers/index.js`: provider registry, configured-provider list, and provider dispatcher.
|
||||
- `packages/web/server/lib/quota/providers/interface.js`: JSDoc provider contract used as implementation reference.
|
||||
- `packages/web/server/lib/quota/providers/google/`: Google-specific auth, API, and transform modules.
|
||||
- `packages/web/server/lib/quota/utils/`: shared auth, transform, and formatting helpers.
|
||||
|
||||
@@ -28,8 +27,8 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
||||
| `openrouter` | OpenRouter | `providers/openrouter.js` | `openrouter` |
|
||||
| `zai-coding-plan` | z.ai | `providers/zai.js` | `zai-coding-plan`, `zai`, `z.ai` |
|
||||
| `zhipuai-coding-plan` | Zhipu AI Coding Plan | `providers/zhipuai-coding-plan.js` | `zhipuai-coding-plan`, `zhipuai`, `zhipu` |
|
||||
| `minimax-coding-plan` | MiniMax Coding Plan (minimax.io) | `providers/minimax-coding-plan.js` | `minimax-coding-plan` |
|
||||
| `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` | `minimax-cn-coding-plan` |
|
||||
| `minimax-coding-plan` | MiniMax Coding Plan (minimax.io) | `providers/minimax-coding-plan.js` / `providers/minimax-shared.js` | `minimax-coding-plan` |
|
||||
| `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` / `providers/minimax-shared.js` | `minimax-cn-coding-plan` |
|
||||
| `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Cookie file at `~/.config/ollama-quota/cookie` (raw session cookie string) |
|
||||
| `wafer` | Wafer.ai | `providers/wafer.js` | `wafer`, `wafer-ai`, `wafer_ai`, `wafer.ai` |
|
||||
|
||||
@@ -42,6 +41,9 @@ All providers should return results via shared helpers to preserve API shape:
|
||||
- Optional field: `error`
|
||||
- Unsupported provider requests should return `ok: false`, `configured: false`, `error: Unsupported provider`
|
||||
|
||||
Provider modules must export `providerId`, `providerName`, `aliases`, `isConfigured(auth?)`, and `fetchQuota()`.
|
||||
`fetchQuota()` should return a quota result with `usage.windows` keyed by window name (for example `5h`, `7d`, `daily`) and optional provider-specific `usage.models` data.
|
||||
|
||||
## Add a new provider (quick steps)
|
||||
1. Choose module shape based on complexity:
|
||||
- Simple providers: create `packages/web/server/lib/quota/providers/<provider>.js`.
|
||||
@@ -53,6 +55,16 @@ All providers should return results via shared helpers to preserve API shape:
|
||||
6. Update this file with the new provider ID, module path, and alias/auth details.
|
||||
7. Validate with `bun run type-check`, `bun run lint`, and `bun run build`.
|
||||
|
||||
## MiniMax M3 / Token Plan migration
|
||||
|
||||
In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 model release. The API underwent breaking changes:
|
||||
|
||||
- **Endpoint fallback**: The provider tries `/v1/token_plan/remains` (M3) first, falling back to legacy `/v1/api/openplatform/coding_plan/remains`.
|
||||
- **Field semantics**: On the `token_plan/remains` endpoint, `current_interval_usage_count` returns **remaining** quota (not consumed). The provider computes `used = total - remaining` for this endpoint. The legacy `coding_plan/remains` endpoint retains the old semantics (`usage_count = consumed`).
|
||||
- **Percentage-based plans**: Legacy Coding Plan accounts return `current_interval_total_count: 0` but include `current_interval_remaining_percent`. The provider prefers this field when count fields are absent.
|
||||
- **model_remains array**: Now contains entries for multiple model categories (chat, speech, video, image). The provider selects the chat-model entry by matching `MiniMax-M*`, then `general`/`chat`/`text` by name, then any entry with a remaining percent.
|
||||
- **Window status**: The `current_interval_status` and `current_weekly_status` fields indicate whether a window is active. Status `3` means the window is not applicable for the current plan tier (e.g. legacy plans without weekly limits). The provider omits inactive windows.
|
||||
|
||||
## Notes for contributors
|
||||
- Keep provider IDs stable; clients use them directly.
|
||||
- Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs.
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
export const providerId = 'claude';
|
||||
export const providerName = 'Claude';
|
||||
export const aliases = ['anthropic', 'claude'];
|
||||
const aliases = ['anthropic', 'claude'];
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
|
||||
export const providerId = 'codex';
|
||||
export const providerName = 'Codex';
|
||||
export const aliases = ['openai', 'codex', 'chatgpt'];
|
||||
const aliases = ['openai', 'codex', 'chatgpt'];
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
|
||||
@@ -40,7 +40,7 @@ const buildCopilotWindows = (payload) => {
|
||||
|
||||
export const providerId = 'github-copilot';
|
||||
export const providerName = 'GitHub Copilot';
|
||||
export const aliases = ['github-copilot', 'copilot'];
|
||||
const aliases = ['github-copilot', 'copilot'];
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
|
||||
@@ -21,7 +21,7 @@ const STATE_DB = join(homedir(), 'Library', 'Application Support', 'Cursor', 'Us
|
||||
|
||||
export const providerId = 'cursor';
|
||||
export const providerName = 'Cursor';
|
||||
export const aliases = ['cursor'];
|
||||
const aliases = ['cursor'];
|
||||
|
||||
const readJwtPayload = (token) => {
|
||||
try {
|
||||
|
||||
@@ -39,7 +39,7 @@ export const resolveGoogleOAuthClient = (sourceId) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveGeminiCliAuth = (auth) => {
|
||||
const resolveGeminiCliAuth = (auth) => {
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['google', 'google.oauth']));
|
||||
const entryObject = asObject(entry);
|
||||
if (!entryObject) {
|
||||
@@ -64,7 +64,7 @@ export const resolveGeminiCliAuth = (auth) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveAntigravityAuth = () => {
|
||||
const resolveAntigravityAuth = () => {
|
||||
for (const filePath of ANTIGRAVITY_ACCOUNTS_PATHS) {
|
||||
const data = readJsonFile(filePath);
|
||||
const accounts = data?.accounts;
|
||||
|
||||
@@ -1,30 +1,3 @@
|
||||
/**
|
||||
* Google Provider
|
||||
*
|
||||
* Google quota provider implementation.
|
||||
* @module quota/providers/google
|
||||
*/
|
||||
|
||||
export {
|
||||
resolveGoogleOAuthClient,
|
||||
resolveGeminiCliAuth,
|
||||
resolveAntigravityAuth,
|
||||
resolveGoogleAuthSources,
|
||||
DEFAULT_PROJECT_ID
|
||||
} from './auth.js';
|
||||
|
||||
export {
|
||||
resolveGoogleWindow,
|
||||
transformQuotaBucket,
|
||||
transformModelData
|
||||
} from './transforms.js';
|
||||
|
||||
export {
|
||||
refreshGoogleAccessToken,
|
||||
fetchGoogleQuotaBuckets,
|
||||
fetchGoogleModels
|
||||
} from './api.js';
|
||||
|
||||
import { buildResult } from '../../utils/index.js';
|
||||
import {
|
||||
resolveGoogleAuthSources,
|
||||
@@ -38,12 +11,20 @@ import {
|
||||
fetchGoogleModels
|
||||
} from './api.js';
|
||||
|
||||
export { resolveGoogleAuthSources } from './auth.js';
|
||||
|
||||
export const providerId = 'google';
|
||||
export const providerName = 'Google';
|
||||
export const aliases = ['google', 'google.oauth'];
|
||||
|
||||
export const isConfigured = () => resolveGoogleAuthSources().length > 0;
|
||||
|
||||
export const fetchGoogleQuota = async () => {
|
||||
const authSources = resolveGoogleAuthSources();
|
||||
if (!authSources.length) {
|
||||
return buildResult({
|
||||
providerId: 'google',
|
||||
providerName: 'Google',
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Not configured'
|
||||
@@ -103,8 +84,8 @@ export const fetchGoogleQuota = async () => {
|
||||
|
||||
if (!Object.keys(models).length) {
|
||||
return buildResult({
|
||||
providerId: 'google',
|
||||
providerName: 'Google',
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: sourceErrors[0] ?? 'Failed to fetch models'
|
||||
@@ -112,8 +93,8 @@ export const fetchGoogleQuota = async () => {
|
||||
}
|
||||
|
||||
return buildResult({
|
||||
providerId: 'google',
|
||||
providerName: 'Google',
|
||||
providerId,
|
||||
providerName,
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: {
|
||||
|
||||
@@ -29,7 +29,7 @@ export const parseGoogleRefreshToken = (rawRefreshToken) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveGoogleWindow = (sourceId, resetAt) => {
|
||||
const resolveGoogleWindow = (sourceId, resetAt) => {
|
||||
if (sourceId === 'gemini') {
|
||||
return { label: 'daily', seconds: GOOGLE_DAILY_WINDOW_SECONDS };
|
||||
}
|
||||
|
||||
@@ -43,9 +43,9 @@ const registry = {
|
||||
fetchQuota: cursor.fetchQuota
|
||||
},
|
||||
google: {
|
||||
providerId: 'google',
|
||||
providerName: 'Google',
|
||||
isConfigured: () => google.resolveGoogleAuthSources().length > 0,
|
||||
providerId: google.providerId,
|
||||
providerName: google.providerName,
|
||||
isConfigured: google.isConfigured,
|
||||
fetchQuota: google.fetchGoogleQuota
|
||||
},
|
||||
'zai-coding-plan': {
|
||||
@@ -168,7 +168,7 @@ export const fetchCopilotAddonQuota = copilot.fetchQuotaAddon;
|
||||
export const fetchKimiQuota = kimi.fetchQuota;
|
||||
export const fetchOpenRouterQuota = openrouter.fetchQuota;
|
||||
export const fetchZaiQuota = zai.fetchQuota;
|
||||
export const fetchZhipuaiCodingPlanQuota = zhipuaiCodingPlan.fetchQuota;
|
||||
const fetchZhipuaiCodingPlanQuota = zhipuaiCodingPlan.fetchQuota;
|
||||
export const fetchNanoGptQuota = nanogpt.fetchQuota;
|
||||
export const fetchMinimaxCodingPlanQuota = minimaxCodingPlan.fetchQuota;
|
||||
export const fetchMinimaxCnCodingPlanQuota = minimaxCnCodingPlan.fetchQuota;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import * as google from './google/index.js';
|
||||
import { listConfiguredQuotaProviders } from './index.js';
|
||||
|
||||
describe('quota provider registry', () => {
|
||||
it('exposes google provider configuration helpers through the provider module', () => {
|
||||
expect(google.providerId).toBe('google');
|
||||
expect(google.providerName).toBe('Google');
|
||||
expect(typeof google.isConfigured).toBe('function');
|
||||
expect(typeof google.resolveGoogleAuthSources).toBe('function');
|
||||
});
|
||||
|
||||
it('can list configured providers without missing provider exports', () => {
|
||||
expect(() => listConfiguredQuotaProviders()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Quota Provider Interface
|
||||
*
|
||||
* Defines the contract for implementing quota providers.
|
||||
* @module quota/providers
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} UsageWindow
|
||||
* @property {number|null} usedPercent - Percentage of usage (0-100)
|
||||
* @property {number|null} remainingPercent - Percentage remaining (0-100)
|
||||
* @property {number|null} windowSeconds - Window duration in seconds
|
||||
* @property {number|null} resetAfterSeconds - Seconds until reset
|
||||
* @property {number|null} resetAt - Unix timestamp when quota resets
|
||||
* @property {string|null} resetAtFormatted - Human-readable reset time
|
||||
* @property {string|null} resetAfterFormatted - Human-readable time until reset
|
||||
* @property {string|null} valueLabel - Optional label for display (e.g., "$10.00 remaining")
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ProviderUsage
|
||||
* @property {Object.<string, UsageWindow>} windows - Usage windows by key (e.g., '5h', '7d', 'daily')
|
||||
* @property {Object.<string, Object>} [models] - Model-specific usage (provider-specific)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} QuotaProviderResult
|
||||
* @property {string} providerId - Unique identifier for the provider
|
||||
* @property {string} providerName - Display name for the provider
|
||||
* @property {boolean} ok - Whether the fetch was successful
|
||||
* @property {boolean} configured - Whether the provider is configured
|
||||
* @property {ProviderUsage|null} usage - Usage data if successful
|
||||
* @property {string|null} [error] - Error message if not successful
|
||||
* @property {number} fetchedAt - Unix timestamp when the result was fetched
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Function} ProviderQuotaFetcher
|
||||
* @returns {Promise<QuotaProviderResult>}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Function} ProviderConfigurationChecker
|
||||
* @param {Object.<string, unknown>} [auth]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} QuotaProvider
|
||||
* @property {string} providerId
|
||||
* @property {string} providerName
|
||||
* @property {string[]} aliases
|
||||
* @property {ProviderConfigurationChecker} isConfigured
|
||||
* @property {ProviderQuotaFetcher} fetchQuota
|
||||
*/
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
export const providerId = 'kimi-for-coding';
|
||||
export const providerName = 'Kimi for Coding';
|
||||
export const aliases = ['kimi-for-coding', 'kimi'];
|
||||
const aliases = ['kimi-for-coding', 'kimi'];
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
|
||||
@@ -1,140 +1,15 @@
|
||||
// MiniMax Coding Plan Provider (minimaxi.com)
|
||||
import { readAuthFile } from '../../opencode/auth.js';
|
||||
import {
|
||||
getAuthEntry,
|
||||
normalizeAuthEntry,
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
toTimestamp,
|
||||
} from '../utils/index.js';
|
||||
import { createMiniMaxCodingPlanProvider } from './minimax-shared.js';
|
||||
|
||||
export const providerId = 'minimax-cn-coding-plan';
|
||||
export const providerName = 'MiniMax Coding Plan (minimaxi.com)';
|
||||
export const aliases = ['minimax-cn-coding-plan'];
|
||||
const provider = createMiniMaxCodingPlanProvider({
|
||||
providerId: 'minimax-cn-coding-plan',
|
||||
providerName: 'MiniMax Coding Plan (minimaxi.com)',
|
||||
aliases: ['minimax-cn-coding-plan'],
|
||||
tokenPlanUrl: 'https://api.minimaxi.com/v1/token_plan/remains',
|
||||
codingPlanUrl: 'https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains',
|
||||
});
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
return Boolean(entry?.key || entry?.token);
|
||||
};
|
||||
|
||||
export const fetchQuota = async () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
const apiKey = entry?.key ?? entry?.token;
|
||||
|
||||
if (!apiKey) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Not configured',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains',
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: `API error: ${response.status}`,
|
||||
});
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const baseResp = payload?.base_resp;
|
||||
if (baseResp && baseResp.status_code !== 0) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: baseResp.status_msg || `API error: ${baseResp.status_code}`,
|
||||
});
|
||||
}
|
||||
|
||||
const firstModel = payload?.model_remains?.[0];
|
||||
if (!firstModel) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'No model quota data available',
|
||||
});
|
||||
}
|
||||
|
||||
const intervalTotal = toNumber(firstModel.current_interval_total_count);
|
||||
const intervalUsage = toNumber(firstModel.current_interval_usage_count);
|
||||
const intervalStartAt = toTimestamp(firstModel.start_time);
|
||||
const intervalResetAt = toTimestamp(firstModel.end_time);
|
||||
const weeklyTotal = toNumber(firstModel.current_weekly_total_count);
|
||||
const weeklyUsage = toNumber(firstModel.current_weekly_usage_count);
|
||||
const weeklyStartAt = toTimestamp(firstModel.weekly_start_time);
|
||||
const weeklyResetAt = toTimestamp(firstModel.weekly_end_time);
|
||||
|
||||
const intervalUsed = intervalTotal - intervalUsage;
|
||||
const weeklyUsed = weeklyTotal - weeklyUsage;
|
||||
|
||||
const intervalUsedPercent =
|
||||
intervalTotal > 0 && intervalUsed != null
|
||||
? Math.max(0, Math.min(100, (intervalUsed / intervalTotal) * 100))
|
||||
: null;
|
||||
const intervalWindowSeconds =
|
||||
intervalStartAt && intervalResetAt && intervalResetAt > intervalStartAt
|
||||
? Math.floor((intervalResetAt - intervalStartAt) / 1000)
|
||||
: null;
|
||||
const weeklyUsedPercent =
|
||||
weeklyTotal > 0 && weeklyUsed != null
|
||||
? Math.max(0, Math.min(100, (weeklyUsed / weeklyTotal) * 100))
|
||||
: null;
|
||||
const weeklyWindowSeconds =
|
||||
weeklyStartAt && weeklyResetAt && weeklyResetAt > weeklyStartAt
|
||||
? Math.floor((weeklyResetAt - weeklyStartAt) / 1000)
|
||||
: null;
|
||||
|
||||
const windows = {
|
||||
'5h': toUsageWindow({
|
||||
usedPercent: intervalUsedPercent,
|
||||
windowSeconds: intervalWindowSeconds,
|
||||
resetAt: intervalResetAt,
|
||||
}),
|
||||
weekly: toUsageWindow({
|
||||
usedPercent: weeklyUsedPercent,
|
||||
windowSeconds: weeklyWindowSeconds,
|
||||
resetAt: weeklyResetAt,
|
||||
}),
|
||||
};
|
||||
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows },
|
||||
});
|
||||
} catch (error) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: error instanceof Error ? error.message : 'Request failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
export const providerId = provider.providerId;
|
||||
export const providerName = provider.providerName;
|
||||
const aliases = provider.aliases;
|
||||
export const isConfigured = provider.isConfigured;
|
||||
export const fetchQuota = provider.fetchQuota;
|
||||
|
||||
@@ -1,139 +1,15 @@
|
||||
import { readAuthFile } from '../../opencode/auth.js';
|
||||
import {
|
||||
getAuthEntry,
|
||||
normalizeAuthEntry,
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
toTimestamp,
|
||||
} from '../utils/index.js';
|
||||
import { createMiniMaxCodingPlanProvider } from './minimax-shared.js';
|
||||
|
||||
export const providerId = 'minimax-coding-plan';
|
||||
export const providerName = 'MiniMax Coding Plan (minimax.io)';
|
||||
export const aliases = ['minimax-coding-plan'];
|
||||
const provider = createMiniMaxCodingPlanProvider({
|
||||
providerId: 'minimax-coding-plan',
|
||||
providerName: 'MiniMax Coding Plan (minimax.io)',
|
||||
aliases: ['minimax-coding-plan'],
|
||||
tokenPlanUrl: 'https://api.minimax.io/v1/token_plan/remains',
|
||||
codingPlanUrl: 'https://api.minimax.io/v1/api/openplatform/coding_plan/remains',
|
||||
});
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
return Boolean(entry?.key || entry?.token);
|
||||
};
|
||||
|
||||
export const fetchQuota = async () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
const apiKey = entry?.key ?? entry?.token;
|
||||
|
||||
if (!apiKey) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Not configured',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://api.minimax.io/v1/api/openplatform/coding_plan/remains',
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: `API error: ${response.status}`,
|
||||
});
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const baseResp = payload?.base_resp;
|
||||
if (baseResp && baseResp.status_code !== 0) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: baseResp.status_msg || `API error: ${baseResp.status_code}`,
|
||||
});
|
||||
}
|
||||
|
||||
const firstModel = payload?.model_remains?.[0];
|
||||
if (!firstModel) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'No model quota data available',
|
||||
});
|
||||
}
|
||||
|
||||
const intervalTotal = toNumber(firstModel.current_interval_total_count);
|
||||
const intervalUsage = toNumber(firstModel.current_interval_usage_count);
|
||||
const intervalStartAt = toTimestamp(firstModel.start_time);
|
||||
const intervalResetAt = toTimestamp(firstModel.end_time);
|
||||
const weeklyTotal = toNumber(firstModel.current_weekly_total_count);
|
||||
const weeklyUsage = toNumber(firstModel.current_weekly_usage_count);
|
||||
const weeklyStartAt = toTimestamp(firstModel.weekly_start_time);
|
||||
const weeklyResetAt = toTimestamp(firstModel.weekly_end_time);
|
||||
|
||||
const intervalUsed = intervalUsage;
|
||||
const weeklyUsed = weeklyUsage;
|
||||
|
||||
const intervalUsedPercent =
|
||||
intervalTotal > 0 && intervalUsed !== null
|
||||
? Math.max(0, Math.min(100, (intervalUsed / intervalTotal) * 100))
|
||||
: null;
|
||||
const intervalWindowSeconds =
|
||||
intervalStartAt && intervalResetAt && intervalResetAt > intervalStartAt
|
||||
? Math.floor((intervalResetAt - intervalStartAt) / 1000)
|
||||
: null;
|
||||
const weeklyUsedPercent =
|
||||
weeklyTotal > 0 && weeklyUsed !== null
|
||||
? Math.max(0, Math.min(100, (weeklyUsed / weeklyTotal) * 100))
|
||||
: null;
|
||||
const weeklyWindowSeconds =
|
||||
weeklyStartAt && weeklyResetAt && weeklyResetAt > weeklyStartAt
|
||||
? Math.floor((weeklyResetAt - weeklyStartAt) / 1000)
|
||||
: null;
|
||||
|
||||
const windows = {
|
||||
'5h': toUsageWindow({
|
||||
usedPercent: intervalUsedPercent,
|
||||
windowSeconds: intervalWindowSeconds,
|
||||
resetAt: intervalResetAt,
|
||||
}),
|
||||
weekly: toUsageWindow({
|
||||
usedPercent: weeklyUsedPercent,
|
||||
windowSeconds: weeklyWindowSeconds,
|
||||
resetAt: weeklyResetAt,
|
||||
}),
|
||||
};
|
||||
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows },
|
||||
});
|
||||
} catch (error) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: error instanceof Error ? error.message : 'Request failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
export const providerId = provider.providerId;
|
||||
export const providerName = provider.providerName;
|
||||
const aliases = provider.aliases;
|
||||
export const isConfigured = provider.isConfigured;
|
||||
export const fetchQuota = provider.fetchQuota;
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { readAuthFile } from '../../opencode/auth.js';
|
||||
import {
|
||||
getAuthEntry,
|
||||
normalizeAuthEntry,
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
toTimestamp,
|
||||
} from '../utils/index.js';
|
||||
|
||||
// Status 3 indicates the window is not applicable for the current plan tier.
|
||||
const WINDOW_STATUS_INACTIVE = 3;
|
||||
|
||||
const TEXT_MODELS = ['general', 'chat', 'text'];
|
||||
|
||||
const pickChatModel = (modelRemains) => {
|
||||
if (!Array.isArray(modelRemains) || modelRemains.length === 0) return null;
|
||||
|
||||
const m3Candidate = modelRemains.find(
|
||||
(m) => m?.model_name && /^minimax-m/i.test(m.model_name) && toNumber(m.current_interval_total_count) > 0
|
||||
);
|
||||
if (m3Candidate) return m3Candidate;
|
||||
|
||||
const textCandidate = modelRemains.find(
|
||||
(m) => m?.model_name && TEXT_MODELS.includes(m.model_name.toLowerCase())
|
||||
);
|
||||
if (textCandidate) return textCandidate;
|
||||
|
||||
const percentCandidate = modelRemains.find(
|
||||
(m) => typeof m?.current_interval_remaining_percent === 'number'
|
||||
);
|
||||
if (percentCandidate) return percentCandidate;
|
||||
|
||||
return modelRemains[0];
|
||||
};
|
||||
|
||||
const isUsablePayload = (payload) => {
|
||||
const baseResp = payload?.base_resp;
|
||||
if (baseResp && baseResp.status_code !== 0) return false;
|
||||
const rems = payload?.model_remains;
|
||||
return Array.isArray(rems) && rems.length > 0;
|
||||
};
|
||||
|
||||
const fetchEndpoint = async (url, apiKey) => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const payload = await response.json();
|
||||
if (!isUsablePayload(payload)) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const coercePercent = (value) => {
|
||||
const n = toNumber(value);
|
||||
return n !== null ? Math.max(0, Math.min(100, n)) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a window (interval or weekly) is active for the current plan.
|
||||
* Status 3 means the window is not applicable (e.g. legacy plans without weekly limits).
|
||||
* When the status field is absent, default to active.
|
||||
*/
|
||||
const isWindowActive = (status) => {
|
||||
const n = toNumber(status);
|
||||
return n === null || n !== WINDOW_STATUS_INACTIVE;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate window duration in seconds from API timestamps or remains_time.
|
||||
* MiniMax API returns remains_time in milliseconds (confirmed via live API testing:
|
||||
* 9664502 ms = 2.68h in a 5h window, consistent with remaining_percent).
|
||||
*/
|
||||
const calculateWindowSeconds = (startAt, resetAt, remainsTimeMs) => {
|
||||
if (startAt && resetAt && resetAt > startAt) {
|
||||
return Math.floor((resetAt - startAt) / 1000);
|
||||
}
|
||||
if (remainsTimeMs && remainsTimeMs > 0) {
|
||||
return Math.floor(remainsTimeMs / 1000);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const calculateUsage = (model, isTokenPlan) => {
|
||||
const intervalTotal = toNumber(model.current_interval_total_count);
|
||||
const intervalUsageRaw = toNumber(model.current_interval_usage_count);
|
||||
const intervalStartAt = toTimestamp(model.start_time);
|
||||
const intervalResetAt = toTimestamp(model.end_time);
|
||||
const intervalRemainsTime = toNumber(model.remains_time);
|
||||
const intervalRemainingPercent = coercePercent(model.current_interval_remaining_percent);
|
||||
|
||||
const weeklyTotal = toNumber(model.current_weekly_total_count);
|
||||
const weeklyUsageRaw = toNumber(model.current_weekly_usage_count);
|
||||
const weeklyStartAt = toTimestamp(model.weekly_start_time);
|
||||
const weeklyResetAt = toTimestamp(model.weekly_end_time);
|
||||
const weeklyRemainsTime = toNumber(model.weekly_remains_time);
|
||||
const weeklyRemainingPercent = coercePercent(model.current_weekly_remaining_percent);
|
||||
|
||||
let intervalUsedPercent = null;
|
||||
if (intervalRemainingPercent !== null) {
|
||||
intervalUsedPercent = 100 - intervalRemainingPercent;
|
||||
} else if (intervalTotal > 0 && intervalUsageRaw !== null) {
|
||||
const intervalUsed = isTokenPlan
|
||||
? Math.max(0, intervalTotal - intervalUsageRaw)
|
||||
: intervalUsageRaw;
|
||||
intervalUsedPercent = Math.max(0, Math.min(100, (intervalUsed / intervalTotal) * 100));
|
||||
}
|
||||
|
||||
let weeklyUsedPercent = null;
|
||||
if (weeklyRemainingPercent !== null) {
|
||||
weeklyUsedPercent = 100 - weeklyRemainingPercent;
|
||||
} else if (weeklyTotal > 0 && weeklyUsageRaw !== null) {
|
||||
const weeklyUsed = isTokenPlan
|
||||
? Math.max(0, weeklyTotal - weeklyUsageRaw)
|
||||
: weeklyUsageRaw;
|
||||
weeklyUsedPercent = Math.max(0, Math.min(100, (weeklyUsed / weeklyTotal) * 100));
|
||||
}
|
||||
|
||||
const intervalWindowSeconds = calculateWindowSeconds(intervalStartAt, intervalResetAt, intervalRemainsTime);
|
||||
const weeklyWindowSeconds = calculateWindowSeconds(weeklyStartAt, weeklyResetAt, weeklyRemainsTime);
|
||||
|
||||
return {
|
||||
intervalUsedPercent,
|
||||
intervalWindowSeconds,
|
||||
intervalResetAt,
|
||||
weeklyUsedPercent,
|
||||
weeklyWindowSeconds,
|
||||
weeklyResetAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const createMiniMaxCodingPlanProvider = ({ providerId, providerName, aliases, tokenPlanUrl, codingPlanUrl }) => {
|
||||
const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
return Boolean(entry?.key || entry?.token);
|
||||
};
|
||||
|
||||
const fetchQuota = async () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
const apiKey = entry?.key ?? entry?.token;
|
||||
|
||||
if (!apiKey) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Not configured',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
let payload = await fetchEndpoint(tokenPlanUrl, apiKey);
|
||||
let isTokenPlan = true;
|
||||
|
||||
if (!payload) {
|
||||
payload = await fetchEndpoint(codingPlanUrl, apiKey);
|
||||
isTokenPlan = false;
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'API returned no usable quota data',
|
||||
});
|
||||
}
|
||||
|
||||
const model = pickChatModel(payload.model_remains);
|
||||
if (!model) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'No model quota data available',
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
intervalUsedPercent,
|
||||
intervalWindowSeconds,
|
||||
intervalResetAt,
|
||||
weeklyUsedPercent,
|
||||
weeklyWindowSeconds,
|
||||
weeklyResetAt,
|
||||
} = calculateUsage(model, isTokenPlan);
|
||||
|
||||
const windows = {
|
||||
'5h': toUsageWindow({
|
||||
usedPercent: intervalUsedPercent,
|
||||
windowSeconds: intervalWindowSeconds,
|
||||
resetAt: intervalResetAt,
|
||||
}),
|
||||
};
|
||||
|
||||
// Only include the weekly window when the plan tier supports it.
|
||||
// Status 3 = not applicable (e.g. legacy Coding Plan without weekly limits).
|
||||
const weeklyActive = isWindowActive(model.current_weekly_status);
|
||||
const hasWeeklyData =
|
||||
weeklyActive &&
|
||||
(coercePercent(model.current_weekly_remaining_percent) !== null ||
|
||||
toNumber(model.current_weekly_total_count) > 0);
|
||||
|
||||
if (hasWeeklyData) {
|
||||
windows.weekly = toUsageWindow({
|
||||
usedPercent: weeklyUsedPercent,
|
||||
windowSeconds: weeklyWindowSeconds,
|
||||
resetAt: weeklyResetAt,
|
||||
});
|
||||
}
|
||||
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows },
|
||||
});
|
||||
} catch (error) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: error instanceof Error ? error.message : 'Request failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
providerId,
|
||||
providerName,
|
||||
aliases,
|
||||
isConfigured,
|
||||
fetchQuota,
|
||||
};
|
||||
};
|
||||
@@ -12,7 +12,7 @@ const NANO_GPT_DAILY_WINDOW_SECONDS = 86400;
|
||||
|
||||
export const providerId = 'nano-gpt';
|
||||
export const providerName = 'NanoGPT';
|
||||
export const aliases = ['nano-gpt', 'nanogpt', 'nano_gpt'];
|
||||
const aliases = ['nano-gpt', 'nanogpt', 'nano_gpt'];
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
|
||||
@@ -7,7 +7,7 @@ const COOKIE_PATH = join(homedir(), '.config', 'ollama-quota', 'cookie');
|
||||
|
||||
export const providerId = 'ollama-cloud';
|
||||
export const providerName = 'Ollama Cloud';
|
||||
export const aliases = ['ollama-cloud', 'ollamacloud'];
|
||||
const aliases = ['ollama-cloud', 'ollamacloud'];
|
||||
|
||||
const readCookieFile = () => {
|
||||
try {
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
toTimestamp
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const providerId = 'openai';
|
||||
export const providerName = 'OpenAI';
|
||||
export const aliases = ['openai', 'codex', 'chatgpt'];
|
||||
const providerId = 'openai';
|
||||
const providerName = 'OpenAI';
|
||||
const aliases = ['openai', 'codex', 'chatgpt'];
|
||||
|
||||
export const isConfigured = () => {
|
||||
const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
return Boolean(entry?.access || entry?.token);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
export const providerId = 'openrouter';
|
||||
export const providerName = 'OpenRouter';
|
||||
export const aliases = ['openrouter'];
|
||||
const aliases = ['openrouter'];
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
export const providerId = 'wafer';
|
||||
export const providerName = 'Wafer.ai';
|
||||
export const aliases = ['wafer', 'wafer-ai', 'wafer_ai', 'wafer.ai'];
|
||||
const aliases = ['wafer', 'wafer-ai', 'wafer_ai', 'wafer.ai'];
|
||||
|
||||
const WAFER_QUOTA_URL = 'https://pass.wafer.ai/v1/inference/quota';
|
||||
const WAFER_WINDOW_SECONDS = 5 * 3600;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
|
||||
export const providerId = 'zai-coding-plan';
|
||||
export const providerName = 'z.ai';
|
||||
export const aliases = ['zai-coding-plan', 'zai', 'z.ai'];
|
||||
const aliases = ['zai-coding-plan', 'zai', 'z.ai'];
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
|
||||
export const providerId = 'zhipuai-coding-plan';
|
||||
export const providerName = 'Zhipu AI Coding Plan';
|
||||
export const aliases = ['zhipuai-coding-plan', 'zhipuai', 'zhipu'];
|
||||
const aliases = ['zhipuai-coding-plan', 'zhipuai', 'zhipu'];
|
||||
|
||||
function getApiKey() {
|
||||
const auth = readAuthFile();
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
|
||||
const PROXY_SSE_PATH = '/api/openchamber/realtime-proxy/sse';
|
||||
const PROXY_WS_PATH = '/api/openchamber/realtime-proxy/ws';
|
||||
|
||||
const isAllowedSsePath = (pathname) => {
|
||||
return pathname === '/api/event'
|
||||
|| pathname === '/api/global/event'
|
||||
|| pathname === '/api/openchamber/events'
|
||||
|| pathname === '/api/notifications/stream'
|
||||
|| /^\/api\/terminal\/[^/]+\/stream$/.test(pathname);
|
||||
};
|
||||
|
||||
const isAllowedWebSocketPath = (pathname) => {
|
||||
return pathname === '/api/event/ws'
|
||||
|| pathname === '/api/global/event/ws'
|
||||
|| pathname === '/api/terminal/ws';
|
||||
};
|
||||
|
||||
const normalizeBaseUrl = (value) => {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
const sanitizeHeaders = (headers) => {
|
||||
if (!headers || typeof headers !== 'object') return {};
|
||||
const next = {};
|
||||
for (const [rawName, rawValue] of Object.entries(headers)) {
|
||||
const name = typeof rawName === 'string' ? rawName.trim() : '';
|
||||
const value = typeof rawValue === 'string' ? rawValue.trim() : '';
|
||||
if (!name || !value || /[\r\n:]/.test(name) || /[\r\n]/.test(value)) continue;
|
||||
if (name.toLowerCase() === 'authorization') continue;
|
||||
next[name] = value;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const hasHeaders = (headers) => Object.keys(headers).length > 0;
|
||||
|
||||
const getTargetParam = (req) => {
|
||||
let raw = typeof req.query?.url === 'string' ? req.query.url : '';
|
||||
if (!raw) {
|
||||
try {
|
||||
raw = new URL(req.url || '/', 'http://127.0.0.1').searchParams.get('url') || '';
|
||||
} catch {
|
||||
raw = '';
|
||||
}
|
||||
}
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return new URL(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const urlsMatchRuntime = (target, apiBaseUrl) => {
|
||||
const base = normalizeBaseUrl(apiBaseUrl);
|
||||
if (!base) return false;
|
||||
try {
|
||||
const baseUrl = new URL(base);
|
||||
const targetForCompare = new URL(target.toString());
|
||||
if (targetForCompare.protocol === 'ws:') targetForCompare.protocol = 'http:';
|
||||
if (targetForCompare.protocol === 'wss:') targetForCompare.protocol = 'https:';
|
||||
return targetForCompare.origin === baseUrl.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const protocolMatchesProxyType = (target, type) => {
|
||||
if (type === 'ws') return target.protocol === 'ws:' || target.protocol === 'wss:';
|
||||
return target.protocol === 'http:' || target.protocol === 'https:';
|
||||
};
|
||||
|
||||
const pathMatchesProxyType = (target, type) => {
|
||||
return type === 'ws' ? isAllowedWebSocketPath(target.pathname) : isAllowedSsePath(target.pathname);
|
||||
};
|
||||
|
||||
const resolveProxyTarget = (req, getDesktopRuntimeConfig, type) => {
|
||||
const config = typeof getDesktopRuntimeConfig === 'function' ? getDesktopRuntimeConfig() : null;
|
||||
const requestHeaders = sanitizeHeaders(config?.requestHeaders);
|
||||
const apiBaseUrl = normalizeBaseUrl(config?.apiBaseUrl);
|
||||
const target = getTargetParam(req);
|
||||
if (!target || !apiBaseUrl || !hasHeaders(requestHeaders)) return null;
|
||||
if (!protocolMatchesProxyType(target, type)) return null;
|
||||
if (!pathMatchesProxyType(target, type)) return null;
|
||||
if (!urlsMatchRuntime(target, apiBaseUrl)) return null;
|
||||
return { target, requestHeaders };
|
||||
};
|
||||
|
||||
const safeHeader = (headers, name) => {
|
||||
const value = headers?.[name.toLowerCase()];
|
||||
if (Array.isArray(value)) return value.find((item) => typeof item === 'string' && item.trim()) || '';
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
};
|
||||
|
||||
const buildSseRequestHeaders = (req, requestHeaders) => {
|
||||
const headers = {};
|
||||
const accept = safeHeader(req.headers, 'accept');
|
||||
const lastEventId = safeHeader(req.headers, 'last-event-id');
|
||||
if (accept) headers.Accept = accept;
|
||||
if (lastEventId) headers['Last-Event-ID'] = lastEventId;
|
||||
return { ...headers, ...requestHeaders };
|
||||
};
|
||||
|
||||
const rejectWebSocketUpgrade = (socket, statusCode, message) => {
|
||||
socket.write(`HTTP/1.1 ${statusCode} ${message}\r\nConnection: close\r\n\r\n`);
|
||||
socket.destroy();
|
||||
};
|
||||
|
||||
export const buildRealtimeProxySseUrl = (localOrigin, targetUrl) => {
|
||||
const url = new URL(PROXY_SSE_PATH, localOrigin);
|
||||
url.searchParams.set('url', targetUrl);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export const buildRealtimeProxyWsUrl = (localOrigin, targetUrl) => {
|
||||
const url = new URL(PROXY_WS_PATH, localOrigin);
|
||||
url.searchParams.set('url', targetUrl);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export const attachRealtimeProxy = ({ app, server, getDesktopRuntimeConfig, getUiAuthController, isRequestOriginAllowed }) => {
|
||||
if (!app || !server || typeof getDesktopRuntimeConfig !== 'function') {
|
||||
return { stop: () => {} };
|
||||
}
|
||||
|
||||
const originAllowed = async (req) => {
|
||||
if (typeof isRequestOriginAllowed !== 'function') return false;
|
||||
try {
|
||||
return await isRequestOriginAllowed(req);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureAuthenticated = async (req, res) => {
|
||||
const controller = typeof getUiAuthController === 'function' ? getUiAuthController() : null;
|
||||
if (typeof controller?.ensureSessionToken !== 'function') return false;
|
||||
const response = res || { setHeader: () => {} };
|
||||
const token = await controller.ensureSessionToken(req, response);
|
||||
return Boolean(token);
|
||||
};
|
||||
|
||||
app.get(PROXY_SSE_PATH, async (req, res) => {
|
||||
if (!await ensureAuthenticated(req, res)) {
|
||||
res.status(401).json({ error: 'UI authentication required' });
|
||||
return;
|
||||
}
|
||||
if (!await originAllowed(req)) {
|
||||
res.status(403).json({ error: 'Realtime proxy origin is not allowed' });
|
||||
return;
|
||||
}
|
||||
const resolved = resolveProxyTarget(req, getDesktopRuntimeConfig, 'sse');
|
||||
if (!resolved) {
|
||||
res.status(404).json({ error: 'Realtime proxy is unavailable' });
|
||||
return;
|
||||
}
|
||||
|
||||
const abort = new AbortController();
|
||||
req.on('close', () => abort.abort());
|
||||
try {
|
||||
const response = await fetch(resolved.target.toString(), {
|
||||
headers: buildSseRequestHeaders(req, resolved.requestHeaders),
|
||||
signal: abort.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
res.status(response.status || 502).end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(response.status);
|
||||
res.setHeader('Content-Type', response.headers.get('content-type') || 'text/event-stream');
|
||||
res.setHeader('Cache-Control', response.headers.get('cache-control') || 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
for await (const chunk of response.body) {
|
||||
if (abort.signal.aborted) break;
|
||||
res.write(chunk);
|
||||
}
|
||||
res.end();
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted && !res.headersSent) {
|
||||
res.status(502).json({ error: error instanceof Error ? error.message : 'Realtime proxy failed' });
|
||||
} else if (!res.destroyed) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const wsServer = new WebSocketServer({ noServer: true });
|
||||
|
||||
wsServer.on('connection', (client, request) => {
|
||||
const resolved = resolveProxyTarget(request, getDesktopRuntimeConfig, 'ws');
|
||||
if (!resolved) {
|
||||
client.close(1008, 'Realtime proxy is unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
const upstream = new WebSocket(resolved.target.toString(), {
|
||||
headers: resolved.requestHeaders,
|
||||
});
|
||||
const pending = [];
|
||||
|
||||
const flush = () => {
|
||||
while (pending.length > 0 && upstream.readyState === WebSocket.OPEN) {
|
||||
const [data, isBinary] = pending.shift();
|
||||
upstream.send(data, { binary: isBinary });
|
||||
}
|
||||
};
|
||||
|
||||
client.on('message', (data, isBinary) => {
|
||||
if (upstream.readyState === WebSocket.OPEN) {
|
||||
upstream.send(data, { binary: isBinary });
|
||||
return;
|
||||
}
|
||||
if (upstream.readyState === WebSocket.CONNECTING) {
|
||||
pending.push([data, isBinary]);
|
||||
}
|
||||
});
|
||||
upstream.on('open', flush);
|
||||
upstream.on('message', (data, isBinary) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(data, { binary: isBinary });
|
||||
}
|
||||
});
|
||||
upstream.on('close', (code, reason) => {
|
||||
if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) {
|
||||
client.close(code || 1000, reason);
|
||||
}
|
||||
});
|
||||
upstream.on('error', () => {
|
||||
if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) {
|
||||
client.close(1011, 'Realtime proxy upstream error');
|
||||
}
|
||||
});
|
||||
client.on('close', () => {
|
||||
if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
|
||||
upstream.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const upgradeHandler = (req, socket, head) => {
|
||||
const pathname = (() => {
|
||||
try { return new URL(req.url || '/', 'http://127.0.0.1').pathname; } catch { return ''; }
|
||||
})();
|
||||
if (pathname !== PROXY_WS_PATH) return;
|
||||
void ensureAuthenticated(req, null).then((authenticated) => {
|
||||
if (!authenticated) {
|
||||
rejectWebSocketUpgrade(socket, 401, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
void originAllowed(req).then((allowed) => {
|
||||
if (!allowed) {
|
||||
rejectWebSocketUpgrade(socket, 403, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
wsServer.handleUpgrade(req, socket, head, (ws) => {
|
||||
wsServer.emit('connection', ws, req);
|
||||
});
|
||||
}).catch(() => {
|
||||
rejectWebSocketUpgrade(socket, 403, 'Forbidden');
|
||||
});
|
||||
}).catch(() => {
|
||||
rejectWebSocketUpgrade(socket, 401, 'Unauthorized');
|
||||
});
|
||||
};
|
||||
|
||||
server.on('upgrade', upgradeHandler);
|
||||
return {
|
||||
stop: () => {
|
||||
server.off('upgrade', upgradeHandler);
|
||||
wsServer.close();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import express from 'express';
|
||||
import http from 'node:http';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
|
||||
import { attachRealtimeProxy, buildRealtimeProxySseUrl, buildRealtimeProxyWsUrl } from './realtime-proxy.js';
|
||||
import { createUiAuth } from './ui-auth/ui-auth.js';
|
||||
|
||||
const servers = [];
|
||||
|
||||
const listen = async (server) => {
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
servers.push(server);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Expected TCP server address');
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
};
|
||||
|
||||
const closeServer = async (server) => {
|
||||
await new Promise((resolve) => server.close(() => resolve()));
|
||||
};
|
||||
|
||||
const startProxyServer = async ({ apiBaseUrl, authToken = 'ui-token', originAllowed = true } = {}) => {
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const runtime = attachRealtimeProxy({
|
||||
app,
|
||||
server,
|
||||
getDesktopRuntimeConfig: () => ({
|
||||
apiBaseUrl,
|
||||
requestHeaders: { 'X-Proxy-Auth': 'secret' },
|
||||
}),
|
||||
getUiAuthController: () => ({
|
||||
ensureSessionToken: async () => authToken,
|
||||
}),
|
||||
isRequestOriginAllowed: async () => originAllowed,
|
||||
});
|
||||
const origin = await listen(server);
|
||||
return { origin, runtime };
|
||||
};
|
||||
|
||||
const startProxyServerWithAuthController = async ({ apiBaseUrl, uiAuthController, originAllowed = true } = {}) => {
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const runtime = attachRealtimeProxy({
|
||||
app,
|
||||
server,
|
||||
getDesktopRuntimeConfig: () => ({
|
||||
apiBaseUrl,
|
||||
requestHeaders: { 'X-Proxy-Auth': 'secret' },
|
||||
}),
|
||||
getUiAuthController: () => uiAuthController,
|
||||
isRequestOriginAllowed: async () => originAllowed,
|
||||
});
|
||||
const origin = await listen(server);
|
||||
return { origin, runtime };
|
||||
};
|
||||
|
||||
const startSseUpstream = async ({ path = '/api/global/event' } = {}) => {
|
||||
const requests = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
requests.push({ url: req.url, headers: req.headers });
|
||||
if (new URL(req.url || '/', 'http://127.0.0.1').pathname !== path) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
res.write('data: first\n\n');
|
||||
res.end('data: second\n\n');
|
||||
});
|
||||
const origin = await listen(server);
|
||||
return { origin, requests };
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
while (servers.length > 0) {
|
||||
const server = servers.pop();
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
describe('realtime proxy URL builders', () => {
|
||||
it('builds local SSE proxy URLs with target URL encoded as query data', () => {
|
||||
const url = new URL(buildRealtimeProxySseUrl('http://127.0.0.1:57123', 'https://remote.example/api/global/event?x=1'));
|
||||
|
||||
expect(url.origin).toBe('http://127.0.0.1:57123');
|
||||
expect(url.pathname).toBe('/api/openchamber/realtime-proxy/sse');
|
||||
expect(url.searchParams.get('url')).toBe('https://remote.example/api/global/event?x=1');
|
||||
});
|
||||
|
||||
it('builds local WebSocket proxy URLs with ws protocol', () => {
|
||||
const url = new URL(buildRealtimeProxyWsUrl('https://127.0.0.1:57123', 'wss://remote.example/api/global/event/ws'));
|
||||
|
||||
expect(url.protocol).toBe('wss:');
|
||||
expect(url.host).toBe('127.0.0.1:57123');
|
||||
expect(url.pathname).toBe('/api/openchamber/realtime-proxy/ws');
|
||||
expect(url.searchParams.get('url')).toBe('wss://remote.example/api/global/event/ws');
|
||||
});
|
||||
});
|
||||
|
||||
describe('realtime proxy', () => {
|
||||
it('streams SSE chunks and forwards safe SSE headers with configured runtime headers', async () => {
|
||||
const upstream = await startSseUpstream();
|
||||
const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin });
|
||||
|
||||
try {
|
||||
const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), {
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'Last-Event-ID': 'evt-42',
|
||||
Origin: 'openchamber-ui://app',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe('data: first\n\ndata: second\n\n');
|
||||
expect(upstream.requests).toHaveLength(1);
|
||||
expect(upstream.requests[0].headers.accept).toBe('text/event-stream');
|
||||
expect(upstream.requests[0].headers['last-event-id']).toBe('evt-42');
|
||||
expect(upstream.requests[0].headers['x-proxy-auth']).toBe('secret');
|
||||
} finally {
|
||||
runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unauthenticated SSE proxy requests', async () => {
|
||||
const upstream = await startSseUpstream();
|
||||
const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin, authToken: null });
|
||||
|
||||
try {
|
||||
const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), {
|
||||
headers: { Origin: 'openchamber-ui://app' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(upstream.requests).toHaveLength(0);
|
||||
} finally {
|
||||
runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects SSE proxy requests from disallowed origins', async () => {
|
||||
const upstream = await startSseUpstream();
|
||||
const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin, originAllowed: false });
|
||||
|
||||
try {
|
||||
const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), {
|
||||
headers: { Origin: 'https://evil.example' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(upstream.requests).toHaveLength(0);
|
||||
} finally {
|
||||
runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects targets outside the active runtime origin', async () => {
|
||||
const upstream = await startSseUpstream();
|
||||
const { origin, runtime } = await startProxyServer({ apiBaseUrl: 'https://different.example' });
|
||||
|
||||
try {
|
||||
const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), {
|
||||
headers: { Origin: 'openchamber-ui://app' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(upstream.requests).toHaveLength(0);
|
||||
} finally {
|
||||
runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects targets outside the realtime path allowlist', async () => {
|
||||
const upstream = await startSseUpstream({ path: '/api/config/settings' });
|
||||
const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin });
|
||||
|
||||
try {
|
||||
const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/config/settings`), {
|
||||
headers: { Origin: 'openchamber-ui://app' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(upstream.requests).toHaveLength(0);
|
||||
} finally {
|
||||
runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('proxies WebSocket upgrades using query params from the raw upgrade request URL', async () => {
|
||||
let upstreamRequest = null;
|
||||
const upstreamServer = http.createServer();
|
||||
const upstreamWs = new WebSocketServer({ server: upstreamServer });
|
||||
upstreamWs.on('connection', (socket, request) => {
|
||||
upstreamRequest = request;
|
||||
socket.on('message', (data, isBinary) => {
|
||||
socket.send(isBinary ? data : `echo:${data.toString()}`, { binary: isBinary });
|
||||
});
|
||||
});
|
||||
const upstreamOrigin = await listen(upstreamServer);
|
||||
const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstreamOrigin });
|
||||
|
||||
try {
|
||||
const target = `${upstreamOrigin.replace(/^http:/, 'ws:')}/api/global/event/ws?lastEventId=evt-1`;
|
||||
const client = new WebSocket(buildRealtimeProxyWsUrl(origin, target), {
|
||||
headers: { Origin: 'openchamber-ui://app' },
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
client.once('open', resolve);
|
||||
client.once('error', reject);
|
||||
});
|
||||
|
||||
const message = await new Promise((resolve) => {
|
||||
client.once('message', (data) => resolve(data.toString()));
|
||||
client.send('ping');
|
||||
});
|
||||
|
||||
expect(message).toBe('echo:ping');
|
||||
expect(upstreamRequest?.url).toBe('/api/global/event/ws?lastEventId=evt-1');
|
||||
expect(upstreamRequest?.headers['x-proxy-auth']).toBe('secret');
|
||||
client.close();
|
||||
upstreamWs.close();
|
||||
} finally {
|
||||
runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows first passwordless WebSocket proxy upgrade without an existing cookie', async () => {
|
||||
const upstreamServer = http.createServer();
|
||||
const upstreamWs = new WebSocketServer({ server: upstreamServer });
|
||||
upstreamWs.on('connection', (socket) => {
|
||||
socket.send('ready');
|
||||
});
|
||||
const upstreamOrigin = await listen(upstreamServer);
|
||||
const uiAuthController = createUiAuth({ password: '' });
|
||||
const { origin, runtime } = await startProxyServerWithAuthController({ apiBaseUrl: upstreamOrigin, uiAuthController });
|
||||
|
||||
try {
|
||||
const target = `${upstreamOrigin.replace(/^http:/, 'ws:')}/api/global/event/ws`;
|
||||
const client = new WebSocket(buildRealtimeProxyWsUrl(origin, target), {
|
||||
headers: { Origin: 'openchamber-ui://app' },
|
||||
});
|
||||
const message = await new Promise((resolve, reject) => {
|
||||
client.once('message', (data) => resolve(data.toString()));
|
||||
client.once('error', reject);
|
||||
});
|
||||
|
||||
expect(message).toBe('ready');
|
||||
client.close();
|
||||
upstreamWs.close();
|
||||
} finally {
|
||||
runtime.stop();
|
||||
uiAuthController.dispose?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
export const createRequestSecurityRuntime = (deps) => {
|
||||
const { readSettingsFromDiskMigrated } = deps;
|
||||
const packagedClientOrigins = new Set(['openchamber-ui://app']);
|
||||
const packagedClientOrigins = new Set(['openchamber-ui://app', 'capacitor://localhost']);
|
||||
|
||||
const getUiSessionTokenFromRequest = (req) => {
|
||||
const cookieHeader = req?.headers?.cookie;
|
||||
|
||||
@@ -6,7 +6,7 @@ const createRuntime = () => createRequestSecurityRuntime({
|
||||
});
|
||||
|
||||
describe('request security runtime', () => {
|
||||
test('allows packaged client origin for remote client transports', async () => {
|
||||
test('allows packaged client origins for remote client transports', async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await expect(runtime.isRequestOriginAllowed({
|
||||
@@ -16,5 +16,13 @@ describe('request security runtime', () => {
|
||||
},
|
||||
socket: {},
|
||||
})).resolves.toBe(true);
|
||||
|
||||
await expect(runtime.isRequestOriginAllowed({
|
||||
headers: {
|
||||
origin: 'capacitor://localhost',
|
||||
host: '192.168.1.130:1202',
|
||||
},
|
||||
socket: {},
|
||||
})).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,3 @@ export function setCachedScan(key, value, ttlMs = DEFAULT_TTL_MS) {
|
||||
const ttl = Number.isFinite(ttlMs) ? ttlMs : DEFAULT_TTL_MS;
|
||||
cache.set(key, { expiresAt: Date.now() + ttl, value });
|
||||
}
|
||||
|
||||
export function clearCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
@@ -82,38 +82,6 @@ export async function fetchClawdHubSkills({ cursor } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch details for a specific skill version
|
||||
* @param {string} slug - Skill slug/identifier
|
||||
* @param {string} [version='latest'] - Version string or 'latest'
|
||||
* @returns {Promise<{ skill: Object, version: Object }>}
|
||||
*/
|
||||
export async function fetchClawdHubSkillVersion(slug, version = 'latest') {
|
||||
// For 'latest', we need to first get the skill metadata to find the latest version
|
||||
if (version === 'latest') {
|
||||
const skillResponse = await rateLimitedFetch(`${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`);
|
||||
if (!skillResponse.ok) {
|
||||
throw new Error(`ClawdHub skill not found: ${slug}`);
|
||||
}
|
||||
const skillData = await skillResponse.json();
|
||||
const latestVersion = skillData.skill?.tags?.latest || skillData.latestVersion?.version;
|
||||
if (!latestVersion) {
|
||||
throw new Error(`No latest version found for skill: ${slug}`);
|
||||
}
|
||||
version = latestVersion;
|
||||
}
|
||||
|
||||
const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`;
|
||||
const response = await rateLimitedFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`ClawdHub version error (${response.status}): ${text || response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a skill package as a ZIP buffer
|
||||
* @param {string} slug - Skill slug/identifier
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* ClawdHub integration module
|
||||
*
|
||||
* Provides skill browsing and installation from the ClawdHub registry.
|
||||
* https://clawdhub.com
|
||||
*/
|
||||
|
||||
export { scanClawdHub, scanClawdHubPage } from './scan.js';
|
||||
export { installSkillsFromClawdHub } from './install.js';
|
||||
export {
|
||||
fetchClawdHubSkills,
|
||||
fetchClawdHubSkillVersion,
|
||||
fetchClawdHubSkillInfo,
|
||||
downloadClawdHubSkill,
|
||||
} from './api.js';
|
||||
|
||||
/**
|
||||
* Check if a source string refers to ClawdHub
|
||||
* @param {string} source
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isClawdHubSource(source) {
|
||||
return typeof source === 'string' && source.startsWith('clawdhub:');
|
||||
}
|
||||
|
||||
/**
|
||||
* ClawdHub source identifier used in curated sources
|
||||
*/
|
||||
export const CLAWDHUB_SOURCE_ID = 'clawdhub';
|
||||
export const CLAWDHUB_SOURCE_STRING = 'clawdhub:registry';
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import { fetchClawdHubSkills } from './api.js';
|
||||
|
||||
const MAX_PAGES = 20; // Safety limit to prevent infinite loops
|
||||
const CLAWDHUB_PAGE_LIMIT = 25;
|
||||
|
||||
const mapClawdHubItem = (item) => {
|
||||
@@ -39,57 +38,6 @@ const mapClawdHubItem = (item) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Scan ClawdHub registry for all available skills
|
||||
* @returns {Promise<{ ok: boolean, items?: Array, error?: Object }>}
|
||||
*/
|
||||
export async function scanClawdHub() {
|
||||
try {
|
||||
const allItems = [];
|
||||
let cursor = null;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
let items = [];
|
||||
let nextCursor = null;
|
||||
|
||||
try {
|
||||
const pageResult = await fetchClawdHubSkills({ cursor });
|
||||
items = pageResult.items || [];
|
||||
nextCursor = pageResult.nextCursor || null;
|
||||
} catch (error) {
|
||||
if (page > 0 && allItems.length > 0) {
|
||||
console.warn('ClawdHub pagination failed; returning partial results.');
|
||||
break;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
allItems.push(mapClawdHubItem(item));
|
||||
}
|
||||
|
||||
if (!nextCursor) {
|
||||
break;
|
||||
}
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
// Sort by downloads (most popular first)
|
||||
allItems.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0));
|
||||
|
||||
return { ok: true, items: allItems };
|
||||
} catch (error) {
|
||||
console.error('ClawdHub scan error:', error);
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'networkError',
|
||||
message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single ClawdHub page (cursor-based)
|
||||
* @returns {Promise<{ ok: boolean, items?: Array, nextCursor?: string | null, error?: Object }>}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const CURATED_SKILLS_SOURCES = [
|
||||
const CURATED_SKILLS_SOURCES = [
|
||||
{
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* Skills catalog module
|
||||
*
|
||||
* Provides skill scanning, installation, and caching from GitHub repositories and ClawdHub.
|
||||
*/
|
||||
|
||||
export {
|
||||
CURATED_SKILLS_SOURCES,
|
||||
getCuratedSkillsSources,
|
||||
} from './curated-sources.js';
|
||||
|
||||
export {
|
||||
getCacheKey,
|
||||
getCachedScan,
|
||||
setCachedScan,
|
||||
clearCache,
|
||||
} from './cache.js';
|
||||
|
||||
export {
|
||||
parseSkillRepoSource,
|
||||
} from './source.js';
|
||||
|
||||
export {
|
||||
scanSkillsRepository,
|
||||
} from './scan.js';
|
||||
|
||||
export {
|
||||
installSkillsFromRepository,
|
||||
} from './install.js';
|
||||
|
||||
export {
|
||||
scanClawdHub,
|
||||
scanClawdHubPage,
|
||||
installSkillsFromClawdHub,
|
||||
fetchClawdHubSkills,
|
||||
fetchClawdHubSkillVersion,
|
||||
fetchClawdHubSkillInfo,
|
||||
downloadClawdHubSkill,
|
||||
isClawdHubSource,
|
||||
CLAWDHUB_SOURCE_ID,
|
||||
CLAWDHUB_SOURCE_STRING,
|
||||
} from './clawdhub/index.js';
|
||||
@@ -1,4 +1,5 @@
|
||||
const GITHUB_HOST = 'github.com';
|
||||
const CLAWDHUB_SOURCE_PREFIX = 'clawdhub:';
|
||||
|
||||
|
||||
function normalizeGitOwnerRepo(owner, repo) {
|
||||
@@ -85,3 +86,7 @@ export function parseSkillRepoSource(input, options = {}) {
|
||||
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Unsupported repository source format' } };
|
||||
}
|
||||
|
||||
export function isClawdHubSource(input) {
|
||||
return typeof input === 'string' && input.trim().toLowerCase().startsWith(CLAWDHUB_SOURCE_PREFIX);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
export {
|
||||
TERMINAL_WS_PATH,
|
||||
TERMINAL_WS_CONTROL_TAG_JSON,
|
||||
TERMINAL_WS_MAX_PAYLOAD_BYTES,
|
||||
isTerminalWsPathname,
|
||||
parseRequestPathname,
|
||||
normalizeTerminalWsMessageToBuffer,
|
||||
normalizeTerminalWsMessageToText,
|
||||
readTerminalWsControlFrame,
|
||||
createTerminalWsControlFrame,
|
||||
pruneRebindTimestamps,
|
||||
isRebindRateLimited,
|
||||
} from './terminal-ws-protocol.js';
|
||||
|
||||
export {
|
||||
TERMINAL_WS_PATH as TERMINAL_INPUT_WS_PATH,
|
||||
TERMINAL_WS_CONTROL_TAG_JSON as TERMINAL_INPUT_WS_CONTROL_TAG_JSON,
|
||||
TERMINAL_WS_MAX_PAYLOAD_BYTES as TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
||||
normalizeTerminalWsMessageToBuffer as normalizeTerminalInputWsMessageToBuffer,
|
||||
normalizeTerminalWsMessageToText as normalizeTerminalInputWsMessageToText,
|
||||
readTerminalWsControlFrame as readTerminalInputWsControlFrame,
|
||||
createTerminalWsControlFrame as createTerminalInputWsControlFrame,
|
||||
} from './terminal-ws-protocol.js';
|
||||
|
||||
export {
|
||||
TERMINAL_OUTPUT_REPLAY_MAX_BYTES,
|
||||
createTerminalOutputReplayBuffer,
|
||||
appendTerminalOutputReplayChunk,
|
||||
listTerminalOutputReplayChunksSince,
|
||||
getLatestTerminalOutputReplayChunkId,
|
||||
} from './output-replay-buffer.js';
|
||||
@@ -1,18 +1,20 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
import {
|
||||
TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
||||
TERMINAL_INPUT_WS_PATH,
|
||||
TERMINAL_WS_MAX_PAYLOAD_BYTES as TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
||||
TERMINAL_WS_PATH as TERMINAL_INPUT_WS_PATH,
|
||||
createTerminalWsControlFrame as createTerminalInputWsControlFrame,
|
||||
isRebindRateLimited,
|
||||
normalizeTerminalWsMessageToText as normalizeTerminalInputWsMessageToText,
|
||||
parseRequestPathname,
|
||||
pruneRebindTimestamps,
|
||||
readTerminalWsControlFrame as readTerminalInputWsControlFrame,
|
||||
} from './terminal-ws-protocol.js';
|
||||
import {
|
||||
TERMINAL_OUTPUT_REPLAY_MAX_BYTES,
|
||||
appendTerminalOutputReplayChunk,
|
||||
createTerminalOutputReplayBuffer,
|
||||
createTerminalInputWsControlFrame,
|
||||
isRebindRateLimited,
|
||||
listTerminalOutputReplayChunksSince,
|
||||
normalizeTerminalInputWsMessageToText,
|
||||
parseRequestPathname,
|
||||
pruneRebindTimestamps,
|
||||
readTerminalInputWsControlFrame,
|
||||
} from './index.js';
|
||||
} from './output-replay-buffer.js';
|
||||
|
||||
export function createTerminalRuntime({
|
||||
app,
|
||||
|
||||
@@ -25,7 +25,7 @@ export function sanitizeForTTS(text) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function sanitizeForNotification(text) {
|
||||
function sanitizeForNotification(text) {
|
||||
if (!text || typeof text !== 'string') return '';
|
||||
|
||||
return text
|
||||
|
||||
@@ -10,7 +10,7 @@ export const TUNNEL_MODE_MANAGED_LOCAL = 'managed-local';
|
||||
|
||||
export const TUNNEL_INTENT_EPHEMERAL_PUBLIC = 'ephemeral-public';
|
||||
export const TUNNEL_INTENT_PERSISTENT_PUBLIC = 'persistent-public';
|
||||
export const TUNNEL_INTENT_PRIVATE_NETWORK = 'private-network';
|
||||
const TUNNEL_INTENT_PRIVATE_NETWORK = 'private-network';
|
||||
|
||||
const SUPPORTED_TUNNEL_INTENTS = new Set([
|
||||
TUNNEL_INTENT_EPHEMERAL_PUBLIC,
|
||||
@@ -108,7 +108,7 @@ export function normalizeTunnelMode(value) {
|
||||
return TUNNEL_MODE_QUICK;
|
||||
}
|
||||
|
||||
export function normalizeTunnelIntent(value) {
|
||||
function normalizeTunnelIntent(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -295,6 +295,7 @@ const isUrlAuthReadableHttpPath = (pathname) => {
|
||||
return pathname === '/api/event'
|
||||
|| pathname === '/api/global/event'
|
||||
|| pathname === '/api/openchamber/events'
|
||||
|| pathname === '/api/openchamber/realtime-proxy/sse'
|
||||
|| pathname === '/api/notifications/stream'
|
||||
|| pathname === '/api/fs/raw'
|
||||
|| pathname === '/api/fs/serve'
|
||||
@@ -307,6 +308,7 @@ const isUrlAuthReadableHttpPath = (pathname) => {
|
||||
const isUrlAuthWebSocketPath = (pathname) => {
|
||||
return pathname === '/api/event/ws'
|
||||
|| pathname === '/api/global/event/ws'
|
||||
|| pathname === '/api/openchamber/realtime-proxy/ws'
|
||||
|| pathname === '/api/terminal/ws'
|
||||
|| pathname.startsWith('/api/preview/proxy/');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user