Harden remote API security boundaries
This commit is contained in:
+25
-6
@@ -11,6 +11,11 @@ import { fileURLToPath, pathToFileURL } from 'url';
|
||||
import { isModuleCliExecution } from './cli-entry.js';
|
||||
import { cloudflareTunnelProviderCapabilities } from '../server/lib/tunnels/providers/cloudflare.js';
|
||||
import { createRemoteClientAuthRuntime } from '../server/lib/client-auth/remote-clients.js';
|
||||
import {
|
||||
getUnauthenticatedLanErrorMessage,
|
||||
isNetworkExposedBindHost,
|
||||
isUnsafeUnauthenticatedLanAllowed,
|
||||
} from '../server/lib/security/bind-host.js';
|
||||
import {
|
||||
intro as clackIntro, outro as clackOutro, log as clackLog,
|
||||
box as clackBox, confirm as clackConfirm,
|
||||
@@ -147,10 +152,6 @@ function resolveConfiguredBindHost(hostOverride) {
|
||||
return configured || '127.0.0.1';
|
||||
}
|
||||
|
||||
function isWildcardBindHost(host) {
|
||||
return host === '0.0.0.0' || host === '::' || host === '[::]';
|
||||
}
|
||||
|
||||
function resolveApiHost(hostOverride) {
|
||||
const configured = resolveConfiguredBindHost(hostOverride);
|
||||
|
||||
@@ -637,6 +638,20 @@ function hasUiPasswordConfigured(password) {
|
||||
return typeof password === 'string' && password.trim().length > 0;
|
||||
}
|
||||
|
||||
function assertAuthenticatedNetworkExposure({ host, uiPassword }) {
|
||||
const bindHost = resolveConfiguredBindHost(host);
|
||||
if (hasUiPasswordConfigured(uiPassword)) {
|
||||
return;
|
||||
}
|
||||
if (!isNetworkExposedBindHost(bindHost)) {
|
||||
return;
|
||||
}
|
||||
if (isUnsafeUnauthenticatedLanAllowed(process.env)) {
|
||||
return;
|
||||
}
|
||||
throw new TunnelCliError(getUnauthenticatedLanErrorMessage(bindHost), EXIT_CODE.AUTH_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
const BUN_BIN = getBunBinary();
|
||||
|
||||
function isBunRuntime() {
|
||||
@@ -3445,10 +3460,13 @@ const commands = {
|
||||
const logFd = fs.openSync(initialLogPath, 'a');
|
||||
|
||||
const effectiveUiPassword = hasUiPasswordConfigured(options.uiPassword) ? options.uiPassword : undefined;
|
||||
assertAuthenticatedNetworkExposure({
|
||||
host: options.host,
|
||||
uiPassword: effectiveUiPassword,
|
||||
});
|
||||
if (!effectiveUiPassword && !options.suppressUiPasswordWarning) {
|
||||
const bindHost = resolveConfiguredBindHost(options.host);
|
||||
const loopbackHosts = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
||||
const networkExposed = isWildcardBindHost(bindHost) || !loopbackHosts.has(bindHost);
|
||||
const networkExposed = isNetworkExposedBindHost(bindHost);
|
||||
const warningLine = 'OPENCHAMBER_UI_PASSWORD is not set';
|
||||
const warningDetail = networkExposed
|
||||
? `server is bound to ${bindHost} and reachable on your network with no UI auth. `
|
||||
@@ -5710,6 +5728,7 @@ if (isCliExecution) {
|
||||
export {
|
||||
commands,
|
||||
parseArgs,
|
||||
assertAuthenticatedNetworkExposure,
|
||||
hasUiPasswordConfigured,
|
||||
shouldDisplayTunnelQr,
|
||||
isValidTunnelDoctorResponse,
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
import { isModuleCliExecution, normalizeCliEntryPath } from './cli-entry.js';
|
||||
import { parseArgs } from './cli.js';
|
||||
import { assertAuthenticatedNetworkExposure, parseArgs } from './cli.js';
|
||||
|
||||
describe('cli args', () => {
|
||||
it('accepts legacy daemon flags as no-ops', () => {
|
||||
@@ -74,6 +74,37 @@ describe('cli args', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-exposed auth validation', () => {
|
||||
it('allows loopback without a UI password', () => {
|
||||
expect(() => assertAuthenticatedNetworkExposure({ host: '127.0.0.1' })).not.toThrow();
|
||||
expect(() => assertAuthenticatedNetworkExposure({ host: 'localhost' })).not.toThrow();
|
||||
expect(() => assertAuthenticatedNetworkExposure({ host: '::1' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('requires a UI password for LAN and wildcard bind hosts', () => {
|
||||
expect(() => assertAuthenticatedNetworkExposure({ host: '0.0.0.0' })).toThrow(/refuses to bind/);
|
||||
expect(() => assertAuthenticatedNetworkExposure({ host: '192.168.1.10' })).toThrow(/refuses to bind/);
|
||||
});
|
||||
|
||||
it('allows network-exposed bind hosts with a UI password', () => {
|
||||
expect(() => assertAuthenticatedNetworkExposure({ host: '0.0.0.0', uiPassword: 'secret' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows explicit unsafe LAN override from process env only', () => {
|
||||
const previous = process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN;
|
||||
process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN = 'true';
|
||||
try {
|
||||
expect(() => assertAuthenticatedNetworkExposure({ host: '0.0.0.0' })).not.toThrow();
|
||||
} finally {
|
||||
if (typeof previous === 'string') {
|
||||
process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN = previous;
|
||||
} else {
|
||||
delete process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cli entry detection', () => {
|
||||
const modulePath = '/tmp/openchamber/bin/cli.js';
|
||||
const moduleUrl = pathToFileURL(modulePath).href;
|
||||
|
||||
@@ -16,6 +16,11 @@ import { createTunnelProviderRegistry } from './lib/tunnels/registry.js';
|
||||
import { createCloudflareTunnelProvider } from './lib/tunnels/providers/cloudflare.js';
|
||||
import { createNgrokTunnelProvider } from './lib/tunnels/providers/ngrok.js';
|
||||
import { createRequestSecurityRuntime } from './lib/security/request-security.js';
|
||||
import {
|
||||
getUnauthenticatedLanErrorMessage,
|
||||
isNetworkExposedBindHost,
|
||||
isUnsafeUnauthenticatedLanAllowed,
|
||||
} from './lib/security/bind-host.js';
|
||||
import {
|
||||
TUNNEL_MODE_MANAGED_LOCAL,
|
||||
TUNNEL_MODE_MANAGED_REMOTE,
|
||||
@@ -1032,6 +1037,20 @@ const gracefulShutdown = (...args) => gracefulShutdownRuntime.gracefulShutdown(.
|
||||
async function main(options = {}) {
|
||||
const port = Number.isFinite(options.port) && options.port >= 0 ? Math.trunc(options.port) : DEFAULT_PORT;
|
||||
const host = typeof options.host === 'string' && options.host.length > 0 ? options.host : undefined;
|
||||
const effectiveBindHost = host
|
||||
|| (typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0
|
||||
? process.env.OPENCHAMBER_HOST.trim()
|
||||
: '127.0.0.1');
|
||||
const uiPassword = typeof options.uiPassword === 'string'
|
||||
? options.uiPassword
|
||||
: (typeof process.env.OPENCHAMBER_UI_PASSWORD === 'string' ? process.env.OPENCHAMBER_UI_PASSWORD : null);
|
||||
if (
|
||||
isNetworkExposedBindHost(effectiveBindHost)
|
||||
&& !(typeof uiPassword === 'string' && uiPassword.trim().length > 0)
|
||||
&& !isUnsafeUnauthenticatedLanAllowed(process.env)
|
||||
) {
|
||||
throw new Error(getUnauthenticatedLanErrorMessage(effectiveBindHost));
|
||||
}
|
||||
const tryCfTunnel = options.tryCfTunnel === true;
|
||||
const apiOnly = options.apiOnly === true || isEnvFlagEnabled(process.env.OPENCHAMBER_API_ONLY);
|
||||
const shouldUseCanonicalTunnelConfig = typeof options.tunnelMode === 'string'
|
||||
@@ -1103,7 +1122,6 @@ async function main(options = {}) {
|
||||
expressApp = app;
|
||||
server = http.createServer(app);
|
||||
|
||||
const uiPassword = typeof options.uiPassword === 'string' ? options.uiPassword : null;
|
||||
const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, {
|
||||
process,
|
||||
openchamberVersion: OPENCHAMBER_VERSION,
|
||||
|
||||
@@ -1,6 +1,80 @@
|
||||
import { createRealpathCache } from '../path-realpath-cache.js';
|
||||
import nodeFsPromises from 'node:fs/promises';
|
||||
import nodePath from 'node:path';
|
||||
|
||||
const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
|
||||
const OUTSIDE_FILE_GRANT_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const outsideFileGrants = new Map();
|
||||
|
||||
const pruneOutsideFileGrants = () => {
|
||||
const now = Date.now();
|
||||
for (const [token, grant] of outsideFileGrants.entries()) {
|
||||
if (!grant || grant.expiresAt <= now) {
|
||||
outsideFileGrants.delete(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const mintOutsideFileGrant = async (targetPath, {
|
||||
scopes = ['stat', 'read', 'raw'],
|
||||
fsPromises = nodeFsPromises,
|
||||
path = nodePath,
|
||||
crypto = globalThis.crypto,
|
||||
} = {}) => {
|
||||
const raw = typeof targetPath === 'string' ? targetPath.trim() : '';
|
||||
if (!raw) {
|
||||
throw new Error('Path is required');
|
||||
}
|
||||
const canonicalPath = await fsPromises.realpath(raw);
|
||||
const stats = await fsPromises.stat(canonicalPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error('Outside file grants require a file path');
|
||||
}
|
||||
pruneOutsideFileGrants();
|
||||
const token = typeof crypto?.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const normalizedScopes = new Set(
|
||||
(Array.isArray(scopes) ? scopes : [])
|
||||
.filter((scope) => typeof scope === 'string' && scope.trim())
|
||||
.map((scope) => scope.trim())
|
||||
);
|
||||
if (normalizedScopes.size === 0) {
|
||||
normalizedScopes.add('read');
|
||||
}
|
||||
const grant = {
|
||||
canonicalPath,
|
||||
base: path.dirname(canonicalPath),
|
||||
scopes: normalizedScopes,
|
||||
expiresAt: Date.now() + OUTSIDE_FILE_GRANT_TTL_MS,
|
||||
};
|
||||
outsideFileGrants.set(token, grant);
|
||||
return {
|
||||
path: canonicalPath,
|
||||
outsideFileGrant: token,
|
||||
expiresAt: grant.expiresAt,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveOutsideFileGrant = async ({ token, targetPath, scope, fsPromises }) => {
|
||||
pruneOutsideFileGrants();
|
||||
if (typeof token !== 'string' || !token.trim()) {
|
||||
return { ok: false, error: 'Outside workspace file access requires a grant' };
|
||||
}
|
||||
const grant = outsideFileGrants.get(token.trim());
|
||||
if (!grant) {
|
||||
return { ok: false, error: 'Outside workspace file grant is invalid or expired' };
|
||||
}
|
||||
if (!grant.scopes.has(scope)) {
|
||||
return { ok: false, error: 'Outside workspace file grant does not allow this operation' };
|
||||
}
|
||||
const canonicalPath = await fsPromises.realpath(targetPath);
|
||||
if (canonicalPath !== grant.canonicalPath) {
|
||||
return { ok: false, error: 'Outside workspace file grant does not match requested path' };
|
||||
}
|
||||
return { ok: true, base: grant.base, resolved: canonicalPath, granted: true };
|
||||
};
|
||||
|
||||
const createCommandTimeoutMs = () => {
|
||||
const raw = Number(process.env.OPENCHAMBER_FS_EXEC_TIMEOUT_MS);
|
||||
@@ -175,14 +249,19 @@ const escapeCloneSshKeyPath = (sshKeyPath) => {
|
||||
return `'${normalized.replace(/'/g, "'\\''")}'`;
|
||||
};
|
||||
|
||||
const resolveReadPathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
|
||||
const resolveReadPathFromContext = async ({ req, targetPath, scope, resolveProjectDirectory, path, os, fsPromises, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
|
||||
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||
const normalized = normalizeDirectoryPath(targetPath);
|
||||
if (!normalized || typeof normalized !== 'string') {
|
||||
return { ok: false, error: 'Path is required' };
|
||||
}
|
||||
const resolved = path.resolve(normalized);
|
||||
return { ok: true, base: path.dirname(resolved), resolved };
|
||||
return resolveOutsideFileGrant({
|
||||
token: req.query?.outsideFileGrant,
|
||||
targetPath: resolved,
|
||||
scope,
|
||||
fsPromises,
|
||||
});
|
||||
}
|
||||
|
||||
return resolveWorkspacePathFromContext({
|
||||
@@ -451,7 +530,8 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
|
||||
let resolvedPath = '';
|
||||
if (allowOutsideWorkspace) {
|
||||
resolvedPath = path.resolve(normalizeDirectoryPath(dirPath));
|
||||
console.warn('Rejected outside-workspace mkdir without trusted directory grant');
|
||||
return res.status(403).json({ error: 'Outside workspace directory creation requires a grant' });
|
||||
} else {
|
||||
const resolved = await resolveWorkspacePathFromContext({
|
||||
req,
|
||||
@@ -595,13 +675,18 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
const resolved = await resolveReadPathFromContext({
|
||||
req,
|
||||
targetPath: filePath,
|
||||
scope: 'stat',
|
||||
resolveProjectDirectory,
|
||||
path,
|
||||
os,
|
||||
fsPromises,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||
console.warn(`Rejected outside-workspace stat: ${resolved.error}`);
|
||||
}
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
|
||||
@@ -644,13 +729,18 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
const resolved = await resolveReadPathFromContext({
|
||||
req,
|
||||
targetPath: filePath,
|
||||
scope: 'read',
|
||||
resolveProjectDirectory,
|
||||
path,
|
||||
os,
|
||||
fsPromises,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||
console.warn(`Rejected outside-workspace read: ${resolved.error}`);
|
||||
}
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
|
||||
@@ -710,13 +800,18 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
const resolved = await resolveReadPathFromContext({
|
||||
req,
|
||||
targetPath: filePath,
|
||||
scope: 'raw',
|
||||
resolveProjectDirectory,
|
||||
path,
|
||||
os,
|
||||
fsPromises,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||
console.warn(`Rejected outside-workspace raw read: ${resolved.error}`);
|
||||
}
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
|
||||
@@ -756,6 +851,9 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
|
||||
const content = await fsPromises.readFile(canonicalPath);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (resolved.granted) {
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
}
|
||||
return res.type(mimeType).send(content);
|
||||
} catch (error) {
|
||||
const err = error;
|
||||
@@ -989,7 +1087,25 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
pruneGitReadCache();
|
||||
|
||||
try {
|
||||
const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd));
|
||||
if (background === true) {
|
||||
console.warn('Rejected background /api/fs/exec request');
|
||||
return res.status(400).json({ error: 'Background command execution is not allowed' });
|
||||
}
|
||||
const resolvedCwdCandidate = path.resolve(normalizeDirectoryPath(cwd));
|
||||
const resolvedForWorkspace = await resolveWorkspacePathFromContext({
|
||||
req,
|
||||
targetPath: resolvedCwdCandidate,
|
||||
resolveProjectDirectory,
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
});
|
||||
if (!resolvedForWorkspace.ok) {
|
||||
console.warn(`Rejected /api/fs/exec outside workspace: ${resolvedForWorkspace.error}`);
|
||||
return res.status(403).json({ error: resolvedForWorkspace.error });
|
||||
}
|
||||
const resolvedCwd = resolvedForWorkspace.resolved;
|
||||
const stats = await fsPromises.stat(resolvedCwd);
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Specified cwd is not a directory' });
|
||||
@@ -1015,7 +1131,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
|
||||
execJobs.set(jobId, job);
|
||||
|
||||
const isBackground = background === true;
|
||||
const isBackground = false;
|
||||
if (isBackground) {
|
||||
void runExecJob(job).catch((error) => {
|
||||
job.status = 'done';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerFsRoutes } from './routes.js';
|
||||
import { mintOutsideFileGrant, registerFsRoutes } from './routes.js';
|
||||
|
||||
const createRouteRegistry = () => {
|
||||
const routes = new Map();
|
||||
@@ -24,6 +24,7 @@ const createRouteRegistry = () => {
|
||||
const createMockResponse = () => {
|
||||
let statusCode = 200;
|
||||
let body = null;
|
||||
const headers = new Map();
|
||||
return {
|
||||
status(code) {
|
||||
statusCode = code;
|
||||
@@ -40,6 +41,13 @@ const createMockResponse = () => {
|
||||
body = payload;
|
||||
return this;
|
||||
},
|
||||
setHeader(name, value) {
|
||||
headers.set(name.toLowerCase(), value);
|
||||
return this;
|
||||
},
|
||||
getHeader(name) {
|
||||
return headers.get(name.toLowerCase());
|
||||
},
|
||||
get statusCode() {
|
||||
return statusCode;
|
||||
},
|
||||
@@ -152,6 +160,46 @@ const registerRead = (fsPromises) => {
|
||||
return getRoute('GET', '/api/fs/read');
|
||||
};
|
||||
|
||||
const registerRaw = (fsPromises) => {
|
||||
const { app, getRoute } = createRouteRegistry();
|
||||
registerFsRoutes(app, {
|
||||
os: { homedir: () => '/home/user' },
|
||||
path: path.posix,
|
||||
fsPromises: {
|
||||
realpath: async (targetPath) => targetPath,
|
||||
...fsPromises,
|
||||
},
|
||||
spawn: vi.fn(),
|
||||
crypto: { randomUUID: () => 'job-0' },
|
||||
normalizeDirectoryPath: (p) => p,
|
||||
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
||||
buildAugmentedPath: () => '/usr/bin',
|
||||
resolveGitBinaryForSpawn: () => 'git',
|
||||
openchamberUserConfigRoot: '/home/user/.config',
|
||||
});
|
||||
return getRoute('GET', '/api/fs/raw');
|
||||
};
|
||||
|
||||
const registerMkdir = (fsPromises) => {
|
||||
const { app, getRoute } = createRouteRegistry();
|
||||
registerFsRoutes(app, {
|
||||
os: { homedir: () => '/home/user' },
|
||||
path: path.posix,
|
||||
fsPromises: {
|
||||
realpath: async (targetPath) => targetPath,
|
||||
...fsPromises,
|
||||
},
|
||||
spawn: vi.fn(),
|
||||
crypto: { randomUUID: () => 'job-0' },
|
||||
normalizeDirectoryPath: (p) => p,
|
||||
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
||||
buildAugmentedPath: () => '/usr/bin',
|
||||
resolveGitBinaryForSpawn: () => 'git',
|
||||
openchamberUserConfigRoot: '/home/user/.config',
|
||||
});
|
||||
return getRoute('POST', '/api/fs/mkdir');
|
||||
};
|
||||
|
||||
const callExec = async (handler, body) => {
|
||||
const res = createMockResponse();
|
||||
await handler({ body }, res);
|
||||
@@ -170,6 +218,18 @@ const callRead = async (handler, query) => {
|
||||
return res;
|
||||
};
|
||||
|
||||
const callRaw = async (handler, query) => {
|
||||
const res = createMockResponse();
|
||||
await handler({ query }, res);
|
||||
return res;
|
||||
};
|
||||
|
||||
const callMkdir = async (handler, body) => {
|
||||
const res = createMockResponse();
|
||||
await handler({ body }, res);
|
||||
return res;
|
||||
};
|
||||
|
||||
describe('fs write', () => {
|
||||
it('does not rewrite a file when content is unchanged', async () => {
|
||||
const fsPromises = {
|
||||
@@ -254,6 +314,106 @@ describe('fs write', () => {
|
||||
});
|
||||
|
||||
describe('fs read', () => {
|
||||
it('rejects outside workspace reads without a grant', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const fsPromises = {
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: 3 })),
|
||||
readFile: vi.fn(async () => 'secret'),
|
||||
};
|
||||
const handler = registerRead(fsPromises);
|
||||
|
||||
const res = await callRead(handler, { path: '/etc/passwd', allowOutsideWorkspace: 'true' });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Outside workspace file access requires a grant' });
|
||||
expect(fsPromises.readFile).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('allows outside workspace reads with an exact-path grant', async () => {
|
||||
const fsPromises = {
|
||||
realpath: vi.fn(async (targetPath) => targetPath),
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
||||
readFile: vi.fn(async () => 'secret'),
|
||||
};
|
||||
const grant = await mintOutsideFileGrant('/outside/plan.txt', {
|
||||
fsPromises,
|
||||
path: path.posix,
|
||||
crypto: { randomUUID: () => 'grant-read' },
|
||||
});
|
||||
const handler = registerRead(fsPromises);
|
||||
|
||||
const res = await callRead(handler, {
|
||||
path: '/outside/plan.txt',
|
||||
allowOutsideWorkspace: 'true',
|
||||
outsideFileGrant: grant.outsideFileGrant,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe('secret');
|
||||
});
|
||||
|
||||
it('rejects outside workspace grants for a different canonical path', async () => {
|
||||
const fsPromises = {
|
||||
realpath: vi.fn(async (targetPath) => targetPath),
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
||||
readFile: vi.fn(async () => 'secret'),
|
||||
};
|
||||
const grant = await mintOutsideFileGrant('/outside/a.txt', {
|
||||
fsPromises,
|
||||
path: path.posix,
|
||||
crypto: { randomUUID: () => 'grant-mismatch' },
|
||||
});
|
||||
const handler = registerRead(fsPromises);
|
||||
|
||||
const res = await callRead(handler, {
|
||||
path: '/outside/b.txt',
|
||||
allowOutsideWorkspace: 'true',
|
||||
outsideFileGrant: grant.outsideFileGrant,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Outside workspace file grant does not match requested path' });
|
||||
expect(fsPromises.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets no-referrer on raw responses served through outside file grants', async () => {
|
||||
const fsPromises = {
|
||||
realpath: vi.fn(async (targetPath) => targetPath),
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
||||
readFile: vi.fn(async () => Buffer.from('secret')),
|
||||
};
|
||||
const grant = await mintOutsideFileGrant('/outside/image.png', {
|
||||
scopes: ['raw'],
|
||||
fsPromises,
|
||||
path: path.posix,
|
||||
crypto: { randomUUID: () => 'grant-raw' },
|
||||
});
|
||||
const handler = registerRaw(fsPromises);
|
||||
|
||||
const res = await callRaw(handler, {
|
||||
path: '/outside/image.png',
|
||||
allowOutsideWorkspace: 'true',
|
||||
outsideFileGrant: grant.outsideFileGrant,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.getHeader('referrer-policy')).toBe('no-referrer');
|
||||
});
|
||||
|
||||
it('rejects outside workspace mkdir without a trusted directory grant', async () => {
|
||||
const fsPromises = {
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
};
|
||||
const handler = registerMkdir(fsPromises);
|
||||
|
||||
const res = await callMkdir(handler, { path: '/tmp/staging', allowOutsideWorkspace: true });
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'Outside workspace directory creation requires a grant' });
|
||||
expect(fsPromises.mkdir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs when empty-read retries are exhausted after non-empty stat', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const fsPromises = {
|
||||
@@ -280,6 +440,30 @@ describe('fs exec git-read cache', () => {
|
||||
delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS;
|
||||
});
|
||||
|
||||
it('rejects background command execution', async () => {
|
||||
const { spawn } = createSpawn();
|
||||
const handler = registerExec({ spawn });
|
||||
|
||||
const res = await callExec(handler, { commands: ['id'], cwd: '/repo', background: true });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Background command execution is not allowed' });
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects command execution outside the workspace', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { spawn } = createSpawn();
|
||||
const handler = registerExec({ spawn });
|
||||
|
||||
const res = await callExec(handler, { commands: ['id'], cwd: '/' });
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('caches an allowlisted git rev-parse across identical requests', async () => {
|
||||
const command = 'git rev-parse --absolute-git-dir --git-common-dir';
|
||||
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/repo/.git\n.git\n' } });
|
||||
@@ -333,8 +517,8 @@ describe('fs exec git-read cache', () => {
|
||||
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/x/.git\n' } });
|
||||
const handler = registerExec({ spawn });
|
||||
|
||||
await callExec(handler, { commands: [command], cwd: '/repo-a' });
|
||||
await callExec(handler, { commands: [command], cwd: '/repo-b' });
|
||||
await callExec(handler, { commands: [command], cwd: '/repo/a' });
|
||||
await callExec(handler, { commands: [command], cwd: '/repo/b' });
|
||||
|
||||
expect(calls.length).toBe(2);
|
||||
});
|
||||
@@ -355,8 +539,8 @@ describe('fs exec git-read cache', () => {
|
||||
const { spawn, calls } = createSpawn({ stdoutByCommand: {}, exitCode: 128 });
|
||||
const handler = registerExec({ spawn });
|
||||
|
||||
await callExec(handler, { commands: [command], cwd: '/not-a-repo' });
|
||||
await callExec(handler, { commands: [command], cwd: '/not-a-repo' });
|
||||
await callExec(handler, { commands: [command], cwd: '/repo/not-a-repo' });
|
||||
await callExec(handler, { commands: [command], cwd: '/repo/not-a-repo' });
|
||||
|
||||
expect(calls.length).toBe(2);
|
||||
});
|
||||
@@ -398,16 +582,16 @@ describe('fs exec git-read cache', () => {
|
||||
|
||||
// Fill to the 500-entry ceiling with distinct working directories.
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
await callExec(handler, { commands: [command], cwd: `/repo-${i}` });
|
||||
await callExec(handler, { commands: [command], cwd: `/repo/worktree-${i}` });
|
||||
}
|
||||
const afterFill = calls.length;
|
||||
expect(afterFill).toBe(500);
|
||||
|
||||
// One more distinct dir evicts the oldest entry (/repo-0).
|
||||
await callExec(handler, { commands: [command], cwd: '/repo-overflow' });
|
||||
// One more distinct dir evicts the oldest entry (/repo/worktree-0).
|
||||
await callExec(handler, { commands: [command], cwd: '/repo/worktree-overflow' });
|
||||
// Evicted entry must re-run; a surviving entry must still be served.
|
||||
await callExec(handler, { commands: [command], cwd: '/repo-0' }); // evicted -> spawns
|
||||
await callExec(handler, { commands: [command], cwd: '/repo-499' }); // cached -> no spawn
|
||||
await callExec(handler, { commands: [command], cwd: '/repo/worktree-0' }); // evicted -> spawns
|
||||
await callExec(handler, { commands: [command], cwd: '/repo/worktree-499' }); // cached -> no spawn
|
||||
|
||||
expect(calls.length).toBe(afterFill + 2);
|
||||
});
|
||||
|
||||
@@ -222,6 +222,94 @@ export function registerGitRoutes(app) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/primary-root', async (req, res) => {
|
||||
const { resolvePrimaryWorktreeRoot } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
const result = await resolvePrimaryWorktreeRoot(directory);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve git primary root:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to resolve git primary root' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/toplevel', async (req, res) => {
|
||||
const { resolveWorktreeTopLevel } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
const result = await resolveWorktreeTopLevel(directory);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve git worktree toplevel:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to resolve git worktree toplevel' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/commit-summaries', async (req, res) => {
|
||||
const { getCommitSummaries } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
const result = await getCommitSummaries(directory, req.body?.shas);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to get git commit summaries:', error);
|
||||
res.status(400).json({ error: error.message || 'Failed to get git commit summaries' });
|
||||
}
|
||||
});
|
||||
|
||||
const handleIntegrateAction = (action, loadHandler) => {
|
||||
app.post(`/api/git/integrate/${action}`, async (req, res) => {
|
||||
try {
|
||||
const handler = await loadHandler();
|
||||
const result = await handler(req.body || {});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error(`Failed to run git integrate ${action}:`, error);
|
||||
res.status(400).json({ error: error.message || `Failed to run git integrate ${action}` });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleIntegrateAction('plan', async () => {
|
||||
const { computeIntegratePlan } = await getGitLibraries();
|
||||
return (body) => computeIntegratePlan(body);
|
||||
});
|
||||
|
||||
handleIntegrateAction('conflict-details', async () => {
|
||||
const { getIntegrateConflictDetails } = await getGitLibraries();
|
||||
return (body) => getIntegrateConflictDetails(body?.tempWorktreePath);
|
||||
});
|
||||
|
||||
handleIntegrateAction('cherry-pick-status', async () => {
|
||||
const { isCherryPickInProgress } = await getGitLibraries();
|
||||
return (body) => isCherryPickInProgress(body?.tempWorktreePath);
|
||||
});
|
||||
|
||||
handleIntegrateAction('run', async () => {
|
||||
const { integrateWorktreeCommits } = await getGitLibraries();
|
||||
return (body) => integrateWorktreeCommits(body?.plan);
|
||||
});
|
||||
|
||||
handleIntegrateAction('abort', async () => {
|
||||
const { abortIntegrate } = await getGitLibraries();
|
||||
return (body) => abortIntegrate(body?.state);
|
||||
});
|
||||
|
||||
handleIntegrateAction('continue', async () => {
|
||||
const { continueIntegrate } = await getGitLibraries();
|
||||
return (body) => continueIntegrate(body?.state);
|
||||
});
|
||||
|
||||
app.get('/api/git/diff', async (req, res) => {
|
||||
const { getDiff } = await getGitLibraries();
|
||||
try {
|
||||
|
||||
@@ -864,6 +864,407 @@ const runGitCommandOrThrow = async (cwd, args, fallbackMessage) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const derivePrimaryWorktreeRootFromGitDir = (gitDir) => {
|
||||
const normalized = normalizePath(gitDir);
|
||||
if (!normalized) return null;
|
||||
if (normalized.endsWith('/.git')) {
|
||||
return normalized.slice(0, -'/.git'.length) || null;
|
||||
}
|
||||
const marker = '/.git/worktrees/';
|
||||
const markerIndex = normalized.indexOf(marker);
|
||||
if (markerIndex > 0) {
|
||||
return normalized.slice(0, markerIndex) || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export async function resolvePrimaryWorktreeRoot(directory) {
|
||||
const result = await runGitCommand(directory, ['rev-parse', '--absolute-git-dir', '--git-common-dir']);
|
||||
if (!result.success) {
|
||||
return { root: directory };
|
||||
}
|
||||
const lines = String(result.stdout || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const absoluteGitDir = normalizePath(lines[0] || '');
|
||||
const rootFromAbsoluteGitDir = derivePrimaryWorktreeRootFromGitDir(absoluteGitDir);
|
||||
if (rootFromAbsoluteGitDir) {
|
||||
return { root: rootFromAbsoluteGitDir };
|
||||
}
|
||||
const rawCommonDir = normalizePath(lines[1] || '');
|
||||
if (rawCommonDir) {
|
||||
const commonDir = path.isAbsolute(rawCommonDir)
|
||||
? rawCommonDir
|
||||
: path.resolve(directory, rawCommonDir);
|
||||
const rootFromCommonDir = derivePrimaryWorktreeRootFromGitDir(commonDir);
|
||||
if (rootFromCommonDir) {
|
||||
return { root: rootFromCommonDir };
|
||||
}
|
||||
}
|
||||
return { root: directory };
|
||||
}
|
||||
|
||||
export async function resolveWorktreeTopLevel(directory) {
|
||||
const result = await runGitCommand(directory, ['rev-parse', '--show-toplevel']);
|
||||
if (!result.success) {
|
||||
return { root: directory };
|
||||
}
|
||||
const root = normalizePath(String(result.stdout || '').trim());
|
||||
return { root: root || directory };
|
||||
}
|
||||
|
||||
export async function getCommitSummaries(directory, shas) {
|
||||
const commits = Array.isArray(shas)
|
||||
? shas.map((sha) => String(sha || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
if (commits.length === 0) {
|
||||
return { commits: [] };
|
||||
}
|
||||
if (commits.some((sha) => !/^[0-9a-fA-F]{4,64}$/.test(sha))) {
|
||||
throw new Error('Invalid commit SHA');
|
||||
}
|
||||
const result = await runGitCommandOrThrow(
|
||||
directory,
|
||||
['show', '-s', '--format=%H%x09%h%x09%s', ...commits, '--'],
|
||||
'Failed to get commit summaries'
|
||||
);
|
||||
const parsed = String(result.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [sha, short, subject] = line.split('\t');
|
||||
return { sha: sha || '', short: short || '', subject: subject || '' };
|
||||
})
|
||||
.filter((entry) => entry.sha && entry.short);
|
||||
return { commits: parsed };
|
||||
}
|
||||
|
||||
const trimGitLines = (value) => String(value || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const gitStdoutText = (result) => String(result?.stdout || '').trim();
|
||||
const gitStderrText = (result) => String(result?.stderr || result?.message || '').trim();
|
||||
|
||||
const normalizeIntegrateBranch = (value, fieldName) => {
|
||||
const branch = String(value || '').trim();
|
||||
if (!branch) {
|
||||
throw new Error(`${fieldName} is required`);
|
||||
}
|
||||
if (branch.startsWith('-') || branch.includes('\0')) {
|
||||
throw new Error(`Invalid ${fieldName}`);
|
||||
}
|
||||
return branch;
|
||||
};
|
||||
|
||||
const normalizeIntegrateSha = (value) => {
|
||||
const sha = String(value || '').trim();
|
||||
if (!/^[0-9a-fA-F]{4,64}$/.test(sha)) {
|
||||
throw new Error('Invalid commit SHA');
|
||||
}
|
||||
return sha;
|
||||
};
|
||||
|
||||
const normalizeIntegratePath = (value, fieldName) => {
|
||||
const target = normalizeDirectoryPath(value);
|
||||
if (!target) {
|
||||
throw new Error(`${fieldName} is required`);
|
||||
}
|
||||
return path.resolve(target);
|
||||
};
|
||||
|
||||
const runGitOk = (result) => Boolean(result?.success);
|
||||
|
||||
const listGitWorktreesForIntegrate = async (repoRoot) => {
|
||||
const out = await runGitCommandOrThrow(repoRoot, ['worktree', 'list', '--porcelain'], 'Failed to list git worktrees');
|
||||
const entries = [];
|
||||
let current = null;
|
||||
for (const line of String(out.stdout || '').split(/\r?\n/)) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
if (current) entries.push(current);
|
||||
current = { path: line.slice('worktree '.length).trim(), branchRef: null };
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
if (line.startsWith('branch ')) {
|
||||
current.branchRef = line.slice('branch '.length).trim();
|
||||
}
|
||||
}
|
||||
if (current) entries.push(current);
|
||||
return entries.filter((entry) => Boolean(entry.path));
|
||||
};
|
||||
|
||||
const ensureLocalIntegrateBranch = async (repoRoot, candidate) => {
|
||||
const raw = normalizeIntegrateBranch(candidate, 'targetBranch');
|
||||
if (raw === 'HEAD') {
|
||||
return 'HEAD';
|
||||
}
|
||||
|
||||
const hasLocal = await runGitCommand(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${raw}`]);
|
||||
if (runGitOk(hasLocal)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
if (raw.startsWith('remotes/')) {
|
||||
const remoteRef = raw.slice('remotes/'.length);
|
||||
const parts = remoteRef.split('/');
|
||||
const remote = normalizeIntegrateBranch(parts[0] || 'origin', 'remote');
|
||||
const name = normalizeIntegrateBranch(parts.slice(1).join('/'), 'branch');
|
||||
await runGitCommandOrThrow(repoRoot, ['branch', '--track', name, `${remote}/${name}`], 'Failed to track remote branch');
|
||||
return name;
|
||||
}
|
||||
|
||||
const remoteCheck = await runGitCommand(repoRoot, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${raw}`]);
|
||||
if (runGitOk(remoteCheck)) {
|
||||
await runGitCommandOrThrow(repoRoot, ['branch', '--track', raw, `origin/${raw}`], 'Failed to track remote branch');
|
||||
return raw;
|
||||
}
|
||||
|
||||
return raw;
|
||||
};
|
||||
|
||||
export async function computeIntegratePlan(input = {}) {
|
||||
const repoRoot = normalizeIntegratePath(input.repoRoot, 'repoRoot');
|
||||
const sourceBranch = normalizeIntegrateBranch(input.sourceBranch, 'sourceBranch');
|
||||
const targetBranchRaw = normalizeIntegrateBranch(input.targetBranch, 'targetBranch');
|
||||
if (sourceBranch === 'HEAD' || targetBranchRaw === 'HEAD') {
|
||||
return { repoRoot, sourceBranch, targetBranch: targetBranchRaw, commits: [] };
|
||||
}
|
||||
|
||||
const targetBranch = await ensureLocalIntegrateBranch(repoRoot, targetBranchRaw);
|
||||
const cherry = await runGitCommandOrThrow(repoRoot, ['cherry', targetBranch, sourceBranch], 'Failed to compute cherry commits');
|
||||
const plus = new Set();
|
||||
for (const line of trimGitLines(cherry.stdout)) {
|
||||
const match = line.match(/^\+\s+([0-9a-f]{7,40})\b/i);
|
||||
if (match) {
|
||||
plus.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const revList = await runGitCommandOrThrow(repoRoot, ['rev-list', '--reverse', `${targetBranch}..${sourceBranch}`], 'Failed to list commits');
|
||||
const commits = trimGitLines(revList.stdout).filter((sha) => plus.has(sha));
|
||||
return { repoRoot, sourceBranch, targetBranch, commits };
|
||||
}
|
||||
|
||||
const createIntegrateTempWorktree = async (repoRoot, targetBranch) => {
|
||||
const tmpParent = path.join(os.homedir(), '.config', 'openchamber', 'tmp');
|
||||
await fsp.mkdir(tmpParent, { recursive: true });
|
||||
const tmpDir = await fsp.mkdtemp(path.join(tmpParent, 'oc-integrate-'));
|
||||
try {
|
||||
await runGitCommandOrThrow(repoRoot, ['worktree', 'add', '--force', tmpDir, targetBranch], 'Failed to create temp worktree');
|
||||
return tmpDir;
|
||||
} catch (error) {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const removeIntegrateTempWorktree = async (repoRoot, tmpDir) => {
|
||||
await runGitCommand(repoRoot, ['worktree', 'remove', '--force', tmpDir]).catch(() => undefined);
|
||||
await runGitCommand(repoRoot, ['worktree', 'prune']).catch(() => undefined);
|
||||
};
|
||||
|
||||
const maybeFastForwardIntegrateUpstream = async (tmpDir) => {
|
||||
const upstream = await runGitCommand(tmpDir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
||||
const upstreamRef = gitStdoutText(upstream);
|
||||
if (!upstreamRef) {
|
||||
return;
|
||||
}
|
||||
await runGitCommand(tmpDir, ['fetch']);
|
||||
const ff = await runGitCommand(tmpDir, ['merge', '--ff-only', upstreamRef]);
|
||||
if (!runGitOk(ff)) {
|
||||
throw new Error(gitStderrText(ff) || 'Fast-forward failed');
|
||||
}
|
||||
};
|
||||
|
||||
export async function getIntegrateConflictDetails(tmpDir) {
|
||||
const target = normalizeIntegratePath(tmpDir, 'tempWorktreePath');
|
||||
const [status, unmerged, diff, meta, patch] = await Promise.all([
|
||||
runGitCommand(target, ['status', '--porcelain']),
|
||||
runGitCommand(target, ['diff', '--name-only', '--diff-filter=U']),
|
||||
runGitCommand(target, ['diff']),
|
||||
runGitCommand(target, ['show', '--no-patch', '--pretty=fuller', 'CHERRY_PICK_HEAD']),
|
||||
runGitCommand(target, ['show', 'CHERRY_PICK_HEAD']),
|
||||
]);
|
||||
|
||||
return {
|
||||
statusPorcelain: String(status.stdout || ''),
|
||||
unmergedFiles: trimGitLines(unmerged.stdout),
|
||||
diff: String(diff.stdout || diff.stderr || ''),
|
||||
currentPatchMeta: String(meta.stdout || meta.stderr || ''),
|
||||
currentPatch: String(patch.stdout || patch.stderr || ''),
|
||||
};
|
||||
}
|
||||
|
||||
export async function isCherryPickInProgress(tmpDir) {
|
||||
const target = normalizeIntegratePath(tmpDir, 'tempWorktreePath');
|
||||
const head = await runGitCommand(target, ['rev-parse', '--verify', '--quiet', 'CHERRY_PICK_HEAD']);
|
||||
return { inProgress: runGitOk(head) };
|
||||
}
|
||||
|
||||
const computeCleanIntegrateWorktreesToSync = async ({ repoRoot, targetBranch, excludePaths }) => {
|
||||
const targetRef = `refs/heads/${targetBranch}`;
|
||||
const exclude = new Set(excludePaths);
|
||||
const entries = await listGitWorktreesForIntegrate(repoRoot);
|
||||
const candidates = entries
|
||||
.filter((entry) => entry.branchRef === targetRef)
|
||||
.map((entry) => entry.path)
|
||||
.filter((candidate) => candidate && !exclude.has(candidate));
|
||||
|
||||
const clean = [];
|
||||
for (const candidate of candidates) {
|
||||
const status = await runGitCommand(candidate, ['status', '--porcelain']);
|
||||
if (!gitStdoutText(status)) {
|
||||
clean.push(candidate);
|
||||
}
|
||||
}
|
||||
return clean;
|
||||
};
|
||||
|
||||
const syncCleanIntegrateTargetWorktrees = async (paths) => {
|
||||
for (const target of paths) {
|
||||
await runGitCommand(target, ['reset', '--hard']).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeIntegratePlan = async (plan = {}) => {
|
||||
const repoRoot = normalizeIntegratePath(plan.repoRoot, 'repoRoot');
|
||||
const sourceBranch = normalizeIntegrateBranch(plan.sourceBranch, 'sourceBranch');
|
||||
const targetBranch = normalizeIntegrateBranch(plan.targetBranch, 'targetBranch');
|
||||
const commits = Array.isArray(plan.commits) ? plan.commits.map(normalizeIntegrateSha) : [];
|
||||
return { repoRoot, sourceBranch, targetBranch, commits };
|
||||
};
|
||||
|
||||
const normalizeIntegrateState = (state = {}) => ({
|
||||
repoRoot: normalizeIntegratePath(state.repoRoot, 'repoRoot'),
|
||||
tempWorktreePath: normalizeIntegratePath(state.tempWorktreePath, 'tempWorktreePath'),
|
||||
sourceBranch: normalizeIntegrateBranch(state.sourceBranch, 'sourceBranch'),
|
||||
targetBranch: normalizeIntegrateBranch(state.targetBranch, 'targetBranch'),
|
||||
cleanTargetWorktrees: Array.isArray(state.cleanTargetWorktrees)
|
||||
? state.cleanTargetWorktrees.map((entry) => normalizeIntegratePath(entry, 'cleanTargetWorktree'))
|
||||
: [],
|
||||
remainingCommits: Array.isArray(state.remainingCommits) ? state.remainingCommits.map(normalizeIntegrateSha) : [],
|
||||
currentCommit: normalizeIntegrateSha(state.currentCommit),
|
||||
});
|
||||
|
||||
export async function integrateWorktreeCommits(inputPlan = {}) {
|
||||
const plan = await normalizeIntegratePlan(inputPlan);
|
||||
if (plan.commits.length === 0) {
|
||||
return { kind: 'noop', reason: 'No commits to move' };
|
||||
}
|
||||
|
||||
const tmpDir = await createIntegrateTempWorktree(plan.repoRoot, plan.targetBranch);
|
||||
let cleanTargetWorktrees = [];
|
||||
let remaining = [];
|
||||
try {
|
||||
await maybeFastForwardIntegrateUpstream(tmpDir);
|
||||
|
||||
const clean = await runGitCommand(tmpDir, ['status', '--porcelain']);
|
||||
if (gitStdoutText(clean)) {
|
||||
throw new Error('Target branch has local changes; abort integration and retry');
|
||||
}
|
||||
|
||||
cleanTargetWorktrees = await computeCleanIntegrateWorktreesToSync({
|
||||
repoRoot: plan.repoRoot,
|
||||
targetBranch: plan.targetBranch,
|
||||
excludePaths: [tmpDir],
|
||||
}).catch(() => []);
|
||||
|
||||
remaining = [...plan.commits];
|
||||
while (remaining.length > 0) {
|
||||
const sha = remaining[0];
|
||||
const pick = await runGitCommand(tmpDir, ['cherry-pick', sha]);
|
||||
if (runGitOk(pick)) {
|
||||
remaining.shift();
|
||||
continue;
|
||||
}
|
||||
|
||||
const unmerged = await runGitCommand(tmpDir, ['diff', '--name-only', '--diff-filter=U']);
|
||||
const unmergedFiles = trimGitLines(unmerged.stdout);
|
||||
if (unmergedFiles.length > 0) {
|
||||
const details = await getIntegrateConflictDetails(tmpDir);
|
||||
return {
|
||||
kind: 'conflict',
|
||||
state: {
|
||||
repoRoot: plan.repoRoot,
|
||||
tempWorktreePath: tmpDir,
|
||||
sourceBranch: plan.sourceBranch,
|
||||
targetBranch: plan.targetBranch,
|
||||
cleanTargetWorktrees,
|
||||
remainingCommits: remaining,
|
||||
currentCommit: sha,
|
||||
},
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(gitStderrText(pick) || 'Cherry-pick failed');
|
||||
}
|
||||
|
||||
await removeIntegrateTempWorktree(plan.repoRoot, tmpDir);
|
||||
await syncCleanIntegrateTargetWorktrees(cleanTargetWorktrees).catch(() => undefined);
|
||||
return { kind: 'success', moved: plan.commits.length };
|
||||
} catch (error) {
|
||||
await removeIntegrateTempWorktree(plan.repoRoot, tmpDir).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function abortIntegrate(stateInput = {}) {
|
||||
const state = normalizeIntegrateState(stateInput);
|
||||
await runGitCommand(state.tempWorktreePath, ['cherry-pick', '--abort']).catch(() => undefined);
|
||||
await removeIntegrateTempWorktree(state.repoRoot, state.tempWorktreePath);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function continueIntegrate(stateInput = {}) {
|
||||
const state = normalizeIntegrateState(stateInput);
|
||||
const cont = await runGitCommand(state.tempWorktreePath, ['cherry-pick', '--continue']);
|
||||
if (!runGitOk(cont)) {
|
||||
const unmerged = await runGitCommand(state.tempWorktreePath, ['diff', '--name-only', '--diff-filter=U']);
|
||||
if (trimGitLines(unmerged.stdout).length > 0) {
|
||||
const details = await getIntegrateConflictDetails(state.tempWorktreePath);
|
||||
return { kind: 'conflict', state, details };
|
||||
}
|
||||
throw new Error(gitStderrText(cont) || 'Cherry-pick continue failed');
|
||||
}
|
||||
|
||||
const remaining = [...state.remainingCommits];
|
||||
if (remaining.length > 0 && remaining[0] === state.currentCommit) {
|
||||
remaining.shift();
|
||||
}
|
||||
|
||||
const still = [...remaining];
|
||||
while (still.length > 0) {
|
||||
const sha = still[0];
|
||||
const pick = await runGitCommand(state.tempWorktreePath, ['cherry-pick', sha]);
|
||||
if (runGitOk(pick)) {
|
||||
still.shift();
|
||||
continue;
|
||||
}
|
||||
const unmerged = await runGitCommand(state.tempWorktreePath, ['diff', '--name-only', '--diff-filter=U']);
|
||||
if (trimGitLines(unmerged.stdout).length > 0) {
|
||||
const details = await getIntegrateConflictDetails(state.tempWorktreePath);
|
||||
return {
|
||||
kind: 'conflict',
|
||||
state: {
|
||||
...state,
|
||||
remainingCommits: still,
|
||||
currentCommit: sha,
|
||||
},
|
||||
details,
|
||||
};
|
||||
}
|
||||
throw new Error(gitStderrText(pick) || 'Cherry-pick failed');
|
||||
}
|
||||
|
||||
await removeIntegrateTempWorktree(state.repoRoot, state.tempWorktreePath);
|
||||
await syncCleanIntegrateTargetWorktrees(state.cleanTargetWorktrees).catch(() => undefined);
|
||||
return { kind: 'success', moved: state.remainingCommits.length };
|
||||
}
|
||||
|
||||
const ensureOpenCodeProjectId = async (primaryWorktree) => {
|
||||
const gitDir = path.join(primaryWorktree, '.git');
|
||||
const idFile = path.join(gitDir, 'opencode');
|
||||
|
||||
+14
-12
@@ -51,18 +51,6 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
setAutoAcceptSession,
|
||||
} = options;
|
||||
|
||||
registerServerStatusRoutes(app, {
|
||||
express,
|
||||
process,
|
||||
openchamberVersion,
|
||||
runtimeName,
|
||||
serverStartedAt,
|
||||
gracefulShutdown,
|
||||
getHealthSnapshot,
|
||||
});
|
||||
|
||||
registerCommonRequestMiddleware(app, { express, verboseRequestLogs });
|
||||
|
||||
const uiAuthController = createUiAuth({
|
||||
password: uiPassword,
|
||||
readSettingsFromDiskMigrated,
|
||||
@@ -72,6 +60,20 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
console.log('UI password protection enabled for browser sessions');
|
||||
}
|
||||
|
||||
registerServerStatusRoutes(app, {
|
||||
express,
|
||||
process,
|
||||
openchamberVersion,
|
||||
runtimeName,
|
||||
serverStartedAt,
|
||||
gracefulShutdown,
|
||||
getHealthSnapshot,
|
||||
tunnelAuthController,
|
||||
uiAuthController,
|
||||
});
|
||||
|
||||
registerCommonRequestMiddleware(app, { express, verboseRequestLogs });
|
||||
|
||||
registerAuthAndAccessRoutes(app, {
|
||||
express,
|
||||
tunnelAuthController,
|
||||
|
||||
@@ -67,6 +67,8 @@ export const registerServerStatusRoutes = (app, dependencies) => {
|
||||
serverStartedAt,
|
||||
gracefulShutdown,
|
||||
getHealthSnapshot,
|
||||
tunnelAuthController = null,
|
||||
uiAuthController = null,
|
||||
} = dependencies;
|
||||
|
||||
const allocateLoopbackPort = async () => {
|
||||
@@ -232,11 +234,33 @@ export const registerServerStatusRoutes = (app, dependencies) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/system/shutdown', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
gracefulShutdown({ exitProcess: true }).catch((error) => {
|
||||
console.error('Shutdown request failed:', error?.message || error);
|
||||
});
|
||||
const requireShutdownAuth = async (req, res, next) => {
|
||||
if (!uiAuthController || typeof uiAuthController.requireAuth !== 'function') {
|
||||
return next();
|
||||
}
|
||||
const requestScope = typeof tunnelAuthController?.classifyRequestScope === 'function'
|
||||
? tunnelAuthController.classifyRequestScope(req)
|
||||
: 'local';
|
||||
if (
|
||||
(requestScope === 'tunnel' || requestScope === 'unknown-public')
|
||||
&& typeof tunnelAuthController?.requireTunnelSession === 'function'
|
||||
) {
|
||||
return tunnelAuthController.requireTunnelSession(req, res, next);
|
||||
}
|
||||
return uiAuthController.requireAuth(req, res, next);
|
||||
};
|
||||
|
||||
app.post('/api/system/shutdown', async (req, res, next) => {
|
||||
try {
|
||||
await requireShutdownAuth(req, res, () => {
|
||||
res.json({ ok: true });
|
||||
gracefulShutdown({ exitProcess: true }).catch((error) => {
|
||||
console.error('Shutdown request failed:', error?.message || error);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/system/dev-shutdown', express.json({ limit: '64kb' }), async (req, res) => {
|
||||
|
||||
@@ -25,6 +25,88 @@ describe('core-routes', () => {
|
||||
expect(shutdownOpts).toEqual({ exitProcess: true });
|
||||
});
|
||||
|
||||
it('should require UI auth before /api/system/shutdown when auth is configured', async () => {
|
||||
const app = express();
|
||||
const dependencies = {
|
||||
gracefulShutdown: vi.fn(async () => {}),
|
||||
getHealthSnapshot: () => ({ status: 'ok' }),
|
||||
openchamberVersion: '1.0.0',
|
||||
runtimeName: 'test',
|
||||
express,
|
||||
tunnelAuthController: {
|
||||
classifyRequestScope: () => 'local',
|
||||
requireTunnelSession: vi.fn(),
|
||||
},
|
||||
uiAuthController: {
|
||||
requireAuth: vi.fn((_req, res) => res.status(401).json({ error: 'Unauthorized' })),
|
||||
},
|
||||
};
|
||||
|
||||
registerServerStatusRoutes(app, dependencies);
|
||||
|
||||
await request(app)
|
||||
.post('/api/system/shutdown')
|
||||
.expect(401, { error: 'Unauthorized' });
|
||||
|
||||
expect(dependencies.uiAuthController.requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(dependencies.gracefulShutdown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow authenticated /api/system/shutdown requests', async () => {
|
||||
const app = express();
|
||||
const dependencies = {
|
||||
gracefulShutdown: vi.fn(async () => {}),
|
||||
getHealthSnapshot: () => ({ status: 'ok' }),
|
||||
openchamberVersion: '1.0.0',
|
||||
runtimeName: 'test',
|
||||
express,
|
||||
tunnelAuthController: {
|
||||
classifyRequestScope: () => 'local',
|
||||
requireTunnelSession: vi.fn(),
|
||||
},
|
||||
uiAuthController: {
|
||||
requireAuth: vi.fn((_req, _res, next) => next()),
|
||||
},
|
||||
};
|
||||
|
||||
registerServerStatusRoutes(app, dependencies);
|
||||
|
||||
await request(app)
|
||||
.post('/api/system/shutdown')
|
||||
.expect(200, { ok: true });
|
||||
|
||||
expect(dependencies.uiAuthController.requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(dependencies.gracefulShutdown).toHaveBeenCalledWith({ exitProcess: true });
|
||||
});
|
||||
|
||||
it('should require tunnel auth for tunneled /api/system/shutdown requests', async () => {
|
||||
const app = express();
|
||||
const dependencies = {
|
||||
gracefulShutdown: vi.fn(async () => {}),
|
||||
getHealthSnapshot: () => ({ status: 'ok' }),
|
||||
openchamberVersion: '1.0.0',
|
||||
runtimeName: 'test',
|
||||
express,
|
||||
tunnelAuthController: {
|
||||
classifyRequestScope: () => 'tunnel',
|
||||
requireTunnelSession: vi.fn((_req, res) => res.status(401).json({ error: 'Tunnel auth required' })),
|
||||
},
|
||||
uiAuthController: {
|
||||
requireAuth: vi.fn((_req, _res, next) => next()),
|
||||
},
|
||||
};
|
||||
|
||||
registerServerStatusRoutes(app, dependencies);
|
||||
|
||||
await request(app)
|
||||
.post('/api/system/shutdown')
|
||||
.expect(401, { error: 'Tunnel auth required' });
|
||||
|
||||
expect(dependencies.tunnelAuthController.requireTunnelSession).toHaveBeenCalledTimes(1);
|
||||
expect(dependencies.uiAuthController.requireAuth).not.toHaveBeenCalled();
|
||||
expect(dependencies.gracefulShutdown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should parse JSON bodies for snippet config routes', async () => {
|
||||
const app = express();
|
||||
registerCommonRequestMiddleware(app, { express });
|
||||
|
||||
@@ -740,6 +740,15 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
securityScopedBookmarks: bookmarks,
|
||||
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
|
||||
typographySizes: sanitizeTypographySizesPartial(settings.typographySizes),
|
||||
...(process.env.OPENCHAMBER_RUNTIME === 'desktop'
|
||||
? {
|
||||
desktopLanAccessActive: process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE === 'true',
|
||||
desktopLanAccessBlockedReason:
|
||||
process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON === 'missing-password'
|
||||
? 'missing-password'
|
||||
: null,
|
||||
}
|
||||
: {}),
|
||||
showReasoningTraces:
|
||||
typeof settings.showReasoningTraces === 'boolean'
|
||||
? settings.showReasoningTraces
|
||||
|
||||
@@ -165,4 +165,27 @@ describe('settings helpers', () => {
|
||||
const response = helpers.formatSettingsResponse({});
|
||||
expect(response.collapsibleThinkingBlocks).toBe(true);
|
||||
});
|
||||
|
||||
it('includes transient desktop LAN access runtime status in desktop settings response', () => {
|
||||
const helpers = createTestHelpers();
|
||||
const previousRuntime = process.env.OPENCHAMBER_RUNTIME;
|
||||
const previousActive = process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE;
|
||||
const previousReason = process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON;
|
||||
try {
|
||||
process.env.OPENCHAMBER_RUNTIME = 'desktop';
|
||||
process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE = 'false';
|
||||
process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON = 'missing-password';
|
||||
|
||||
const response = helpers.formatSettingsResponse({ desktopLanAccessEnabled: true });
|
||||
expect(response.desktopLanAccessActive).toBe(false);
|
||||
expect(response.desktopLanAccessBlockedReason).toBe('missing-password');
|
||||
} finally {
|
||||
if (typeof previousRuntime === 'string') process.env.OPENCHAMBER_RUNTIME = previousRuntime;
|
||||
else delete process.env.OPENCHAMBER_RUNTIME;
|
||||
if (typeof previousActive === 'string') process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE = previousActive;
|
||||
else delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE;
|
||||
if (typeof previousReason === 'string') process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON = previousReason;
|
||||
else delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import net from 'node:net';
|
||||
|
||||
const stripIpv6Brackets = (value) => {
|
||||
if (typeof value !== 'string') return '';
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizeIpv4MappedAddress = (host) => {
|
||||
const normalized = stripIpv6Brackets(host);
|
||||
const match = normalized.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
||||
return match ? match[1] : normalized;
|
||||
};
|
||||
|
||||
const isLoopbackIpv4 = (host) => {
|
||||
if (net.isIP(host) !== 4) return false;
|
||||
const first = Number.parseInt(host.split('.')[0] || '', 10);
|
||||
return first === 127;
|
||||
};
|
||||
|
||||
export const isLoopbackBindHost = (host) => {
|
||||
const normalized = normalizeIpv4MappedAddress(host);
|
||||
if (!normalized) return false;
|
||||
if (normalized === 'localhost') return true;
|
||||
if (isLoopbackIpv4(normalized)) return true;
|
||||
return net.isIP(normalized) === 6 && normalized === '::1';
|
||||
};
|
||||
|
||||
export const isNetworkExposedBindHost = (host) => !isLoopbackBindHost(host);
|
||||
|
||||
export const isUnsafeUnauthenticatedLanAllowed = (env = process.env) =>
|
||||
env?.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN === 'true';
|
||||
|
||||
export const getUnauthenticatedLanErrorMessage = (host) =>
|
||||
`OpenChamber refuses to bind to ${host || 'a network-exposed host'} without UI authentication. `
|
||||
+ 'Set --ui-password or OPENCHAMBER_UI_PASSWORD before exposing it over LAN, '
|
||||
+ 'or set OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN=true to accept the risk.';
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isLoopbackBindHost,
|
||||
isNetworkExposedBindHost,
|
||||
} from './bind-host.js';
|
||||
|
||||
describe('bind host exposure classification', () => {
|
||||
it('allows only proven loopback bind hosts without authentication', () => {
|
||||
for (const host of ['localhost', '127.0.0.1', '127.25.1.2', '::1', '[::1]', '::ffff:127.0.0.1']) {
|
||||
expect(isLoopbackBindHost(host), host).toBe(true);
|
||||
expect(isNetworkExposedBindHost(host), host).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats wildcard, LAN, IPv6 local, and unknown hosts as exposed', () => {
|
||||
for (const host of [
|
||||
'0.0.0.0',
|
||||
'0',
|
||||
'0x0',
|
||||
'::',
|
||||
'[::]',
|
||||
'192.168.1.10',
|
||||
'10.0.0.5',
|
||||
'172.16.0.2',
|
||||
'::ffff:192.168.1.10',
|
||||
'fe80::1',
|
||||
'fc00::1',
|
||||
'openchamber.local',
|
||||
'example.com',
|
||||
'',
|
||||
]) {
|
||||
expect(isLoopbackBindHost(host), host).toBe(false);
|
||||
expect(isNetworkExposedBindHost(host), host).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -191,14 +191,16 @@ describe('ui auth client credential seam', () => {
|
||||
expect(arbitraryGetCalled).toBe(false);
|
||||
expect(arbitraryGetRes.statusCode).toBe(401);
|
||||
|
||||
const postReq = { method: 'POST', path: '/api/config/settings', url: `/api/config/settings?oc_url_token=${encodeURIComponent(urlToken)}`, headers: { accept: 'application/json' } };
|
||||
const postRes = createResponse();
|
||||
let postCalled = false;
|
||||
await auth.requireAuth(postReq, postRes, () => {
|
||||
postCalled = true;
|
||||
});
|
||||
expect(postCalled).toBe(false);
|
||||
expect(postRes.statusCode).toBe(401);
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
const writeReq = { method, path: '/api/fs/raw', url: `/api/fs/raw?path=%2Ftmp%2Fimage.png&oc_url_token=${encodeURIComponent(urlToken)}`, headers: { accept: 'application/json' } };
|
||||
const writeRes = createResponse();
|
||||
let writeCalled = false;
|
||||
await auth.requireAuth(writeReq, writeRes, () => {
|
||||
writeCalled = true;
|
||||
});
|
||||
expect(writeCalled).toBe(false);
|
||||
expect(writeRes.statusCode).toBe(401);
|
||||
}
|
||||
});
|
||||
|
||||
it('issues desktop client tokens with the UI session expiry', async () => {
|
||||
|
||||
@@ -125,6 +125,9 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({
|
||||
if (options?.allowOutsideWorkspace) {
|
||||
params.set('allowOutsideWorkspace', 'true');
|
||||
}
|
||||
if (options?.outsideFileGrant) {
|
||||
params.set('outsideFileGrant', options.outsideFileGrant);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/fs/stat', params));
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -147,6 +150,9 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({
|
||||
if (options?.allowOutsideWorkspace) {
|
||||
params.set('allowOutsideWorkspace', 'true');
|
||||
}
|
||||
if (options?.outsideFileGrant) {
|
||||
params.set('outsideFileGrant', options.outsideFileGrant);
|
||||
}
|
||||
if (options?.optional) {
|
||||
params.set('optional', 'true');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user