Harden remote API security boundaries

This commit is contained in:
Bohdan Triapitsyn
2026-06-12 18:24:07 +03:00
parent c281937406
commit 106b31a407
52 changed files with 1582 additions and 579 deletions
+1
View File
@@ -34,6 +34,7 @@ local-dev*
.opencode/plans/*
.hive
docs/personal/*
SECURITY_HARDENING_IMPLEMENTATION_PLAN.md
# Build outputs
build/
+6 -3
View File
@@ -15,9 +15,12 @@ services:
- ./data/opencode/config:/home/openchamber/.config/opencode
- ./data/ssh:/home/openchamber/.ssh
- ./workspaces:/home/openchamber/workspaces
#environment:
# OPENCHAMBER_HOST: 0.0.0.0 # Bind address (default in Docker: 0.0.0.0)
# UI_PASSWORD: your_secure_password_here # Uncomment to set UI password
environment:
# Docker binds OpenChamber to 0.0.0.0 for port mapping, so UI auth is required.
# Set this before starting, for example:
# OPENCHAMBER_UI_PASSWORD="$(openssl rand -base64 24)" docker compose up -d
OPENCHAMBER_UI_PASSWORD: ${OPENCHAMBER_UI_PASSWORD:?Set OPENCHAMBER_UI_PASSWORD before exposing OpenChamber through Docker}
# OPENCHAMBER_HOST: 0.0.0.0 # Bind address (default in Docker entrypoint: 0.0.0.0)
# OPENCHAMBER_TUNNEL_PROVIDER: cloudflare
# OPENCHAMBER_TUNNEL_MODE: quick # quick | managed-remote | managed-local
# OPENCHAMBER_TUNNEL_HOSTNAME: app.example.com # required for managed-remote
+48 -1
View File
@@ -13,6 +13,7 @@ import updaterPkg from 'electron-updater';
import { ElectronSshManager } from './ssh-manager.mjs';
import { createTrayController } from './tray.mjs';
import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs';
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
const execFileAsync = promisify(execFile);
@@ -1065,8 +1066,13 @@ const spawnLocalServer = async () => {
// so phones/tablets on the same Wi-Fi can reach the app. UI shows a clear
// warning and persists the flag via /api/config/settings.
const lanAccessEnabled = settings.desktopLanAccessEnabled === true;
const bindHost = lanAccessEnabled ? LAN_BIND_HOST : LOOPBACK_BIND_HOST;
const desktopUiPassword = typeof settings.desktopUiPassword === 'string' ? settings.desktopUiPassword.trim() : '';
const lanAccessBlockedByMissingPassword = lanAccessEnabled && !desktopUiPassword;
const effectiveLanAccessEnabled = lanAccessEnabled && !lanAccessBlockedByMissingPassword;
const bindHost = effectiveLanAccessEnabled ? LAN_BIND_HOST : LOOPBACK_BIND_HOST;
if (lanAccessBlockedByMissingPassword) {
log.warn('[desktop] LAN access was requested without a desktop UI password; starting on loopback only.');
}
// Probe before starting the server — main() in the server module sets up a
// lot of global state before binding, and calling it twice after a listen
@@ -1088,6 +1094,12 @@ const spawnLocalServer = async () => {
// set before the first import. After this point, the same env is used by
// both the Electron main and the server running inside it.
process.env.OPENCHAMBER_HOST = bindHost;
process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE = effectiveLanAccessEnabled ? 'true' : 'false';
if (lanAccessBlockedByMissingPassword) {
process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON = 'missing-password';
} else {
delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON;
}
process.env.OPENCHAMBER_DIST_DIR = resolveWebDistDir();
process.env.OPENCHAMBER_RUNTIME = 'desktop';
// OpenCode uses process cwd as a fallback directory; app userData would make
@@ -4052,10 +4064,45 @@ ipcMain.handle('openchamber:dialog:open', async (event, options) => {
].filter(Boolean),
});
if (result.canceled) return null;
const grantFilePath = async (filePath) => {
if (options?.directory) return { path: filePath };
try {
const grant = await mintOutsideFileGrant(filePath, { scopes: ['stat', 'read', 'raw'], fsPromises: fsp, path });
return { path: grant.path, outsideFileGrant: grant.outsideFileGrant, expiresAt: grant.expiresAt };
} catch (error) {
log.warn(`[ipc] failed to mint outside file grant: ${error?.message || error}`);
return { path: filePath };
}
};
if (options?.returnGrant) {
if (options?.multiple) {
return Promise.all(result.filePaths.map((filePath) => grantFilePath(filePath)));
}
return result.filePaths[0] ? grantFilePath(result.filePaths[0]) : null;
}
if (options?.multiple) return result.filePaths;
return result.filePaths[0] || null;
});
ipcMain.handle('openchamber:file:grant-existing', async (event, filePath) => {
if (!isLocalSender(event.sender)) {
log.warn(`[ipc] rejected file:grant-existing from non-local origin: ${event.sender?.getURL?.() || '(unknown)'}`);
throw new Error('IPC not available for this origin');
}
const targetPath = typeof filePath === 'string' ? filePath.trim() : '';
if (!targetPath) {
throw new Error('Path is required');
}
const grant = await mintOutsideFileGrant(targetPath, { scopes: ['stat', 'read', 'raw'], fsPromises: fsp, path });
return {
path: grant.path,
outsideFileGrant: grant.outsideFileGrant,
expiresAt: grant.expiresAt,
};
});
// --- macOS menu bar (status bar) ---------------------------------------------
// Tray lives only on macOS; the renderer streams a compact state snapshot via
// the `desktop_tray_update` IPC command (see the command switch). Tray clicks
+1
View File
@@ -165,6 +165,7 @@ ipcRenderer.on('openchamber:emit', (_evt, payload) => {
contextBridge.exposeInMainWorld('__OPENCHAMBER_DESKTOP__', {
invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}),
openDialog: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}),
grantFileAccess: (filePath) => ipcRenderer.invoke('openchamber:file:grant-existing', filePath),
openExternal: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }),
listen: async (event, handler) => addListener(event, handler),
});
@@ -33,8 +33,9 @@ import { useDeviceInfo } from '@/lib/device';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { EditorAPI } from '@/lib/api/types';
import { isVSCodeRuntime } from '@/lib/desktop';
import { getDirectoryForFilePath, isAbsoluteFilePath, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
const useCurrentMermaidTheme = () => {
const themeSystem = useOptionalThemeSystem();
@@ -1371,7 +1372,7 @@ const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
};
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
return getDirectoryForFilePath(effectiveDirectory, resolvedPath);
return effectiveDirectory || getDirectoryForFilePath(effectiveDirectory, resolvedPath);
};
const useFileReferenceInteractions = ({
@@ -1441,7 +1442,14 @@ const useFileReferenceInteractions = ({
linkedCount += 1;
void fileReferenceExists(resolved.resolvedPath).then((exists) => {
const canGrantOutsideFile = isDesktopShell()
&& isDesktopLocalOriginActive()
&& !isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory);
const existsPromise = canGrantOutsideFile
? Promise.resolve(true)
: fileReferenceExists(resolved.resolvedPath);
void existsPromise.then((exists) => {
if (cancelled || !exists || !container.contains(candidate)) {
return;
}
@@ -1464,7 +1472,7 @@ const useFileReferenceInteractions = ({
}
};
const openFileReference = (sourceElement: HTMLElement) => {
const openFileReference = async (sourceElement: HTMLElement) => {
const raw = sourceElement.getAttribute('data-openchamber-file-ref') || extractPathCandidateFromElement(sourceElement);
const resolved = getResolvedReference(raw, effectiveDirectory);
if (!resolved) {
@@ -1485,6 +1493,10 @@ const useFileReferenceInteractions = ({
return;
}
if (!isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory)) {
await ensureOutsideFileGrantForDesktop(resolved.resolvedPath, effectiveDirectory);
}
const uiStore = useUIStore.getState();
if (Number.isFinite(resolved.line ?? Number.NaN)) {
uiStore.openContextFileAtLine(
@@ -1514,7 +1526,7 @@ const useFileReferenceInteractions = ({
event.preventDefault();
event.stopPropagation();
openFileReference(fileRefElement);
void openFileReference(fileRefElement);
};
const handleKeyDown = (event: KeyboardEvent) => {
@@ -1530,7 +1542,7 @@ const useFileReferenceInteractions = ({
event.preventDefault();
event.stopPropagation();
openFileReference(target);
void openFileReference(target);
};
annotateFileLinks();
@@ -19,11 +19,12 @@ import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
import ReasoningPart from './ReasoningPart';
import JustificationBlock from './JustificationBlock';
import { areRenderRelevantPartsEqual } from '../renderCompare';
import { getExternalFaviconUrl } from '@/lib/url';
import { getDirectoryForFilePath, getRelativeFilePath, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
import { getDirectoryForFilePath, getRelativeFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-4 sm:!leading-6 tracking-normal';
const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS);
@@ -638,6 +639,19 @@ const StaticToolRowInner: React.FC<{
return;
}
if (!isFilePathWithinDirectory(absolutePath, currentDirectory)) {
void ensureOutsideFileGrantForDesktop(absolutePath, currentDirectory).then(() => {
const uiStore = useUIStore.getState();
const contextDirectory = currentDirectory || getDirectoryForFilePath(currentDirectory, absolutePath);
if (offset && Number.isFinite(offset)) {
uiStore.openContextFileAtLine(contextDirectory, absolutePath, Math.max(1, Math.trunc(offset)), 1);
return;
}
uiStore.openContextFile(contextDirectory, absolutePath);
});
return;
}
const uiStore = useUIStore.getState();
const contextDirectory = getDirectoryForFilePath(currentDirectory, absolutePath);
if (offset && Number.isFinite(offset)) {
@@ -2170,7 +2170,7 @@ export const ContextPanel: React.FC = () => {
}
if (activeTab.mode === 'file' && activeTab.targetPath) {
setSelectedFilePath(directoryKey, activeTab.targetPath);
setSelectedFilePath(directoryKey, activeTab.targetPath, { allowOutsideRoot: true });
return;
}
@@ -22,6 +22,8 @@ export const DesktopNetworkSettings: React.FC = () => {
const [draftValue, setDraftValue] = React.useState(false);
const [savedPassword, setSavedPassword] = React.useState('');
const [draftPassword, setDraftPassword] = React.useState('');
const [lanAccessActive, setLanAccessActive] = React.useState(false);
const [lanAccessBlockedReason, setLanAccessBlockedReason] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [isSaving, setIsSaving] = React.useState(false);
const [launchAtLoginSupported, setLaunchAtLoginSupported] = React.useState(false);
@@ -50,6 +52,8 @@ export const DesktopNetworkSettings: React.FC = () => {
const data = (await response.json().catch(() => null)) as null | {
desktopLanAccessEnabled?: unknown;
desktopUiPassword?: unknown;
desktopLanAccessActive?: unknown;
desktopLanAccessBlockedReason?: unknown;
};
if (cancelled) {
return;
@@ -61,6 +65,10 @@ export const DesktopNetworkSettings: React.FC = () => {
setDraftValue(enabled);
setSavedPassword(password);
setDraftPassword(password);
setLanAccessActive(data?.desktopLanAccessActive === true);
setLanAccessBlockedReason(
typeof data?.desktopLanAccessBlockedReason === 'string' ? data.desktopLanAccessBlockedReason : null
);
setError(null);
} catch (cause) {
if (!cancelled) {
@@ -135,12 +143,22 @@ export const DesktopNetworkSettings: React.FC = () => {
}
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}, []);
const lanUrl = draftValue && lanAddress && currentPort ? `http://${lanAddress}:${currentPort}` : null;
const lanUrl = draftValue && lanAccessActive && lanAddress && currentPort ? `http://${lanAddress}:${currentPort}` : null;
const lanRequiresPassword = draftValue && !draftPassword.trim();
const lanBlockedByMissingPassword = savedValue && !lanAccessActive && lanAccessBlockedReason === 'missing-password';
const saveDisabled = isLoading || isSaving || !isDirty || lanRequiresPassword;
const handleToggle = React.useCallback(() => {
setDraftValue((current) => !current);
}, []);
const handlePasswordChange = React.useCallback((value: string) => {
setDraftPassword(value);
if (!value.trim()) {
setDraftValue(false);
}
}, []);
const handleLaunchAtLoginToggle = React.useCallback(async () => {
if (!launchAtLoginSupported || isSavingLaunchAtLogin) {
return;
@@ -252,9 +270,11 @@ export const DesktopNetworkSettings: React.FC = () => {
type="password"
className="h-7 max-w-sm"
value={draftPassword}
onChange={(event) => setDraftPassword(event.target.value)}
onChange={(event) => handlePasswordChange(event.target.value)}
placeholder={t('settings.openchamber.desktopPassword.field.passwordPlaceholder')}
disabled={isLoading || isSaving}
required={draftValue}
aria-invalid={lanRequiresPassword}
/>
<div className="typography-micro text-muted-foreground/70">
{t('settings.openchamber.desktopPassword.field.passwordDescription')}
@@ -288,6 +308,11 @@ export const DesktopNetworkSettings: React.FC = () => {
<div className="typography-micro text-[var(--status-warning)]/85">
{t('settings.openchamber.desktopNetwork.field.warning')}
</div>
{lanRequiresPassword || lanBlockedByMissingPassword ? (
<div className="typography-micro text-[var(--status-warning)]/85">
{t('settings.openchamber.desktopNetwork.field.passwordRequiredWarning')}
</div>
) : null}
</div>
</div>
@@ -309,7 +334,7 @@ export const DesktopNetworkSettings: React.FC = () => {
type="button"
size="xs"
onClick={handleSaveAndRestart}
disabled={isLoading || isSaving || !isDirty}
disabled={saveDisabled}
className="shrink-0 !font-normal"
>
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.desktopNetwork.actions.saveAndRestart')}
@@ -421,7 +421,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
});
selectedTarget = result.path;
} else if (shouldCreateSelection) {
await opencodeClient.createDirectory(target, { allowOutsideWorkspace: true });
await opencodeClient.createDirectory(target);
}
const added = addProject(selectedTarget);
if (!added) {
@@ -664,7 +664,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
const fullPath = `${creatingInPath}/${dirName}`;
try {
await opencodeClient.createDirectory(fullPath, { allowOutsideWorkspace: true });
await opencodeClient.createDirectory(fullPath);
const children = await loadDirectory(creatingInPath);
const updateItems = (items: DirectoryItem[]): DirectoryItem[] => {
@@ -620,6 +620,9 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
path: result.path,
allowOutsideWorkspace: 'true',
});
if (result.outsideFileGrant) {
params.set('outsideFileGrant', result.outsideFileGrant);
}
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' });
if (!response.ok) {
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'));
+41 -27
View File
@@ -43,9 +43,10 @@ import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDeviceInfo } from '@/lib/device';
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
import { getLanguageFromExtension, getImageMimeType, isDrawioFile, isImageFile } from '@/lib/toolHelpers';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { getOutsideFileGrant } from '@/lib/outsideFileGrants';
import { DiagramEditor } from '@/components/diagram';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { EditorView } from '@codemirror/view';
@@ -770,13 +771,17 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]);
const effectiveSelectedPath = React.useMemo(() => selectedPath ?? openPaths[0] ?? null, [openPaths, selectedPath]);
const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]);
const selectedFilePath = selectedFile?.path ?? '';
const selectedFileIsOutsideWorkspace = Boolean(root && selectedFilePath && !isPathWithinRoot(selectedFilePath, root));
const selectedFileReadOptions = React.useMemo(
() => ({ allowOutsideWorkspace: mode === 'editor-only' && selectedFileIsOutsideWorkspace }),
[mode, selectedFileIsOutsideWorkspace],
);
const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]);
const selectedFilePath = selectedFile?.path ?? '';
const selectedFileIsOutsideWorkspace = Boolean(root && selectedFilePath && !isPathWithinRoot(selectedFilePath, root));
const selectedOutsideFileGrant = selectedFileIsOutsideWorkspace ? getOutsideFileGrant(selectedFilePath) : undefined;
const selectedFileReadOptions = React.useMemo(
() => ({
allowOutsideWorkspace: mode === 'editor-only' && selectedFileIsOutsideWorkspace,
outsideFileGrant: selectedOutsideFileGrant,
}),
[mode, selectedFileIsOutsideWorkspace, selectedOutsideFileGrant],
);
// Editor tabs horizontal scroll fades
const editorTabsScrollRef = React.useRef<HTMLDivElement>(null);
@@ -1444,16 +1449,19 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
};
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; optional?: boolean }): Promise<string> => {
const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string; optional?: boolean }): Promise<string> => {
if (files.readFile) {
const result = await files.readFile(path, options);
return result.content ?? '';
}
const params = new URLSearchParams({ path });
if (options?.allowOutsideWorkspace) {
params.set('allowOutsideWorkspace', 'true');
}
if (options?.allowOutsideWorkspace) {
params.set('allowOutsideWorkspace', 'true');
}
if (options?.outsideFileGrant) {
params.set('outsideFileGrant', options.outsideFileGrant);
}
if (options?.optional) {
params.set('optional', 'true');
}
@@ -1468,7 +1476,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return response.text();
}, [files, t]);
const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean }): Promise<FileStatSnapshot | null> => {
const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string }): Promise<FileStatSnapshot | null> => {
if (files.statFile) {
const result = await files.statFile(path, options);
return {
@@ -1711,7 +1719,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setFileLoading(true);
const readOptions = { allowOutsideWorkspace: mode === 'editor-only' && Boolean(root) && !isPathWithinRoot(node.path, root) };
const outsideFileGrant = getOutsideFileGrant(node.path);
const readOptions = {
allowOutsideWorkspace: mode === 'editor-only' && Boolean(root) && !isPathWithinRoot(node.path, root),
outsideFileGrant,
};
await readFile(node.path, readOptions)
.then((content) => {
@@ -2798,9 +2810,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
[lightTheme.metadata.id, darkTheme.metadata.id],
);
const imageAssetAuthKey = selectedFile?.path && isSelectedImage && !runtime.isDesktop && !isSelectedSvg
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}`
: '';
const imageAssetAuthKey = selectedFile?.path && isSelectedImage && !runtime.isDesktop && !isSelectedSvg
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
: '';
React.useEffect(() => {
if (!imageAssetAuthKey) {
@@ -2830,10 +2842,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
: desktopImageSrc)
: (isSelectedSvg
? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
: imageAssetAuthReadyKey === imageAssetAuthKey ? getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
path: selectedFile.path,
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
}) : ''))
: imageAssetAuthReadyKey === imageAssetAuthKey ? getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
path: selectedFile.path,
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
}) : ''))
: '';
React.useEffect(() => {
@@ -2849,10 +2862,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const srcPromise = files.readFileBinary
? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl)
: Promise.resolve(getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
path: selectedFile.path,
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
}));
: Promise.resolve(getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
path: selectedFile.path,
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
}));
await srcPromise
.then((src) => {
@@ -18,7 +18,7 @@ import { Icon } from "@/components/icon/Icon";
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore';
import { execCommand } from '@/lib/execCommands';
import { getGitCommitSummaries } from '@/lib/gitApi';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import {
abortIntegrate,
@@ -143,18 +143,7 @@ export const IntegrateCommitsSection: React.FC<{
const max = 50;
// Show newest -> oldest.
const subset = plan.commits.slice(-max).reverse();
const quoted = subset.map((s) => JSON.stringify(s)).join(' ');
const result = await execCommand(
`git show -s --format=%H%x09%h%x09%s ${quoted}`,
repoRoot
);
const lines = (result.stdout || '').split(/\r?\n/).filter(Boolean);
const parsed: Array<{ sha: string; short: string; subject: string }> = [];
for (const line of lines) {
const [sha, short, subject] = line.split('\t');
if (!sha || !short) continue;
parsed.push({ sha, short, subject: subject || '' });
}
const parsed = await getGitCommitSummaries(repoRoot, subset);
if (!cancelled) {
setCommitSummaries(parsed);
setShowAllCommits(false);
@@ -1,5 +1,5 @@
import React from 'react';
import { execCommand } from '@/lib/execCommands';
import { resolveGitPrimaryRoot, resolveGitTopLevel } from '@/lib/gitApi';
import type { WorktreeMetadata } from '@/types/worktree';
const normalizePath = (value: string): string => {
@@ -9,29 +9,10 @@ const normalizePath = (value: string): string => {
return replaced.replace(/\/+$/, '');
};
/**
* Derive the primary worktree (project) root from the absolute git directory.
*
* Secondary worktree: /project/.git/worktrees/<name> /project
* Primary worktree: /project/.git null (not a secondary)
*/
const deriveProjectRoot = (gitDir: string): string | null => {
const normalized = normalizePath(gitDir);
if (!normalized) return null;
const marker = '/.git/worktrees/';
const idx = normalized.indexOf(marker);
if (idx > 0) {
return normalized.slice(0, idx) || null;
}
return null;
};
/**
* When the store-based WorktreeMetadata lookup fails, this hook falls back to
* a single `git rev-parse --absolute-git-dir` call to detect whether
* `currentDirectory` is a secondary worktree. If it is, a minimal
* narrow git runtime APIs to detect whether `currentDirectory` is a secondary
* worktree. If it is, a minimal
* WorktreeMetadata is synthesised so that "Re-integrate commits" and other
* worktree features can function without explicit store entries.
*
@@ -62,29 +43,19 @@ export function useDetectedWorktreeMetadata(
let cancelled = false;
void (async () => {
const [gitDirResult, toplevelResult] = await Promise.all([
execCommand('git rev-parse --absolute-git-dir', currentDirectory),
execCommand('git rev-parse --show-toplevel', currentDirectory),
]);
const [projectRootRaw, worktreePathRaw] = await Promise.all([
resolveGitPrimaryRoot(currentDirectory),
resolveGitTopLevel(currentDirectory),
]).catch(() => ['', '']);
if (cancelled) return;
if (!gitDirResult.success || !toplevelResult.success) {
return;
}
const gitDir = normalizePath((gitDirResult.stdout || '').trim());
const projectRoot = deriveProjectRoot(gitDir);
if (!projectRoot) {
return;
}
const projectRoot = normalizePath(projectRootRaw);
// Use the worktree toplevel, not the active sub-directory, so that
// worktree operations (e.g. `git worktree remove`) receive a valid root path.
const worktreePath = normalizePath((toplevelResult.stdout || '').trim());
const worktreePath = normalizePath(worktreePathRaw);
// Sanity-check: secondary worktree path must differ from project root
if (!worktreePath || worktreePath === projectRoot) {
if (!projectRoot || !worktreePath || worktreePath === projectRoot) {
return;
}
+1
View File
@@ -592,6 +592,7 @@ export interface ListDirectoryOptions {
export interface FileReadOptions {
allowOutsideWorkspace?: boolean;
outsideFileGrant?: string;
optional?: boolean;
}
+2 -2
View File
@@ -32,11 +32,11 @@ const classifyReadError = (error: unknown): ContextFileOpenFailureReason => {
const readFileContent = async (files: FilesAPI, path: string): Promise<string> => {
if (files.readFile) {
const result = await files.readFile(path, { allowOutsideWorkspace: true, optional: true });
const result = await files.readFile(path, { optional: true });
return result.content ?? '';
}
const params = new URLSearchParams({ path, allowOutsideWorkspace: 'true', optional: 'true' });
const params = new URLSearchParams({ path, optional: 'true' });
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, {
// Avoid conditional requests (304 + empty body).
cache: 'no-store',
+53 -3
View File
@@ -192,6 +192,7 @@ export type DesktopSettings = {
type DesktopBridgeGlobal = {
invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
openDialog?: (options: Record<string, unknown>) => Promise<unknown>;
grantFileAccess?: (path: string) => Promise<unknown>;
openExternal?: (url: string) => Promise<unknown>;
listen?: (
event: string,
@@ -433,22 +434,43 @@ export const requestDirectoryAccess = async (
return { success: true, path: directoryPath };
};
const isDesktopFileGrantResult = (
value: unknown
): value is { path?: unknown; outsideFileGrant?: unknown } => (
value !== null && typeof value === 'object' && !Array.isArray(value)
);
export const requestFileAccess = async (
options?: { filters?: Array<{ name: string; extensions: string[] }>; defaultPath?: string }
): Promise<{ success: boolean; path?: string; error?: string }> => {
): Promise<{ success: boolean; path?: string; outsideFileGrant?: string; error?: string }> => {
if (hasDesktopInvoke() && isDesktopLocalOriginActive()) {
try {
const selected = await getDesktopBridge()?.openDialog?.({
directory: false,
multiple: false,
title: 'Select File',
returnGrant: true,
...(options?.filters ? { filters: options.filters } : {}),
...(options?.defaultPath ? { defaultPath: options.defaultPath } : {}),
});
if (!selected || typeof selected !== 'string') {
if (!selected) {
return { success: false, error: 'File selection cancelled' };
}
return { success: true, path: selected };
if (typeof selected === 'string') {
return { success: true, path: selected };
}
if (!isDesktopFileGrantResult(selected)) {
return { success: false, error: 'File selection cancelled' };
}
const path = typeof selected.path === 'string' ? selected.path : '';
if (!path) {
return { success: false, error: 'File selection cancelled' };
}
return {
success: true,
path,
outsideFileGrant: typeof selected.outsideFileGrant === 'string' ? selected.outsideFileGrant : undefined,
};
} catch (error) {
console.warn('Failed to request file access', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
@@ -458,6 +480,34 @@ export const requestFileAccess = async (
return { success: false, error: 'Native file picker not available' };
};
export const requestExistingFileAccess = async (
path: string
): Promise<{ success: boolean; path?: string; outsideFileGrant?: string; error?: string }> => {
const targetPath = typeof path === 'string' ? path.trim() : '';
if (!targetPath) {
return { success: false, error: 'Path is required' };
}
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
return { success: false, error: 'Native file access not available' };
}
try {
const selected = await getDesktopBridge()?.grantFileAccess?.(targetPath);
if (!isDesktopFileGrantResult(selected)) {
return { success: false, error: 'File access was not granted' };
}
const grantedPath = typeof selected.path === 'string' ? selected.path : '';
const outsideFileGrant = typeof selected.outsideFileGrant === 'string' ? selected.outsideFileGrant : '';
if (!grantedPath || !outsideFileGrant) {
return { success: false, error: 'File access was not granted' };
}
return { success: true, path: grantedPath, outsideFileGrant };
} catch (error) {
console.warn('Failed to request existing file access', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
};
export const startAccessingDirectory = async (
directoryPath: string
): Promise<{ success: boolean; error?: string }> => {
@@ -1,5 +1,4 @@
import type { CommandExecResult } from '@/lib/api/types';
import { execCommand } from '@/lib/execCommands';
import { runtimeFetch } from '@/lib/runtime-fetch';
export type IntegratePlan = {
repoRoot: string;
@@ -32,334 +31,44 @@ export type IntegrateResult =
| { kind: 'success'; moved: number }
| { kind: 'conflict'; state: IntegrateInProgress; details: IntegrateConflictDetails };
const shellQuote = (value: string): string => {
const v = value.trim();
if (!v) return "''";
return `'${v.replace(/'/g, `'\\''`)}'`;
const postIntegrate = async <T>(action: string, body: unknown): Promise<T> => {
const response = await runtimeFetch(`/api/git/integrate/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const payload = await response.json().catch(() => null) as { error?: string } | null;
throw new Error(payload?.error || `Git integrate request failed: ${response.statusText}`);
}
return response.json() as Promise<T>;
};
const trimLines = (value: string | undefined): string[] =>
(value || '')
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const isOk = (result: CommandExecResult): boolean => Boolean(result.success);
const stdoutText = (result: CommandExecResult): string => (result.stdout || '').trim();
const stderrText = (result: CommandExecResult): string => (result.stderr || '').trim();
type GitWorktreeEntry = { path: string; branchRef: string | null };
async function listGitWorktrees(repoRoot: string): Promise<GitWorktreeEntry[]> {
const out = await execCommand('git worktree list --porcelain', repoRoot);
const lines = (out.stdout || '').split(/\r?\n/);
const entries: GitWorktreeEntry[] = [];
let current: GitWorktreeEntry | null = null;
for (const line of lines) {
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((e) => Boolean(e.path));
}
async function computeCleanWorktreesToSync(args: {
repoRoot: string;
targetBranch: string;
excludePaths: string[];
}): Promise<string[]> {
const targetRef = `refs/heads/${args.targetBranch}`;
const exclude = new Set(args.excludePaths);
const entries = await listGitWorktrees(args.repoRoot);
const candidates = entries
.filter((e) => e.branchRef === targetRef)
.map((e) => e.path)
.filter((p) => p && !exclude.has(p));
const clean: string[] = [];
for (const path of candidates) {
const status = await execCommand('git status --porcelain', path);
if (!stdoutText(status)) {
clean.push(path);
}
}
return clean;
}
async function syncCleanTargetWorktrees(repoRoot: string, paths: string[]): Promise<void> {
for (const path of paths) {
await execCommand('git reset --hard', path).catch(() => undefined);
}
}
async function ensureLocalBranch(repoRoot: string, candidate: string): Promise<string> {
const raw = candidate.trim();
if (!raw || raw === 'HEAD') {
return 'HEAD';
}
const hasLocal = await execCommand(
`git show-ref --verify --quiet ${shellQuote(`refs/heads/${raw}`)}`,
repoRoot
);
if (isOk(hasLocal)) {
return raw;
}
// remotes/origin/main -> main (track origin/main)
if (raw.startsWith('remotes/')) {
const remoteRef = raw.slice('remotes/'.length);
const parts = remoteRef.split('/');
const remote = parts[0] || 'origin';
const name = parts.slice(1).join('/');
if (name) {
await execCommand(`git branch --track ${shellQuote(name)} ${shellQuote(`${remote}/${name}`)}`, repoRoot);
return name;
}
}
// Try origin/<raw>
const remoteCheck = await execCommand(
`git show-ref --verify --quiet ${shellQuote(`refs/remotes/origin/${raw}`)}`,
repoRoot
);
if (isOk(remoteCheck)) {
await execCommand(`git branch --track ${shellQuote(raw)} ${shellQuote(`origin/${raw}`)}`, repoRoot);
return raw;
}
return raw;
}
export async function computeIntegratePlan(args: {
repoRoot: string;
sourceBranch: string;
targetBranch: string;
}): Promise<IntegratePlan> {
const repoRoot = args.repoRoot;
const sourceBranch = args.sourceBranch.trim();
const targetBranchRaw = args.targetBranch.trim();
if (!sourceBranch || !targetBranchRaw) {
return { repoRoot, sourceBranch, targetBranch: targetBranchRaw, commits: [] };
}
const targetBranch = await ensureLocalBranch(repoRoot, targetBranchRaw);
const cherry = await execCommand(`git cherry ${shellQuote(targetBranch)} ${shellQuote(sourceBranch)}`, repoRoot);
const cherryLines = trimLines(cherry.stdout);
const plus = new Set<string>();
for (const line of cherryLines) {
const match = line.match(/^\+\s+([0-9a-f]{7,40})\b/i);
if (match) {
plus.add(match[1]);
}
}
const revList = await execCommand(
`git rev-list --reverse ${shellQuote(`${targetBranch}..${sourceBranch}`)}`,
repoRoot
);
const ordered = trimLines(revList.stdout);
const commits = ordered.filter((sha) => plus.has(sha));
return { repoRoot, sourceBranch, targetBranch, commits };
}
async function createTempWorktree(repoRoot: string, targetBranch: string): Promise<string> {
// Use two separate execCommand calls instead of shell-specific && operator
// to support non-POSIX shells like Nushell (see #870)
const mkdirResult = await execCommand('mkdir -p "$HOME/.config/openchamber/tmp"', repoRoot);
if (!isOk(mkdirResult)) {
throw new Error(stderrText(mkdirResult) || 'Failed to create temp directory parent');
}
const tmp = await execCommand(
'mktemp -d "$HOME/.config/openchamber/tmp/oc-integrate-XXXXXX"',
repoRoot
);
const tmpDir = stdoutText(tmp);
if (!tmpDir) {
throw new Error(stderrText(tmp) || 'Failed to create temp directory');
}
const add = await execCommand(
`git worktree add --force ${shellQuote(tmpDir)} ${shellQuote(targetBranch)}`,
repoRoot
);
if (!isOk(add)) {
throw new Error(stderrText(add) || 'Failed to create temp worktree');
}
return tmpDir;
}
async function removeTempWorktree(repoRoot: string, tmpDir: string): Promise<void> {
await execCommand(`git worktree remove --force ${shellQuote(tmpDir)}`, repoRoot).catch(() => undefined);
await execCommand('git worktree prune', repoRoot).catch(() => undefined);
}
async function maybeFastForwardUpstream(tmpDir: string): Promise<void> {
const upstream = await execCommand('git rev-parse --abbrev-ref --symbolic-full-name @{u}', tmpDir);
const upstreamRef = stdoutText(upstream);
if (!upstreamRef) {
return;
}
await execCommand('git fetch', tmpDir);
const ff = await execCommand(`git merge --ff-only ${shellQuote(upstreamRef)}`, tmpDir);
if (!isOk(ff)) {
throw new Error(stderrText(ff) || 'Fast-forward failed');
}
}
async function collectConflictDetails(tmpDir: string): Promise<IntegrateConflictDetails> {
const status = await execCommand('git status --porcelain', tmpDir);
const unmerged = await execCommand('git diff --name-only --diff-filter=U', tmpDir);
const diff = await execCommand('git diff', tmpDir);
const meta = await execCommand('git show --no-patch --pretty=fuller CHERRY_PICK_HEAD', tmpDir);
const patch = await execCommand('git show CHERRY_PICK_HEAD', tmpDir);
return {
statusPorcelain: status.stdout || '',
unmergedFiles: trimLines(unmerged.stdout),
diff: diff.stdout || diff.stderr || '',
currentPatchMeta: meta.stdout || meta.stderr || '',
currentPatch: patch.stdout || patch.stderr || '',
};
return postIntegrate<IntegratePlan>('plan', args);
}
export async function getIntegrateConflictDetails(tmpDir: string): Promise<IntegrateConflictDetails> {
return collectConflictDetails(tmpDir);
return postIntegrate<IntegrateConflictDetails>('conflict-details', { tempWorktreePath: tmpDir });
}
export async function isCherryPickInProgress(tmpDir: string): Promise<boolean> {
const head = await execCommand('git rev-parse --verify --quiet CHERRY_PICK_HEAD', tmpDir);
return isOk(head);
const result = await postIntegrate<{ inProgress: boolean }>('cherry-pick-status', { tempWorktreePath: tmpDir });
return result.inProgress;
}
export async function integrateWorktreeCommits(plan: IntegratePlan): Promise<IntegrateResult> {
if (plan.commits.length === 0) {
return { kind: 'noop', reason: 'No commits to move' };
}
const tmpDir = await createTempWorktree(plan.repoRoot, plan.targetBranch);
let remaining: string[] = [];
try {
await maybeFastForwardUpstream(tmpDir);
const clean = await execCommand('git status --porcelain', tmpDir);
if (stdoutText(clean)) {
throw new Error('Target branch has local changes; abort integration and retry');
}
const cleanTargetWorktrees = await computeCleanWorktreesToSync({
repoRoot: plan.repoRoot,
targetBranch: plan.targetBranch,
excludePaths: [tmpDir],
}).catch(() => []);
remaining = [...plan.commits];
while (remaining.length > 0) {
const sha = remaining[0];
const pick = await execCommand(`git cherry-pick ${shellQuote(sha)}`, tmpDir);
if (isOk(pick)) {
remaining.shift();
continue;
}
const unmerged = await execCommand('git diff --name-only --diff-filter=U', tmpDir);
const unmergedFiles = trimLines(unmerged.stdout);
if (unmergedFiles.length > 0) {
const details = await collectConflictDetails(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(stderrText(pick) || 'Cherry-pick failed');
}
await removeTempWorktree(plan.repoRoot, tmpDir);
await syncCleanTargetWorktrees(plan.repoRoot, cleanTargetWorktrees).catch(() => undefined);
return { kind: 'success', moved: plan.commits.length };
} catch (e) {
// Cleanup on any non-conflict error.
await removeTempWorktree(plan.repoRoot, tmpDir).catch(() => undefined);
throw e;
}
return postIntegrate<IntegrateResult>('run', { plan });
}
export async function abortIntegrate(state: IntegrateInProgress): Promise<void> {
await execCommand('git cherry-pick --abort', state.tempWorktreePath).catch(() => undefined);
await removeTempWorktree(state.repoRoot, state.tempWorktreePath);
await postIntegrate<{ success: boolean }>('abort', { state });
}
export async function continueIntegrate(state: IntegrateInProgress): Promise<IntegrateResult> {
const cont = await execCommand('git cherry-pick --continue', state.tempWorktreePath);
if (!isOk(cont)) {
const unmerged = await execCommand('git diff --name-only --diff-filter=U', state.tempWorktreePath);
const unmergedFiles = trimLines(unmerged.stdout);
if (unmergedFiles.length > 0) {
const details = await collectConflictDetails(state.tempWorktreePath);
return { kind: 'conflict', state, details };
}
throw new Error(stderrText(cont) || 'Cherry-pick continue failed');
}
const tmpDir = state.tempWorktreePath;
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 execCommand(`git cherry-pick ${shellQuote(sha)}`, tmpDir);
if (isOk(pick)) {
still.shift();
continue;
}
const unmerged = await execCommand('git diff --name-only --diff-filter=U', tmpDir);
const unmergedFiles = trimLines(unmerged.stdout);
if (unmergedFiles.length > 0) {
const details = await collectConflictDetails(tmpDir);
return {
kind: 'conflict',
state: {
repoRoot: state.repoRoot,
tempWorktreePath: tmpDir,
sourceBranch: state.sourceBranch,
targetBranch: state.targetBranch,
cleanTargetWorktrees: state.cleanTargetWorktrees,
remainingCommits: still,
currentCommit: sha,
},
details,
};
}
throw new Error(stderrText(pick) || 'Cherry-pick failed');
}
await removeTempWorktree(state.repoRoot, state.tempWorktreePath);
await syncCleanTargetWorktrees(state.repoRoot, state.cleanTargetWorktrees).catch(() => undefined);
return { kind: 'success', moved: state.remainingCommits.length };
return postIntegrate<IntegrateResult>('continue', { state });
}
+18
View File
@@ -101,6 +101,24 @@ export async function getGitStatus(directory: string, options?: { mode?: 'light'
return gitHttp.getGitStatus(directory, options);
}
export async function resolveGitPrimaryRoot(directory: string): Promise<string> {
const result = await gitHttp.resolveGitPrimaryRoot(directory);
return result.root;
}
export async function resolveGitTopLevel(directory: string): Promise<string> {
const result = await gitHttp.resolveGitTopLevel(directory);
return result.root;
}
export async function getGitCommitSummaries(
directory: string,
shas: string[]
): Promise<Array<{ sha: string; short: string; subject: string }>> {
const result = await gitHttp.getGitCommitSummaries(directory, shas);
return result.commits;
}
export async function getGitDiff(directory: string, options: import('./api/types').GetGitDiffOptions): Promise<import('./api/types').GitDiffResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getGitDiff(directory, options);
+46
View File
@@ -133,6 +133,52 @@ export async function getGitStatus(directory: string, options?: { mode?: 'light'
}
}
export async function resolveGitPrimaryRoot(directory: string): Promise<{ root: string }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/primary-root`, directory));
if (!response.ok) {
throw new Error(`Failed to resolve git primary root: ${response.statusText}`);
}
const payload = await response.json().catch(() => ({})) as { root?: string };
return { root: typeof payload.root === 'string' && payload.root ? payload.root : directory };
}
export async function resolveGitTopLevel(directory: string): Promise<{ root: string }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/toplevel`, directory));
if (!response.ok) {
throw new Error(`Failed to resolve git toplevel: ${response.statusText}`);
}
const payload = await response.json().catch(() => ({})) as { root?: string };
return { root: typeof payload.root === 'string' && payload.root ? payload.root : directory };
}
export async function getGitCommitSummaries(
directory: string,
shas: string[]
): Promise<{ commits: Array<{ sha: string; short: string; subject: string }> }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/commit-summaries`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shas }),
});
if (!response.ok) {
throw new Error(`Failed to get git commit summaries: ${response.statusText}`);
}
const payload = await response.json().catch(() => ({})) as {
commits?: Array<{ sha?: string; short?: string; subject?: string }>;
};
return {
commits: Array.isArray(payload.commits)
? payload.commits
.map((entry) => ({
sha: typeof entry.sha === 'string' ? entry.sha : '',
short: typeof entry.short === 'string' ? entry.short : '',
subject: typeof entry.subject === 'string' ? entry.subject : '',
}))
.filter((entry) => entry.sha && entry.short)
: [],
};
}
export async function getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse> {
const { path, staged, contextLines } = options;
if (!path) {
@@ -809,6 +809,7 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccess': 'Let other devices on your local network open this app',
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'Restarts the app so phones, tablets, and other computers on your Wi-Fi can open it.',
'settings.openchamber.desktopNetwork.field.warning': 'Warning: while enabled, the app is reachable by anyone on the same local network.',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN access requires a Desktop UI Password. Until one is set, the desktop app starts local-only.',
'settings.openchamber.desktopPassword.field.password': 'Desktop UI Password',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'No password required',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber asks after restart, then when the login session expires: after 12 hours, or 7 days with Trust this device. Leave empty to disable login.',
@@ -776,6 +776,7 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.allowLanAccess": "Permitir que otros dispositivos en tu red local abran esta aplicación",
"settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Reinicia la aplicación para que los teléfonos, tablets y otros ordenadores en tu Wi-Fi puedan abrirla.",
"settings.openchamber.desktopNetwork.field.warning": "Advertencia: mientras esté habilitado, la aplicación es accesible por cualquiera en la misma red local.",
"settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "El acceso LAN requiere una contraseña de UI de escritorio. Hasta que se configure, la app de escritorio se inicia solo localmente.",
"settings.openchamber.desktopPassword.field.password": "Contraseña de UI de escritorio",
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "No se requiere contraseña",
"settings.openchamber.desktopPassword.field.passwordDescription": "OpenChamber la pide después del reinicio y luego cuando vence la sesión: tras 12 horas, o 7 días con Confiar en este dispositivo. Déjalo vacío para desactivar el inicio de sesión.",
@@ -765,6 +765,7 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccess': 'Autorisez les autres appareils de votre réseau local à ouvrir cette application',
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'Redémarre l\'application afin que les téléphones, tablettes et autres ordinateurs connectés à votre réseau Wi-Fi puissent l\'ouvrir.',
'settings.openchamber.desktopNetwork.field.warning': 'Attention : lorsqu\'elle est activée, l\'application est accessible à toute personne se trouvant sur le même réseau local.',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'L\'accès LAN nécessite un mot de passe de l\'interface utilisateur du bureau. Tant qu\'il n\'est pas défini, l\'application de bureau démarre en accès local uniquement.',
'settings.openchamber.desktopPassword.field.password': 'Mot de passe de l\'interface utilisateur du bureau',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Aucun mot de passe requis',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber demande après le redémarrage, puis quand la session de connexion expire : après 12 heures, ou 7 jours avec Trust this device. Laissez vide pour désactiver la connexion.',
@@ -776,6 +776,7 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccess': '로컬 네트워크의 다른 기기에서 이 앱 열기 허용',
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '휴대폰, 태블릿, Wi-Fi의 다른 컴퓨터에서 열 수 있도록 앱을 다시 시작합니다.',
'settings.openchamber.desktopNetwork.field.warning': '경고: 활성화된 동안 같은 로컬 네트워크의 누구나 앱에 접속할 수 있습니다.',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN 접속에는 Desktop UI 비밀번호가 필요합니다. 설정하기 전까지 desktop 앱은 로컬 전용으로 시작됩니다.',
'settings.openchamber.desktopPassword.field.password': 'Desktop UI 비밀번호',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': '비밀번호 필요 없음',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber는 다시 시작 후 비밀번호를 요청하고, 이후 로그인 세션이 만료되면 다시 요청합니다. 기본 12시간, 이 디바이스 신뢰 선택 시 7일입니다. 로그인을 끄려면 비워 두세요.',
@@ -685,6 +685,7 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.launchAtLoginAria': 'Uruchamiaj OpenChamber przy logowaniu',
'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': 'Uruchamia aplikację w tle bez otwierania okna. Kliknij ikonę w Docku, aby ją otworzyć.',
'settings.openchamber.desktopNetwork.field.warning': 'Ostrzeżenie: po włączeniu aplikacja jest dostępna dla każdego w tej samej sieci lokalnej.',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'Dostęp LAN wymaga hasła UI pulpitu. Dopóki go nie ustawisz, aplikacja pulpitu uruchamia się tylko lokalnie.',
'settings.openchamber.desktopPassword.field.password': 'Hasło UI pulpitu',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Hasło nie jest wymagane',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber pyta po restarcie, a potem po wygaśnięciu sesji logowania: po 12 godzinach albo po 7 dniach z opcją Zaufaj temu urządzeniu. Zostaw puste, aby wyłączyć logowanie.',
@@ -776,6 +776,7 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.allowLanAccess": "Permitir que outros dispositivos na sua rede local abram este aplicativo",
"settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Reinicia o aplicativo para que os telefones, tablets e outros computadores em seu Wi-Fi possam abri-lo.",
"settings.openchamber.desktopNetwork.field.warning": "Aviso: enquanto estiver habilitado, o aplicativo ficará acessível a qualquer pessoa na mesma rede local.",
"settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "O acesso LAN exige uma senha da UI do desktop. Até configurar uma, o app de desktop inicia apenas localmente.",
"settings.openchamber.desktopPassword.field.password": "Senha da UI do desktop",
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "Nenhuma senha obrigatória",
"settings.openchamber.desktopPassword.field.passwordDescription": "O OpenChamber pede após reiniciar e depois quando a sessão expira: em 12 horas, ou 7 dias com Confiar neste dispositivo. Deixe vazio para desativar o login.",
@@ -776,6 +776,7 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.allowLanAccess": "Дозволити іншим пристроям у локальній мережі відкривати цей застосунок",
"settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Перезапускає застосунок, щоб телефони, планшети та інші комп’ютери в мережі Wi-Fi могли його відкрити.",
"settings.openchamber.desktopNetwork.field.warning": "Попередження: якщо це ввімкнено, застосунок доступний усім у тій самій локальній мережі.",
"settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "Для LAN-доступу потрібен пароль десктопного UI. Доки його не задано, десктопний застосунок запускається лише локально.",
"settings.openchamber.desktopPassword.field.password": "Пароль для десктопного UI",
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "Пароль не потрібен",
"settings.openchamber.desktopPassword.field.passwordDescription": "OpenChamber попросить пароль після перезапуску, а потім коли сесія логіну спливе: через 12 годин або через 7 днів із «Довіряти цьому пристрою». Залиште порожнім, щоб вимкнути логін.",
@@ -776,6 +776,7 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccess': '允许你本地网络中的其他设备打开此应用',
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '会重启应用,以便手机、平板和同一 Wi‑Fi 下的其他电脑访问。',
'settings.openchamber.desktopNetwork.field.warning': '警告:启用后,同一本地网络中的任何人都可访问此应用。',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': '局域网访问需要桌面 UI 密码。在设置密码之前,桌面应用只会以本机访问模式启动。',
'settings.openchamber.desktopPassword.field.password': '桌面 UI 密码',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': '不需要密码',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber 会在重启后要求输入密码,之后在登录会话过期时再次要求:12 小时后,或选择“信任此设备”后 7 天。留空可关闭登录。',
@@ -770,6 +770,7 @@
'settings.openchamber.desktopNetwork.field.allowLanAccess': '允許你本機網路中的其他裝置開啟此應用程式',
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '會重新啟動應用程式,以便手機、平板和同一 Wi‑Fi 下的其他電腦存取。',
'settings.openchamber.desktopNetwork.field.warning': '警告:啟用後,同一區域網路中的任何人都可存取此應用程式。',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': '區域網路存取需要桌面 UI 密碼。設定前,桌面應用程式只會以本機模式啟動。',
'settings.openchamber.desktopNetwork.hint.openAfterRestart': '重新啟動後可在其他裝置開啟:',
'settings.openchamber.desktopNetwork.hint.openNow': '可在其他裝置開啟:',
'settings.openchamber.desktopNetwork.actions.saveAndRestart': '儲存並重新啟動',
+73
View File
@@ -0,0 +1,73 @@
import { requestExistingFileAccess } from '@/lib/desktop';
import { isFilePathWithinDirectory, normalizeFilePath } from '@/lib/path-utils';
type OutsideFileGrantEntry = {
outsideFileGrant: string;
expiresAt: number;
};
const DEFAULT_GRANT_TTL_MS = 10 * 60 * 1000;
const grantsByPath = new Map<string, OutsideFileGrantEntry>();
export const getOutsideFileGrant = (path: string): string | undefined => {
const normalizedPath = normalizeFilePath(path);
if (!normalizedPath) {
return undefined;
}
const entry = grantsByPath.get(normalizedPath);
if (!entry) {
return undefined;
}
if (entry.expiresAt <= Date.now()) {
grantsByPath.delete(normalizedPath);
return undefined;
}
return entry.outsideFileGrant;
};
export const rememberOutsideFileGrant = (
path: string,
outsideFileGrant: string,
expiresAt?: number,
): void => {
const normalizedPath = normalizeFilePath(path);
if (!normalizedPath || !outsideFileGrant) {
return;
}
grantsByPath.set(normalizedPath, {
outsideFileGrant,
expiresAt: typeof expiresAt === 'number' && Number.isFinite(expiresAt)
? expiresAt
: Date.now() + DEFAULT_GRANT_TTL_MS,
});
};
export const ensureOutsideFileGrantForDesktop = async (
path: string,
workspaceRoot: string,
): Promise<string | undefined> => {
const normalizedPath = normalizeFilePath(path);
if (!normalizedPath || !workspaceRoot || isFilePathWithinDirectory(normalizedPath, workspaceRoot)) {
return undefined;
}
const existing = getOutsideFileGrant(normalizedPath);
if (existing) {
return existing;
}
const result = await requestExistingFileAccess(normalizedPath);
if (!result.success || !result.path || !result.outsideFileGrant) {
return undefined;
}
rememberOutsideFileGrant(result.path, result.outsideFileGrant);
if (normalizeFilePath(result.path) !== normalizedPath) {
rememberOutsideFileGrant(normalizedPath, result.outsideFileGrant);
}
return result.outsideFileGrant;
};
+7 -2
View File
@@ -15,7 +15,7 @@ export interface RuntimeUrlResolver {
authenticatedAsset(path: string, query?: RuntimeUrlQuery): string;
auth(path: string, query?: RuntimeUrlQuery): string;
health(query?: RuntimeUrlQuery): string;
rawFile(path: string, options?: { download?: boolean }): string;
rawFile(path: string, options?: { download?: boolean; allowOutsideWorkspace?: boolean; outsideFileGrant?: string }): string;
sse(path: string, query?: RuntimeUrlQuery): string;
websocket(path: string, query?: RuntimeUrlQuery): string;
}
@@ -118,7 +118,12 @@ export const createRuntimeUrlResolver = (config: RuntimeUrlConfig = {}): Runtime
authenticatedAsset: (path, query) => withUrlAuth(http(path, query)),
auth: http,
health: (query) => http('/health', query),
rawFile: (path, options) => http('/api/fs/raw', { path, download: options?.download === true ? true : undefined }),
rawFile: (path, options) => http('/api/fs/raw', {
path,
download: options?.download === true ? true : undefined,
allowOutsideWorkspace: options?.allowOutsideWorkspace === true ? true : undefined,
outsideFileGrant: options?.outsideFileGrant,
}),
sse: (path, query) => withUrlAuth(realtime(path, query)),
websocket: (path, query) => toWebSocketUrl(withUrlAuth(realtime(path, query)), config),
};
@@ -1,21 +1,15 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
type ExecResult = { command: string; success: boolean; stdout?: string };
// Per-test controllable behaviour plus manual call tracking (the project's
// tsconfig does not load bun-test's mock matcher types, so existing tests track
// calls via plain arrays rather than `toHaveBeenCalled*`).
let execImpl: (command: string, cwd: string) => ExecResult | Promise<ExecResult> = () => ({ command: '', success: false });
let resolveRootImpl: (directory: string) => string | Promise<string> = (directory) => directory;
let statusImpl: (directory: string) => { current: string } = () => ({ current: 'HEAD' });
const execCalls: Array<{ command: string; cwd: string }> = [];
const resolveRootCalls: string[] = [];
const statusCalls: string[] = [];
mock.module('@/lib/execCommands', () => ({
execCommand: (command: string, cwd: string) => {
execCalls.push({ command, cwd });
return Promise.resolve(execImpl(command, cwd));
},
execCommands: () => Promise.resolve({ success: false, results: [] }),
}));
@@ -24,28 +18,25 @@ mock.module('@/lib/gitApi', () => ({
statusCalls.push(directory);
return Promise.resolve(statusImpl(directory));
},
resolveGitPrimaryRoot: (directory: string) => {
resolveRootCalls.push(directory);
return Promise.resolve(resolveRootImpl(directory));
},
}));
const { getRootBranch, invalidateResolvedProjectRootCache } = await import('./worktreeStatus');
// Helper: a single `git rev-parse --absolute-git-dir --git-common-dir` reply.
const revParse = (absoluteGitDir: string, commonDir: string): ExecResult => ({
command: 'git rev-parse --absolute-git-dir --git-common-dir',
success: true,
stdout: `${absoluteGitDir}\n${commonDir}`,
});
describe('worktreeStatus.getRootBranch', () => {
beforeEach(() => {
invalidateResolvedProjectRootCache();
execCalls.length = 0;
resolveRootCalls.length = 0;
statusCalls.length = 0;
execImpl = () => ({ command: '', success: false });
resolveRootImpl = (directory) => directory;
statusImpl = () => ({ current: 'HEAD' });
});
test('derives root from absolute-git-dir and returns its branch', async () => {
execImpl = () => revParse('/repo/.git', '.git');
resolveRootImpl = () => '/repo';
statusImpl = () => ({ current: 'main' });
expect(await getRootBranch('/repo')).toBe('main');
@@ -53,39 +44,38 @@ describe('worktreeStatus.getRootBranch', () => {
});
test('caches root resolution across repeated calls', async () => {
execImpl = () => revParse('/repo/.git', '.git');
resolveRootImpl = () => '/repo';
statusImpl = () => ({ current: 'main' });
await getRootBranch('/repo');
await getRootBranch('/repo');
await getRootBranch('/repo');
// rev-parse runs once; the static root resolution is cached.
expect(execCalls.length).toBe(1);
expect(resolveRootCalls.length).toBe(1);
});
test('dedupes concurrent resolutions of the same directory', async () => {
execImpl = () => revParse('/repo/.git', '.git');
resolveRootImpl = () => '/repo';
statusImpl = () => ({ current: 'main' });
await Promise.all([getRootBranch('/repo'), getRootBranch('/repo'), getRootBranch('/repo')]);
expect(execCalls.length).toBe(1);
expect(resolveRootCalls.length).toBe(1);
});
test('invalidation forces re-resolution', async () => {
execImpl = () => revParse('/repo/.git', '.git');
resolveRootImpl = () => '/repo';
statusImpl = () => ({ current: 'main' });
await getRootBranch('/repo');
invalidateResolvedProjectRootCache('/repo');
await getRootBranch('/repo');
expect(execCalls.length).toBe(2);
expect(resolveRootCalls.length).toBe(2);
});
test('falls back to the directory itself in a non-git folder', async () => {
execImpl = () => ({ command: '', success: false });
resolveRootImpl = (directory) => directory;
statusImpl = () => ({ current: 'HEAD' });
expect(await getRootBranch('/plain')).toBe('HEAD');
@@ -93,8 +83,7 @@ describe('worktreeStatus.getRootBranch', () => {
});
test('resolves a linked worktree to its primary root and fetches that branch', async () => {
// Worktree's own git dir lives under the primary repo's .git/worktrees.
execImpl = () => revParse('/repo/.git/worktrees/wt', '/repo/.git');
resolveRootImpl = () => '/repo';
statusImpl = () => ({ current: 'main' });
// knownBranch is the *worktree* branch, which must NOT be returned for the root.
@@ -103,10 +92,10 @@ describe('worktreeStatus.getRootBranch', () => {
});
test('invalidation mid-flight does not let a stale resolve re-seed the cache', async () => {
let releaseExec: (result: ExecResult) => void = () => {};
execImpl = () =>
new Promise<ExecResult>((resolve) => {
releaseExec = resolve;
let releaseResolve: (result: string) => void = () => {};
resolveRootImpl = () =>
new Promise<string>((resolve) => {
releaseResolve = resolve;
});
statusImpl = () => ({ current: 'main' });
@@ -115,24 +104,24 @@ describe('worktreeStatus.getRootBranch', () => {
// A worktree topology change invalidates the cache while the resolve runs.
invalidateResolvedProjectRootCache();
// Now let the original resolve settle — it must NOT populate the cache.
releaseExec(revParse('/repo/.git', '.git'));
releaseResolve('/repo');
await pending;
execImpl = () => revParse('/repo/.git', '.git');
resolveRootImpl = () => '/repo';
await getRootBranch('/repo');
// Second call recomputes because the stale in-flight result was discarded.
expect(execCalls.length).toBe(2);
expect(resolveRootCalls.length).toBe(2);
});
test('bounds the root cache by evicting the least-recently-used entry past the count cap', async () => {
execImpl = (_command, cwd) => revParse(`${cwd}/.git`, '.git');
resolveRootImpl = (directory) => directory;
statusImpl = () => ({ current: 'main' });
for (let i = 0; i < 500; i += 1) {
await getRootBranch(`/repo-${i}`);
}
const afterFill = execCalls.length;
const afterFill = resolveRootCalls.length;
expect(afterFill).toBe(500);
await getRootBranch('/repo-overflow');
@@ -140,11 +129,11 @@ describe('worktreeStatus.getRootBranch', () => {
await getRootBranch('/repo-499');
// /repo-overflow and evicted /repo-0 re-run; /repo-499 remains cached.
expect(execCalls.length).toBe(afterFill + 2);
expect(resolveRootCalls.length).toBe(afterFill + 2);
});
test('uses knownBranch fast-path when the directory is its own root', async () => {
execImpl = () => revParse('/repo/.git', '.git');
resolveRootImpl = () => '/repo';
expect(await getRootBranch('/repo', { knownBranch: 'develop' })).toBe('develop');
// No git status round-trip needed in the fast path.
@@ -1,5 +1,4 @@
import { getGitStatus } from '@/lib/gitApi';
import { execCommand } from '@/lib/execCommands';
import { getGitStatus, resolveGitPrimaryRoot } from '@/lib/gitApi';
import type { WorktreeMetadata } from '@/types/worktree';
const normalizePath = (value: string): string => {
@@ -13,39 +12,6 @@ const normalizePath = (value: string): string => {
return replaced.replace(/\/+$/, '');
};
const toAbsolutePath = (baseDir: string, maybeRelativePath: string): string => {
const normalizedBase = normalizePath(baseDir);
const normalizedInput = normalizePath(maybeRelativePath);
if (!normalizedInput) return normalizedBase;
if (normalizedInput.startsWith('/')) return normalizedInput;
const stack = normalizedBase.split('/').filter(Boolean);
const parts = normalizedInput.split('/').filter(Boolean);
for (const part of parts) {
if (part === '.') continue;
if (part === '..') {
stack.pop();
continue;
}
stack.push(part);
}
return `/${stack.join('/')}`;
};
const derivePrimaryWorktreeRootFromGitDir = (gitDir: string): string | null => {
const normalized = normalizePath(gitDir);
if (!normalized) return null;
if (normalized.endsWith('/.git')) {
return normalized.slice(0, -'/.git'.length) || null;
}
const worktreesMarker = '/.git/worktrees/';
const markerIndex = normalized.indexOf(worktreesMarker);
if (markerIndex > 0) {
return normalized.slice(0, markerIndex) || null;
}
return null;
};
export async function getWorktreeStatus(worktreePath: string): Promise<WorktreeMetadata['status']> {
const normalizedPath = normalizePath(worktreePath);
const status = await getGitStatus(normalizedPath);
@@ -118,38 +84,7 @@ export function invalidateResolvedProjectRootCache(directory?: string): void {
}
const computeProjectRoot = async (directory: string): Promise<string> => {
// A single `git rev-parse` invocation returns both paths (absolute-git-dir on
// the first line, git-common-dir on the second), halving subprocess spawns
// versus issuing the two queries separately. In a non-git directory the whole
// command fails, mirroring the previous fall-through to `directory`.
const result = await execCommand('git rev-parse --absolute-git-dir --git-common-dir', directory);
if (!result.success) {
return directory;
}
const lines = (result.stdout || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const absoluteGitDir = normalizePath(lines[0] || '');
if (absoluteGitDir) {
const rootFromAbsoluteGitDir = derivePrimaryWorktreeRootFromGitDir(absoluteGitDir);
if (rootFromAbsoluteGitDir) {
return rootFromAbsoluteGitDir;
}
}
const rawCommonDir = normalizePath(lines[1] || '');
if (rawCommonDir) {
const commonDir = toAbsolutePath(directory, rawCommonDir);
const rootFromCommonDir = derivePrimaryWorktreeRootFromGitDir(commonDir);
if (rootFromCommonDir) {
return rootFromCommonDir;
}
}
return directory;
return resolveGitPrimaryRoot(directory).catch(() => directory);
};
export const resolveProjectRoot = async (directory: string): Promise<string> => {
@@ -15,10 +15,10 @@ type FilesViewTabsState = {
};
type FilesViewTabsActions = {
addOpenPath: (root: string, path: string) => void;
addOpenPath: (root: string, path: string, options?: { allowOutsideRoot?: boolean }) => void;
removeOpenPath: (root: string, path: string) => void;
removeOpenPathsByPrefix: (root: string, prefixPath: string) => void;
setSelectedPath: (root: string, path: string | null) => void;
setSelectedPath: (root: string, path: string | null, options?: { allowOutsideRoot?: boolean }) => void;
ensureSelectedPath: (root: string) => void;
toggleExpandedPath: (root: string, path: string) => void;
expandPath: (root: string, path: string) => void;
@@ -163,10 +163,10 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
(set, get) => ({
byRoot: {},
addOpenPath: (root, path) => {
addOpenPath: (root, path, options) => {
const normalizedRoot = normalizePath((root || '').trim());
const normalizedPath = normalizePath((path || '').trim());
if (!normalizedRoot || !normalizedPath || !isPathWithinRoot(normalizedPath, normalizedRoot)) {
if (!normalizedRoot || !normalizedPath || (!options?.allowOutsideRoot && !isPathWithinRoot(normalizedPath, normalizedRoot))) {
return;
}
@@ -270,10 +270,10 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
});
},
setSelectedPath: (root, path) => {
setSelectedPath: (root, path, options) => {
const normalizedRoot = normalizePath((root || '').trim());
const normalizedPath = path ? normalizePath(path.trim()) : null;
if (!normalizedRoot || (normalizedPath && !isPathWithinRoot(normalizedPath, normalizedRoot))) {
if (!normalizedRoot || (normalizedPath && !options?.allowOutsideRoot && !isPathWithinRoot(normalizedPath, normalizedRoot))) {
return;
}
@@ -424,7 +424,7 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
byRoot: sanitizeByRoot(rawByRoot),
};
},
partialize: (state) => ({ byRoot: state.byRoot }),
partialize: (state) => ({ byRoot: sanitizeByRoot(state.byRoot) }),
}
),
{ name: 'files-view-tabs-store' }
+1
View File
@@ -399,6 +399,7 @@ export async function handleFsBridgeMessage(
const { stdout, stderr } = await execAsync(`${shell} ${shellFlag} "${cmd.replace(/"/g, '\\"')}"`, {
cwd: resolvedCwd,
env: augmentedEnv,
windowsHide: true,
timeout: 300000,
});
return {
+25 -6
View File
@@ -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,
+32 -1
View File
@@ -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;
+19 -1
View File
@@ -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,
+121 -5
View File
@@ -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';
+194 -10
View File
@@ -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);
});
+88
View File
@@ -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 {
+401
View File
@@ -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
View File
@@ -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 () => {
+6
View File
@@ -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');
}
+10 -4
View File
@@ -39,8 +39,14 @@ if [ -f "${SSH_PUBLIC_KEY_PATH}" ]; then
cat "${SSH_PUBLIC_KEY_PATH}"
fi
# Handle UI password environment variable
if [ -n "${UI_PASSWORD:-}" ]; then
# Handle UI password environment variables. UI_PASSWORD is kept as a legacy
# alias; OPENCHAMBER_UI_PASSWORD is the canonical runtime variable.
if [ -z "${OPENCHAMBER_UI_PASSWORD:-}" ] && [ -n "${UI_PASSWORD:-}" ]; then
OPENCHAMBER_UI_PASSWORD="$UI_PASSWORD"
export OPENCHAMBER_UI_PASSWORD
fi
if [ -n "${OPENCHAMBER_UI_PASSWORD:-}" ]; then
echo "[entrypoint] UI password set, enabling authentication"
fi
@@ -69,8 +75,8 @@ if [ "$#" -gt 0 ]; then
fi
set -- bun packages/web/bin/cli.js
if [ -n "${UI_PASSWORD:-}" ]; then
set -- "$@" --ui-password "$UI_PASSWORD"
if [ -n "${OPENCHAMBER_UI_PASSWORD:-}" ]; then
set -- "$@" --ui-password "$OPENCHAMBER_UI_PASSWORD"
fi
"$@"