diff --git a/.gitignore b/.gitignore index 38a3d8d8..f69072c8 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ local-dev* .opencode/plans/* .hive docs/personal/* +SECURITY_HARDENING_IMPLEMENTATION_PLAN.md # Build outputs build/ diff --git a/docker-compose.yml b/docker-compose.yml index f2a0da46..0a5feb1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index a1aa16de..9c4f8c88 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -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 diff --git a/packages/electron/preload.mjs b/packages/electron/preload.mjs index 3e917d5b..bddbe344 100644 --- a/packages/electron/preload.mjs +++ b/packages/electron/preload.mjs @@ -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), }); diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index e1390129..7edd7bb5 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -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 => { }; 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(); diff --git a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx index d688d606..83dfab3a 100644 --- a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx +++ b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx @@ -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)) { diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index c6de7376..bd91b5ab 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -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; } diff --git a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx index 72ee4160..a2861c3c 100644 --- a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx @@ -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(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} />
{t('settings.openchamber.desktopPassword.field.passwordDescription')} @@ -288,6 +308,11 @@ export const DesktopNetworkSettings: React.FC = () => {
{t('settings.openchamber.desktopNetwork.field.warning')}
+ {lanRequiresPassword || lanBlockedByMissingPassword ? ( +
+ {t('settings.openchamber.desktopNetwork.field.passwordRequiredWarning')} +
+ ) : null}
@@ -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')} diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index 533d1e2b..e91d49fd 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -421,7 +421,7 @@ export const DirectoryExplorerDialog: React.FC = ( }); selectedTarget = result.path; } else if (shouldCreateSelection) { - await opencodeClient.createDirectory(target, { allowOutsideWorkspace: true }); + await opencodeClient.createDirectory(target); } const added = addProject(selectedTarget); if (!added) { diff --git a/packages/ui/src/components/session/DirectoryTree.tsx b/packages/ui/src/components/session/DirectoryTree.tsx index eb1c5100..276e17c7 100644 --- a/packages/ui/src/components/session/DirectoryTree.tsx +++ b/packages/ui/src/components/session/DirectoryTree.tsx @@ -664,7 +664,7 @@ export const DirectoryTree: React.FC = ({ 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[] => { diff --git a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx index 1dfe375a..71c84efc 100644 --- a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx +++ b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx @@ -620,6 +620,9 @@ export const ProjectNotesTodoPanel: React.FC = ({ 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')); diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 422ed88c..5b236b28 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -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 = ({ 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(null); @@ -1444,16 +1449,19 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }; }, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]); - const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; optional?: boolean }): Promise => { + const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string; optional?: boolean }): Promise => { 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 = ({ mode = 'full' }) => { return response.text(); }, [files, t]); - const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean }): Promise => { + const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string }): Promise => { if (files.statFile) { const result = await files.statFile(path, options); return { @@ -1711,7 +1719,11 @@ export const FilesView: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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) => { diff --git a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx index 666a99ff..77155258 100644 --- a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx +++ b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx @@ -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); diff --git a/packages/ui/src/hooks/useDetectedWorktreeRoot.ts b/packages/ui/src/hooks/useDetectedWorktreeRoot.ts index 6f9e38e9..e0dc4dc7 100644 --- a/packages/ui/src/hooks/useDetectedWorktreeRoot.ts +++ b/packages/ui/src/hooks/useDetectedWorktreeRoot.ts @@ -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/ → /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; } diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 6fe294f6..11949848 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -592,6 +592,7 @@ export interface ListDirectoryOptions { export interface FileReadOptions { allowOutsideWorkspace?: boolean; + outsideFileGrant?: string; optional?: boolean; } diff --git a/packages/ui/src/lib/contextFileOpenGuard.ts b/packages/ui/src/lib/contextFileOpenGuard.ts index f4412320..e4339fab 100644 --- a/packages/ui/src/lib/contextFileOpenGuard.ts +++ b/packages/ui/src/lib/contextFileOpenGuard.ts @@ -32,11 +32,11 @@ const classifyReadError = (error: unknown): ContextFileOpenFailureReason => { const readFileContent = async (files: FilesAPI, path: string): Promise => { 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', diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index d36067dc..5bbb22b5 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -192,6 +192,7 @@ export type DesktopSettings = { type DesktopBridgeGlobal = { invoke?: (cmd: string, args?: Record) => Promise; openDialog?: (options: Record) => Promise; + grantFileAccess?: (path: string) => Promise; openExternal?: (url: string) => Promise; 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 }> => { diff --git a/packages/ui/src/lib/git/integrateWorktreeCommits.ts b/packages/ui/src/lib/git/integrateWorktreeCommits.ts index 745ba4dd..3d08cc3a 100644 --- a/packages/ui/src/lib/git/integrateWorktreeCommits.ts +++ b/packages/ui/src/lib/git/integrateWorktreeCommits.ts @@ -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 (action: string, body: unknown): Promise => { + 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; }; -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 { - 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 { - 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 { - for (const path of paths) { - await execCommand('git reset --hard', path).catch(() => undefined); - } -} - -async function ensureLocalBranch(repoRoot: string, candidate: string): Promise { - 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/ - 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 { - 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(); - 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 { - // 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 { - 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 { - 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 { - 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('plan', args); } export async function getIntegrateConflictDetails(tmpDir: string): Promise { - return collectConflictDetails(tmpDir); + return postIntegrate('conflict-details', { tempWorktreePath: tmpDir }); } export async function isCherryPickInProgress(tmpDir: string): Promise { - 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 { - 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('run', { plan }); } export async function abortIntegrate(state: IntegrateInProgress): Promise { - 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 { - 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('continue', { state }); } diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 78ee52d4..c584767d 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -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 { + const result = await gitHttp.resolveGitPrimaryRoot(directory); + return result.root; +} + +export async function resolveGitTopLevel(directory: string): Promise { + const result = await gitHttp.resolveGitTopLevel(directory); + return result.root; +} + +export async function getGitCommitSummaries( + directory: string, + shas: string[] +): Promise> { + const result = await gitHttp.getGitCommitSummaries(directory, shas); + return result.commits; +} + export async function getGitDiff(directory: string, options: import('./api/types').GetGitDiffOptions): Promise { const runtime = getRuntimeGit(); if (runtime) return runtime.getGitDiff(directory, options); diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index d67c40f4..f4a5a5fb 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -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 { const { path, staged, contextLines } = options; if (!path) { diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 5eaaebb3..90026578 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index d52f0c24..855c81a2 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -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.", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 7fc9bbf1..272cd53f 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index cc4c3f5d..b46cfbb2 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -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일입니다. 로그인을 끄려면 비워 두세요.', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 3b5db2f9..e5a25f36 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index c524c24c..633b24b8 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -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.", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index c29c728f..5d7bbd04 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -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 днів із «Довіряти цьому пристрою». Залиште порожнім, щоб вимкнути логін.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 44f57c5a..fe43327e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -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 天。留空可关闭登录。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 25bd8400..f5033b21 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -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': '儲存並重新啟動', diff --git a/packages/ui/src/lib/outsideFileGrants.ts b/packages/ui/src/lib/outsideFileGrants.ts new file mode 100644 index 00000000..41e51b2f --- /dev/null +++ b/packages/ui/src/lib/outsideFileGrants.ts @@ -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(); + +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 => { + 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; +}; diff --git a/packages/ui/src/lib/runtime-url.ts b/packages/ui/src/lib/runtime-url.ts index d2675b81..6eafc9c9 100644 --- a/packages/ui/src/lib/runtime-url.ts +++ b/packages/ui/src/lib/runtime-url.ts @@ -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), }; diff --git a/packages/ui/src/lib/worktrees/worktreeStatus.test.ts b/packages/ui/src/lib/worktrees/worktreeStatus.test.ts index 13ec0b51..d959fb56 100644 --- a/packages/ui/src/lib/worktrees/worktreeStatus.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeStatus.test.ts @@ -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 = () => ({ command: '', success: false }); +let resolveRootImpl: (directory: string) => string | Promise = (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((resolve) => { - releaseExec = resolve; + let releaseResolve: (result: string) => void = () => {}; + resolveRootImpl = () => + new Promise((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. diff --git a/packages/ui/src/lib/worktrees/worktreeStatus.ts b/packages/ui/src/lib/worktrees/worktreeStatus.ts index 0deec1a5..392e1943 100644 --- a/packages/ui/src/lib/worktrees/worktreeStatus.ts +++ b/packages/ui/src/lib/worktrees/worktreeStatus.ts @@ -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 { 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 => { - // 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 => { diff --git a/packages/ui/src/stores/useFilesViewTabsStore.ts b/packages/ui/src/stores/useFilesViewTabsStore.ts index 0a7080ca..66e43c59 100644 --- a/packages/ui/src/stores/useFilesViewTabsStore.ts +++ b/packages/ui/src/stores/useFilesViewTabsStore.ts @@ -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()( (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()( }); }, - 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()( byRoot: sanitizeByRoot(rawByRoot), }; }, - partialize: (state) => ({ byRoot: state.byRoot }), + partialize: (state) => ({ byRoot: sanitizeByRoot(state.byRoot) }), } ), { name: 'files-view-tabs-store' } diff --git a/packages/vscode/src/bridge-fs-runtime.ts b/packages/vscode/src/bridge-fs-runtime.ts index 36cb3f73..5e150bd0 100644 --- a/packages/vscode/src/bridge-fs-runtime.ts +++ b/packages/vscode/src/bridge-fs-runtime.ts @@ -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 { diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index 3e045765..dbc15dba 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -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, diff --git a/packages/web/bin/cli.test.js b/packages/web/bin/cli.test.js index 5b8ab736..7c4fea16 100644 --- a/packages/web/bin/cli.test.js +++ b/packages/web/bin/cli.test.js @@ -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; diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 01f76a40..f4a8a2a5 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -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, diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index e83db46f..c9a909a3 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -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'; diff --git a/packages/web/server/lib/fs/routes.test.js b/packages/web/server/lib/fs/routes.test.js index a7550574..edef9b70 100644 --- a/packages/web/server/lib/fs/routes.test.js +++ b/packages/web/server/lib/fs/routes.test.js @@ -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); }); diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index be1cc188..c6b19796 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -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 { diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 95b0ba56..6c4fb52b 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -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'); diff --git a/packages/web/server/lib/opencode/bootstrap-runtime.js b/packages/web/server/lib/opencode/bootstrap-runtime.js index 3820b532..7b41d17c 100644 --- a/packages/web/server/lib/opencode/bootstrap-runtime.js +++ b/packages/web/server/lib/opencode/bootstrap-runtime.js @@ -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, diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index 6454fb16..5b4fae2c 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -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) => { diff --git a/packages/web/server/lib/opencode/core-routes.test.js b/packages/web/server/lib/opencode/core-routes.test.js index dcd9765d..c95ebbb6 100644 --- a/packages/web/server/lib/opencode/core-routes.test.js +++ b/packages/web/server/lib/opencode/core-routes.test.js @@ -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 }); diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 4664992f..c58ddf43 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -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 diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index 96df30c8..943c2ba1 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -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; + } + }); }); diff --git a/packages/web/server/lib/security/bind-host.js b/packages/web/server/lib/security/bind-host.js new file mode 100644 index 00000000..52d40153 --- /dev/null +++ b/packages/web/server/lib/security/bind-host.js @@ -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.'; diff --git a/packages/web/server/lib/security/bind-host.test.js b/packages/web/server/lib/security/bind-host.test.js new file mode 100644 index 00000000..bfa528bb --- /dev/null +++ b/packages/web/server/lib/security/bind-host.test.js @@ -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); + } + }); +}); diff --git a/packages/web/server/lib/ui-auth/ui-auth.test.js b/packages/web/server/lib/ui-auth/ui-auth.test.js index ed1a38c9..ffb503c3 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.test.js +++ b/packages/web/server/lib/ui-auth/ui-auth.test.js @@ -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 () => { diff --git a/packages/web/src/api/files.ts b/packages/web/src/api/files.ts index 8eff1cac..a3fad996 100644 --- a/packages/web/src/api/files.ts +++ b/packages/web/src/api/files.ts @@ -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'); } diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index e9e4be22..ba5e57f4 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -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 "$@"