diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index 6929346cee..c9cc1bfe55 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -9,10 +9,12 @@ menus, keyboard integration, cache invalidation, preload IPC, and the renderer bootstrap. Desktop does not start a local server, package database files, embed Qdrant, or -install a companion CLI. It may embed the `@memohai/runtime` SDK so the Electron -main process can connect this computer to the hosted server as a trusted Remote -Runtime. Use `MEMOH_DESKTOP_BASE_URL` to point the app at the target server. The -default dev target is `http://localhost:18080`. +install a companion CLI. It embeds the `@memohai/runtime` SDK plus exact +JavaScript-only ACP adapters so the Electron main process can connect this +computer to the hosted server as a trusted Remote Runtime. Native Codex and +Claude Code executables are user-installed and are never redistributed in the +Desktop package. Use `MEMOH_DESKTOP_BASE_URL` to point the app at the target +server. The default dev target is `http://localhost:18080`. ## Tech Stack @@ -95,6 +97,7 @@ source aliases just to silence type errors. - cache invalidation broadcast - renderer `/api` proxy target via `MEMOH_DESKTOP_BASE_URL` - encrypted Remote Runtime configuration and `RuntimeSession` lifecycle +- fixed ACP adapter discovery and local Codex/Claude CLI selection The preload bridge is the only renderer API surface for Electron/main-process behavior. Keep it small and typed in both `src/preload/index.ts` and @@ -124,6 +127,13 @@ localhost policy, filesystem paths, and commands are owned by Main. Do not add IPC for local database auth, project-folder picking, server lifecycle, arbitrary filesystem/command access, or CLI installation. +`src/main/acp-adapters.ts` is the only Desktop adapter resolver. It passes +fixed Main-owned absolute paths to `RuntimeSession`, never to IPC. Both aliases +must be present: a descriptor when a local CLI is found, or `false` +to suppress ambient PATH fallback. macOS and Linux are supported; Windows must +keep both aliases disabled. Desktop and Runtime launch the local process but do +not own provider authentication or local Agent configuration. + ## Renderer `src/renderer/src/main.ts` creates the Vue app, installs the reused web plugins, @@ -157,6 +167,14 @@ unpacked for the gRPC loader. Do not add server binaries, installed CLI binaries, database files, provider templates, container runtimes, Qdrant, or media runtimes to Desktop packaging. +Remote ACP packaging pins `@agentclientprotocol/codex-acp` and +`@agentclientprotocol/claude-agent-acp` exactly and leaves their JavaScript +dependency graph inside `app.asar`. The two optional platform-native agent +package families must remain excluded: users supply their own Codex and Claude +Code CLIs. Keep the `runAsNode` fuse enabled, the packaged Electron/ASAR smoke +hook green, adapter LICENSE files present, and `resources/THIRD_PARTY_NOTICES.md` +current. + `scripts/build.mjs` owns build-time environment loading and signing setup. Process/CI values override `apps/desktop/.env`; it always passes `--publish never`, so release workflows upload completed artifacts rather than diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 1d6cf1ad58..013f95b39c 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -64,6 +64,26 @@ installers. Clone the repository and run the platform build command above, or use a downstream distribution workflow that signs and publishes the generated files. +### Remote ACP adapters + +On macOS and Linux, Desktop can expose this computer as a Remote ACP Runtime. +The package includes exact JavaScript adapters (`codex-acp` 1.2.0 and +`claude-agent-acp` 0.66.0), executed with Electron's bundled Node.js 24. It does +not require a system Node/npm installation and does not redistribute either +provider's native CLI. Desktop discovers an independently installed `codex` or +`claude` executable and gives it to Runtime through a Main-process-only typed +descriptor. Windows does not advertise these bundled adapters in the first +release. + +Adapter and native-agent paths stay in Electron Main and are never exposed to +the renderer or sent in the Runtime handshake. Remote ACP does not copy or +manage Codex/Claude credentials or configuration; the local tools own their own +setup. + +Every package build runs an unpacked-app smoke test with the packaged Electron: +it checks Node.js 22+, exact adapter versions, and absence of optional native +agent packages. + ### Build environment | Variable | Purpose | GitHub setting | diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 76df0e9bb9..0b552b6c73 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -1,6 +1,11 @@ appId: ai.memoh.desktop productName: Memoh copyright: Copyright © 2026 Memoh +afterPack: scripts/acp-packaging.mjs +afterSign: scripts/acp-packaging.mjs +electronFuses: + # Runtime-owned adapter bootstraps use packaged Electron as Node 24. + runAsNode: true directories: buildResources: build output: dist @@ -9,6 +14,10 @@ files: - package.json - resources/icon.png - resources/tray-icon.png + # Native Codex / Claude Code executables are supplied by the user. Keep the + # fixed JavaScript adapters, but never redistribute their optional binaries. + - "!node_modules/@openai/codex-{darwin,linux,win32}-*{,/**/*}" + - "!node_modules/@anthropic-ai/claude-agent-sdk-{darwin,linux,win32}-*{,/**/*}" - "!**/.vscode/*" - "!src/*" - "!scripts/*" @@ -72,3 +81,8 @@ nsis: npmRebuild: false asarUnpack: - node_modules/@memohai/runtime/dist/bridge.proto +extraResources: + - from: resources/THIRD_PARTY_NOTICES.md + to: THIRD_PARTY_NOTICES.md + - from: resources/licenses + to: licenses diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a2abb6e8de..edbbf21394 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -29,10 +29,12 @@ "typecheck:node": "pnpm run prepare:runtime && tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "pnpm run typecheck:node && pnpm run typecheck:web", - "test:release": "vitest run src/shared/updates.test.ts src/main/external-links.test.ts && node --test scripts/build-env.test.mjs", + "test:release": "vitest run src/shared/updates.test.ts src/main/external-links.test.ts && node --test scripts/build-env.test.mjs scripts/acp-packaging.test.mjs", "icons": "node scripts/build-icons.mjs" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "0.66.0", + "@agentclientprotocol/codex-acp": "1.2.0", "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", "@fontsource-variable/inter": "^5.2.8", diff --git a/apps/desktop/resources/THIRD_PARTY_NOTICES.md b/apps/desktop/resources/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000000..0bdee111e7 --- /dev/null +++ b/apps/desktop/resources/THIRD_PARTY_NOTICES.md @@ -0,0 +1,25 @@ +# Remote ACP third-party notices + +Memoh Desktop includes these pinned JavaScript adapters: + +- `@agentclientprotocol/codex-acp` 1.2.0 — Apache License 2.0 +- `@agentclientprotocol/claude-agent-acp` 0.66.0 — Apache License 2.0 + +Their package license files are retained with the packaged modules. The +adapters' non-optional JavaScript dependencies are also included under their +respective licenses. + +`@openai/codex` 0.147.0 is included only as the adapter's JavaScript wrapper; +its platform-native optional packages are excluded. It is Apache-2.0 licensed. +The release package includes the repository's Apache License 2.0 text, its +upstream NOTICE attribution for the 0.147.0 tag, and the npm package +metadata/README. The published npm wrapper itself contains no NOTICE file. + +`claude-agent-acp` depends on `@anthropic-ai/claude-agent-sdk` 0.3.220. That +package points distributors to Anthropic's applicable commercial and consumer +terms. A distribution owner must complete its own legal review before shipping +this feature. + +Memoh Desktop does not redistribute the native Codex or Claude Code CLI. Users +install those tools separately and remain responsible for their provider +accounts, authentication, local configuration, and terms. diff --git a/apps/desktop/resources/licenses/Apache-2.0.txt b/apps/desktop/resources/licenses/Apache-2.0.txt new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/apps/desktop/resources/licenses/Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/apps/desktop/resources/licenses/OpenAI-Codex-NOTICE.txt b/apps/desktop/resources/licenses/OpenAI-Codex-NOTICE.txt new file mode 100644 index 0000000000..c0de4be46c --- /dev/null +++ b/apps/desktop/resources/licenses/OpenAI-Codex-NOTICE.txt @@ -0,0 +1,7 @@ +OpenAI Codex +Copyright 2025 OpenAI + +This project includes code derived from Ratatui +(https://github.com/ratatui/ratatui), licensed under the MIT license. +Copyright (c) 2016-2022 Florian Dehau +Copyright (c) 2023-2025 The Ratatui Developers diff --git a/apps/desktop/scripts/acp-packaging-smoke.mjs b/apps/desktop/scripts/acp-packaging-smoke.mjs new file mode 100644 index 0000000000..e13cf7d5dc --- /dev/null +++ b/apps/desktop/scripts/acp-packaging-smoke.mjs @@ -0,0 +1,114 @@ +import { execFile } from 'node:child_process' +import { access, readFile, readdir, realpath, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) +const expectedAdapters = Object.freeze([ + Object.freeze({ + packageName: '@agentclientprotocol/codex-acp', + version: '1.2.0', + versionOutput: '@agentclientprotocol/codex-acp 1.2.0', + }), + Object.freeze({ + packageName: '@agentclientprotocol/claude-agent-acp', + version: '0.66.0', + versionOutput: '0.66.0', + }), +]) + +export async function verifyPackagedACP(resourcesDirectory) { + const applicationArchive = join(resourcesDirectory, 'app.asar') + const nodeModules = join(applicationArchive, 'node_modules') + const nodeVersion = await runNode(['-p', 'process.versions.node']) + const nodeMajor = Number.parseInt(nodeVersion, 10) + if (!Number.isInteger(nodeMajor) || nodeMajor < 22) { + throw new Error(`packaged Electron must provide Node.js 22+; received ${nodeVersion}`) + } + + for (const adapter of expectedAdapters) { + const packageRoot = join(nodeModules, ...adapter.packageName.split('/')) + const packageJson = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) + if (packageJson.version !== adapter.version) { + throw new Error(`${adapter.packageName} must be exactly ${adapter.version}`) + } + const entry = join(packageRoot, 'dist', 'index.js') + await access(entry) + const canonicalEntry = await realpath(entry) + if (!(await stat(canonicalEntry)).isFile()) { + throw new Error(`${adapter.packageName} entry must be a regular ASAR file`) + } + const version = await runNode([entry, '--version']) + if (version !== adapter.versionOutput) { + throw new Error(`${adapter.packageName} returned unexpected version ${version}`) + } + } + + await assertNoRedistributedNativeAgents(resourcesDirectory) + process.stdout.write('✓ packaged Remote ACP adapters verified\n') +} + +async function runNode(args) { + const result = await execFileAsync(process.execPath, args, { + encoding: 'utf8', + env: childEnvironment(), + maxBuffer: 16 * 1024, + timeout: 10_000, + windowsHide: true, + }) + return String(result.stdout).trim() +} + +function childEnvironment() { + const environment = { + PATH: '/usr/bin:/bin:/usr/sbin:/sbin', + ELECTRON_RUN_AS_NODE: '1', + } + for (const name of ['HOME', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'LC_ALL', 'SystemRoot']) { + const value = process.env[name] + if (value && !value.includes('\0')) environment[name] = value + } + return environment +} + +async function assertNoRedistributedNativeAgents(resourcesDirectory) { + const forbidden = /(?:^|\/)(?:codex-(?:darwin|linux|win32)-[^/]+|claude-agent-sdk-(?:darwin|linux|win32)-[^/]+)(?:\/|$)/ + const roots = [resourcesDirectory, join(resourcesDirectory, 'app.asar')] + for (const root of roots) { + for (const entry of await recursiveEntries(root)) { + const normalized = entry.replaceAll('\\', '/') + if (forbidden.test(normalized)) { + throw new Error(`native agent package must not be redistributed: ${normalized}`) + } + } + } +} + +async function recursiveEntries(root) { + const entries = [] + const queue = [root] + while (queue.length > 0) { + const directory = queue.pop() + let children + try { + children = await readdir(directory, { withFileTypes: true }) + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') continue + throw error + } + for (const child of children) { + const path = join(directory, child.name) + entries.push(path) + if (child.isDirectory()) queue.push(path) + } + } + return entries +} + +const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : '' +if (import.meta.url === invokedPath) { + const resourcesDirectory = process.argv[2] + if (!resourcesDirectory) throw new Error('packaged resources directory is required') + await verifyPackagedACP(resourcesDirectory) +} diff --git a/apps/desktop/scripts/acp-packaging.mjs b/apps/desktop/scripts/acp-packaging.mjs new file mode 100644 index 0000000000..8e674b6c95 --- /dev/null +++ b/apps/desktop/scripts/acp-packaging.mjs @@ -0,0 +1,91 @@ +import { execFile } from 'node:child_process' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) +const smokeScript = join(dirname(fileURLToPath(import.meta.url)), 'acp-packaging-smoke.mjs') + +export function packagedApplicationPaths(context) { + const platform = context.electronPlatformName + const productFilename = context.packager.appInfo.productFilename + if (platform === 'darwin') { + const application = join(context.appOutDir, `${productFilename}.app`) + return { + executable: join(application, 'Contents', 'MacOS', productFilename), + resources: join(application, 'Contents', 'Resources'), + } + } + const executableName = platform === 'linux' + ? (context.packager.executableName ?? context.packager.appInfo.sanitizedName.toLowerCase()) + : `${productFilename}.exe` + return { + executable: join(context.appOutDir, executableName), + resources: join(context.appOutDir, 'resources'), + } +} + +export async function runPackagedACPSmoke(paths) { + const runtimeExecutable = paths.runtimeExecutable ?? paths.executable + const result = await execFileAsync(runtimeExecutable, [smokeScript, paths.resources], { + encoding: 'utf8', + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + }, + maxBuffer: 64 * 1024, + timeout: 30_000, + windowsHide: true, + }) + process.stdout.write(String(result.stdout)) +} + +export function smokeRuntimeExecutable(context, hostPlatform, installedElectron) { + const paths = packagedApplicationPaths(context) + return context.electronPlatformName === hostPlatform ? paths.executable : installedElectron +} + +export async function afterPack(context) { + const paths = packagedApplicationPaths(context) + paths.runtimeExecutable = smokeRuntimeExecutable( + context, + process.platform, + await installedElectronExecutable(), + ) + await runPackagedACPSmoke(paths) +} + +export async function afterSign(context) { + const paths = packagedApplicationPaths(context) + paths.runtimeExecutable = smokeRuntimeExecutable( + context, + process.platform, + await installedElectronExecutable(), + ) + await runPackagedACPSmoke(paths) + if ( + context.electronPlatformName === 'darwin' + && process.platform === 'darwin' + && shouldVerifyMacSignature(process.env) + ) { + await execFileAsync('/usr/bin/codesign', [ + '--verify', + '--deep', + '--strict', + join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`), + ], { + encoding: 'utf8', + maxBuffer: 64 * 1024, + timeout: 30_000, + }) + } +} + +export function shouldVerifyMacSignature(environment) { + return Boolean(environment.CSC_LINK?.trim()) +} + +async function installedElectronExecutable() { + const electron = await import('electron') + return electron.default +} diff --git a/apps/desktop/scripts/acp-packaging.test.mjs b/apps/desktop/scripts/acp-packaging.test.mjs new file mode 100644 index 0000000000..ab378d1d8e --- /dev/null +++ b/apps/desktop/scripts/acp-packaging.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { test } from 'node:test' + +import { + packagedApplicationPaths, + shouldVerifyMacSignature, + smokeRuntimeExecutable, +} from './acp-packaging.mjs' + +const desktopRoot = resolve(import.meta.dirname, '..') + +test('release pins both Remote ACP adapters exactly', async () => { + const packageJson = JSON.parse(await readFile(join(desktopRoot, 'package.json'), 'utf8')) + assert.equal(packageJson.dependencies['@agentclientprotocol/codex-acp'], '1.2.0') + assert.equal(packageJson.dependencies['@agentclientprotocol/claude-agent-acp'], '0.66.0') +}) + +test('packaging keeps RUN_AS_NODE enabled and excludes native agent packages', async () => { + const config = await readFile(join(desktopRoot, 'electron-builder.yml'), 'utf8') + assert.match(config, /electronFuses:\s*[\s\S]*?runAsNode: true/) + assert.match(config, /afterPack: scripts\/acp-packaging\.mjs/) + assert.match(config, /afterSign: scripts\/acp-packaging\.mjs/) + assert.match(config, /codex-\{darwin,linux,win32\}-\*\{,\/\*\*\/\*\}/) + assert.match(config, /claude-agent-sdk-\{darwin,linux,win32\}-\*\{,\/\*\*\/\*\}/) +}) + +test('packaged paths address the real platform executable and resources', () => { + const appInfo = { productFilename: 'Memoh', sanitizedName: 'memoh' } + assert.deepEqual(packagedApplicationPaths({ + electronPlatformName: 'darwin', + appOutDir: '/build/mac-arm64', + packager: { appInfo }, + }), { + executable: join('/build/mac-arm64', 'Memoh.app', 'Contents', 'MacOS', 'Memoh'), + resources: join('/build/mac-arm64', 'Memoh.app', 'Contents', 'Resources'), + }) + assert.deepEqual(packagedApplicationPaths({ + electronPlatformName: 'linux', + appOutDir: '/build/linux-unpacked', + packager: { appInfo, executableName: 'memoh' }, + }), { + executable: join('/build/linux-unpacked', 'memoh'), + resources: join('/build/linux-unpacked', 'resources'), + }) +}) + +test('cross-build smoke uses host Electron while native builds use the target executable', () => { + const context = { + electronPlatformName: 'linux', + appOutDir: '/build/linux-unpacked', + packager: { + appInfo: { productFilename: 'Memoh', sanitizedName: 'memoh' }, + executableName: 'memoh', + }, + } + assert.equal(smokeRuntimeExecutable(context, 'linux', '/host/electron'), join('/build/linux-unpacked', 'memoh')) + assert.equal(smokeRuntimeExecutable(context, 'darwin', '/host/electron'), '/host/electron') +}) + +test('signature verification runs only for an explicitly configured macOS identity', () => { + assert.equal(shouldVerifyMacSignature({}), false) + assert.equal(shouldVerifyMacSignature({ CSC_LINK: ' ' }), false) + assert.equal(shouldVerifyMacSignature({ CSC_LINK: 'certificate.p12' }), true) +}) + +test('third-party notices state the transitive versions the lockfile installs', async () => { + const notices = await readFile(join(desktopRoot, 'resources', 'THIRD_PARTY_NOTICES.md'), 'utf8') + const lockfile = await readFile(join(desktopRoot, '..', '..', 'pnpm-lock.yaml'), 'utf8') + for (const name of ['@openai/codex', '@anthropic-ai/claude-agent-sdk']) { + const versions = new Set( + [...lockfile.matchAll(new RegExp(`'${name}@(\\d+\\.\\d+\\.\\d+)['(]`, 'g'))] + .map(match => match[1]), + ) + assert.equal(versions.size, 1, `expected exactly one locked version of ${name}, saw ${[...versions].join(', ')}`) + const [version] = versions + assert.ok( + notices.includes(`\`${name}\` ${version}`), + `THIRD_PARTY_NOTICES.md must state ${name} ${version}; a lockfile refresh changed the installed version without updating the notice`, + ) + } +}) diff --git a/apps/desktop/src/main/acp-adapters.test.ts b/apps/desktop/src/main/acp-adapters.test.ts new file mode 100644 index 0000000000..a22847b404 --- /dev/null +++ b/apps/desktop/src/main/acp-adapters.test.ts @@ -0,0 +1,190 @@ +import { chmod, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { discoverBundledACPLaunchers } from './acp-adapters' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(path => rm(path, { recursive: true, force: true }))) +}) + +describe('discoverBundledACPLaunchers', () => { + it.each(['darwin', 'linux'] as const)('uses fixed adapters and local Agent entries on %s', async (platform) => { + const fixture = await executableFixture(['codex', 'claude']) + const launchers = await discoverBundledACPLaunchers({ + platform, + electronExecutable: '/Applications/Memoh.app/Contents/MacOS/Memoh', + appPath: '/workspace/apps/desktop', + resourcesPath: '/Applications/Memoh.app/Contents/Resources', + isPackaged: false, + homeDirectory: fixture.home, + pathValue: fixture.bin, + loginShell: false, + }) + + expect(launchers).toEqual({ + 'codex-acp': { + nodeExecutable: '/Applications/Memoh.app/Contents/MacOS/Memoh', + adapterEntry: join( + '/workspace/apps/desktop/node_modules', + '@agentclientprotocol/codex-acp/dist/index.js', + ), + codexExecutable: join(fixture.bin, 'codex'), + }, + 'claude-agent-acp': { + nodeExecutable: '/Applications/Memoh.app/Contents/MacOS/Memoh', + adapterEntry: join( + '/workspace/apps/desktop/node_modules', + '@agentclientprotocol/claude-agent-acp/dist/index.js', + ), + claudeCodeExecutable: join(fixture.bin, 'claude'), + }, + }) + }) + + it('uses app.asar for packaged adapters and disables a missing local Agent', async () => { + const fixture = await executableFixture(['codex']) + const launchers = await discoverBundledACPLaunchers({ + platform: 'darwin', + electronExecutable: '/Applications/Memoh.app/Contents/MacOS/Memoh', + appPath: '/unused', + resourcesPath: '/Applications/Memoh.app/Contents/Resources', + isPackaged: true, + homeDirectory: fixture.home, + pathValue: fixture.bin, + loginShell: false, + }) + + expect(launchers['codex-acp']).toEqual(expect.objectContaining({ + adapterEntry: join( + '/Applications/Memoh.app/Contents/Resources/app.asar/node_modules', + '@agentclientprotocol/codex-acp/dist/index.js', + ), + })) + expect(launchers['claude-agent-acp']).toBe(false) + }) + + it('explicitly disables both aliases on Windows', async () => { + await expect(discoverBundledACPLaunchers({ + platform: 'win32', + electronExecutable: String.raw`C:\Program Files\Memoh\Memoh.exe`, + appPath: String.raw`C:\Program Files\Memoh\resources\app.asar`, + resourcesPath: String.raw`C:\Program Files\Memoh\resources`, + isPackaged: true, + homeDirectory: String.raw`C:\Users\memoh`, + })).resolves.toEqual({ + 'codex-acp': false, + 'claude-agent-acp': false, + }) + }) + + it('prefers the login-shell PATH and keeps symlinked entries unresolved', async () => { + const fixture = await executableFixture(['codex']) + // A fake login shell that reports a managed claude the fallback + // directories cannot see, plus no codex, exercising both probe branches. + const managedBin = join(fixture.home, 'managed', 'bin') + await executable(join(managedBin, 'claude-real')) + await symlink(join(managedBin, 'claude-real'), join(managedBin, 'claude')) + const shell = join(fixture.home, 'fake-shell') + await writeFile(shell, [ + '#!/bin/sh', + `printf 'codex:%s\\nclaude:%s\\n' '' '${join(managedBin, 'claude')}'`, + '', + ].join('\n'), { mode: 0o700 }) + await chmod(shell, 0o700) + + const launchers = await discoverBundledACPLaunchers({ + platform: 'linux', + electronExecutable: '/opt/Memoh/memoh', + appPath: '/opt/Memoh/resources/app', + resourcesPath: '/opt/Memoh/resources', + isPackaged: false, + homeDirectory: fixture.home, + pathValue: fixture.bin, + loginShell: shell, + }) + + expect(launchers['claude-agent-acp']).toEqual(expect.objectContaining({ + claudeCodeExecutable: join(managedBin, 'claude'), + })) + // codex missed the probe but the fallback PATH directory still finds it. + expect(launchers['codex-acp']).toEqual(expect.objectContaining({ + codexExecutable: join(fixture.bin, 'codex'), + })) + }) + + it('ignores a login shell that reports garbage or fails', async () => { + const fixture = await executableFixture([]) + const shell = join(fixture.home, 'broken-shell') + await writeFile(shell, '#!/bin/sh\necho nonsense\nexit 1\n', { mode: 0o700 }) + await chmod(shell, 0o700) + + await expect(discoverBundledACPLaunchers({ + platform: 'linux', + electronExecutable: '/opt/Memoh/memoh', + appPath: '/opt/Memoh/resources/app', + resourcesPath: '/opt/Memoh/resources', + isPackaged: false, + homeDirectory: fixture.home, + pathValue: fixture.bin, + loginShell: shell, + })).resolves.toEqual({ 'codex-acp': false, 'claude-agent-acp': false }) + }) + + it('finds CLIs installed under nvm-managed node without a login shell', async () => { + const fixture = await executableFixture([]) + const nvmBin = join(fixture.home, '.nvm', 'versions', 'node', 'v22.11.0', 'bin') + const olderBin = join(fixture.home, '.nvm', 'versions', 'node', 'v20.9.0', 'bin') + await executable(join(olderBin, 'codex')) + await executable(join(nvmBin, 'codex')) + + const launchers = await discoverBundledACPLaunchers({ + platform: 'linux', + electronExecutable: '/opt/Memoh/memoh', + appPath: '/opt/Memoh/resources/app', + resourcesPath: '/opt/Memoh/resources', + isPackaged: false, + homeDirectory: fixture.home, + loginShell: false, + }) + + // The newest node version wins, matching the shell's likely resolution. + expect(launchers['codex-acp']).toEqual(expect.objectContaining({ + codexExecutable: join(nvmBin, 'codex'), + })) + }) + + it('ignores relative PATH entries', async () => { + const fixture = await executableFixture([]) + await expect(discoverBundledACPLaunchers({ + platform: 'linux', + electronExecutable: '/opt/Memoh/memoh', + appPath: '/opt/Memoh/resources/app', + resourcesPath: '/opt/Memoh/resources', + isPackaged: false, + homeDirectory: fixture.home, + pathValue: 'relative-bin', + loginShell: false, + })).resolves.toEqual({ 'codex-acp': false, 'claude-agent-acp': false }) + }) +}) + +async function executableFixture(names: Array<'codex' | 'claude'>) { + const home = await realpath(await mkdtemp(join(tmpdir(), 'memoh-desktop-acp-'))) + temporaryDirectories.push(home) + const bin = join(home, 'custom-bin') + for (const name of names) { + await executable(join(bin, name)) + } + return { home, bin } +} + +async function executable(path: string): Promise { + await mkdir(join(path, '..'), { recursive: true }) + await writeFile(path, '#!/bin/sh\nexit 0\n', { mode: 0o700 }) + await chmod(path, 0o700) +} diff --git a/apps/desktop/src/main/acp-adapters.ts b/apps/desktop/src/main/acp-adapters.ts new file mode 100644 index 0000000000..869d667321 --- /dev/null +++ b/apps/desktop/src/main/acp-adapters.ts @@ -0,0 +1,225 @@ +import { execFile } from 'node:child_process' +import { constants } from 'node:fs' +import { access, readdir, stat } from 'node:fs/promises' +import { delimiter, isAbsolute, join } from 'node:path' +import { promisify } from 'node:util' + +import type { TrustedACPLaunchers } from '@memohai/runtime' + +const execFileAsync = promisify(execFile) +const loginShellProbeTimeoutMs = 5_000 + +const adapterEntries = { + 'codex-acp': ['@agentclientprotocol', 'codex-acp', 'dist', 'index.js'], + 'claude-agent-acp': ['@agentclientprotocol', 'claude-agent-acp', 'dist', 'index.js'], +} as const + +export interface BundledACPLauncherOptions { + platform: NodeJS.Platform + electronExecutable: string + appPath: string + resourcesPath: string + isPackaged: boolean + homeDirectory: string + pathValue?: string + /** + * Login shell used to resolve the user's real PATH (Finder-launched apps + * inherit only the system default). Pass false to skip the probe in tests. + */ + loginShell?: string | false +} + +/** + * Resolves the fixed adapters bundled with Desktop and the user's local + * Codex/Claude entry points. Paths stay in Electron Main and are never sent + * to the renderer or Memoh Server. + * + * Discovery order per CLI: + * 1. a login-shell `command -v` probe, which sees the PATH the user's login + * profile builds (volta, pnpm, mise, homebrew, ...); + * 2. a deterministic fallback list — fixed directories plus enumerated + * nvm/fnm node installs, whose PATH lives in interactive rc files the + * login probe cannot see. + * + * Found paths are validated but deliberately not realpath-resolved: version + * managers like volta dispatch on the symlink's basename, and resolving the + * link would record the shim binary under the wrong name. + */ +export async function discoverBundledACPLaunchers( + options: BundledACPLauncherOptions, +): Promise { + if (options.platform !== 'darwin' && options.platform !== 'linux') { + return disabledLaunchers() + } + const electronExecutable = assertAbsolute(options.electronExecutable, 'Electron executable') + const applicationRoot = options.isPackaged + ? join(assertAbsolute(options.resourcesPath, 'resources path'), 'app.asar') + : assertAbsolute(options.appPath, 'application path') + const homeDirectory = assertAbsolute(options.homeDirectory, 'home directory') + const probed = await probeLoginShell(options) + const directories = executableSearchDirectories( + homeDirectory, + options.pathValue, + await versionManagerDirectories(homeDirectory), + ) + const [codexExecutable, claudeCodeExecutable] = await Promise.all([ + resolveExecutable('codex', probed, directories), + resolveExecutable('claude', probed, directories), + ]) + + return Object.freeze({ + 'codex-acp': codexExecutable + ? Object.freeze({ + nodeExecutable: electronExecutable, + adapterEntry: join(applicationRoot, 'node_modules', ...adapterEntries['codex-acp']), + codexExecutable, + }) + : false, + 'claude-agent-acp': claudeCodeExecutable + ? Object.freeze({ + nodeExecutable: electronExecutable, + adapterEntry: join( + applicationRoot, + 'node_modules', + ...adapterEntries['claude-agent-acp'], + ), + claudeCodeExecutable, + }) + : false, + }) +} + +function disabledLaunchers(): TrustedACPLaunchers { + return Object.freeze({ + 'codex-acp': false, + 'claude-agent-acp': false, + }) +} + +/** + * Asks the user's login shell where `codex` and `claude` live. `-l` sources + * the login profile (where PATH additions from version managers live) without + * `-i`, which would pull in interactive-only config and TTY expectations. + */ +async function probeLoginShell( + options: BundledACPLauncherOptions, +): Promise> { + const found = new Map<'codex' | 'claude', string>() + const shell = options.loginShell === false + ? undefined + : options.loginShell ?? process.env.SHELL + if (!shell || !isAbsolute(shell)) { + return found + } + let stdout: string + try { + ({ stdout } = await execFileAsync( + shell, + ['-lc', 'printf "codex:%s\\nclaude:%s\\n" "$(command -v codex || true)" "$(command -v claude || true)"'], + // SIGKILL: a profile that traps TERM must not wedge the connect loop + // past the timeout. + { timeout: loginShellProbeTimeoutMs, killSignal: 'SIGKILL', windowsHide: true }, + )) + } catch { + // A hanging or failing login shell falls back to the directory list. + return found + } + for (const line of stdout.split('\n')) { + const match = /^(codex|claude):(\/.+)$/.exec(line.trim()) + if (!match) continue + const name = match[1] as 'codex' | 'claude' + if (!found.has(name) && await isExecutableFile(match[2])) { + found.set(name, match[2]) + } + } + return found +} + +// nvm and fnm set PATH from interactive rc files (~/.zshrc, ~/.bashrc) that a +// non-interactive login shell does not source, so their node installs are +// enumerated directly. Newest version first, matching what the user's shell +// would most likely resolve. +async function versionManagerDirectories(homeDirectory: string): Promise { + const layouts = [ + { root: join(homeDirectory, '.nvm', 'versions', 'node'), suffix: ['bin'] }, + { root: join(homeDirectory, '.local', 'share', 'fnm', 'node-versions'), suffix: ['installation', 'bin'] }, + { root: join(homeDirectory, 'Library', 'Application Support', 'fnm', 'node-versions'), suffix: ['installation', 'bin'] }, + ] + const directories: string[] = [] + for (const { root, suffix } of layouts) { + let entries: string[] + try { + entries = await readdir(root) + } catch { + continue + } + const versions = entries + .filter(entry => /^v?\d/.test(entry)) + .sort((left, right) => right.localeCompare(left, undefined, { numeric: true })) + for (const version of versions) { + directories.push(join(root, version, ...suffix)) + } + } + return directories +} + +function executableSearchDirectories( + homeDirectory: string, + pathValue?: string, + versionManagerBins: readonly string[] = [], +): readonly string[] { + const knownDirectories = [ + join(homeDirectory, '.local', 'bin'), + join(homeDirectory, '.npm-global', 'bin'), + join(homeDirectory, '.bun', 'bin'), + join(homeDirectory, '.volta', 'bin'), + join(homeDirectory, '.local', 'share', 'mise', 'shims'), + join(homeDirectory, '.local', 'share', 'pnpm'), + join(homeDirectory, 'Library', 'pnpm'), + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/bin', + ] + const inheritedDirectories = (pathValue ?? '') + .split(delimiter) + .map(value => value.trim()) + .filter(value => value.length > 0 && isAbsolute(value)) + return Object.freeze([...new Set([...knownDirectories, ...versionManagerBins, ...inheritedDirectories])]) +} + +async function resolveExecutable( + name: 'codex' | 'claude', + probed: ReadonlyMap<'codex' | 'claude', string>, + directories: readonly string[], +): Promise { + const fromShell = probed.get(name) + if (fromShell) { + return fromShell + } + for (const directory of directories) { + const executable = join(directory, name) + if (await isExecutableFile(executable)) { + return executable + } + } + return undefined +} + +async function isExecutableFile(path: string): Promise { + try { + // stat/access follow symlinks, validating the target while keeping the + // recorded path the user-facing one. + const entry = await stat(path) + if (!entry.isFile()) return false + await access(path, constants.X_OK) + return true + } catch { + return false + } +} + +function assertAbsolute(value: string, label: string): string { + if (!isAbsolute(value)) throw new Error(`the Desktop ${label} must be absolute`) + return value +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 24ba03f610..dcb6a58876 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -24,6 +24,7 @@ import { dispatchFocusedWindowCommand } from './window-commands' import { dispatchRendererNavigate } from './window-navigation' import { macWindowChromeOptions } from './window-chrome' import { maybeSelfInstallMacOS } from './self-install' +import { discoverBundledACPLaunchers } from './acp-adapters' import { DesktopRemoteRuntimeManager } from './remote-runtime' import { isTrustedRendererUrl } from './renderer-trust' import { normalizeExternalUrl, resolveNavigationGuardAction } from './external-links' @@ -634,11 +635,25 @@ app.whenReady().then(async () => { attachExternalLinkGuards(window.webContents) }) + // A provider instead of a one-shot value: the runtime re-resolves it on + // every connection attempt, so a CLI installed after launch is picked up on + // the next reconnect or re-configure without restarting the app. + const trustedACPLaunchers = () => discoverBundledACPLaunchers({ + platform: process.platform, + electronExecutable: process.execPath, + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + isPackaged: app.isPackaged, + homeDirectory: homedir(), + pathValue: process.env.PATH, + }) + remoteRuntimeManager = new DesktopRemoteRuntimeManager({ configPath: join(app.getPath('userData'), 'remote-runtime.json'), currentServerUrl: getDesktopApiBaseUrl, workspaceBase: homedir(), deviceName: hostname(), + trustedACPLaunchers, encryption: { isAvailable: () => safeStorage.isEncryptionAvailable(), encrypt: value => safeStorage.encryptString(value), diff --git a/apps/desktop/src/main/remote-runtime.test.ts b/apps/desktop/src/main/remote-runtime.test.ts index 6f083570e9..f1f68295f6 100644 --- a/apps/desktop/src/main/remote-runtime.test.ts +++ b/apps/desktop/src/main/remote-runtime.test.ts @@ -34,7 +34,14 @@ describe('DesktopRemoteRuntimeManager', () => { }) const restoredFactory = vi.fn(() => resolvedSession()) - const restored = fixture.manager({ createSession: restoredFactory }) + const trustedACPLaunchers = { + 'codex-acp': false, + 'claude-agent-acp': false, + } as const + const restored = fixture.manager({ + createSession: restoredFactory, + trustedACPLaunchers, + }) const state = await restored.restore() expect(state).toMatchObject({ @@ -52,7 +59,10 @@ describe('DesktopRemoteRuntimeManager', () => { workspaceBase: fixture.workspaceBase, insecureLocalhost: true, }, - expect.objectContaining({ onStatus: expect.any(Function) }), + expect.objectContaining({ + onStatus: expect.any(Function), + trustedACPLaunchers, + }), ) }) diff --git a/apps/desktop/src/main/remote-runtime.ts b/apps/desktop/src/main/remote-runtime.ts index df08877043..0c206790c9 100644 --- a/apps/desktop/src/main/remote-runtime.ts +++ b/apps/desktop/src/main/remote-runtime.ts @@ -9,6 +9,7 @@ import { type RuntimeClientConfig, type RuntimeSessionOptions, type RuntimeSessionStatus, + type TrustedACPLaunchersSource, } from '@memohai/runtime' import type { @@ -50,6 +51,7 @@ export interface DesktopRemoteRuntimeManagerOptions { workspaceBase: string deviceName: string encryption: RuntimeEncryption + trustedACPLaunchers?: TrustedACPLaunchersSource createSession?: RuntimeSessionFactory warn?: (message: string, error?: unknown) => void } @@ -236,6 +238,7 @@ export class DesktopRemoteRuntimeManager { { onStatus: (status, error) => this.handleSessionStatus(token, runtimeId, key, status, error), warn: message => this.options.warn?.(message), + trustedACPLaunchers: this.options.trustedACPLaunchers, }, ) return { session, token } diff --git a/apps/web/src/components/computer/connect-computer-dialog.vue b/apps/web/src/components/computer/connect-computer-dialog.vue index d433eadf0d..6dcab18ac4 100644 --- a/apps/web/src/components/computer/connect-computer-dialog.vue +++ b/apps/web/src/components/computer/connect-computer-dialog.vue @@ -14,6 +14,9 @@

{{ t('computerConnect.commandDescription') }}

+

+ {{ t('computerConnect.trustNote') }} +

diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 65a244f3ac..d93861ca85 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -106,7 +106,8 @@ "computerConnect": { "title": "Connect computer", "commandLabel": "Connection command", - "commandDescription": "Run it on the computer you want to connect, and keep it running.", + "commandDescription": "Requires macOS or Linux with Node.js 22+. Run it on the computer you want to connect, and keep it running.", + "trustNote": "The connected computer runs Agents locally. For Remote ACP, Memoh forwards the adapter process streams and does not configure Codex or Claude Code. The command contains a private Memoh connection key that stops working when you disconnect the computer.", "waiting": "Waiting for the computer to connect…", "finish": "Done" }, @@ -193,6 +194,8 @@ }, "workspace": { "unreachable": "The workspace could not be reached.", + "target_in_use": "This computer is still used by a folder and cannot be disconnected.", + "read_permission_required": "Ask the bot owner for file-read access before using this connected computer.", "image_incompatible": "This workspace image is incompatible. Rebuild it from the current Memoh workspace image and try again.", "template_bootstrap_failed": "The workspace files could not be initialized. Please try rebuilding the workspace.", "display_prepare_failed": "Display preparation failed." @@ -1041,6 +1044,8 @@ "agentAuthInvalid": "External agent authentication is invalid. Update the agent setup and try again.", "noWorkspaceExec": "You do not have permission to run workspace commands for this bot.", "runtimeOwnerMissing": "This external-agent session has no runtime owner. Start a new session to continue.", + "remoteOSUnsupported": "Connected-computer Agents currently support macOS and Linux only.", + "remoteAdapterMissing": "This computer does not have the required Agent adapter or local CLI. Update Memoh Desktop, or disconnect it and reconnect with the latest command from the Computers page.", "discussUnsupported": "This external agent cannot run in discuss mode.", "groupChatUnsupported": "Group chats cannot create a chat-mode external-agent session. Use /new codex or /new discuss codex.", "projectModeInvalid": "The external agent project mode is invalid.", diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json index 48af18ac0f..9106f2173e 100644 --- a/apps/web/src/i18n/locales/zh.json +++ b/apps/web/src/i18n/locales/zh.json @@ -106,7 +106,8 @@ "computerConnect": { "title": "连接电脑", "commandLabel": "连接命令", - "commandDescription": "在要连接的电脑上运行,保持进程运行。", + "commandDescription": "需要 macOS 或 Linux,以及 Node.js 22+。在要连接的电脑上运行这条命令,并保持进程运行。", + "trustNote": "已连接电脑会在本地运行 Agent。Remote ACP 只通过 Memoh 转发适配器进程数据流,不由 Memoh 配置 Codex 或 Claude Code。命令中含有私密的 Memoh 连接密钥;断开电脑后,该密钥会立即失效。", "waiting": "等待电脑连接…", "finish": "完成" }, @@ -193,6 +194,8 @@ }, "workspace": { "unreachable": "暂时无法连接工作区,请稍后重试。", + "target_in_use": "这台电脑仍被文件夹使用,无法断开连接。", + "read_permission_required": "使用这台已连接电脑前,请让 Bot 所有者为你授予文件读取权限。", "image_incompatible": "工作区镜像与当前版本不兼容,请基于最新 Memoh Workspace 镜像重建后重试。", "template_bootstrap_failed": "工作区文件初始化失败,请尝试重建工作区。", "display_prepare_failed": "显示环境准备失败,请稍后重试。" @@ -1041,6 +1044,8 @@ "agentAuthInvalid": "外部 Agent 认证无效。请更新 Agent 配置后重试。", "noWorkspaceExec": "你没有执行该 Bot 工作区命令的权限。", "runtimeOwnerMissing": "这个外部 Agent 会话没有运行时所有者。请重新开始一个会话。", + "remoteOSUnsupported": "已连接电脑上的 Agent 目前仅支持 macOS 和 Linux。", + "remoteAdapterMissing": "这台电脑缺少所需的 Agent 适配器或本地 CLI。请更新 Memoh Desktop,或断开连接后使用“电脑”页面中的最新命令重新连接。", "discussUnsupported": "这个外部 Agent 无法在讨论模式中运行。", "groupChatUnsupported": "群聊不能创建对话模式的外部 Agent 会话。请使用 /new codex 或 /new discuss codex。", "projectModeInvalid": "外部 Agent 的项目模式无效。", diff --git a/apps/web/src/pages/home/components/chat-pane.vue b/apps/web/src/pages/home/components/chat-pane.vue index 20256ea45b..aa5aca5ad6 100644 --- a/apps/web/src/pages/home/components/chat-pane.vue +++ b/apps/web/src/pages/home/components/chat-pane.vue @@ -1299,12 +1299,7 @@ watch(() => currentBotId.value, (botId) => { const activeSessionWorkdirId = computed(() => (activeSession.value?.workdir_id ?? '').trim()) const draftWorkingFolder = computed(() => { if (activeSession.value || !currentBotId.value) return null - const workdir = workdirsStore.workingWorkdirFor(currentBotId.value) - if (!workdir) return null - // ACP sessions can only bind native-workspace workdirs; a remote working - // workdir is skipped at creation, so don't pretend it applies here. - if (activeUsesACPComposer.value && workdir.target_kind === 'remote') return null - return workdir + return workdirsStore.workingWorkdirFor(currentBotId.value) }) const composerFolderLocked = computed(() => ( !!activeSessionWorkdirId.value || !!draftWorkingFolder.value @@ -1316,12 +1311,8 @@ const composerFolderName = computed(() => { } return draftWorkingFolder.value?.name?.trim() || t('chat.folderUnavailable') }) -// Folders a draft may bind to. ACP runs only in the native workspace, so a -// remote folder is left out rather than offered as a choice that binds nothing. const selectableFolders = computed(() => { - const folders = workdirsStore.workdirsFor(currentBotId.value).filter(folder => !folder.archived && !!folder.id) - if (activeUsesACPComposer.value) return folders.filter(folder => folder.target_kind !== 'remote') - return folders + return workdirsStore.workdirsFor(currentBotId.value).filter(folder => !folder.archived && !!folder.id) }) // The picker only makes sense before the session exists; an empty folder list // falls through to the locked entry (or to nothing at all). diff --git a/apps/web/src/pages/providers/components/provider-form-test-connection.test.ts b/apps/web/src/pages/providers/components/provider-form-test-connection.test.ts index 5535bd417e..5ac9cd34dc 100644 --- a/apps/web/src/pages/providers/components/provider-form-test-connection.test.ts +++ b/apps/web/src/pages/providers/components/provider-form-test-connection.test.ts @@ -97,7 +97,7 @@ describe('provider test connection states', () => { const providerForm = (await import('./provider-form.vue')).default const root = document.createElement('div') document.body.append(root) - + const app = createApp(providerForm, { provider: { id: 'provider-id', diff --git a/apps/web/src/pages/runtimes/command.test.ts b/apps/web/src/pages/runtimes/command.test.ts index fd5d114c61..0ba95aef42 100644 --- a/apps/web/src/pages/runtimes/command.test.ts +++ b/apps/web/src/pages/runtimes/command.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from 'vitest' -import { buildRuntimeConnectCommand } from './command' +import packageMetadata from '../../../package.json' with { type: 'json' } +import { buildRuntimeConnectCommand, pinnedACPAdapterVersions } from './command' const key = `mrk_${'a'.repeat(64)}` const teamId = '11111111-1111-4111-8111-111111111111' +const runtimeEnvironment = [ + 'npx --yes', + `--package=@memohai/runtime@${packageMetadata.version}`, + '--package=@agentclientprotocol/codex-acp@1.2.0', + '--package=@agentclientprotocol/claude-agent-acp@0.66.0', + '-- memoh-runtime', +].join(' ') describe('buildRuntimeConnectCommand', () => { it('includes the credential team ID required by hosted gateways', () => { @@ -11,13 +19,13 @@ describe('buildRuntimeConnectCommand', () => { key, team_id: teamId, })).toBe( - `npx --yes @memohai/runtime --server https://memoh.example/api --key ${key} --team-id ${teamId}`, + `${runtimeEnvironment} --server 'https://memoh.example/api' --key '${key}' --team-id '${teamId}'`, ) }) it('keeps credentials from older self-hosted servers usable', () => { expect(buildRuntimeConnectCommand('https://memoh.example/api', { key })) - .toBe(`npx --yes @memohai/runtime --server https://memoh.example/api --key ${key}`) + .toBe(`${runtimeEnvironment} --server 'https://memoh.example/api' --key '${key}'`) }) it('enables plaintext WebSockets only for loopback development servers', () => { @@ -25,7 +33,28 @@ describe('buildRuntimeConnectCommand', () => { key, team_id: teamId, })).toBe( - `npx --yes @memohai/runtime --server http://127.0.0.1:18080 --key ${key} --team-id ${teamId} --insecure-localhost`, + `${runtimeEnvironment} --server 'http://127.0.0.1:18080' --key '${key}' --team-id '${teamId}' --insecure-localhost`, ) }) + + it('shell-quotes server-controlled values before invoking npx', () => { + const serverUrl = 'https://memoh.example/api?label=o\'hare&next=$(id)' + + expect(buildRuntimeConnectCommand(serverUrl, { key })) + .toContain('--server \'https://memoh.example/api?label=o\'\\\'\'hare&next=$(id)\'') + }) + + it('does not emit a partial command without a connection key', () => { + expect(buildRuntimeConnectCommand('https://memoh.example/api', null)).toBe('') + expect(buildRuntimeConnectCommand('https://memoh.example/api', { key: ' ' })).toBe('') + }) +}) + +describe('adapter version pins', () => { + it('matches the versions Desktop bundles', async () => { + const desktopManifest = await import('../../../../desktop/package.json') + for (const [name, version] of Object.entries(pinnedACPAdapterVersions)) { + expect(desktopManifest.dependencies[name as keyof typeof desktopManifest.dependencies]).toBe(version) + } + }) }) diff --git a/apps/web/src/pages/runtimes/command.ts b/apps/web/src/pages/runtimes/command.ts index e3126ed747..6b5c12d5a5 100644 --- a/apps/web/src/pages/runtimes/command.ts +++ b/apps/web/src/pages/runtimes/command.ts @@ -1,8 +1,26 @@ +import packageMetadata from '../../../package.json' with { type: 'json' } + export interface RuntimeCommandCredential { key?: string team_id?: string } +// Memoh releases all workspace packages at one version. Deriving this pin +// keeps the generated command on the Runtime artifact built from the same +// source as the Server capability contract. +const runtimePackage = `@memohai/runtime@${packageMetadata.version}` + +// The adapter pins must match the versions Desktop bundles +// (apps/desktop/package.json) — the Server capability contract assumes one +// adapter version per release. command.test.ts cross-asserts them. +export const pinnedACPAdapterVersions = Object.freeze({ + '@agentclientprotocol/codex-acp': '1.2.0', + '@agentclientprotocol/claude-agent-acp': '0.66.0', +}) + +const codexACPPackage = `@agentclientprotocol/codex-acp@${pinnedACPAdapterVersions['@agentclientprotocol/codex-acp']}` +const claudeAgentACPPackage = `@agentclientprotocol/claude-agent-acp@${pinnedACPAdapterVersions['@agentclientprotocol/claude-agent-acp']}` + export function buildRuntimeConnectCommand( serverUrl: string, credential: RuntimeCommandCredential | null | undefined, @@ -10,23 +28,36 @@ export function buildRuntimeConnectCommand( const key = credential?.key?.trim() if (!key) return '' - const args = [ - 'npx', - '--yes', - '@memohai/runtime', + const runtimeArgs = [ + 'memoh-runtime', '--server', - serverUrl, + quoteShellWord(serverUrl), '--key', - key, + quoteShellWord(key), ] const teamId = credential?.team_id?.trim() if (teamId) { - args.push('--team-id', teamId) + runtimeArgs.push('--team-id', quoteShellWord(teamId)) } if (isInsecureLocalhost(serverUrl)) { - args.push('--insecure-localhost') + runtimeArgs.push('--insecure-localhost') } - return args.join(' ') + + return [ + 'npx', + '--yes', + `--package=${runtimePackage}`, + `--package=${codexACPPackage}`, + `--package=${claudeAgentACPPackage}`, + '--', + ...runtimeArgs, + ].join(' ') +} + +function quoteShellWord(value: string): string { + const quote = '\'' + const escapedQuote = `${quote}\\${quote}${quote}` + return `${quote}${value.replaceAll(quote, escapedQuote)}${quote}` } function isInsecureLocalhost(serverUrl: string): boolean { diff --git a/apps/web/src/store/chat-list.test.ts b/apps/web/src/store/chat-list.test.ts index dde39b1f61..85f54664ed 100644 --- a/apps/web/src/store/chat-list.test.ts +++ b/apps/web/src/store/chat-list.test.ts @@ -13,8 +13,10 @@ import type { } from '@/composables/api/useChat' import { REASONING_EFFORT_DISABLE } from '@/pages/bots/components/reasoning-effort' import { AUTH_SESSION_CLEARED_EVENT } from '@/lib/auth-session' +import type { BotWorkdir } from '@/composables/api/useWorkdirs' import { useChatSelectionStore } from './chat-selection' import { useChatStore } from './chat-list' +import { useWorkdirsStore } from './workdirs' const api = vi.hoisted(() => ({ createSession: vi.fn(), @@ -49,6 +51,10 @@ const sdk = vi.hoisted(() => ({ getBotsByBotIdSettings: vi.fn(), })) +const workdirsApi = vi.hoisted(() => ({ + fetchWorkdirs: vi.fn(), +})) + vi.hoisted(() => { for (const name of ['localStorage', 'sessionStorage']) { Object.defineProperty(globalThis, name, { @@ -64,6 +70,10 @@ vi.hoisted(() => { }) vi.mock('@/composables/api/useChat', () => api) +vi.mock('@/composables/api/useWorkdirs', async (importOriginal) => { + const original = await importOriginal() + return { ...original, fetchWorkdirs: workdirsApi.fetchWorkdirs } +}) vi.mock('@memohai/sdk', () => ({ getBotsByBotIdSettings: sdk.getBotsByBotIdSettings })) vi.mock('vue-sonner', () => ({ toast })) vi.mock('@felinic/ui', async (importOriginal) => { @@ -397,6 +407,7 @@ beforeEach(() => { api.fetchMessagesUI.mockResolvedValue([]) api.executeQuickAction.mockResolvedValue(null) api.fetchSafeSkillCatalog.mockResolvedValue([]) + workdirsApi.fetchWorkdirs.mockResolvedValue([]) sdk.getBotsByBotIdSettings.mockResolvedValue({ data: { chat_runtime: 'model' } }) api.streamBotSessionsActivityEvents.mockImplementation((_botId: string, signal: AbortSignal, onEvent: (event: BotSessionActivityEvent) => void) => new Promise((resolve) => { h.sessionsActivityHandler = onEvent @@ -1526,6 +1537,120 @@ describe('chat-list store', () => { }) }) + it.each([ + { + targetKind: 'native' as const, + workdirId: 'native-workdir', + name: 'Native project', + path: '/data/project', + workspaceTargetId: 'native', + }, + { + targetKind: 'remote' as const, + workdirId: 'remote-workdir', + name: 'Laptop project', + path: '/Users/example/project', + workspaceTargetId: 'runtime-1', + }, + ])('cold-starts a staged ACP agent after binding a $targetKind workdir', async ({ + targetKind, + workdirId, + name, + path, + workspaceTargetId, + }) => { + h.sendUpdates = [runtime.completed] + const selectedWorkdir: BotWorkdir = { + id: workdirId, + bot_id: 'bot-1', + name, + path, + target_kind: targetKind, + workspace_target_id: workspaceTargetId, + } + const workdirsLoad = deferred() + workdirsApi.fetchWorkdirs.mockReturnValueOnce(workdirsLoad.promise) + api.createSession.mockResolvedValueOnce({ + id: 'bound-acp-session', + bot_id: 'bot-1', + title: '', + type: 'acp_agent', + workdir_id: workdirId, + metadata: { + acp_agent_id: 'codex', + project_path: path, + acp_project_mode: 'project', + }, + }) + const store = useChatStore() + + await store.selectBot('bot-1') + const workdirs = useWorkdirsStore() + // This mirrors a Folder ID restored from localStorage before the list + // request has completed. + workdirs.setWorkingWorkdir('bot-1', workdirId) + store.stageACPSession({ agentId: 'codex' }) + + await expect(store.ensurePendingACPRuntime()).resolves.toBeUndefined() + expect(api.createACPRuntime).not.toHaveBeenCalled() + expect(store.pendingACPRuntimeId).toBe('') + + const sending = store.sendMessage('run in the selected folder') + await flushPromises() + + expect(workdirsApi.fetchWorkdirs).toHaveBeenCalledWith('bot-1') + expect(api.createSession).not.toHaveBeenCalled() + + workdirsLoad.resolve([selectedWorkdir]) + const result = await sending + + expect(result.ok).toBe(true) + const createInput = api.createSession.mock.calls.at(-1)?.[1] + expect(createInput).toMatchObject({ + type: 'chat', + sessionMode: 'chat', + runtimeType: 'acp_agent', + workdirId, + }) + expect(createInput?.acpRuntimeId).toBeUndefined() + expect(api.createACPRuntime).not.toHaveBeenCalled() + expect(api.ensureACPRuntime).not.toHaveBeenCalled() + expect(h.sentWSMessages[0]).toMatchObject({ + session_id: 'bound-acp-session', + text: 'run in the selected folder', + }) + }) + + it('discards a Primary warm runtime before a native-folder-bound first send', async () => { + h.sendUpdates = [runtime.completed] + const store = useChatStore() + + await store.selectBot('bot-1') + store.stageACPSession({ agentId: 'codex' }) + await store.ensurePendingACPRuntime() + expect(store.pendingACPRuntimeId).toBe('rt_warm') + + const workdirs = useWorkdirsStore() + workdirsApi.fetchWorkdirs.mockResolvedValueOnce([{ + id: 'native-workdir', + bot_id: 'bot-1', + name: 'Native project', + path: '/data/project', + target_kind: 'native', + workspace_target_id: 'native', + }]) + workdirs.setWorkingWorkdir('bot-1', 'native-workdir') + + const result = await store.sendMessage('use the selected folder') + + expect(result.ok).toBe(true) + expect(api.closeACPRuntime).toHaveBeenCalledWith('bot-1', 'rt_warm') + expect(api.createACPRuntime).toHaveBeenCalledTimes(1) + const createInput = api.createSession.mock.calls.at(-1)?.[1] + expect(createInput?.workdirId).toBe('native-workdir') + expect(createInput?.acpRuntimeId).toBeUndefined() + }) + it('refreshes a staged runtime instead of reusing a stale capability snapshot', async () => { const store = useChatStore() diff --git a/apps/web/src/store/chat-list.ts b/apps/web/src/store/chat-list.ts index 15fcbced7b..65620deb98 100644 --- a/apps/web/src/store/chat-list.ts +++ b/apps/web/src/store/chat-list.ts @@ -298,7 +298,8 @@ export const useChatStore = defineStore('chat', () => { removeSessionFromList, ensureBot, knownSession: knownSessionSummary, - draftWorkdirIdFor: (botId, opts) => workdirsStore.sessionWorkdirIdFor(botId, opts), + draftWorkdirBindingFor: botId => workdirsStore.sessionWorkdirBindingFor(botId), + resolveDraftWorkdirIdFor: botId => workdirsStore.resolveSessionWorkdirIdFor(botId), }) const { acpRuntimeStatuses, acpRuntimePending, acpRuntimeKey, clearACPRuntimeStatus, ensureACPRuntime, diff --git a/apps/web/src/store/chat/acp-controller.ts b/apps/web/src/store/chat/acp-controller.ts index 9bdb44fdb3..be7069266e 100644 --- a/apps/web/src/store/chat/acp-controller.ts +++ b/apps/web/src/store/chat/acp-controller.ts @@ -36,7 +36,8 @@ export function createACPController(deps: { removeSessionFromList: (sessionId: string) => void ensureBot: () => Promise knownSession: (sessionId: string) => SessionSummary | null | undefined - draftWorkdirIdFor: (botId: string, opts: { acp: boolean }) => string + draftWorkdirBindingFor: (botId: string) => { id: string, kind: string, path: string } + resolveDraftWorkdirIdFor: (botId: string) => Promise }) { const runtimeRegistry = createACPRuntimeRegistry({ currentBotId: deps.currentBotId, @@ -85,6 +86,19 @@ export function createACPController(deps: { draftViewCommandVersions.delete(deps.draftCreationKey(target)) }, resetWorkspaceTargetSelection: deps.resetWorkspaceTargetSelection, + // A native Folder shares the workspace every chat already uses, so the + // draft may prewarm there (on the Folder's path) and keep the model and + // reasoning pickers live. A remote Folder — or one whose kind is not yet + // confirmed — must not start anything before session creation commits the + // immutable binding on the selected computer. + shouldPrewarmDraftACP: (target) => { + const binding = deps.draftWorkdirBindingFor(target.botId) + return !binding.id || binding.kind === 'native' + }, + draftProjectPathFor: (target) => { + const binding = deps.draftWorkdirBindingFor(target.botId) + return binding.id && binding.kind === 'native' ? binding.path : '' + }, }) const defaults = createACPDefaults({ currentBotId: deps.currentBotId, @@ -106,7 +120,7 @@ export function createACPController(deps: { userScopeGeneration: deps.userScopeGeneration, normalizeTarget: deps.normalizeTarget, targetDraftForACP: orchestration.targetDraftForACP, - pendingACPStateFor: orchestration.pendingACPStateFor, + pendingACPStateFor: orchestration.pendingACPStateForSession, isFocusedTarget: deps.isFocusedTarget, upsertSession: deps.upsertSession, rememberSession: deps.rememberSession, @@ -130,7 +144,8 @@ export function createACPController(deps: { endDraftCreation: target => { deps.draftSessionCreations.delete(deps.draftCreationKey(target)) }, - draftWorkdirIdFor: deps.draftWorkdirIdFor, + draftWorkdirBindingFor: deps.draftWorkdirBindingFor, + resolveDraftWorkdirIdFor: deps.resolveDraftWorkdirIdFor, }) const draftViewRequested = ref<{ diff --git a/apps/web/src/store/chat/acp-orchestration.ts b/apps/web/src/store/chat/acp-orchestration.ts index a238379823..47050dff0d 100644 --- a/apps/web/src/store/chat/acp-orchestration.ts +++ b/apps/web/src/store/chat/acp-orchestration.ts @@ -18,6 +18,11 @@ export interface ACPOrchestrationDeps { invalidateDraftCommand: (target: ChatViewTarget) => void forgetDraftCommand: (target: ChatViewTarget) => void resetWorkspaceTargetSelection: (target: ChatViewTarget) => void + shouldPrewarmDraftACP: (target: ChatViewTarget) => boolean + // The project path the draft's Folder binding dictates ('' when the draft + // has no Folder or the Folder must not prewarm). A prewarmed runtime is + // only reusable when it was created on this exact path. + draftProjectPathFor?: (target: ChatViewTarget) => string } function draftStageKey(botId: string, viewId: string) { @@ -39,6 +44,7 @@ export function createACPOrchestration(deps: ACPOrchestrationDeps) { setPendingACPModel: setFocusedPendingACPModel, setPendingACPMode: setFocusedPendingACPMode, setPendingACPReasoning: setFocusedPendingACPReasoning, + discardPendingACPRuntime: discardFocusedPendingACPRuntime, detachPendingACPSession, restorePendingACPSession, releasePendingACPSession, @@ -212,10 +218,29 @@ export function createACPOrchestration(deps: ACPOrchestrationDeps) { if (options.clearPendingACP !== false) forgetDraftStage(draft) } + // A Folder binding dictates the runtime's project path. Re-staging with the + // Folder's path changes the staging identity, so a runtime warmed on + // another path is closed instead of being silently reused for the Folder. + function alignFocusedDraftProjectPath(draft: ChatViewTarget) { + const folderPath = deps.draftProjectPathFor?.(draft) ?? '' + if (!folderPath) return + const pending = pendingACPSessionInput.value + if (!pending || (pending.projectPath ?? '').trim() === folderPath) return + // Deliberately marks the selection explicit (stageACPSession's default): + // the Folder-derived path must not be overwritten by the default-ACP + // re-stage watcher, which would oscillate with this alignment forever. + stageFocusedACPSession({ ...pending, projectPath: folderPath }) + } + async function ensurePendingACPRuntime(target?: ChatViewTarget) { const draft = targetDraft(target) activateDraftStage(draft) try { + if (!deps.shouldPrewarmDraftACP(draft)) { + discardFocusedPendingACPRuntime() + return undefined + } + alignFocusedDraftProjectPath(draft) return await ensureFocusedPendingACPRuntime() } finally { syncLiveDraftStage() @@ -227,6 +252,11 @@ export function createACPOrchestration(deps: ACPOrchestrationDeps) { deps.invalidateDraftCommand(draft) activateDraftStage(draft) try { + if (!deps.shouldPrewarmDraftACP(draft)) { + discardFocusedPendingACPRuntime() + return undefined + } + alignFocusedDraftProjectPath(draft) return await setFocusedPendingACPModel(modelId) } finally { syncLiveDraftStage() @@ -238,6 +268,11 @@ export function createACPOrchestration(deps: ACPOrchestrationDeps) { deps.invalidateDraftCommand(draft) activateDraftStage(draft) try { + if (!deps.shouldPrewarmDraftACP(draft)) { + discardFocusedPendingACPRuntime() + return undefined + } + alignFocusedDraftProjectPath(draft) return await setFocusedPendingACPReasoning(effort) } finally { syncLiveDraftStage() @@ -249,6 +284,14 @@ export function createACPOrchestration(deps: ACPOrchestrationDeps) { deps.invalidateDraftCommand(draft) activateDraftStage(draft) try { + // Same suppression as ensure/model/reasoning: /permission on a pending + // draft must not resurrect or mutate a Primary runtime for a draft + // whose Folder pins another computer. + if (!deps.shouldPrewarmDraftACP(draft)) { + discardFocusedPendingACPRuntime() + return undefined + } + alignFocusedDraftProjectPath(draft) return await setFocusedPendingACPMode(modeId) } finally { syncLiveDraftStage() @@ -265,6 +308,17 @@ export function createACPOrchestration(deps: ACPOrchestrationDeps) { && state.metadata.acp_project_mode === metadata.acp_project_mode } + function pendingACPStateForSession(target: ChatViewTarget) { + const draft = targetDraft(target) + const state = pendingACPStateFor(draft) + if (!state || deps.shouldPrewarmDraftACP(draft)) return state + + activateDraftStage(draft) + discardFocusedPendingACPRuntime() + syncLiveDraftStage() + return pendingACPStateFor(draft) + } + function reset() { draftStages.value = {} liveDraft = null @@ -277,6 +331,7 @@ export function createACPOrchestration(deps: ACPOrchestrationDeps) { pendingACPRuntimeStatus, pendingACPRuntimeEnsuring, pendingACPStateFor, + pendingACPStateForSession, targetDraftForACP: targetDraft, stageACPSession, stageDefaultACPSession, diff --git a/apps/web/src/store/chat/acp-sessions.ts b/apps/web/src/store/chat/acp-sessions.ts index ca5e3a7e4c..d5f4864054 100644 --- a/apps/web/src/store/chat/acp-sessions.ts +++ b/apps/web/src/store/chat/acp-sessions.ts @@ -46,10 +46,11 @@ export interface ACPSessionDeps { isDraftCreationActive: (target: ChatViewTarget) => boolean beginDraftCreation: (target: ChatViewTarget) => void endDraftCreation: (target: ChatViewTarget) => void - // Resolves the bot's working workdir for a new session ('' = no binding). - // ACP sessions can only bind native-workspace workdirs, so the resolver is - // told which runtime the session will use. - draftWorkdirIdFor: (botId: string, opts: { acp: boolean }) => string + // The synchronous value conservatively retains a persisted selection so ACP + // prewarm can be suppressed while the list loads. Creation uses the async + // resolver to validate the immutable binding first. + draftWorkdirBindingFor: (botId: string) => { id: string, kind: string, path: string } + resolveDraftWorkdirIdFor: (botId: string) => Promise } function normalizedACPInput(input: ACPAgentSessionInput): ACPAgentSessionInput { @@ -66,13 +67,13 @@ export function createACPSessions(deps: ACPSessionDeps) { async function createACPSessionRecord( botId: string, input: ACPAgentSessionInput, + workdirId: string, ): Promise { const id = botId.trim() if (!id) throw new Error('Bot not ready') const metadata = acpSessionMetadata(input) const runtimeId = input.runtimeId?.trim() ?? '' const sessionMode = input.sessionMode === 'discuss' ? 'discuss' : 'chat' - const workdirId = deps.draftWorkdirIdFor(id, { acp: true }) return createSession(id, { botAgentId: input.botAgentId, title: input.title ?? '', @@ -91,6 +92,7 @@ export function createACPSessions(deps: ACPSessionDeps) { draft: ChatViewTarget, stagedInput: ACPAgentSessionInput, stagedRuntimeId: string, + droppedRuntimeId: string, generation: number, ) { if (generation !== deps.userScopeGeneration()) return @@ -103,7 +105,10 @@ export function createACPSessions(deps: ACPSessionDeps) { deps.removeSessionFromList(created.id) } - deps.forgetDraftStage(draft) + // A staged runtime that was not handed to the session must be closed, not + // merely forgotten — otherwise it idles server-side until the reaper. + if (droppedRuntimeId) deps.discardDraftStage(draft) + else deps.forgetDraftStage(draft) deps.rememberDraftStage(draft, { botId: draft.botId, input: normalizedACPInput({ ...stagedInput, runtimeId: undefined }), @@ -124,8 +129,22 @@ export function createACPSessions(deps: ACPSessionDeps) { const draft = deps.targetDraftForACP(target) const generation = deps.userScopeGeneration() const stagedBeforeCreate = deps.pendingACPStateFor(draft) - const runtimeId = input.runtimeId?.trim() ?? '' - const created = await createACPSessionRecord(draft.botId, input) + const workdirId = await deps.resolveDraftWorkdirIdFor(draft.botId) + // A native Folder shares Primary's workspace, so a runtime prewarmed on + // the Folder's path is reusable — the backend still refuses the bind if + // the paths disagree. Any other binding may target a different computer: + // never hand it a prewarmed Primary runtime. + const binding = workdirId ? deps.draftWorkdirBindingFor(draft.botId) : null + const reusableFolderRuntime = !!binding + && binding.id === workdirId + && binding.kind === 'native' + && !!binding.path + && (input.projectPath ?? '').trim() === binding.path + const runtimeId = workdirId && !reusableFolderRuntime ? '' : input.runtimeId?.trim() ?? '' + const created = await createACPSessionRecord(draft.botId, { + ...input, + runtimeId: runtimeId || undefined, + }, workdirId) if ( generation !== deps.userScopeGeneration() || (deps.currentBotId.value ?? '').trim() !== draft.botId @@ -135,6 +154,9 @@ export function createACPSessions(deps: ACPSessionDeps) { draft, stagedBeforeCreate?.input ?? input, runtimeId, + stagedBeforeCreate?.runtimeId && stagedBeforeCreate.runtimeId !== runtimeId + ? stagedBeforeCreate.runtimeId + : '', generation, ) const error = new Error('Chat scope changed during ACP Session creation') @@ -263,7 +285,7 @@ export function createACPSessions(deps: ACPSessionDeps) { } const generation = deps.userScopeGeneration() - const workdirId = deps.draftWorkdirIdFor(target.botId, { acp: false }) + const workdirId = await deps.resolveDraftWorkdirIdFor(target.botId) const created = await createSession(target.botId, { workdirId: workdirId || undefined, }) diff --git a/apps/web/src/store/chat/acp-staging.ts b/apps/web/src/store/chat/acp-staging.ts index c9d6685070..3d72c2492b 100644 --- a/apps/web/src/store/chat/acp-staging.ts +++ b/apps/web/src/store/chat/acp-staging.ts @@ -15,8 +15,8 @@ import type { ACPAgentSessionInput } from './types' // Pending-ACP session staging — the state machine behind the "draft composer // pointed at an ACP agent" flow: staging an agent before any session exists, -// warming a runtime for it, switching its model or reasoning effort, and handing the warm runtime -// over to the real session on first send. +// optionally warming a runtime for it, switching its model or reasoning effort, +// and handing a compatible warm runtime over to the real session on first send. // // This factory calls transports directly (createACPRuntime / closeACPRuntime / // setACPRuntimeModelByID) — an exception to the "factories don't touch @@ -386,6 +386,21 @@ export function createACPStaging(deps: ACPStagingDeps) { pendingACPBotId.value = '' } + // A bound workdir must cold-start after the session has been immutably bound + // to its target and path. Keep the staged Agent choice, but invalidate and + // close any runtime that was warmed against the previous Primary target. + function discardPendingACPRuntime() { + const runtimeId = pendingACPRuntimeId.value + const creating = pendingACPCreating.value || pendingACPCreateRequest !== null + if (!runtimeId && !creating) return + const botId = pendingACPBotId.value + nextPendingACPGeneration() + pendingACPConfigRequestVersion += 1 + clearPendingACPCreateTracking() + pendingACPRuntimeId.value = '' + closeStagedRuntime(botId, runtimeId) + } + // Detaches the staged ACP session without closing its warm runtime, so the // first send can bind the runtime to the real session. function detachPendingACPSession(): DetachedACPSession | null { @@ -450,6 +465,7 @@ export function createACPStaging(deps: ACPStagingDeps) { setPendingACPModel, setPendingACPMode, setPendingACPReasoning, + discardPendingACPRuntime, clearPendingACPSession, detachPendingACPSession, restorePendingACPSession, diff --git a/apps/web/src/store/workdirs.test.ts b/apps/web/src/store/workdirs.test.ts new file mode 100644 index 0000000000..d64f3e8952 --- /dev/null +++ b/apps/web/src/store/workdirs.test.ts @@ -0,0 +1,71 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' +import type { BotWorkdir } from '@/composables/api/useWorkdirs' +import { useWorkdirsStore } from './workdirs' + +function workdir(id: string, targetKind: 'native' | 'remote'): BotWorkdir { + return { + id, + bot_id: 'bot-1', + name: `${targetKind} folder`, + path: targetKind === 'remote' ? '/Users/example/project' : '/data/project', + target_kind: targetKind, + workspace_target_id: targetKind === 'remote' ? 'runtime-1' : 'native', + } +} + +describe('workdir session binding', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it.each([ + ['native', 'native-workdir'], + ['remote', 'remote-workdir'], + ] as const)('keeps a selected %s workdir for new sessions', (targetKind, workdirId) => { + const store = useWorkdirsStore() + store.workdirsByBot = { + 'bot-1': [workdir(workdirId, targetKind)], + } + + store.setWorkingWorkdir('bot-1', workdirId) + + expect(store.sessionWorkdirIdFor('bot-1')).toBe(workdirId) + }) + + it('does not bind a session after the working folder is cleared', () => { + const store = useWorkdirsStore() + store.workdirsByBot = { + 'bot-1': [workdir('remote-workdir', 'remote')], + } + store.setWorkingWorkdir('bot-1', 'remote-workdir') + + store.setWorkingWorkdir('bot-1', null) + + expect(store.sessionWorkdirIdFor('bot-1')).toBe('') + }) +}) + +describe('sessionWorkdirBindingFor', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('reports an unknown kind until the authoritative list is loaded', () => { + const store = useWorkdirsStore() + // Directly seeded lists (e.g. cache restores) are not authoritative. + store.workdirsByBot = { 'bot-1': [workdir('native-workdir', 'native')] } + store.setWorkingWorkdir('bot-1', 'native-workdir') + + expect(store.sessionWorkdirBindingFor('bot-1')).toEqual({ + id: 'native-workdir', + kind: '', + path: '', + }) + }) + + it('returns an empty binding without a working folder', () => { + const store = useWorkdirsStore() + expect(store.sessionWorkdirBindingFor('bot-1')).toEqual({ id: '', kind: '', path: '' }) + }) +}) diff --git a/apps/web/src/store/workdirs.ts b/apps/web/src/store/workdirs.ts index 911fe25b24..d6acdb5e6a 100644 --- a/apps/web/src/store/workdirs.ts +++ b/apps/web/src/store/workdirs.ts @@ -84,15 +84,45 @@ export const useWorkdirsStore = defineStore('workdirs', () => { workingWorkdirByBot.value = next } - // sessionWorkdirIdFor answers "which workdir should this new session bind - // to". ACP sessions can only run in native-workspace workdirs (the runtime - // cannot reach a remote computer yet), so a remote working workdir is - // skipped rather than producing a session the backend would reject. - function sessionWorkdirIdFor(botId: string | null | undefined, opts: { acp?: boolean } = {}): string { - const workdir = workingWorkdirFor(botId) - if (!workdir?.id) return '' - if (opts.acp && workdir.target_kind === 'remote') return '' - return workdir.id + // Before the list loads, retain the persisted ID conservatively. A raw + // Folder selection must suppress ACP prewarm instead of briefly looking like + // "no Folder" and starting a runtime against Primary. Once loaded, validate + // the ID so an archived/deleted Folder degrades to no binding. + function sessionWorkdirIdFor(botId: string | null | undefined): string { + const bid = (botId ?? '').trim() + if (!bid) return '' + const workdirId = workingWorkdirByBot.value[bid]?.trim() ?? '' + if (!workdirId || !loadedBots.has(bid)) return workdirId + const workdir = workdirById(bid, workdirId) + return workdir && !workdir.archived ? workdirId : '' + } + + // The draft Folder binding with its target kind and path. Kind stays empty + // until the authoritative list confirms it, so callers treating only an + // explicit "native" as safe-to-prewarm degrade conservatively while the + // list loads or when the Folder is unknown. + function sessionWorkdirBindingFor( + botId: string | null | undefined, + ): { id: string, kind: string, path: string } { + const bid = (botId ?? '').trim() + const id = sessionWorkdirIdFor(bid) + if (!id || !loadedBots.has(bid)) return { id, kind: '', path: '' } + const workdir = workdirById(bid, id) + return { + id, + kind: (workdir?.target_kind ?? '').trim(), + path: (workdir?.path ?? '').trim(), + } + } + + // Session creation waits for the authoritative list before committing its + // immutable Folder binding. A load failure rejects creation rather than + // silently creating an unbound Session on Primary. + async function resolveSessionWorkdirIdFor(botId: string | null | undefined): Promise { + const bid = (botId ?? '').trim() + if (!bid || !sessionWorkdirIdFor(bid)) return '' + await ensureWorkdirs(bid) + return sessionWorkdirIdFor(bid) } return { @@ -105,5 +135,7 @@ export const useWorkdirsStore = defineStore('workdirs', () => { workingWorkdirFor, setWorkingWorkdir, sessionWorkdirIdFor, + sessionWorkdirBindingFor, + resolveSessionWorkdirIdFor, } }) diff --git a/cmd/agent/module.go b/cmd/agent/module.go index b7fd0e4d2b..ae18c79aa2 100644 --- a/cmd/agent/module.go +++ b/cmd/agent/module.go @@ -88,7 +88,7 @@ func commonOptions() fx.Option { provideServerHandler(handlers.NewBotRemoteRuntimeHandler), provideServerHandler(handlers.NewWorkdirHandler), provideServerHandler(handlers.NewACPHandler), - provideServerHandler(handlers.NewACPRuntimeHandler), + provideServerHandler(handlers.NewACPRuntimeHandlerWithWorkspaceAccess), provideServerHandler(handlers.NewSwaggerHandler), provideServerHandler(handlers.NewProvidersHandler), provideServerHandler(handlers.NewProviderTemplatesHandler), diff --git a/cmd/internal/core/providers.go b/cmd/internal/core/providers.go index 2c9b234ea7..dcc2bd12cc 100644 --- a/cmd/internal/core/providers.go +++ b/cmd/internal/core/providers.go @@ -539,8 +539,8 @@ func provideACPRunner(log *slog.Logger, manager *workspace.Manager) *acpclient.R return acpclient.NewRunner(log, manager) } -func provideACPSessionPool(lc fx.Lifecycle, log *slog.Logger, runner *acpclient.Runner, botService *bots.Service, sessionService *sessionpkg.Service, queries dbstore.Queries, toolGateway *mcp.ToolGatewayService, toolContexts *mcp.ToolSessionContextStore, toolApproval *toolapproval.Service, userInput *userinput.Service, containerdHandler *handlers.ContainerdHandler, sessionRuntime *sessionruntime.Manager) *acpagent.SessionPool { - pool := acpagent.NewSessionPool(log, runner, botService, acpsessionadapter.NewSource(sessionService)) +func provideACPSessionPool(lc fx.Lifecycle, log *slog.Logger, runner *acpclient.Runner, botService *bots.Service, sessionService *sessionpkg.Service, workdirService *workdir.Service, queries dbstore.Queries, toolGateway *mcp.ToolGatewayService, toolContexts *mcp.ToolSessionContextStore, toolApproval *toolapproval.Service, userInput *userinput.Service, containerdHandler *handlers.ContainerdHandler, sessionRuntime *sessionruntime.Manager) *acpagent.SessionPool { + pool := acpagent.NewSessionPool(log, runner, botService, acpsessionadapter.NewSource(sessionService, workdirService)) pool.SetSessionRuntime(sessionRuntime) pool.SetSessionStateStore(acpsessionadapter.NewStateStore(queries)) pool.SetToolGateway(toolGateway) diff --git a/db/postgres/migrations/0001_init.up.sql b/db/postgres/migrations/0001_init.up.sql index d24be58fc5..c71617769f 100644 --- a/db/postgres/migrations/0001_init.up.sql +++ b/db/postgres/migrations/0001_init.up.sql @@ -2490,8 +2490,9 @@ CREATE TABLE IF NOT EXISTS public.bot_workdirs ( bot_id UUID NOT NULL, name TEXT NOT NULL, target_kind TEXT NOT NULL, - -- Non-null exactly when target_kind = 'remote'. Unbinding the remote - -- runtime cascades here, which is what removes its workdirs. + -- Non-null exactly when target_kind = 'remote'. A live or archived + -- workdir keeps the remote binding pinned so existing sessions cannot + -- silently lose their workspace target when a computer is disconnected. remote_binding_id UUID, path TEXT NOT NULL, created_by_user_id UUID, @@ -2513,7 +2514,7 @@ CREATE TABLE IF NOT EXISTS public.bot_workdirs ( REFERENCES public.bots(team_id, id) ON DELETE CASCADE, CONSTRAINT bot_workdirs_remote_binding_fkey FOREIGN KEY (team_id, remote_binding_id) - REFERENCES public.bot_remote_runtime_bindings(team_id, id) ON DELETE CASCADE + REFERENCES public.bot_remote_runtime_bindings(team_id, id) ON DELETE RESTRICT ); -- One live workdir per directory per target. Native rows carry a NULL diff --git a/db/postgres/migrations/0142_bot_workdirs_remote_binding_restrict.down.sql b/db/postgres/migrations/0142_bot_workdirs_remote_binding_restrict.down.sql new file mode 100644 index 0000000000..96831f18b4 --- /dev/null +++ b/db/postgres/migrations/0142_bot_workdirs_remote_binding_restrict.down.sql @@ -0,0 +1,13 @@ +-- 0142_bot_workdirs_remote_binding_restrict +-- Restore cascading deletion of workdirs when a remote workspace binding is removed. + +ALTER TABLE public.bot_workdirs + DROP CONSTRAINT IF EXISTS bot_workdirs_remote_binding_fkey; + +-- Match the up migration's RLS-safe installation. NOT VALID skips only the +-- historical scan; new writes and cascading deletes remain enforced. +ALTER TABLE public.bot_workdirs + ADD CONSTRAINT bot_workdirs_remote_binding_fkey + FOREIGN KEY (team_id, remote_binding_id) + REFERENCES public.bot_remote_runtime_bindings(team_id, id) ON DELETE CASCADE + NOT VALID; diff --git a/db/postgres/migrations/0142_bot_workdirs_remote_binding_restrict.up.sql b/db/postgres/migrations/0142_bot_workdirs_remote_binding_restrict.up.sql new file mode 100644 index 0000000000..126b7338b2 --- /dev/null +++ b/db/postgres/migrations/0142_bot_workdirs_remote_binding_restrict.up.sql @@ -0,0 +1,14 @@ +-- 0142_bot_workdirs_remote_binding_restrict +-- Keep remote workspace bindings while any live or archived workdir refers to them. + +ALTER TABLE public.bot_workdirs + DROP CONSTRAINT IF EXISTS bot_workdirs_remote_binding_fkey; + +-- NOT VALID avoids a validation scan through the referenced table's FORCE RLS +-- policy. Migration connections intentionally have no memoh.team_id and must +-- continue to fail closed; PostgreSQL still enforces this FK for new writes. +ALTER TABLE public.bot_workdirs + ADD CONSTRAINT bot_workdirs_remote_binding_fkey + FOREIGN KEY (team_id, remote_binding_id) + REFERENCES public.bot_remote_runtime_bindings(team_id, id) ON DELETE RESTRICT + NOT VALID; diff --git a/docs/design/remote-acp.md b/docs/design/remote-acp.md new file mode 100644 index 0000000000..e0507baa03 --- /dev/null +++ b/docs/design/remote-acp.md @@ -0,0 +1,110 @@ +# Remote ACP design + +## Scope + +Remote ACP is a process and stdio bridge. Memoh Server starts a fixed ACP +adapter on a connected computer and exchanges ACP JSON-RPC over that process's +stdin and stdout. + +Remote ACP does not manage Codex or Claude Code credentials. It does not copy, +stage, rewrite, synchronize, or delete either tool's local configuration. It +does not inject provider API keys or decide which login method the local tool +uses. The adapter and local CLI run with the connected computer's own local +setup. + +## Data path + +```text +Memoh Server (ACP client) + │ + │ bridgepb.ContainerService.Exec (bidirectional gRPC stream) + │ +existing outbound WebSocket / HTTP2 gRPC connection + │ +@memohai/runtime + │ +fixed private launcher alias + │ +bundled codex-acp or claude-agent-acp + │ +user-installed local Codex or Claude Code CLI +``` + +The computer opens one outbound WebSocket connection to Server. gRPC uses that +connection as an HTTP/2 transport. Every ACP adapter process gets its own +`Exec` stream on that connection: + +- the first client message contains command, working directory, and process + options; +- later client messages contain stdin bytes; +- server messages are explicitly tagged stdout, stderr, or exit; +- cancelling or closing one stream terminates only that stream's supervised + process; +- closing the computer connection terminates all processes owned by that + connection. + +HTTP/2 multiplexing keeps stream flow control and message ordering independent, +so concurrent ACP sessions do not share stdin or stdout state and cannot splice +their messages together. No ACP-specific network protocol or second connection +is required. + +## Trust model + +Remote ACP inherits the trust model of the Runtime's existing `exec` +capability: a connected computer already lets Memoh Server run arbitrary +shell commands in any directory as the connecting user. The fixed launcher +mechanism is name resolution and path hygiene, not confinement — it +guarantees that the aliases `codex-acp` and `claude-agent-acp` resolve to the +bundled adapters and that local CLI paths never leave the computer, but it +does not reduce what a compromised or malicious Server could execute. +Connecting a computer is therefore an act of full trust in the Server, and +the UI must present it that way. A future capability-scoped Exec mode +(allowlist of the two launcher aliases, cwd restricted to approved Folders) +would be required before Remote ACP could be granted without general shell +access; no such mode exists today. + +## Responsibilities + +Memoh Server remains the ACP client. It owns session routing, prompts, ACP +message parsing, approvals, runtime ownership, and the immutable Computer/Folder +binding. It never receives a local adapter path or local CLI path. + +`@memohai/runtime` owns the local gRPC service, safe process environment, +private launcher aliases, child-process supervision, and byte forwarding. It +advertises `acp_codex` or `acp_claude_code` only when the corresponding local +launcher can be constructed. + +Desktop bundles exact JavaScript adapter versions and locates the user's local +`codex` or `claude` entry in Electron Main. These paths are passed directly to +Runtime through a typed in-process descriptor; they are not exposed to the +renderer or Server. The native Codex and Claude Code CLIs are not bundled. + +The CLI connection command installs `@memohai/runtime`, `codex-acp`, and +`claude-agent-acp` into the same temporary npm execution environment. Their bin +entries therefore appear on the Runtime process PATH without a separate global +installation. + +## Workspace and lifecycle + +The Session's persisted Folder fixes the remote target and absolute workdir. +Server re-resolves that target for each turn and never silently falls back to a +different Primary computer. Removing a target that is still referenced by a +Folder is rejected. + +Remote adapters currently run on macOS and Linux. A disconnected Runtime or a +Server restart starts a new ACP process and native ACP session; Memoh chat +history remains durable, but native ACP session resume is a separate feature. + +The Runtime connection key authenticates the computer to Memoh. It is transport +authentication, not a Codex or Claude Code credential. + +## Core verification + +The implementation keeps focused coverage for: + +- capability detection and fixed launcher construction; +- adapter startup with the local CLI path kept inside Desktop/Runtime; +- bidirectional Exec stream stdin/stdout/stderr/exit behavior; +- independent concurrent streams and connection-owned process cleanup; +- no Server-managed Agent environment or state on the remote path; +- immutable workspace target/workdir routing. diff --git a/internal/agent/adapter/acpsession/source.go b/internal/agent/adapter/acpsession/source.go index ca13923a34..67957dc58f 100644 --- a/internal/agent/adapter/acpsession/source.go +++ b/internal/agent/adapter/acpsession/source.go @@ -5,21 +5,36 @@ package acpsession import ( "context" "errors" + "strings" acp "github.com/felinics/memoh/internal/agent/runtime/acp" "github.com/felinics/memoh/internal/chat/thread" + "github.com/felinics/memoh/internal/workdir" ) type Source struct { - threads threadGetter + threads threadGetter + workdirs sessionWorkdirResolver } type threadGetter interface { Get(ctx context.Context, sessionID string) (thread.Thread, error) } -func NewSource(threads *thread.Service) *Source { - return &Source{threads: threads} +type sessionWorkdirResolver interface { + ResolveForSession(ctx context.Context, botID, workdirID string) (workdir.Resolved, error) +} + +func NewSource(threads *thread.Service, workdirs *workdir.Service) *Source { + return &Source{threads: threads, workdirs: workdirs} +} + +func newSource(threads threadGetter, workdirs ...sessionWorkdirResolver) *Source { + var resolver sessionWorkdirResolver + if len(workdirs) > 0 { + resolver = workdirs[0] + } + return &Source{threads: threads, workdirs: resolver} } func (s *Source) Get(ctx context.Context, sessionID string) (acp.SessionDescriptor, error) { @@ -30,11 +45,24 @@ func (s *Source) Get(ctx context.Context, sessionID string) (acp.SessionDescript if err != nil { return acp.SessionDescriptor{}, err } - return acp.SessionDescriptor{ + descriptor := acp.SessionDescriptor{ BotID: item.BotID, SessionType: item.Type, Metadata: item.Metadata, RuntimeMetadata: item.RuntimeMetadata, IsACP: thread.IsACPRuntime(item), - }, nil + } + if workdirID := strings.TrimSpace(item.WorkdirID); workdirID != "" { + if s.workdirs == nil { + return acp.SessionDescriptor{}, errors.New("workdir resolver unavailable for bound ACP session") + } + resolved, err := s.workdirs.ResolveForSession(ctx, item.BotID, workdirID) + if err != nil { + return acp.SessionDescriptor{}, err + } + descriptor.WorkspaceTargetID = strings.TrimSpace(resolved.TargetID) + descriptor.WorkspaceTargetKind = strings.TrimSpace(resolved.Kind) + descriptor.WorkdirPath = strings.TrimSpace(resolved.WorkDir) + } + return descriptor, nil } diff --git a/internal/agent/adapter/acpsession/source_test.go b/internal/agent/adapter/acpsession/source_test.go index 3e23f05a24..27555b2d76 100644 --- a/internal/agent/adapter/acpsession/source_test.go +++ b/internal/agent/adapter/acpsession/source_test.go @@ -6,6 +6,7 @@ import ( "github.com/felinics/memoh/internal/agent/sessionmode" "github.com/felinics/memoh/internal/chat/thread" + "github.com/felinics/memoh/internal/workdir" ) type fakeThreadGetter struct { @@ -16,6 +17,14 @@ func (f fakeThreadGetter) Get(context.Context, string) (thread.Thread, error) { return f.item, nil } +type fakeWorkdirResolver struct { + resolved workdir.Resolved +} + +func (f fakeWorkdirResolver) ResolveForSession(context.Context, string, string) (workdir.Resolved, error) { + return f.resolved, nil +} + func TestSourceProjectsThreadDescriptor(t *testing.T) { t.Parallel() @@ -37,3 +46,26 @@ func TestSourceProjectsThreadDescriptor(t *testing.T) { t.Fatalf("metadata not preserved: %#v", got) } } + +func TestSourceProjectsBoundWorkdirTarget(t *testing.T) { + t.Parallel() + + item := thread.Thread{ + BotID: "bot-1", + Type: sessionmode.ACPAgent, + RuntimeType: thread.RuntimeACPAgent, + WorkdirID: "workdir-1", + } + got, err := newSource(fakeThreadGetter{item: item}, fakeWorkdirResolver{resolved: workdir.Resolved{ + WorkdirID: "workdir-1", + TargetID: "computer-1", + Kind: "remote", + WorkDir: "/Users/alice/project", + }}).Get(context.Background(), "session-1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.WorkspaceTargetID != "computer-1" || got.WorkspaceTargetKind != "remote" || got.WorkdirPath != "/Users/alice/project" { + t.Fatalf("workdir descriptor = %#v", got) + } +} diff --git a/internal/agent/application/service.go b/internal/agent/application/service.go index 265a6a95c1..700303feea 100644 --- a/internal/agent/application/service.go +++ b/internal/agent/application/service.go @@ -682,24 +682,18 @@ func (s *Service) Chat(ctx context.Context, req ChatRequest) (ChatResponse, erro if err := s.rejectRequestedSkillsIfUnsupportedContext(ctx, req); err != nil { return ChatResponse{}, err } - if isACP, err := s.isACPAgentSession(ctx, req); err != nil { + var err error + ctx, req, err = s.prepareWorkspaceRequest(ctx, req) + if err != nil { + return ChatResponse{}, err + } + if _, err := s.isACPAgentSession(ctx, req); err != nil { return ChatResponse{}, err - } else if isACP { - if err := rejectACPWorkspaceTarget(req); err != nil { - return ChatResponse{}, err - } - } else { - var err error - ctx, req, err = s.prepareWorkspaceRequest(ctx, req) - if err != nil { - return ChatResponse{}, err - } } if req.RawQuery == "" { req.RawQuery = strings.TrimSpace(req.Query) } - var err error if !req.UserMessagePersisted { req, err = s.applyUserMessageHook(ctx, req) if err != nil { diff --git a/internal/agent/application/service_acp.go b/internal/agent/application/service_acp.go index 8d8c92d3ab..fdf5c98395 100644 --- a/internal/agent/application/service_acp.go +++ b/internal/agent/application/service_acp.go @@ -301,6 +301,16 @@ func (s *Service) streamACPAgentWS(ctx context.Context, req ChatRequest, eventCh // in arrival order and the frontend sorts by ID, so pre-creating the text // block would pin the answer text above any reasoning that streams first. // The first text_delta lazily creates the text block instead. + workspaceTargetID := strings.TrimSpace(req.WorkspaceTargetID) + workspaceTargetKind := "" + workspaceTargetName := "" + if req.WorkspaceTarget != nil { + if resolvedID := strings.TrimSpace(req.WorkspaceTarget.TargetID); resolvedID != "" { + workspaceTargetID = resolvedID + } + workspaceTargetKind = strings.TrimSpace(req.WorkspaceTarget.Kind) + workspaceTargetName = strings.TrimSpace(req.WorkspaceTarget.Name) + } result, err := s.acpPool.Prompt(idleCtx, acpagent.PromptInput{ BotID: req.BotID, @@ -310,6 +320,9 @@ func (s *Service) streamACPAgentWS(ctx context.Context, req ChatRequest, eventCh RouteID: req.RouteID, AgentID: agentID, ProjectPath: projectPath, + WorkspaceTargetID: workspaceTargetID, + WorkspaceTargetKind: workspaceTargetKind, + WorkspaceTargetName: workspaceTargetName, ModelID: strings.TrimSpace(req.Model), ReasoningEffort: strings.TrimSpace(req.ReasoningEffort), Prompt: req.Query, diff --git a/internal/agent/application/service_stream.go b/internal/agent/application/service_stream.go index f655ebde5a..43768805c9 100644 --- a/internal/agent/application/service_stream.go +++ b/internal/agent/application/service_stream.go @@ -174,7 +174,13 @@ func (s *Service) StreamChat(ctx context.Context, req ChatRequest) (<-chan Strea errCh <- err return } - if ok, err := s.isACPAgentSession(ctx, streamReq); err != nil { + streamCtx, preparedReq, prepareErr := s.prepareWorkspaceRequest(ctx, streamReq) + if prepareErr != nil { + errCh <- prepareErr + return + } + streamReq = preparedReq + if ok, err := s.isACPAgentSession(streamCtx, streamReq); err != nil { s.logger.Error("StreamChat: ACP session check failed", slog.String("bot_id", streamReq.BotID), slog.String("session_id", streamReq.ThreadID), @@ -183,19 +189,9 @@ func (s *Service) StreamChat(ctx context.Context, req ChatRequest) (<-chan Strea errCh <- err return } else if ok { - if err := rejectACPWorkspaceTarget(streamReq); err != nil { - errCh <- err - return - } - s.streamACPAgentChunks(ctx, streamReq, chunkCh, errCh) - return - } - streamCtx, preparedReq, prepareErr := s.prepareWorkspaceRequest(ctx, streamReq) - if prepareErr != nil { - errCh <- prepareErr + s.streamACPAgentChunks(streamCtx, streamReq, chunkCh, errCh) return } - streamReq = preparedReq if streamReq.RawQuery == "" { streamReq.RawQuery = strings.TrimSpace(streamReq.Query) @@ -479,6 +475,11 @@ func (s *Service) streamChatWSResultWithHooks( if err := s.rejectRequestedSkillsIfUnsupportedContext(ctx, req); err != nil { return nil, err } + var prepareErr error + ctx, req, prepareErr = s.prepareWorkspaceRequest(ctx, req) + if prepareErr != nil { + return nil, prepareErr + } if ok, err := s.isACPAgentSession(ctx, req); err != nil { s.logger.Error("StreamChatWS: ACP session check failed", slog.String("bot_id", req.BotID), @@ -487,9 +488,6 @@ func (s *Service) streamChatWSResultWithHooks( ) return nil, err } else if ok { - if err := rejectACPWorkspaceTarget(req); err != nil { - return nil, err - } // Hooks currently mean retry/edit turn replacement. ACP runtimes have // no rewind primitive, so running the turn would leave their in-process // context inconsistent with the visible history. @@ -498,11 +496,6 @@ func (s *Service) streamChatWSResultWithHooks( } return nil, s.streamACPAgentWS(ctx, req, eventCh, abortCh) } - var prepareErr error - ctx, req, prepareErr = s.prepareWorkspaceRequest(ctx, req) - if prepareErr != nil { - return nil, prepareErr - } if preflight != nil { if err := preflight(ctx); err != nil { diff --git a/internal/agent/application/service_workspace_history_test.go b/internal/agent/application/service_workspace_history_test.go index 06a4c2ed7f..a23f80d586 100644 --- a/internal/agent/application/service_workspace_history_test.go +++ b/internal/agent/application/service_workspace_history_test.go @@ -2,6 +2,7 @@ package application import ( "context" + "errors" "strings" "testing" @@ -14,13 +15,37 @@ import ( type workspaceRequestTargetService struct{} func (workspaceRequestTargetService) ResolveWorkspaceTarget(_ context.Context, _ string, targetID string) (workspace.ResolvedWorkspaceTarget, error) { + targetID = strings.TrimSpace(targetID) + if targetID == "" || targetID == workspace.WorkspaceTargetNative { + return workspace.ResolvedWorkspaceTarget{ + TargetID: workspace.WorkspaceTargetNative, + Kind: workspace.WorkspaceTargetNative, + Name: "Server Workspace", + }, nil + } return workspace.ResolvedWorkspaceTarget{ - TargetID: strings.TrimSpace(targetID), + TargetID: targetID, Kind: workspace.WorkspaceTargetRemote, Name: "Computer B", }, nil } +// workspaceRequestRemotePrimaryService models a bot whose Primary workspace is +// a connected computer: ambient resolution (no requested target) lands remote. +type workspaceRequestRemotePrimaryService struct{} + +func (workspaceRequestRemotePrimaryService) ResolveWorkspaceTarget(_ context.Context, _ string, targetID string) (workspace.ResolvedWorkspaceTarget, error) { + id := strings.TrimSpace(targetID) + if id == "" { + id = "computer-primary" + } + return workspace.ResolvedWorkspaceTarget{ + TargetID: id, + Kind: workspace.WorkspaceTargetRemote, + Name: "Primary Computer", + }, nil +} + type workspaceRequestPermission bool func (allowed workspaceRequestPermission) HasBotPermission(_ context.Context, _, _, permission string) (bool, error) { @@ -31,7 +56,7 @@ func TestPrepareWorkspaceRequestRequiresWorkspaceRead(t *testing.T) { base := ChatRequest{BotID: "bot-1", WorkspaceTargetID: "computer-b"} resolver := &Service{workspaceTargets: workspaceRequestTargetService{}} - if _, _, err := resolver.prepareWorkspaceRequest(t.Context(), base); err == nil || !strings.Contains(err.Error(), "user id") { + if _, _, err := resolver.prepareWorkspaceRequest(t.Context(), base); !errors.Is(err, ErrWorkspaceTargetNeedsActor) { t.Fatalf("missing user error = %v", err) } @@ -306,3 +331,43 @@ func assertGovernedWorkspaceRuns(t *testing.T, retained []historyfrag.HistoryRec } _ = current } + +func TestPrepareWorkspaceRequestAmbientRemotePrimaryRequiresWorkspaceRead(t *testing.T) { + // No workdir binding and no explicit target: the turn still lands on the + // bot's remote Primary computer, so it crosses the same permission + // boundary as an explicit selection. + req := ChatRequest{BotID: "bot-1", ThreadID: "s1", UserID: "user-1"} + + denied := &Service{ + workspaceTargets: workspaceRequestRemotePrimaryService{}, + botPermissions: workspaceRequestPermission(false), + } + if _, _, err := denied.prepareWorkspaceRequest(t.Context(), req); err == nil || !strings.Contains(err.Error(), "workspace_read") { + t.Fatalf("denied error = %v, want workspace_read denial", err) + } + + allowed := &Service{ + workspaceTargets: workspaceRequestRemotePrimaryService{}, + botPermissions: workspaceRequestPermission(true), + } + _, got, err := allowed.prepareWorkspaceRequest(t.Context(), req) + if err != nil { + t.Fatalf("allowed error = %v", err) + } + if got.WorkspaceTargetID != "computer-primary" { + t.Fatalf("WorkspaceTargetID = %q, want computer-primary", got.WorkspaceTargetID) + } +} + +func TestPrepareWorkspaceRequestAmbientNativePrimaryStaysUngated(t *testing.T) { + // Plain chat on a native-Primary bot must not demand workspace_read. + // botPermissions is deliberately nil: any check would error loudly. + service := &Service{workspaceTargets: workspaceRequestTargetService{}} + _, got, err := service.prepareWorkspaceRequest(t.Context(), ChatRequest{BotID: "bot-1", ThreadID: "s1"}) + if err != nil { + t.Fatalf("prepare error = %v", err) + } + if got.WorkspaceTargetID != workspace.WorkspaceTargetNative { + t.Fatalf("WorkspaceTargetID = %q, want native", got.WorkspaceTargetID) + } +} diff --git a/internal/agent/application/service_workspace_target.go b/internal/agent/application/service_workspace_target.go index e8d5d316af..1327390e91 100644 --- a/internal/agent/application/service_workspace_target.go +++ b/internal/agent/application/service_workspace_target.go @@ -12,8 +12,6 @@ import ( "github.com/felinics/memoh/internal/workspace" ) -var ErrWorkspaceTargetACPUnsupported = errors.New("workspace_target_id is not supported for ACP sessions") - // ValidateWorkspaceTarget validates a user-selected Computer without changing // the Bot's Primary target. It is used by handlers before creating a session. func (s *Service) ValidateWorkspaceTarget(ctx context.Context, botID, targetID string) error { @@ -30,11 +28,11 @@ func (s *Service) ValidateWorkspaceTarget(ctx context.Context, botID, targetID s func (s *Service) prepareWorkspaceRequest(ctx context.Context, req ChatRequest) (context.Context, ChatRequest, error) { requestedTargetID := strings.TrimSpace(req.WorkspaceTargetID) + explicitSelection := requestedTargetID != "" bound, hasWorkdir, err := s.resolveSessionWorkdirBinding(ctx, req.BotID, req.ThreadID) if err != nil { return ctx, req, err } - enforceSelection := requestedTargetID != "" if hasWorkdir { // The workdir pins the target for the session's whole life. An // explicit different target is rejected loudly — silently ignoring @@ -44,28 +42,6 @@ func (s *Service) prepareWorkspaceRequest(ctx context.Context, req ChatRequest) } requestedTargetID = bound.TargetID req.WorkspaceTargetID = bound.TargetID - // Reaching a remote computer is a permission boundary whether the - // target comes from the request or from the workdir binding. A - // native workdir adds no capability beyond the default workspace, - // so it does not demand workspace_read just to chat. - if bound.Kind == workdir.TargetKindRemote { - enforceSelection = true - } - } - if enforceSelection { - if strings.TrimSpace(req.UserID) == "" { - return ctx, req, errors.New("user id is required to select a computer") - } - if s == nil || s.botPermissions == nil { - return ctx, req, errors.New("workspace target permission checker not configured") - } - allowed, err := s.botPermissions.HasBotPermission(ctx, req.BotID, req.UserID, bots.PermissionWorkspaceRead) - if err != nil { - return ctx, req, fmt.Errorf("check workspace target permission: %w", err) - } - if !allowed { - return ctx, req, errors.New("workspace_read permission is required to select a computer") - } } if s == nil || s.workspaceTargets == nil { if requestedTargetID != "" { @@ -77,6 +53,17 @@ func (s *Service) prepareWorkspaceRequest(ctx context.Context, req ChatRequest) if err != nil { return ctx, req, err } + // Reaching a remote computer is a permission boundary no matter how the + // target was chosen: explicit selection, a workdir binding, or ambient + // resolution of a remote Primary. Gating on the resolved kind keeps every + // path through one check. A native workdir adds no capability beyond the + // default workspace, so plain chat stays ungated there unless the user + // explicitly selected a computer. + if explicitSelection || strings.EqualFold(strings.TrimSpace(resolved.Kind), workdir.TargetKindRemote) { + if err := s.requireWorkspaceRead(ctx, req.BotID, req.UserID); err != nil { + return ctx, req, err + } + } req.WorkspaceTargetID = strings.TrimSpace(resolved.TargetID) req.WorkspaceTarget = &WorkspaceTarget{ TargetID: strings.TrimSpace(resolved.TargetID), @@ -87,6 +74,34 @@ func (s *Service) prepareWorkspaceRequest(ctx context.Context, req ChatRequest) return ctx, req, nil } +// ErrWorkspaceTargetNeedsActor marks a turn that would reach a connected +// computer without an acting user to authorize it. System-driven turns that +// should reach a computer (schedules, heartbeats) act as the bot owner and +// therefore never hit this; turns with no user identity at all — such as +// bot-to-bot discuss — are refused by design rather than silently landing on +// someone's machine. +var ErrWorkspaceTargetNeedsActor = errors.New("reaching a connected computer requires an acting user with workspace_read") + +// requireWorkspaceRead is the single turn-level permission check for reaching +// a computer. Handlers keep their own HTTP-layer equivalent, but every chat +// turn funnels through here regardless of transport. +func (s *Service) requireWorkspaceRead(ctx context.Context, botID, userID string) error { + if strings.TrimSpace(userID) == "" { + return ErrWorkspaceTargetNeedsActor + } + if s == nil || s.botPermissions == nil { + return errors.New("workspace target permission checker not configured") + } + allowed, err := s.botPermissions.HasBotPermission(ctx, botID, userID, bots.PermissionWorkspaceRead) + if err != nil { + return fmt.Errorf("check workspace target permission: %w", err) + } + if !allowed { + return errors.New("workspace_read permission is required to select a computer") + } + return nil +} + func (s *Service) resolveWorkspaceTargetSnapshot(ctx context.Context, botID, targetID string) (*WorkspaceTarget, error) { if s == nil || s.workspaceTargets == nil { if strings.TrimSpace(targetID) == "" { @@ -115,10 +130,3 @@ func workspaceTargetFromRunConfig(cfg native.RunConfig) *WorkspaceTarget { Name: strings.TrimSpace(cfg.Identity.WorkspaceTargetName), } } - -func rejectACPWorkspaceTarget(req ChatRequest) error { - if strings.TrimSpace(req.WorkspaceTargetID) == "" { - return nil - } - return ErrWorkspaceTargetACPUnsupported -} diff --git a/internal/agent/decision/approval/service.go b/internal/agent/decision/approval/service.go index 0cdc1c4c6a..7e08c1e19d 100644 --- a/internal/agent/decision/approval/service.go +++ b/internal/agent/decision/approval/service.go @@ -100,7 +100,11 @@ func (s *Service) policyEvaluation(ctx context.Context, input CreatePendingInput if !ok { return Evaluation{}, errors.New("workspace tool input must be an object") } - target, err := s.targets.ResolveWorkspaceTargetPolicy(ctx, input.BotID, readString(args, "target_id")) + targetID := readString(args, "target_id") + if targetID == "" { + targetID = strings.TrimSpace(input.WorkspaceTargetID) + } + target, err := s.targets.ResolveWorkspaceTargetPolicy(ctx, input.BotID, targetID) if err != nil { return Evaluation{}, err } diff --git a/internal/agent/decision/approval/service_target_test.go b/internal/agent/decision/approval/service_target_test.go index 390c79e066..961d8a6b92 100644 --- a/internal/agent/decision/approval/service_target_test.go +++ b/internal/agent/decision/approval/service_target_test.go @@ -35,6 +35,7 @@ func TestEvaluatePolicyUsesTargetConfigAndPinsCanonicalTarget(t *testing.T) { BotID: "bot-1", ToolName: "write", ToolInput: input, + WorkspaceTargetID: "session-target", WorkspaceTargeted: true, }) if err != nil { @@ -57,6 +58,44 @@ func TestEvaluatePolicyUsesTargetConfigAndPinsCanonicalTarget(t *testing.T) { } } +func TestEvaluatePolicyUsesSessionTargetWhenExplicitTargetIsOmitted(t *testing.T) { + resolver := &targetPolicyResolverStub{policy: WorkspaceTargetPolicy{ + TargetID: "canonical-session-target", + Kind: "remote", + Name: "Office Mac", + Config: PolicyConfig{ + Enabled: true, + Exec: ExecPolicy{Mode: PolicyModeAsk}, + }, + }} + service := NewService(nil, nil, nil) + service.SetWorkspaceTargetPolicyResolver(resolver) + input := map[string]any{"command": "make test", "target_id": " "} + + evaluation, err := service.EvaluatePolicy(context.Background(), CreatePendingInput{ + BotID: "bot-1", + ToolName: "exec", + ToolInput: input, + WorkspaceTargetID: " session-target ", + WorkspaceTargeted: true, + }) + if err != nil { + t.Fatalf("EvaluatePolicy() error = %v", err) + } + if evaluation.Decision != DecisionNeedsApproval { + t.Fatalf("decision = %q, want %q", evaluation.Decision, DecisionNeedsApproval) + } + if resolver.requested != "session-target" { + t.Fatalf("resolver target = %q, want session target", resolver.requested) + } + if got := input["target_id"]; got != "canonical-session-target" { + t.Fatalf("canonical target_id = %#v", got) + } + if evaluation.ExecutionLocation == nil || evaluation.ExecutionLocation.TargetID != "canonical-session-target" { + t.Fatalf("execution location = %#v", evaluation.ExecutionLocation) + } +} + func TestEvaluatePolicyPinsPrimaryWhenTargetIsOmitted(t *testing.T) { resolver := &targetPolicyResolverStub{policy: WorkspaceTargetPolicy{ TargetID: "primary-at-approval-time", diff --git a/internal/agent/decision/feedback/feedback.go b/internal/agent/decision/feedback/feedback.go index fd37ded652..ae7621a1cf 100644 --- a/internal/agent/decision/feedback/feedback.go +++ b/internal/agent/decision/feedback/feedback.go @@ -17,6 +17,8 @@ const ( CodeAgentAuthInvalid = "acp_agent_auth_invalid" CodeNoWorkspaceExec = "no_workspace_exec" CodeRuntimeOwnerMissing = "acp_runtime_owner_missing" + CodeRemoteOSUnsupported = "acp_remote_os_unsupported" + CodeRemoteAdapterMissing = "acp_remote_adapter_missing" CodeDiscussUnsupported = "acp_discuss_unsupported" CodeGroupChatUnsupported = "group_chat_acp_unsupported" CodeProjectModeInvalid = "acp_project_mode_invalid" diff --git a/internal/agent/runtime/acp/client/client.go b/internal/agent/runtime/acp/client/client.go index cd10dc76d8..0a51b484d0 100644 --- a/internal/agent/runtime/acp/client/client.go +++ b/internal/agent/runtime/acp/client/client.go @@ -916,6 +916,7 @@ func (c *clientCallbacks) requireToolApproval(ctx context.Context, toolCallID, t } return c.cancelApprovalOnAbort(ctx, req, reason) } + _, workspaceTargeted := toolapproval.OperationForTool(toolName) ctx = runtimefence.WithContext(ctx, session.RuntimeFence) return toolapproval.RunFlow(ctx, c.approval, toolapproval.FlowRequest{ Input: toolapproval.CreatePendingInput{ @@ -923,6 +924,7 @@ func (c *clientCallbacks) requireToolApproval(ctx context.Context, toolCallID, t SessionID: session.SessionID, RouteID: session.RouteID, ChannelIdentityID: session.ChannelIdentityID, + WorkspaceTargetID: session.WorkspaceTargetID, RequestedByChannelIdentityID: session.ChannelIdentityID, ToolCallID: toolCallID, ToolName: toolName, @@ -932,6 +934,7 @@ func (c *clientCallbacks) requireToolApproval(ctx context.Context, toolCallID, t SourcePlatform: session.CurrentPlatform, ReplyTarget: session.ReplyTarget, ConversationType: session.ConversationType, + WorkspaceTargeted: workspaceTargeted, }, Interactive: strings.TrimSpace(session.RunID) != "", RegisterWaiter: c.approval.RegisterWaiter, diff --git a/internal/agent/runtime/acp/client/process.go b/internal/agent/runtime/acp/client/process.go index d0443df0f7..15f05d179d 100644 --- a/internal/agent/runtime/acp/client/process.go +++ b/internal/agent/runtime/acp/client/process.go @@ -47,6 +47,7 @@ type WorkspaceBackend string const ( WorkspaceBackendContainer WorkspaceBackend = "container" + WorkspaceBackendRemote WorkspaceBackend = "remote" ) type SetupMode string @@ -80,6 +81,7 @@ type bridgeProcess struct { lifecycleCtx context.Context env []string toolEnv []string + cleanEnv bool unsetEnv []string lease *runtimeLease logger *slog.Logger @@ -92,6 +94,16 @@ type bridgeProcess struct { finalizeErr error } +// Done closes when the adapter process or its transport exits. +func (p *bridgeProcess) Done() <-chan struct{} { + if p == nil { + closed := make(chan struct{}) + close(closed) + return closed + } + return p.done +} + func startBridgeProcess(ctx context.Context, client *bridge.Client, command string, args []string, workDir string, timeout time.Duration, opts processOptions) (*bridgeProcess, error) { if client == nil { return nil, errors.New("workspace bridge client is required") @@ -112,11 +124,26 @@ func startBridgeProcess(ctx context.Context, client *bridge.Client, command stri timeoutSeconds = int32(DefaultRunTimeout.Seconds()) } - lease, err := prepareRuntimeLease(ctx, client, opts) - if err != nil { - return nil, err + var lease *runtimeLease + var env, toolEnv, unsetEnv []string + runtimeOpts := opts + if opts.Backend == WorkspaceBackendRemote { + // A connected computer owns provider authentication and agent state. + runtimeOpts.Env = nil + runtimeOpts.CleanEnv = false + runtimeOpts.UnsetEnv = nil + } else { + var err error + lease, err = prepareRuntimeLease(ctx, client, opts) + if err != nil { + return nil, err + } + env = lease.agentEnv + toolEnv = lease.toolEnv + unsetEnv = lease.unsetEnv + runtimeOpts.UnsetEnv = unsetEnv } - if opts.Resume != nil { + if opts.Resume != nil && lease != nil { // Materialize the database checkpoint before the adapter (and any // child app-server it launches) can scan its process-local home. if err := lease.restoreSessionState(ctx, opts.Resume); err != nil { @@ -126,15 +153,18 @@ func startBridgeProcess(ctx context.Context, client *bridge.Client, command stri return nil, fmt.Errorf("restore ACP session state: %w", err) } } - env := lease.agentEnv - runtimeOpts := opts - runtimeOpts.UnsetEnv = lease.unsetEnv - - resolvedCommand, err := resolveCommand(ctx, client, command, workDir, env, runtimeOpts) - if err != nil { + cleanupLease := func() { + if lease == nil { + return + } cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() _ = lease.finalize(cleanupCtx, false) + } + + resolvedCommand, err := resolveCommand(ctx, client, command, workDir, env, runtimeOpts) + if err != nil { + cleanupLease() return nil, err } @@ -145,9 +175,7 @@ func startBridgeProcess(ctx context.Context, client *bridge.Client, command stri UnsetEnv: runtimeOpts.UnsetEnv, }) if err != nil { - cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) - defer cancel() - _ = lease.finalize(cleanupCtx, false) + cleanupLease() return nil, err } @@ -161,8 +189,9 @@ func startBridgeProcess(ctx context.Context, client *bridge.Client, command stri done: make(chan struct{}), lifecycleCtx: ctx, env: append([]string(nil), env...), - toolEnv: append([]string(nil), lease.toolEnv...), - unsetEnv: append([]string(nil), lease.unsetEnv...), + toolEnv: append([]string(nil), toolEnv...), + cleanEnv: runtimeOpts.CleanEnv, + unsetEnv: append([]string(nil), unsetEnv...), lease: lease, logger: opts.Logger, finalizeDone: make(chan struct{}), @@ -237,7 +266,7 @@ func resolveCommand(ctx context.Context, client *bridge.Client, command, workDir if resolved != "" || err != nil { return resolved, err } - return "", commandNotAvailableError(command, lastResult, requiresPinnedToolkitAdapter(opts.AgentID)) + return "", commandNotAvailableError(command, lastResult, opts.Backend, requiresPinnedToolkitAdapter(opts.AgentID)) } deadline := time.Now().Add(commandResolveWindow) @@ -261,7 +290,7 @@ func resolveCommand(ctx context.Context, client *bridge.Client, command, workDir return resolved, nil } } - return "", commandNotAvailableError(command, lastResult, requiresPinnedToolkitAdapter(opts.AgentID)) + return "", commandNotAvailableError(command, lastResult, opts.Backend, requiresPinnedToolkitAdapter(opts.AgentID)) } func resolveCommandOnce(ctx context.Context, client *bridge.Client, command, workDir string, env []string, opts processOptions) (string, *bridge.ExecResult, error) { @@ -285,7 +314,7 @@ func resolveCommandOnce(ctx context.Context, client *bridge.Client, command, wor // the Memoh toolkit. A same-named binary earlier on PATH may use a different // transcript layout or omit the Claude flush/receipt contract, so built-in // resumable profiles must never silently execute it. - if requiresPinnedToolkitAdapter(opts.AgentID) { + if opts.Backend != WorkspaceBackendRemote && requiresPinnedToolkitAdapter(opts.AgentID) { toolkitCommand := containerToolkitBin + "/" + command toolkitResult, err := checkCommand(ctx, client, "test -x "+escapeShellArg(toolkitCommand), workDir, env, opts) if err != nil { @@ -304,6 +333,9 @@ func resolveCommandOnce(ctx context.Context, client *bridge.Client, command, wor if result.ExitCode == 0 { return command, result, nil } + if opts.Backend == WorkspaceBackendRemote { + return "", result, nil + } toolkitCommand := containerToolkitBin + "/" + command toolkitResult, err := checkCommand(ctx, client, "test -x "+escapeShellArg(toolkitCommand), workDir, env, opts) if err != nil { @@ -324,7 +356,7 @@ func checkCommand(ctx context.Context, client *bridge.Client, check, workDir str }) } -func commandNotAvailableError(command string, result *bridge.ExecResult, pinned bool) error { +func commandNotAvailableError(command string, result *bridge.ExecResult, backend WorkspaceBackend, pinned bool) error { detail := "" if result != nil { detail = strings.TrimSpace(result.Stderr) @@ -335,6 +367,9 @@ func commandNotAvailableError(command string, result *bridge.ExecResult, pinned if detail != "" { detail = ": " + detail } + if backend == WorkspaceBackendRemote { + return fmt.Errorf("ACP command %q is not available on the connected computer%s; reconnect it with the Remote ACP adapter package installed", command, detail) + } if pinned { // Resumable agents deliberately never fall back to PATH, so pointing // at PATH here would send operators of custom images down the wrong @@ -476,6 +511,9 @@ func (p *bridgeProcess) finalizeAfterExit(parent context.Context) { } p.finalizeOnce.Do(func() { defer close(p.finalizeDone) + if p.lease == nil { + return + } p.stateMu.Lock() commit := p.activated p.stateMu.Unlock() diff --git a/internal/agent/runtime/acp/client/process_test.go b/internal/agent/runtime/acp/client/process_test.go index 93bf8900f8..15c53b3fe4 100644 --- a/internal/agent/runtime/acp/client/process_test.go +++ b/internal/agent/runtime/acp/client/process_test.go @@ -179,6 +179,39 @@ func TestStartBridgeProcessHermesManagedPassesCleanEnvControls(t *testing.T) { } } +func TestStartBridgeProcessRemoteDoesNotStageOrInjectManagedState(t *testing.T) { + client, server := newRecordingBridgeClient(t) + proc, err := startBridgeProcess(context.Background(), client, "codex-acp", nil, "/Users/alice/project", time.Minute, processOptions{ + Backend: WorkspaceBackendRemote, + BotID: "bot-remote", + AgentID: "codex", + SetupMode: SetupModeAPIKey, + Env: []string{"OPENAI_API_KEY=server-managed", "HOME=/server/home"}, + CleanEnv: true, + UnsetEnv: []string{"PATH", "HOME"}, + }) + if err != nil { + t.Fatalf("startBridgeProcess() error = %v", err) + } + if len(proc.toolEnv) != 0 || proc.cleanEnv || len(proc.unsetEnv) != 0 { + t.Fatalf("remote terminal controls = env %#v clean %t unset %#v", proc.toolEnv, proc.cleanEnv, proc.unsetEnv) + } + server.waitForRecordWithTimeout(t, int32(time.Minute.Seconds()), 2*time.Second) + if err := proc.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + record, ok := findRecordWithTimeout(server.records(), int32(time.Minute.Seconds())) + if !ok { + t.Fatalf("missing process exec record: %#v", server.records()) + } + if len(record.Env) != 0 || record.CleanEnv || len(record.UnsetEnv) != 0 { + t.Fatalf("remote exec received Server-managed environment controls: %#v", record) + } + if writes := server.writes(); len(writes) != 0 { + t.Fatalf("remote exec staged workspace state: %#v", writes) + } +} + func TestCreateTerminalFiltersBlockedHermesEnv(t *testing.T) { client, server := newRecordingBridgeClient(t) manager := newTerminalManager( diff --git a/internal/agent/runtime/acp/client/session.go b/internal/agent/runtime/acp/client/session.go index 6f433e447e..48b58c0a97 100644 --- a/internal/agent/runtime/acp/client/session.go +++ b/internal/agent/runtime/acp/client/session.go @@ -289,7 +289,7 @@ func (r *Runner) StartSession(ctx context.Context, req StartRequest, sink EventS if preflightGateway == nil { preflightGateway = req.ToolGateway } - callbacks := newClientCallbacks(lifecycleCtx, client, root, projectPath, timeout, sink, proc.toolEnv, req.CleanEnv, proc.unsetEnv, req.ToolApproval, preflightGateway, toolSession, acpprofile.QuirksFor(req.AgentID)) + callbacks := newClientCallbacks(lifecycleCtx, client, root, projectPath, timeout, sink, proc.toolEnv, proc.cleanEnv, proc.unsetEnv, req.ToolApproval, preflightGateway, toolSession, acpprofile.QuirksFor(req.AgentID)) callbacks.userInput = req.UserInput callbacks.logger = r.logger conn := newClientConnection(callbacks, proc, proc) @@ -693,6 +693,14 @@ func (s *Session) ProjectPath() string { return s.projectPath } +// Done closes when the owned ACP process or its bridge transport exits. +func (s *Session) Done() (<-chan struct{}, bool) { + if s == nil || s.proc == nil { + return nil, false + } + return s.proc.Done(), true +} + // SnapshotSessionState captures the adapter-native JSONL files needed to // reconstruct this session in a later process. The process owns path discovery // and validation; callers only persist the returned opaque, ordered state. diff --git a/internal/agent/runtime/acp/client/session_context.go b/internal/agent/runtime/acp/client/session_context.go index 60a5c41cde..ad21fb646a 100644 --- a/internal/agent/runtime/acp/client/session_context.go +++ b/internal/agent/runtime/acp/client/session_context.go @@ -1,7 +1,9 @@ package client import ( + "errors" "fmt" + "path" "strings" "github.com/felinics/memoh/internal/workspace/bridge" @@ -10,10 +12,12 @@ import ( const HermesContainerHome = dataMountPath + "/.memoh-hermes" type SessionContextInput struct { - AgentID string - SetupMode SetupMode - Backend string - ProjectPath string + AgentID string + SetupMode SetupMode + Backend string + OS string + DefaultWorkDir string + ProjectPath string } type ResolvedSessionContext struct { @@ -31,11 +35,28 @@ func ResolveSessionContext(input SessionContextInput) (ResolvedSessionContext, e switch strings.ToLower(strings.TrimSpace(input.Backend)) { case "", bridge.WorkspaceBackendContainer: backend = WorkspaceBackendContainer + case bridge.WorkspaceBackendRemote: + backend = WorkspaceBackendRemote default: return ResolvedSessionContext{}, fmt.Errorf("unsupported workspace backend %q", input.Backend) } resolvedRoot := dataMountPath - projectPath, err := ResolvePathUnderVirtualRoot(resolvedRoot, input.ProjectPath) + projectPath := "" + var err error + if backend == WorkspaceBackendRemote { + osName := strings.ToLower(strings.TrimSpace(input.OS)) + if osName != "darwin" && osName != "linux" { + return ResolvedSessionContext{}, fmt.Errorf("unsupported remote ACP operating system %q", input.OS) + } + remoteHome := path.Clean(strings.TrimSpace(input.DefaultWorkDir)) + if remoteHome == "." || !path.IsAbs(remoteHome) { + return ResolvedSessionContext{}, errorsRemoteHomeRequired() + } + resolvedRoot = "/" + projectPath, err = resolveRemoteProjectPath(remoteHome, input.ProjectPath) + } else { + projectPath, err = ResolvePathUnderVirtualRoot(resolvedRoot, input.ProjectPath) + } if err != nil { return ResolvedSessionContext{}, err } @@ -54,10 +75,40 @@ func ResolveSessionContext(input SessionContextInput) (ResolvedSessionContext, e return ctx, nil } +func resolveRemoteProjectPath(home, raw string) (string, error) { + home = path.Clean(strings.TrimSpace(home)) + if home == "." || !path.IsAbs(home) { + return "", errorsRemoteHomeRequired() + } + target := strings.TrimSpace(raw) + switch { + case target == "", target == dataMountPath, target == "~": + target = home + case strings.HasPrefix(target, dataMountPath+"/"): + target = path.Join(home, strings.TrimPrefix(target, dataMountPath+"/")) + case strings.HasPrefix(target, "~/"): + target = path.Join(home, strings.TrimPrefix(target, "~/")) + case path.IsAbs(target): + target = path.Clean(target) + default: + target = path.Join(home, target) + } + if !path.IsAbs(target) { + return "", errors.New("remote ACP project path must be absolute") + } + return path.Clean(target), nil +} + +func errorsRemoteHomeRequired() error { + return errors.New("remote ACP workspace home must be an absolute path") +} + func resolveWorkspacePaths(info bridge.WorkspaceInfo, rawProjectPath string) (string, string, WorkspaceBackend, error) { ctx, err := ResolveSessionContext(SessionContextInput{ - Backend: info.Backend, - ProjectPath: rawProjectPath, + Backend: info.Backend, + OS: info.OS, + DefaultWorkDir: info.DefaultWorkDir, + ProjectPath: rawProjectPath, }) if err != nil { return "", "", WorkspaceBackendContainer, err diff --git a/internal/agent/runtime/acp/client/session_context_test.go b/internal/agent/runtime/acp/client/session_context_test.go index 53da19d2f9..e5a8406c06 100644 --- a/internal/agent/runtime/acp/client/session_context_test.go +++ b/internal/agent/runtime/acp/client/session_context_test.go @@ -6,16 +6,67 @@ import ( ) func TestResolveSessionContextRejectsUnknownBackend(t *testing.T) { - _, err := ResolveSessionContext(SessionContextInput{ - AgentID: "hermes", - SetupMode: SetupModeAPIKey, - Backend: "remote", - }) + _, err := ResolveSessionContext(SessionContextInput{Backend: "virtual-machine"}) if err == nil || !strings.Contains(err.Error(), "unsupported workspace backend") { t.Fatalf("ResolveSessionContext() error = %v, want unsupported backend", err) } } +func TestResolveSessionContextRemoteResolvesHostPaths(t *testing.T) { + tests := []struct { + name string + home string + projectPath string + want string + }{ + {name: "data alias", home: "/Users/alice", projectPath: "/data/projects/memoh", want: "/Users/alice/projects/memoh"}, + {name: "absolute folder", home: "/home/alice", projectPath: "/srv/project", want: "/srv/project"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolved, err := ResolveSessionContext(SessionContextInput{ + Backend: "remote", + OS: "darwin", + DefaultWorkDir: tt.home, + ProjectPath: tt.projectPath, + }) + if err != nil { + t.Fatalf("ResolveSessionContext() error = %v", err) + } + if resolved.WorkspaceRoot != "/" || resolved.ProjectPath != tt.want || resolved.CWD != tt.want { + t.Fatalf("remote context = %#v, want root / and path %q", resolved, tt.want) + } + }) + } +} + +func TestResolveSessionContextRemoteRejectsUnsupportedPlatformOrHome(t *testing.T) { + tests := []struct { + name string + input SessionContextInput + want string + }{ + { + name: "windows", + input: SessionContextInput{Backend: "remote", OS: "win32", DefaultWorkDir: `C:\\Users\\alice`}, + want: "unsupported remote ACP operating system", + }, + { + name: "relative home", + input: SessionContextInput{Backend: "remote", OS: "linux", DefaultWorkDir: "home/alice"}, + want: "workspace home must be an absolute path", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ResolveSessionContext(tt.input) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("ResolveSessionContext() error = %v, want %q", err, tt.want) + } + }) + } +} + func TestResolveSessionContextHermesManagedHome(t *testing.T) { resolved, err := ResolveSessionContext(SessionContextInput{ AgentID: "hermes", diff --git a/internal/agent/runtime/acp/profile/profile.go b/internal/agent/runtime/acp/profile/profile.go index 3d7f4e8b51..7b2f327937 100644 --- a/internal/agent/runtime/acp/profile/profile.go +++ b/internal/agent/runtime/acp/profile/profile.go @@ -49,10 +49,11 @@ type Profile struct { ForceHTTPMCPServer bool // RuntimeStorage is the internal allowlist and environment contract that // separates durable configuration/credentials from process-local state. - RuntimeStorage RuntimeStoragePolicy - ManagedFields []ManagedField - SupportedBackends []string - SetupModes []string + RuntimeStorage RuntimeStoragePolicy + ManagedFields []ManagedField + SupportedBackends []string + BackendCapabilities map[string]string + SetupModes []string } // LaunchPolicy declares how an ACP profile resolves its process command. A @@ -233,7 +234,10 @@ func genericACPProfile() Profile { Help: "Optional process arguments, one argument per line.", }, }, - SupportedBackends: []string{"container"}, + SupportedBackends: []string{"container", "remote"}, + BackendCapabilities: map[string]string{ + "remote": "acp_codex", + }, // api_key is an internal managed-mode marker here; generic ACP has no // authentication UI of its own and only needs Memoh-managed launch data. SetupModes: []string{setupModeAPIKey}, @@ -265,7 +269,10 @@ func codexProfile() Profile { Help: "Optional Codex provider base URL.", }, }, - SupportedBackends: []string{"container"}, + SupportedBackends: []string{"container", "remote"}, + BackendCapabilities: map[string]string{ + "remote": "acp_codex", + }, // OAuth first: signing in with a ChatGPT account is the path we want // users to reach for; the API key stays available behind it. SetupModes: []string{setupModeOAuth, setupModeAPIKey, setupModeSelf}, @@ -315,7 +322,10 @@ func claudeCodeProfile() Profile { Help: "Used by OAuth setup to authenticate Claude Code.", }, }, - SupportedBackends: []string{"container"}, + SupportedBackends: []string{"container", "remote"}, + BackendCapabilities: map[string]string{ + "remote": "acp_claude_code", + }, // OAuth first, same reasoning as Codex: the Claude account sign-in is // the primary path, the API key is the fallback. SetupModes: []string{setupModeOAuth, setupModeAPIKey, setupModeSelf}, diff --git a/internal/agent/runtime/acp/session_pool.go b/internal/agent/runtime/acp/session_pool.go index 8f6df108b8..1754b097b1 100644 --- a/internal/agent/runtime/acp/session_pool.go +++ b/internal/agent/runtime/acp/session_pool.go @@ -10,10 +10,13 @@ package acp import ( "context" + "crypto/subtle" "errors" "fmt" "log/slog" + "net" "net/http" + "net/url" "strings" "sync" "time" @@ -31,6 +34,7 @@ import ( "github.com/felinics/memoh/internal/bots" "github.com/felinics/memoh/internal/mcp" "github.com/felinics/memoh/internal/runtimefence" + "github.com/felinics/memoh/internal/workspace" "github.com/felinics/memoh/internal/workspace/bridge" ) @@ -144,11 +148,14 @@ type botGetter interface { // SessionDescriptor contains the minimal persisted session metadata required // to launch an ACP runtime. The Chat domain supplies it through an adapter. type SessionDescriptor struct { - BotID string - SessionType string - Metadata map[string]any - RuntimeMetadata map[string]any - IsACP bool + BotID string + SessionType string + Metadata map[string]any + RuntimeMetadata map[string]any + WorkspaceTargetID string + WorkspaceTargetKind string + WorkdirPath string + IsACP bool } // SessionDescriptorReader resolves runtime metadata without exposing Chat @@ -176,7 +183,10 @@ type runtimeHandle struct { runtimeConfigEpoch RuntimeConfigEpoch // ownerCtx is a value-only context retained for detached runtime cleanup. // Its cancellation and deadline do not describe request liveness. - ownerCtx context.Context + ownerCtx context.Context + workspaceTargetID string + workspaceTargetKind string + workspaceTargetName string // op serializes operations (start, prompt, runtime config, bind, close). op sync.Mutex @@ -245,6 +255,9 @@ type PromptInput struct { ContextURI string ContextMarkdown string RuntimeOwnerAccountID string + WorkspaceTargetID string + WorkspaceTargetKind string + WorkspaceTargetName string ForceFreshRuntime bool Sink client.EventSink RuntimeGuard func(context.Context) error @@ -281,6 +294,8 @@ type RuntimeStatus struct { AgentID string `json:"agent_id,omitempty"` ProjectPath string `json:"project_path,omitempty"` RuntimeOwnerAccountID string `json:"-"` + WorkspaceTargetID string `json:"-"` + WorkspaceTargetKind string `json:"-"` State string `json:"state"` ACPSession string `json:"acp_session_id,omitempty"` Models *client.ModelState `json:"models,omitempty"` @@ -420,6 +435,10 @@ func (p *SessionPool) CreateRuntime(ctx context.Context, input CreateRuntimeInpu if runtimeOwnerAccountID == "" { return RuntimeStatus{}, runtimeOwnerMissingError() } + _, _, _, _, workspaceInfo, err := p.resolveAgentSetup(ctx, botID, agentID) + if err != nil { + return RuntimeStatus{}, err + } p.reapIdle(time.Now()) //nolint:contextcheck // reaper uses each handle's owner context. @@ -431,13 +450,13 @@ func (p *SessionPool) CreateRuntime(ctx context.Context, input CreateRuntimeInpu projectPath: projectPath, runtimeOwnerAccountID: runtimeOwnerAccountID, ownerCtx: context.WithoutCancel(ctx), + workspaceTargetID: strings.TrimSpace(workspaceInfo.TargetID), + workspaceTargetKind: strings.TrimSpace(workspaceInfo.TargetKind), + workspaceTargetName: strings.TrimSpace(workspaceInfo.TargetName), status: stateStarting, lastActive: time.Now(), } - var ( - victims []*runtimeHandle - err error - ) + var victims []*runtimeHandle for { p.mu.Lock() resetDone := p.historyResetBots[botID] @@ -522,7 +541,7 @@ func (p *SessionPool) unboundBudgetLocked(botID string) ([]*runtimeHandle, error // session's prompts reuse the warm process. Returns ErrRuntimeBindRejected // when the runtime cannot serve this session; callers fall back to a cold // start and must not treat that as fatal. -func (p *SessionPool) BindRuntime(ctx context.Context, botID, runtimeID, sessionID, agentID, projectPath, runtimeOwnerAccountID string) error { +func (p *SessionPool) BindRuntime(ctx context.Context, botID, runtimeID, sessionID, agentID, projectPath, workspaceTargetID, runtimeOwnerAccountID string) error { if ctx == nil { return errors.New("runtime bind context is required") } @@ -557,6 +576,7 @@ func (p *SessionPool) BindRuntime(ctx context.Context, botID, runtimeID, session normalizedAgent = acpprofile.AgentCodexID } projectPath = strings.TrimSpace(projectPath) + workspaceTargetID = strings.TrimSpace(workspaceTargetID) // Waits out an in-flight model change on the runtime. h.op.Lock() @@ -568,9 +588,10 @@ func (p *SessionPool) BindRuntime(ctx context.Context, botID, runtimeID, session h.state.Lock() epochMatches := h.runtimeConfigEpoch.Bot == actualEpoch.Bot + targetMatches := workspaceTargetID == "" || h.workspaceTargetID == workspaceTargetID ok := !h.closed && h.session != nil && h.boundSession == "" && h.agentID == normalizedAgent && h.projectPath == projectPath && - h.runtimeOwnerAccountID == runtimeOwnerAccountID && + targetMatches && h.runtimeOwnerAccountID == runtimeOwnerAccountID && epochMatches if ok { // Publish the binding on the handle before indexing it. A reset that @@ -778,13 +799,16 @@ func (p *SessionPool) ResolveRuntimeToolContext(botID, runtimeID, toolToken stri if err != nil { return mcp.ToolSessionContext{}, false } - if strings.TrimSpace(h.toolToken) == "" || strings.TrimSpace(toolToken) != h.toolToken { + expectedToken := strings.TrimSpace(h.toolToken) + providedToken := strings.TrimSpace(toolToken) + if expectedToken == "" || subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 { return mcp.ToolSessionContext{}, false } h.state.Lock() closed := h.closed + sess := h.session h.state.Unlock() - if closed { + if closed || sessionProcessExited(sess) { return mcp.ToolSessionContext{}, false } return h.toolContext(), true @@ -806,9 +830,19 @@ func (p *SessionPool) prepareInput(ctx context.Context, input PromptInput) (Prom if strings.TrimSpace(resolved.BotID) == "" { return PromptInput{}, errors.New("bot_id is required") } - if _, _, _, _, _, err := p.resolveAgentSetup(ctx, resolved.BotID, resolved.AgentID); err != nil { + setupCtx := contextWithWorkspaceTarget(ctx, resolved.WorkspaceTargetID) + _, _, _, _, workspaceInfo, err := p.resolveAgentSetup(setupCtx, resolved.BotID, resolved.AgentID) + if err != nil { return PromptInput{}, err } + requestedTargetID := strings.TrimSpace(resolved.WorkspaceTargetID) + actualTargetID := strings.TrimSpace(workspaceInfo.TargetID) + if requestedTargetID != "" && requestedTargetID != actualTargetID { + return PromptInput{}, fmt.Errorf("resolved workspace target %q does not match requested target %q", actualTargetID, requestedTargetID) + } + resolved.WorkspaceTargetID = actualTargetID + resolved.WorkspaceTargetKind = strings.TrimSpace(workspaceInfo.TargetKind) + resolved.WorkspaceTargetName = strings.TrimSpace(workspaceInfo.TargetName) return resolved, nil } @@ -1423,6 +1457,9 @@ func (p *SessionPool) runtimeForSession(ctx context.Context, input PromptInput) if identityErr != nil { return nil, identityErr } + workspaceTargetID := strings.TrimSpace(input.WorkspaceTargetID) + workspaceTargetKind := strings.TrimSpace(input.WorkspaceTargetKind) + workspaceTargetName := strings.TrimSpace(input.WorkspaceTargetName) for attempt := 0; attempt < 3; { p.mu.Lock() @@ -1470,6 +1507,9 @@ func (p *SessionPool) runtimeForSession(ctx context.Context, input PromptInput) runtimeOwnerAccountID: runtimeOwnerAccountID, ownerCtx: context.WithoutCancel(ctx), disableSessionState: input.disableSessionState, + workspaceTargetID: workspaceTargetID, + workspaceTargetKind: workspaceTargetKind, + workspaceTargetName: workspaceTargetName, status: stateStarting, lastActive: time.Now(), boundSession: sessionID, @@ -1499,16 +1539,19 @@ func (p *SessionPool) runtimeForSession(ctx context.Context, input PromptInput) h.state.Lock() matches := h.agentID == agentID && h.projectPath == projectPath && h.runtimeOwnerAccountID == runtimeOwnerAccountID && - h.disableSessionState == input.disableSessionState + h.disableSessionState == input.disableSessionState && + h.workspaceTargetID == workspaceTargetID closed := h.closed starting := h.session == nil + sess := h.session + exited := sessionProcessExited(sess) if matches && !closed { // Resolving counts as activity: a session whose UI keeps the // runtime ensured (without prompting) must not be idle-reaped. h.lastActive = time.Now() } h.state.Unlock() - if matches && !closed { + if matches && !closed && !exited { if starting { // A concurrent startRuntime still owns h.op and has not // published nativeHead/epoch yet; comparing the zero values @@ -1536,6 +1579,22 @@ func (p *SessionPool) runtimeForSession(ctx context.Context, input PromptInput) return nil, errors.New("ACP runtime is restarting, retry the request") } +func sessionProcessExited(sess *client.Session) bool { + if sess == nil { + return false + } + done, observable := sess.Done() + if !observable { + return false + } + select { + case <-done: + return true + default: + return false + } +} + type startOptions struct { ToolHTTPURL string Sink client.EventSink @@ -1547,6 +1606,7 @@ type startOptions struct { // //nolint:contextcheck // startup failure cleanup uses the handle owner context. func (p *SessionPool) startRuntime(ctx context.Context, h *runtimeHandle, opts startOptions) error { + ctx = contextWithWorkspaceTarget(ctx, h.workspaceTargetID) startCtx, cancelStart := context.WithCancel(ctx) defer cancelStart() h.state.Lock() @@ -1593,27 +1653,33 @@ func (p *SessionPool) startRuntime(ctx context.Context, h *runtimeHandle, opts s return fail(fmt.Errorf("resolve ACP launch command: %w", err)) } supportsSessionState := len(profile.RuntimeStorage.SessionRoots) > 0 + if targetID := strings.TrimSpace(workspaceInfo.TargetID); h.workspaceTargetID != "" && targetID != "" && h.workspaceTargetID != targetID { + return fail(errors.New("workspace target changed while starting ACP runtime")) + } resolved, err := client.ResolveSessionContext(client.SessionContextInput{ - AgentID: h.agentID, - SetupMode: mode, - Backend: workspaceInfo.Backend, - ProjectPath: h.projectPath, + AgentID: h.agentID, + SetupMode: mode, + Backend: workspaceInfo.Backend, + OS: workspaceInfo.OS, + DefaultWorkDir: workspaceInfo.DefaultWorkDir, + ProjectPath: h.projectPath, }) if err != nil { return fail(fmt.Errorf("resolve ACP session context: %w", err)) } - if err := p.reconcileManagedACPConfig(startCtx, h.botID, profile, setup, mode, resolved, runtimeSyncGuard); err != nil { - return fail(fmt.Errorf("prepare %s managed config: %w", profile.DisplayName, err)) - } - // Managed env (Claude Code BYOK tokens) is injected for every session. - // managedProcessEnv returns nil for self mode and for Codex, which is - // configured via CODEX_HOME files instead of env. var env []string - env, err = managedProcessEnv(profile, setup.Managed, mode) - if err != nil { - return fail(err) + var cleanEnv bool + var unsetEnv []string + if resolved.Backend != client.WorkspaceBackendRemote { + if err := p.reconcileManagedACPConfig(startCtx, h.botID, profile, setup, mode, resolved, runtimeSyncGuard); err != nil { + return fail(fmt.Errorf("prepare %s managed config: %w", profile.DisplayName, err)) + } + env, err = managedProcessEnv(profile, setup.Managed, mode) + if err != nil { + return fail(err) + } + cleanEnv, unsetEnv = managedEnvControls(profile, mode, resolved.Backend) } - cleanEnv, unsetEnv := managedEnvControls(profile, mode, resolved.Backend) toolHTTPURL, err := p.resolveToolHTTPURL(opts.ToolHTTPURL, workspaceInfo) if err != nil { @@ -1855,6 +1921,8 @@ func (*SessionPool) statusOf(h *runtimeHandle) RuntimeStatus { AgentID: h.agentID, ProjectPath: h.projectPath, RuntimeOwnerAccountID: h.runtimeOwnerAccountID, + WorkspaceTargetID: h.workspaceTargetID, + WorkspaceTargetKind: h.workspaceTargetKind, State: h.status, DefaultModelID: h.defaultModelID, } @@ -2451,6 +2519,41 @@ func (p *SessionPool) CloseBotAgentRuntimes(botID, agentID string) error { return firstErr } +// CloseBotWorkspaceTargetRuntimes tears down every runtime pinned to one +// workspace target. Target deletion is rejected while a workdir still refers +// to it, but an unbound prewarm may otherwise survive after the target record +// is removed and keep running commands on a computer the user disconnected. +func (p *SessionPool) CloseBotWorkspaceTargetRuntimes(botID, targetID string) error { + if p == nil { + return nil + } + botID = strings.TrimSpace(botID) + targetID = strings.TrimSpace(targetID) + if botID == "" || targetID == "" { + return nil + } + p.mu.RLock() + handles := make([]*runtimeHandle, 0) + for _, h := range p.runtimes { + if h == nil || h.botID != botID || h.workspaceTargetID != targetID { + continue + } + handles = append(handles, h) + } + p.mu.RUnlock() + + var firstErr error + for _, h := range handles { + // Like bot/agent reconfiguration, target removal must not wait for an + // active prompt or approval. Teardown closes the process first and lets + // the operation holder unwind through the closed handle. + if err := p.teardown(h); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + func (p *SessionPool) reapIdle(now time.Time) int { if p == nil { return 0 @@ -2522,9 +2625,23 @@ func (p *SessionPool) resolveSessionMetadata(ctx context.Context, input PromptIn if ownerID := metadataString(sess.Metadata, "runtime_owner_account_id"); ownerID != "" { input.RuntimeOwnerAccountID = ownerID } + if targetID := strings.TrimSpace(sess.WorkspaceTargetID); targetID != "" { + input.WorkspaceTargetID = targetID + input.WorkspaceTargetKind = strings.TrimSpace(sess.WorkspaceTargetKind) + } + if workdirPath := strings.TrimSpace(sess.WorkdirPath); workdirPath != "" { + input.ProjectPath = workdirPath + } return input, nil } +func contextWithWorkspaceTarget(ctx context.Context, targetID string) context.Context { + if targetID = strings.TrimSpace(targetID); targetID != "" { + return workspace.WithWorkspaceTarget(ctx, targetID) + } + return ctx +} + func (p *SessionPool) resolveAgentSetup(ctx context.Context, botID, agentID string) (bots.Bot, acpprofile.Profile, acpprofile.AgentSetup, client.SetupMode, bridge.WorkspaceInfo, error) { agentID = acpprofile.NormalizeAgentID(agentID) profile, ok := acpprofile.Lookup(agentID) @@ -2566,7 +2683,8 @@ func (p *SessionPool) resolveAgentSetup(ctx context.Context, botID, agentID stri // api_key to preserve the original validation behaviour. mode = client.SetupModeAPIKey } - if !profileSupportsSetupMode(profile, mode) { + isRemote := strings.EqualFold(strings.TrimSpace(workspaceInfo.Backend), bridge.WorkspaceBackendRemote) + if !isRemote && !profileSupportsSetupMode(profile, mode) { reason := fmt.Sprintf("does not support setup mode %q", mode) return bots.Bot{}, acpprofile.Profile{}, acpprofile.AgentSetup{}, "", bridge.WorkspaceInfo{}, feedback.New( feedback.CodeAgentNotConfigured, @@ -2588,7 +2706,32 @@ func (p *SessionPool) resolveAgentSetup(ctx context.Context, botID, agentID stri map[string]string{"agent_id": agentID, "workspace_backend": workspaceInfo.Backend}, ) } - if mode != client.SetupModeSelf { + if isRemote { + osName := strings.ToLower(strings.TrimSpace(workspaceInfo.OS)) + if osName != "darwin" && osName != "linux" { + reason := fmt.Sprintf("does not support remote operating system %q", workspaceInfo.OS) + return bots.Bot{}, acpprofile.Profile{}, acpprofile.AgentSetup{}, "", bridge.WorkspaceInfo{}, feedback.New( + feedback.CodeRemoteOSUnsupported, + reason, + http.StatusBadRequest, + "chat.acp.remoteOSUnsupported", + fmt.Sprintf("%s %s", profile.DisplayName, reason), + map[string]string{"agent_id": agentID, "workspace_backend": bridge.WorkspaceBackendRemote}, + ) + } + if required := profileBackendCapability(profile, bridge.WorkspaceBackendRemote); required != "" && !hasWorkspaceCapability(workspaceInfo.Capabilities, required) { + reason := fmt.Sprintf("requires connected-computer capability %q", required) + return bots.Bot{}, acpprofile.Profile{}, acpprofile.AgentSetup{}, "", bridge.WorkspaceInfo{}, feedback.New( + feedback.CodeRemoteAdapterMissing, + reason, + http.StatusBadRequest, + "chat.acp.remoteAdapterMissing", + fmt.Sprintf("%s %s", profile.DisplayName, reason), + map[string]string{"agent_id": agentID, "workspace_backend": bridge.WorkspaceBackendRemote}, + ) + } + } + if !isRemote && mode != client.SetupModeSelf { if err := validateManagedFields(profile, setup.Managed, mode); err != nil { return bots.Bot{}, acpprofile.Profile{}, acpprofile.AgentSetup{}, "", bridge.WorkspaceInfo{}, feedback.New( feedback.CodeAgentNotConfigured, @@ -2627,11 +2770,15 @@ func validateManagedFields(profile acpprofile.Profile, managed map[string]string // re-configuration. func (h *runtimeHandle) stableToolIdentity() client.ToolSessionContext { return client.ToolSessionContext{ - BotID: h.botID, - ChatID: h.botID, - RuntimeID: h.id, - RuntimeToken: h.toolToken, - SessionType: sessionmode.ACPAgent, + BotID: h.botID, + ChatID: h.botID, + RuntimeID: h.id, + RuntimeToken: h.toolToken, + SessionType: sessionmode.ACPAgent, + WorkspaceTargetID: h.workspaceTargetID, + WorkspaceTargetKind: h.workspaceTargetKind, + WorkspaceTargetName: h.workspaceTargetName, + WorkdirPath: h.projectPath, } } @@ -2642,12 +2789,16 @@ func (h *runtimeHandle) toolContext() mcp.ToolSessionContext { h.state.Lock() defer h.state.Unlock() ctx := mcp.ToolSessionContext{ - BotID: h.botID, - ChatID: h.botID, - RuntimeID: h.id, - SessionID: h.boundSession, - SessionType: sessionmode.ACPAgent, - CanListUserInput: true, + BotID: h.botID, + ChatID: h.botID, + RuntimeID: h.id, + SessionID: h.boundSession, + SessionType: sessionmode.ACPAgent, + WorkspaceTargetID: h.workspaceTargetID, + WorkspaceTargetKind: h.workspaceTargetKind, + WorkspaceTargetName: h.workspaceTargetName, + WorkdirPath: h.projectPath, + CanListUserInput: true, } if h.active == nil { return ctx @@ -2706,18 +2857,22 @@ func (h *runtimeHandle) setStatus(status string) { func toolSessionContext(ctx context.Context, input PromptInput, h *runtimeHandle) client.ToolSessionContext { fence, _ := runtimefence.FromContext(ctx) return client.ToolSessionContext{ - BotID: h.botID, - ChatID: firstNonEmpty(input.ChatID, h.botID), - RuntimeID: h.id, - SessionID: strings.TrimSpace(input.SessionID), - RunID: strings.TrimSpace(input.RunID), - SessionType: firstNonEmpty(input.SessionType, sessionmode.ACPAgent), - RouteID: input.RouteID, - ChannelIdentityID: input.ChannelIdentityID, - SessionToken: input.SessionToken, - CurrentPlatform: input.CurrentPlatform, - ReplyTarget: input.ReplyTarget, - ConversationType: input.ConversationType, + BotID: h.botID, + ChatID: firstNonEmpty(input.ChatID, h.botID), + RuntimeID: h.id, + SessionID: strings.TrimSpace(input.SessionID), + RunID: strings.TrimSpace(input.RunID), + SessionType: firstNonEmpty(input.SessionType, sessionmode.ACPAgent), + RouteID: input.RouteID, + ChannelIdentityID: input.ChannelIdentityID, + SessionToken: input.SessionToken, + CurrentPlatform: input.CurrentPlatform, + ReplyTarget: input.ReplyTarget, + ConversationType: input.ConversationType, + WorkspaceTargetID: h.workspaceTargetID, + WorkspaceTargetKind: h.workspaceTargetKind, + WorkspaceTargetName: h.workspaceTargetName, + WorkdirPath: h.projectPath, // PromptInput.ReasoningEffort is the current turn's explicit selection. // The bot-stored fallback is loaded by SpawnProvider when this ACP tool // context does not already carry one. @@ -2766,7 +2921,30 @@ func (p *SessionPool) resolveToolHTTPURL(inputURL string, workspaceInfo bridge.W if backend == "" || backend == bridge.WorkspaceBackendContainer { return strings.TrimSpace(workspaceInfo.ACPToolsHTTPURL), nil } - return strings.TrimSpace(inputURL), nil + raw := strings.TrimSpace(inputURL) + if raw == "" { + return "", nil + } + parsed, err := url.Parse(raw) + allowedScheme := parsed != nil && + (parsed.Scheme == "https" || (parsed.Scheme == "http" && isLoopbackHost(parsed.Hostname()))) + if err != nil || !allowedScheme || strings.TrimSpace(parsed.Host) == "" || parsed.User != nil { + return "", errors.New("remote ACP Memoh tools URL must be an absolute HTTPS URL without embedded credentials (plain HTTP is allowed only for loopback development)") + } + return parsed.String(), nil +} + +// isLoopbackHost reports whether the URL host is the local machine. A +// loopback tools URL only ever works when the connected computer is the +// server host itself — the local development flow — so plain HTTP is +// acceptable there and rejected everywhere else. +func isLoopbackHost(hostname string) bool { + host := strings.Trim(strings.TrimSpace(hostname), "[]") + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() } // toolHTTPHandler serves the runtime's MCP tool requests. Identity comes @@ -2783,7 +2961,7 @@ func (p *SessionPool) toolHTTPHandler(h *runtimeHandle) http.Handler { } func (p *SessionPool) reconcileManagedACPConfig(ctx context.Context, botID string, profile acpprofile.Profile, setup acpprofile.AgentSetup, mode client.SetupMode, resolved client.ResolvedSessionContext, guard client.RuntimeSyncGuard) error { - if mode == client.SetupModeSelf { + if resolved.Backend == client.WorkspaceBackendRemote || mode == client.SetupModeSelf { return nil } runner, hasWorkspaceClient := p.runner.(workspaceClientRunner) @@ -2965,6 +3143,28 @@ func profileSupportsBackend(profile acpprofile.Profile, backend string) bool { return false } +func profileBackendCapability(profile acpprofile.Profile, backend string) string { + for configuredBackend, capability := range profile.BackendCapabilities { + if strings.EqualFold(strings.TrimSpace(configuredBackend), strings.TrimSpace(backend)) { + return strings.ToLower(strings.TrimSpace(capability)) + } + } + return "" +} + +func hasWorkspaceCapability(capabilities []string, required string) bool { + required = strings.ToLower(strings.TrimSpace(required)) + if required == "" { + return true + } + for _, capability := range capabilities { + if strings.ToLower(strings.TrimSpace(capability)) == required { + return true + } + } + return false +} + func managedProcessEnv(profile acpprofile.Profile, values map[string]string, mode client.SetupMode) ([]string, error) { switch profile.ID { case acpprofile.AgentClaudeCodeID: diff --git a/internal/agent/runtime/acp/sessionpool_test.go b/internal/agent/runtime/acp/sessionpool_test.go index 96d0e1dc33..9381839f27 100644 --- a/internal/agent/runtime/acp/sessionpool_test.go +++ b/internal/agent/runtime/acp/sessionpool_test.go @@ -34,6 +34,7 @@ import ( "github.com/felinics/memoh/internal/config" "github.com/felinics/memoh/internal/mcp" "github.com/felinics/memoh/internal/runtimefence" + "github.com/felinics/memoh/internal/workspace" "github.com/felinics/memoh/internal/workspace/bridge" pb "github.com/felinics/memoh/internal/workspace/bridgepb" "github.com/felinics/memoh/internal/workspace/bridgesvc" @@ -573,6 +574,26 @@ func TestSessionPoolCreateRuntimeGeneratesIDAndReportsModels(t *testing.T) { } } +func TestRuntimeStatusKeepsWorkspaceTargetInternal(t *testing.T) { + pool := &SessionPool{} + status := pool.statusOf(&runtimeHandle{ + id: "rt-remote", + workspaceTargetID: "computer-1", + workspaceTargetKind: workspace.WorkspaceTargetRemote, + status: stateIdle, + }) + if status.WorkspaceTargetID != "computer-1" || status.WorkspaceTargetKind != workspace.WorkspaceTargetRemote { + t.Fatalf("runtime target = %q/%q", status.WorkspaceTargetID, status.WorkspaceTargetKind) + } + payload, err := json.Marshal(status) + if err != nil { + t.Fatalf("Marshal(RuntimeStatus): %v", err) + } + if strings.Contains(string(payload), "computer-1") || strings.Contains(string(payload), "workspace_target") { + t.Fatalf("runtime target leaked into public status: %s", payload) + } +} + func TestSessionPoolBindRuntimeAttachesWarmProcessToSession(t *testing.T) { type contextKey struct{} @@ -596,7 +617,7 @@ func TestSessionPoolBindRuntimeAttachesWarmProcessToSession(t *testing.T) { context.WithValue(context.Background(), contextKey{}, "bind-scope"), ) defer cancelBind() - if err := pool.BindRuntime(bindCtx, "bot-1", created.RuntimeID, "session-1", acpprofile.AgentCodexID, "/data/project", "user-1"); err != nil { + if err := pool.BindRuntime(bindCtx, "bot-1", created.RuntimeID, "session-1", acpprofile.AgentCodexID, "/data/project", "", "user-1"); err != nil { t.Fatalf("BindRuntime() error = %v", err) } cancelBind() @@ -639,7 +660,7 @@ func TestSessionPoolBindRuntimeAttachesWarmProcessToSession(t *testing.T) { } // A bound runtime cannot be bound again. - if err := pool.BindRuntime(context.Background(), "bot-1", created.RuntimeID, "session-2", acpprofile.AgentCodexID, "/data/project", "user-1"); !errors.Is(err, ErrRuntimeBindRejected) { + if err := pool.BindRuntime(context.Background(), "bot-1", created.RuntimeID, "session-2", acpprofile.AgentCodexID, "/data/project", "", "user-1"); !errors.Is(err, ErrRuntimeBindRejected) { t.Fatalf("second BindRuntime() error = %v, want ErrRuntimeBindRejected", err) } } @@ -721,30 +742,30 @@ func TestSessionPoolBindRuntimeRejectsMismatches(t *testing.T) { {"wrong project", "bot-2", "real", acpprofile.AgentCodexID, "/other", ErrRuntimeBindRejected}, } for _, tc := range cases { - if err := pool.BindRuntime(context.Background(), tc.botID, pending.id, tc.sessionID, tc.agent, tc.path, "user-1"); !errors.Is(err, tc.wantErr) { + if err := pool.BindRuntime(context.Background(), tc.botID, pending.id, tc.sessionID, tc.agent, tc.path, "", "user-1"); !errors.Is(err, tc.wantErr) { t.Fatalf("%s: BindRuntime() error = %v, want %v", tc.name, err, tc.wantErr) } } - if err := pool.BindRuntime(context.Background(), "bot-2", "rt_missing", "real", acpprofile.AgentCodexID, "/data", "user-1"); !errors.Is(err, ErrRuntimeNotFound) { + if err := pool.BindRuntime(context.Background(), "bot-2", "rt_missing", "real", acpprofile.AgentCodexID, "/data", "", "user-1"); !errors.Is(err, ErrRuntimeNotFound) { t.Fatalf("missing runtime: BindRuntime() error = %v, want ErrRuntimeNotFound", err) } // Session already served by another runtime. other := &runtimeHandle{id: newRuntimeID(), botID: "bot-2", boundSession: "real", status: stateIdle} injectRuntime(pool, other) - if err := pool.BindRuntime(context.Background(), "bot-2", pending.id, "real", acpprofile.AgentCodexID, "/data", "user-1"); !errors.Is(err, ErrRuntimeBindRejected) { + if err := pool.BindRuntime(context.Background(), "bot-2", pending.id, "real", acpprofile.AgentCodexID, "/data", "", "user-1"); !errors.Is(err, ErrRuntimeBindRejected) { t.Fatalf("occupied session: BindRuntime() error = %v, want ErrRuntimeBindRejected", err) } // A still-starting runtime (no live process yet) is not bindable. starting := &runtimeHandle{id: newRuntimeID(), botID: "bot-2", agentID: acpprofile.AgentCodexID, projectPath: "/data", status: stateStarting} injectRuntime(pool, starting) - if err := pool.BindRuntime(context.Background(), "bot-2", starting.id, "real-2", acpprofile.AgentCodexID, "/data", "user-1"); !errors.Is(err, ErrRuntimeBindRejected) { + if err := pool.BindRuntime(context.Background(), "bot-2", starting.id, "real-2", acpprofile.AgentCodexID, "/data", "", "user-1"); !errors.Is(err, ErrRuntimeBindRejected) { t.Fatalf("starting runtime: BindRuntime() error = %v, want ErrRuntimeBindRejected", err) } // Everything matching succeeds. - if err := pool.BindRuntime(context.Background(), "bot-2", pending.id, "real-2", acpprofile.AgentCodexID, "/data", "user-1"); err != nil { + if err := pool.BindRuntime(context.Background(), "bot-2", pending.id, "real-2", acpprofile.AgentCodexID, "/data", "", "user-1"); err != nil { t.Fatalf("matching BindRuntime() error = %v", err) } if pool.sessionHandle("real-2") != pending { @@ -752,6 +773,27 @@ func TestSessionPoolBindRuntimeRejectsMismatches(t *testing.T) { } } +func TestSessionPoolBindRuntimeRejectsDifferentWorkspaceTarget(t *testing.T) { + pool := newSessionPool(nil, nil, nil) + pending := &runtimeHandle{ + id: newRuntimeID(), + botID: "bot-1", + agentID: acpprofile.AgentCodexID, + projectPath: "/Users/alice/project", + workspaceTargetID: "computer-a", + runtimeOwnerAccountID: "user-1", + session: &client.Session{}, + status: stateIdle, + lastActive: time.Now(), + } + injectRuntime(pool, pending) + + err := pool.BindRuntime(context.Background(), "bot-1", pending.id, "session-1", acpprofile.AgentCodexID, "/Users/alice/project", "computer-b", "user-1") + if !errors.Is(err, ErrRuntimeBindRejected) { + t.Fatalf("BindRuntime() error = %v, want ErrRuntimeBindRejected", err) + } +} + func TestSessionPoolOwnedGateHasZeroSideEffectsAcrossBots(t *testing.T) { pool := newSessionPool(nil, nil, nil) foreign := &runtimeHandle{ @@ -775,7 +817,7 @@ func TestSessionPoolOwnedGateHasZeroSideEffectsAcrossBots(t *testing.T) { if err := pool.CloseRuntime("bot-1", foreign.id); !errors.Is(err, ErrRuntimeNotFound) { t.Fatalf("CloseRuntime(cross bot) error = %v, want ErrRuntimeNotFound", err) } - if err := pool.BindRuntime(context.Background(), "bot-1", foreign.id, "my-session", acpprofile.AgentCodexID, "/data", "user-1"); !errors.Is(err, ErrRuntimeNotFound) { + if err := pool.BindRuntime(context.Background(), "bot-1", foreign.id, "my-session", acpprofile.AgentCodexID, "/data", "", "user-1"); !errors.Is(err, ErrRuntimeNotFound) { t.Fatalf("BindRuntime(cross bot) error = %v, want ErrRuntimeNotFound", err) } if _, ok := pool.ResolveRuntimeToolContext("bot-1", foreign.id, "runtime-token-1"); ok { @@ -848,6 +890,67 @@ func TestSessionPoolCloseBotAgentRuntimesDoesNotWaitForActivePrompt(t *testing.T } } +func TestSessionPoolCloseBotWorkspaceTargetRuntimesIsPinnedAndNonBlocking(t *testing.T) { + pool := newSessionPool(nil, nil, nil) + matching := &runtimeHandle{ + id: newRuntimeID(), + botID: "bot-1", + agentID: acpprofile.AgentCodexID, + workspaceTargetID: "computer-1", + session: &client.Session{}, + status: stateActive, + lastActive: time.Now(), + boundSession: "session-1", + } + otherTarget := &runtimeHandle{ + id: newRuntimeID(), + botID: "bot-1", + agentID: acpprofile.AgentCodexID, + workspaceTargetID: "computer-2", + session: &client.Session{}, + status: stateIdle, + lastActive: time.Now(), + } + otherBot := &runtimeHandle{ + id: newRuntimeID(), + botID: "bot-2", + agentID: acpprofile.AgentCodexID, + workspaceTargetID: "computer-1", + session: &client.Session{}, + status: stateIdle, + lastActive: time.Now(), + } + injectRuntime(pool, matching) + injectRuntime(pool, otherTarget) + injectRuntime(pool, otherBot) + matching.op.Lock() + defer matching.op.Unlock() + + done := make(chan error, 1) + go func() { + done <- pool.CloseBotWorkspaceTargetRuntimes("bot-1", "computer-1") + }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("CloseBotWorkspaceTargetRuntimes() error = %v", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("CloseBotWorkspaceTargetRuntimes waited for the active prompt op lock") + } + + pool.mu.RLock() + _, matchingExists := pool.runtimes[matching.id] + _, otherTargetExists := pool.runtimes[otherTarget.id] + _, otherBotExists := pool.runtimes[otherBot.id] + pool.mu.RUnlock() + if matchingExists || !otherTargetExists || !otherBotExists { + t.Fatalf("target close scope = matching:%v other-target:%v other-bot:%v", + matchingExists, otherTargetExists, otherBotExists) + } +} + func TestSessionPoolUnboundCapEvictsOldestIdle(t *testing.T) { pool := newFakeScriptPool(t) @@ -1870,6 +1973,192 @@ func TestSessionPoolRejectsUnsupportedBackend(t *testing.T) { } } +func TestSessionPoolRemoteACPStartsLocalAdapterWithoutManagedState(t *testing.T) { + tests := []struct { + name string + agentID string + command string + capability string + bot bots.Bot + }{ + { + name: "Codex", + agentID: acpprofile.AgentCodexID, + command: "codex-acp", + capability: "acp_codex", + bot: enabledACPBot("bot-1", "api_key", map[string]any{"api_key": "must-not-cross"}), + }, + { + name: "Claude Code", + agentID: acpprofile.AgentClaudeCodeID, + command: "claude-agent-acp", + capability: "acp_claude_code", + bot: enabledACPAgentBot( + "bot-1", + acpprofile.AgentClaudeCodeID, + "api_key", + map[string]any{"api_key": "must-not-cross"}, + ), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runner := &recordingRunner{ + info: bridge.WorkspaceInfo{ + Backend: bridge.WorkspaceBackendRemote, + OS: "darwin", + DefaultWorkDir: "/Users/alice", + Capabilities: []string{"fs", "exec", "host_fs", tt.capability}, + TargetID: "computer-1", + TargetKind: "remote", + TargetName: "Alice's Mac", + }, + startErr: errors.New("started"), + } + pool := newSessionPool(nil, runner, fakeBotGetter{bot: tt.bot}) + _, err := pool.Prompt(context.Background(), PromptInput{ + BotID: "bot-1", + SessionID: "session-1", + AgentID: tt.agentID, + ProjectPath: "/data/project", + WorkspaceTargetID: "computer-1", + RuntimeOwnerAccountID: "user-1", + Prompt: "run", + }) + if err == nil || err.Error() != "started" { + t.Fatalf("Prompt() error = %v, want runner start error", err) + } + if runner.req.Command != tt.command || runner.req.Resolved == nil { + t.Fatalf("remote request = %#v", runner.req) + } + if runner.req.Resolved.Backend != client.WorkspaceBackendRemote || + runner.req.Resolved.WorkspaceRoot != "/" || + runner.req.Resolved.ProjectPath != "/Users/alice/project" { + t.Fatalf("remote resolved context = %#v", runner.req.Resolved) + } + if len(runner.req.Env) != 0 || runner.req.CleanEnv || len(runner.req.UnsetEnv) != 0 { + t.Fatalf("remote request carried Server-managed environment: %#v", runner.req) + } + }) + } +} + +func TestSessionPoolRemoteACPRequiresCapabilityAndSupportedOS(t *testing.T) { + base := bridge.WorkspaceInfo{ + Backend: bridge.WorkspaceBackendRemote, + OS: "darwin", + DefaultWorkDir: "/Users/alice", + Capabilities: []string{"fs", "exec", "host_fs", "acp_codex"}, + TargetID: "computer-1", + TargetKind: "remote", + } + tests := []struct { + name string + info bridge.WorkspaceInfo + code string + key string + }{ + { + name: "adapter missing", + info: func() bridge.WorkspaceInfo { + value := base + value.Capabilities = []string{"fs", "exec", "host_fs"} + return value + }(), + code: feedback.CodeRemoteAdapterMissing, + key: "chat.acp.remoteAdapterMissing", + }, + { + name: "unsupported OS", + info: func() bridge.WorkspaceInfo { + value := base + value.OS = "win32" + value.DefaultWorkDir = `C:\\Users\\alice` + return value + }(), + code: feedback.CodeRemoteOSUnsupported, + key: "chat.acp.remoteOSUnsupported", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runner := &recordingRunner{info: tt.info, startErr: errors.New("must not start")} + pool := newSessionPool(nil, runner, fakeBotGetter{bot: enabledACPBot("bot-1", "api_key", map[string]any{"api_key": "ignored"})}) + _, err := pool.Prompt(context.Background(), PromptInput{ + BotID: "bot-1", + SessionID: "session-1", + AgentID: acpprofile.AgentCodexID, + WorkspaceTargetID: "computer-1", + RuntimeOwnerAccountID: "user-1", + Prompt: "run", + }) + var feedbackErr *feedback.Error + if !errors.As(err, &feedbackErr) || feedbackErr.Code != tt.code || feedbackErr.I18nKey != tt.key { + t.Fatalf("Prompt() feedback = %#v, want %s/%s", feedbackErr, tt.code, tt.key) + } + if runner.req.AgentID != "" { + t.Fatalf("runner started unexpectedly: %#v", runner.req) + } + }) + } +} + +func TestSessionPoolUsesPersistedWorkdirTargetForEveryWorkspaceLookup(t *testing.T) { + runner := &targetRecordingRunner{ + info: bridge.WorkspaceInfo{ + Backend: bridge.WorkspaceBackendRemote, + OS: "linux", + DefaultWorkDir: "/home/alice", + Capabilities: []string{"acp_codex"}, + TargetID: "computer-1", + TargetKind: "remote", + }, + startErr: errors.New("started"), + } + pool := newSessionPool(nil, runner, fakeBotGetter{bot: enabledACPBot("bot-1", "self", nil)}, fakeSessionGetter{session: SessionDescriptor{ + BotID: "bot-1", + SessionType: sessionmode.ACPAgent, + Metadata: map[string]any{"acp_agent_id": "codex", "runtime_owner_account_id": "user-1"}, + WorkspaceTargetID: "computer-1", + WorkspaceTargetKind: "remote", + WorkdirPath: "/home/alice/project", + IsACP: true, + }}) + _, err := pool.Prompt(context.Background(), PromptInput{SessionID: "session-1", Prompt: "run"}) + if err == nil || err.Error() != "started" { + t.Fatalf("Prompt() error = %v, want runner start error", err) + } + if len(runner.targets) < 2 { + t.Fatalf("workspace lookups = %#v, want prepare and start", runner.targets) + } + for _, targetID := range runner.targets { + if targetID != "computer-1" { + t.Fatalf("workspace lookup target = %q, want computer-1; all = %#v", targetID, runner.targets) + } + } +} + +func TestSessionPoolRejectsPinnedTargetWhenWorkspaceResolutionDropsIdentity(t *testing.T) { + runner := &recordingRunner{info: bridge.WorkspaceInfo{ + Backend: bridge.WorkspaceBackendRemote, + OS: "linux", + DefaultWorkDir: "/home/alice", + Capabilities: []string{"acp_codex"}, + }} + pool := newSessionPool(nil, runner, fakeBotGetter{bot: enabledACPBot("bot-1", "self", nil)}) + + _, err := pool.prepareInput(context.Background(), PromptInput{ + BotID: "bot-1", + SessionID: "session-1", + AgentID: acpprofile.AgentCodexID, + WorkspaceTargetID: "computer-1", + RuntimeOwnerAccountID: "user-1", + }) + if err == nil || !strings.Contains(err.Error(), `does not match requested target "computer-1"`) { + t.Fatalf("prepareInput() error = %v, want missing target identity rejection", err) + } +} + func TestProfileSupportsBackend(t *testing.T) { if !profileSupportsBackend(acpprofile.Profile{}, "custom-backend") { t.Fatal("profile with no supported_backends should allow any backend") @@ -2001,6 +2290,31 @@ func TestSessionPoolUsesWorkspaceACPToolsEndpointForContainer(t *testing.T) { } } +func TestSessionPoolRequiresHTTPSMemohToolsEndpointForRemote(t *testing.T) { + pool := newSessionPool(nil, nil, nil) + pool.SetToolGateway(mcp.NewToolGatewayService(nil, nil)) + remote := bridge.WorkspaceInfo{Backend: bridge.WorkspaceBackendRemote} + + got, err := pool.resolveToolHTTPURL("https://memoh.example/bots/bot-1/tools", remote) + if err != nil || got != "https://memoh.example/bots/bot-1/tools" { + t.Fatalf("remote HTTPS ToolHTTPURL = %q, %v", got, err) + } + // Loopback HTTP is the local development flow: the connected computer is + // the server host itself. + if got, err := pool.resolveToolHTTPURL("http://127.0.0.1:18080/bots/bot-1/tools", remote); err != nil || got != "http://127.0.0.1:18080/bots/bot-1/tools" { + t.Fatalf("loopback tools URL = %q, %v", got, err) + } + if got, err := pool.resolveToolHTTPURL("http://localhost:18080/bots/bot-1/tools", remote); err != nil || got != "http://localhost:18080/bots/bot-1/tools" { + t.Fatalf("localhost tools URL = %q, %v", got, err) + } + if _, err := pool.resolveToolHTTPURL("http://memoh.example/bots/bot-1/tools", remote); err == nil || !strings.Contains(err.Error(), "HTTPS") { + t.Fatalf("remote HTTP ToolHTTPURL error = %v, want HTTPS requirement", err) + } + if got, err := pool.resolveToolHTTPURL("", remote); err != nil || got != "" { + t.Fatalf("empty remote ToolHTTPURL = %q, %v", got, err) + } +} + func TestRuntimeHandleToolContextOverlaysActivePrompt(t *testing.T) { h := &runtimeHandle{ id: "rt_test", @@ -2728,6 +3042,12 @@ type recordingRunner struct { startErr error } +type targetRecordingRunner struct { + info bridge.WorkspaceInfo + startErr error + targets []string +} + type hermesRecordingRunner struct { info bridge.WorkspaceInfo client *bridge.Client @@ -2795,6 +3115,15 @@ func (r *recordingRunner) StartSession(_ context.Context, req client.StartReques return nil, r.startErr } +func (r *targetRecordingRunner) WorkspaceInfo(ctx context.Context, _ string) (bridge.WorkspaceInfo, error) { + r.targets = append(r.targets, workspace.WorkspaceTargetFromContext(ctx)) + return r.info, nil +} + +func (r *targetRecordingRunner) StartSession(_ context.Context, _ client.StartRequest, _ client.EventSink) (*client.Session, error) { + return nil, r.startErr +} + func (r *hermesRecordingRunner) WorkspaceInfo(context.Context, string) (bridge.WorkspaceInfo, error) { return r.info, nil } diff --git a/internal/agent/tool/native_source.go b/internal/agent/tool/native_source.go index 72a1c9b576..c300623739 100644 --- a/internal/agent/tool/native_source.go +++ b/internal/agent/tool/native_source.go @@ -306,6 +306,7 @@ func (s *NativeToolSource) requireApproval(ctx context.Context, session mcp.Tool SessionID: session.SessionID, RouteID: session.RouteID, ChannelIdentityID: session.ChannelIdentityID, + WorkspaceTargetID: session.WorkspaceTargetID, RequestedByChannelIdentityID: session.ChannelIdentityID, ToolCallID: toolCallID, ToolName: toolName, @@ -428,6 +429,10 @@ func sessionFromMCP(session mcp.ToolSessionContext) SessionContext { CurrentPlatform: session.CurrentPlatform, ReplyTarget: session.ReplyTarget, ConversationType: session.ConversationType, + WorkspaceTargetID: session.WorkspaceTargetID, + WorkspaceTargetKind: session.WorkspaceTargetKind, + WorkspaceTargetName: session.WorkspaceTargetName, + WorkdirPath: session.WorkdirPath, CanRequestUserInput: session.CanRequestUserInput, CanListUserInput: session.CanListUserInput, SupportsImageInput: session.SupportsImageInput, diff --git a/internal/agent/tool/native_source_test.go b/internal/agent/tool/native_source_test.go index 8561674a19..6e02ff8213 100644 --- a/internal/agent/tool/native_source_test.go +++ b/internal/agent/tool/native_source_test.go @@ -98,6 +98,23 @@ func TestMCPSessionRoundTripPreservesReasoningIntent(t *testing.T) { } } +func TestSessionFromMCPPreservesWorkspaceBinding(t *testing.T) { + session := sessionFromMCP(mcp.ToolSessionContext{ + BotID: "bot-1", + WorkspaceTargetID: "target-1", + WorkspaceTargetKind: "remote", + WorkspaceTargetName: "Office Mac", + WorkdirPath: "/Users/alice/project", + }) + + if session.WorkspaceTargetID != "target-1" || + session.WorkspaceTargetKind != "remote" || + session.WorkspaceTargetName != "Office Mac" || + session.WorkdirPath != "/Users/alice/project" { + t.Fatalf("workspace binding = %#v", session) + } +} + func TestNativeToolSourceAllowlistIgnoresUnknownNames(t *testing.T) { provider := &nativeSourceTestProvider{ tools: []sdk.Tool{{ @@ -376,6 +393,7 @@ func TestNativeToolSourceWaitsForApprovalAndPublishesRequest(t *testing.T) { RunID: "run-1", ToolCallID: "mcp-http-call-1", ChannelIdentityID: "user-1", + WorkspaceTargetID: "target-1", CurrentPlatform: "web", ReplyTarget: "reply-1", ConversationType: "private", @@ -392,6 +410,9 @@ func TestNativeToolSourceWaitsForApprovalAndPublishesRequest(t *testing.T) { if approval.created.ToolCallID != "mcp-http-call-1" { t.Fatalf("approval tool_call_id = %q, want existing MCP tool call id", approval.created.ToolCallID) } + if approval.created.WorkspaceTargetID != "target-1" { + t.Fatalf("approval workspace target = %q, want session target", approval.created.WorkspaceTargetID) + } if len(toolEvents.events) != 2 { t.Fatalf("tool events = %d, want pending and approved approval events", len(toolEvents.events)) } diff --git a/internal/apperror/error.go b/internal/apperror/error.go index 282a973dec..abb458cc74 100644 --- a/internal/apperror/error.go +++ b/internal/apperror/error.go @@ -25,6 +25,8 @@ const ( CodeContextBudgetUnsatisfied Code = "context.budget_unsatisfied" CodeContextProtectedOverflow Code = "context.protected_overflow" CodeWorkspaceUnreachable Code = "workspace.unreachable" + CodeWorkspaceReadPermissionRequired Code = "workspace.read_permission_required" + CodeWorkspaceTargetInUse Code = "workspace.target_in_use" CodeWorkspaceImageIncompatible Code = "workspace.image_incompatible" CodeWorkspaceTemplateBootstrapFailed Code = "workspace.template_bootstrap_failed" CodeWorkspaceDisplayPrepareFailed Code = "workspace.display_prepare_failed" @@ -163,6 +165,14 @@ var catalog = map[Code]Definition{ HTTPStatus: http.StatusServiceUnavailable, Detail: "The workspace could not be reached.", }, + CodeWorkspaceReadPermissionRequired: { + HTTPStatus: http.StatusForbidden, + Detail: "Ask the bot owner for file-read access before using this connected computer.", + }, + CodeWorkspaceTargetInUse: { + HTTPStatus: http.StatusConflict, + Detail: "This computer is still used by a folder and cannot be disconnected.", + }, CodeWorkspaceImageIncompatible: { HTTPStatus: http.StatusUnprocessableEntity, Detail: "The workspace image is incompatible with this version of Memoh.", diff --git a/internal/apperror/error_test.go b/internal/apperror/error_test.go index 382b8ab72e..2d9fdf433a 100644 --- a/internal/apperror/error_test.go +++ b/internal/apperror/error_test.go @@ -46,6 +46,22 @@ func TestProblemFromUsesCatalogAndDoesNotExposeCause(t *testing.T) { } } +func TestWorkspaceReadPermissionRequiredProblem(t *testing.T) { + problem, ok := ProblemFrom(New(CodeWorkspaceReadPermissionRequired, nil), "req-read") + if !ok { + t.Fatal("ProblemFrom() did not recognize workspace read permission error") + } + if problem.Status != http.StatusForbidden { + t.Fatalf("status = %d, want %d", problem.Status, http.StatusForbidden) + } + if problem.Type != "urn:memoh:error:workspace.read_permission_required" { + t.Fatalf("type = %q", problem.Type) + } + if problem.Detail != "Ask the bot owner for file-read access before using this connected computer." { + t.Fatalf("detail = %q", problem.Detail) + } +} + func TestPublicFromIsSharedByTransportAdapters(t *testing.T) { err := New(CodeBotNameTaken, map[string]string{"field": "name"}) public, ok := PublicFrom(err, "req-public") diff --git a/internal/db/acp_session_state_migration_integration_test.go b/internal/db/acp_session_state_migration_integration_test.go index 22bc03d68f..a8db4e7044 100644 --- a/internal/db/acp_session_state_migration_integration_test.go +++ b/internal/db/acp_session_state_migration_integration_test.go @@ -19,10 +19,11 @@ func TestACPSessionStateMigrationAndCanonicalSchema(t *testing.T) { assertACPSessionStateSchema(t, ctx, pool, true) assertACPSessionRunCandidateIndex(t, ctx, pool, true, true) - // 0141 adds Bot Agents, 0140 removes Heartbeat, and 0139 is the reset - // fence. Crossing 0138 removes the ACP tables and detaches the constraint - // while preserving 0137's standalone index. - stepDown(t, dsn, 4) + // 0142 changes the workdir FK, 0141 adds Bot Agents, 0140 removes + // Heartbeat, and 0139 is the reset fence. Crossing 0138 removes the ACP + // tables and detaches the constraint while preserving 0137's standalone + // index. + stepDown(t, dsn, 5) assertACPSessionStateSchema(t, ctx, pool, false) assertACPSessionRunCandidateIndex(t, ctx, pool, true, false) @@ -33,7 +34,7 @@ func TestACPSessionStateMigrationAndCanonicalSchema(t *testing.T) { stepUp(t, dsn, 1) assertACPSessionRunCandidateIndex(t, ctx, pool, true, false) - stepUp(t, dsn, 4) + stepUp(t, dsn, 5) assertACPSessionStateSchema(t, ctx, pool, true) assertACPSessionRunCandidateIndex(t, ctx, pool, true, true) }) @@ -121,7 +122,7 @@ func assertACPSessionStateSchema(t *testing.T, ctx context.Context, pool *pgxpoo } if !want { if states || lines || publications || candidateKey != "" || runFK != "" || linesFK != "" || publicationRunFK != "" { - t.Fatalf("ACP schema survived 0135 down: states=%t lines=%t publications=%t candidate=%q runFK=%q linesFK=%q publicationFK=%q", + t.Fatalf("ACP schema survived 0138 down: states=%t lines=%t publications=%t candidate=%q runFK=%q linesFK=%q publicationFK=%q", states, lines, publications, candidateKey, runFK, linesFK, publicationRunFK) } return diff --git a/internal/db/bot_agents_migration_integration_test.go b/internal/db/bot_agents_migration_integration_test.go index 2ce06352c3..4f4d39191d 100644 --- a/internal/db/bot_agents_migration_integration_test.go +++ b/internal/db/bot_agents_migration_integration_test.go @@ -18,7 +18,9 @@ func TestBotAgentsMigrationAndCanonicalSchema(t *testing.T) { dsn := teamMigrationDSN(t) pool := freshMigratedDB(t) - stepDown(t, dsn, 1) + // 0142 follows the Bot Agent migration, so cross both migrations to + // inspect and seed the 0140 schema. + stepDown(t, dsn, 2) assertBotAgentsSchema(t, ctx, pool, false) const ( @@ -113,6 +115,7 @@ func TestBotAgentsMigrationAndCanonicalSchema(t *testing.T) { stepUp(t, dsn, 1) assertBotAgentsSchema(t, ctx, pool, true) assertBotAgentConstraintsValidated(t, ctx, pool, false) + stepUp(t, dsn, 1) }) t.Run("canonical init contains final Bot Agent schema", func(t *testing.T) { diff --git a/internal/db/bot_workdir_remote_binding_migration_integration_test.go b/internal/db/bot_workdir_remote_binding_migration_integration_test.go new file mode 100644 index 0000000000..3c339bf0af --- /dev/null +++ b/internal/db/bot_workdir_remote_binding_migration_integration_test.go @@ -0,0 +1,150 @@ +//go:build integration + +package db_test + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/felinics/memoh/internal/team" +) + +func TestBotWorkdirRemoteBindingRestrictMigrationRoundTrip(t *testing.T) { + ctx := context.Background() + pool := freshMigratedDB(t) + dsn := teamMigrationDSN(t) + + assertBotWorkdirRemoteBindingDeleteAction(t, ctx, pool, "r", false) + stepDown(t, dsn, countMigrationsFrom(t, "0142_bot_workdirs_remote_binding_restrict.up.sql")) + assertBotWorkdirRemoteBindingDeleteAction(t, ctx, pool, "c", false) + stepUp(t, dsn, countMigrationsFrom(t, "0142_bot_workdirs_remote_binding_restrict.up.sql")) + assertBotWorkdirRemoteBindingDeleteAction(t, ctx, pool, "r", false) +} + +func TestReferencedRemoteBindingCannotBeDeleted(t *testing.T) { + ctx := context.Background() + pool := freshMigratedDB(t) + conn, err := pool.Acquire(ctx) + if err != nil { + t.Fatalf("acquire connection: %v", err) + } + defer conn.Release() + if _, err := conn.Exec(ctx, "SELECT set_config('memoh.team_id', $1, false)", team.DefaultTeamID); err != nil { + t.Fatalf("set team context: %v", err) + } + + userID := uuid.NewString() + botID := uuid.NewString() + runtimeID := uuid.NewString() + targetID := uuid.NewString() + workdirID := uuid.NewString() + sessionID := uuid.NewString() + seeds := []struct { + name string + query string + args []any + }{ + {"user", "INSERT INTO users (id, username) VALUES ($1, $2)", []any{userID, "guard-" + userID[:8]}}, + {"membership", "INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, 'admin')", []any{team.DefaultTeamID, userID}}, + {"bot", "INSERT INTO bots (id, owner_user_id, name) VALUES ($1, $2, $3)", []any{botID, userID, "guard-bot-" + botID[:8]}}, + {"runtime", "INSERT INTO user_runtimes (id, user_id, name, api_token) VALUES ($1, $2, 'Guard Runtime', $3)", []any{runtimeID, userID, "guard-token-" + runtimeID}}, + {"target", "INSERT INTO bot_remote_runtime_bindings (id, bot_id, runtime_id) VALUES ($1, $2, $3)", []any{targetID, botID, runtimeID}}, + {"workdir", `INSERT INTO bot_workdirs (id, bot_id, name, target_kind, remote_binding_id, path) +VALUES ($1, $2, 'Pinned Folder', 'remote', $3, '/tmp/pinned')`, []any{workdirID, botID, targetID}}, + {"session", "INSERT INTO bot_sessions (id, bot_id, workdir_id) VALUES ($1, $2, $3)", []any{sessionID, botID, workdirID}}, + } + for _, seed := range seeds { + if _, err := conn.Exec(ctx, seed.query, seed.args...); err != nil { + t.Fatalf("seed referenced target %s: %v", seed.name, err) + } + } + + _, err = conn.Exec(ctx, ` +INSERT INTO bot_workdirs (id, bot_id, name, target_kind, remote_binding_id, path) +VALUES ($1, $2, 'Invalid Folder', 'remote', $3, '/tmp/invalid')`, + uuid.NewString(), botID, uuid.NewString(), + ) + assertRemoteBindingForeignKeyViolation(t, err) + + _, err = conn.Exec(ctx, "DELETE FROM bot_remote_runtime_bindings WHERE id = $1", targetID) + assertRemoteBindingRestrictViolation(t, err) + if _, err := conn.Exec(ctx, "UPDATE bot_workdirs SET archived_at = now() WHERE id = $1", workdirID); err != nil { + t.Fatalf("archive workdir: %v", err) + } + _, err = conn.Exec(ctx, "DELETE FROM bot_remote_runtime_bindings WHERE id = $1", targetID) + assertRemoteBindingRestrictViolation(t, err) + + var pinnedWorkdirID string + if err := conn.QueryRow(ctx, "SELECT workdir_id::text FROM bot_sessions WHERE id = $1", sessionID).Scan(&pinnedWorkdirID); err != nil { + t.Fatalf("read pinned session: %v", err) + } + if pinnedWorkdirID != workdirID { + t.Fatalf("session workdir = %q, want %q", pinnedWorkdirID, workdirID) + } + + unreferencedRuntimeID := uuid.NewString() + unreferencedTargetID := uuid.NewString() + if _, err := conn.Exec(ctx, + "INSERT INTO user_runtimes (id, user_id, name, api_token) VALUES ($1, $2, 'Unused Runtime', $3)", + unreferencedRuntimeID, userID, "guard-token-"+unreferencedRuntimeID, + ); err != nil { + t.Fatalf("seed unreferenced runtime: %v", err) + } + if _, err := conn.Exec(ctx, + "INSERT INTO bot_remote_runtime_bindings (id, bot_id, runtime_id) VALUES ($1, $2, $3)", + unreferencedTargetID, botID, unreferencedRuntimeID, + ); err != nil { + t.Fatalf("seed unreferenced target: %v", err) + } + result, err := conn.Exec(ctx, "DELETE FROM bot_remote_runtime_bindings WHERE id = $1", unreferencedTargetID) + if err != nil { + t.Fatalf("delete unreferenced target: %v", err) + } + if result.RowsAffected() != 1 { + t.Fatalf("deleted rows = %d, want 1", result.RowsAffected()) + } +} + +func assertRemoteBindingForeignKeyViolation(t *testing.T, err error) { + t.Helper() + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + t.Fatalf("insert invalid remote binding error = %v, want PostgreSQL error", err) + } + if pgErr.Code != "23503" || pgErr.ConstraintName != "bot_workdirs_remote_binding_fkey" { + t.Fatalf("insert invalid remote binding = SQLSTATE %q constraint %q, want 23503 bot_workdirs_remote_binding_fkey", pgErr.Code, pgErr.ConstraintName) + } +} + +func assertRemoteBindingRestrictViolation(t *testing.T, err error) { + t.Helper() + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + t.Fatalf("delete referenced target error = %v, want PostgreSQL error", err) + } + if pgErr.Code != "23001" || pgErr.ConstraintName != "bot_workdirs_remote_binding_fkey" { + t.Fatalf("delete referenced target = SQLSTATE %q constraint %q, want 23001 bot_workdirs_remote_binding_fkey", pgErr.Code, pgErr.ConstraintName) + } +} + +func assertBotWorkdirRemoteBindingDeleteAction(t *testing.T, ctx context.Context, pool *pgxpool.Pool, want string, wantValidated bool) { + t.Helper() + var deleteAction string + var validated bool + if err := pool.QueryRow(ctx, ` +SELECT confdeltype::text, convalidated +FROM pg_constraint +WHERE conrelid = 'public.bot_workdirs'::regclass + AND conname = 'bot_workdirs_remote_binding_fkey' +`).Scan(&deleteAction, &validated); err != nil { + t.Fatalf("inspect bot_workdirs remote binding constraint: %v", err) + } + if deleteAction != want || validated != wantValidated { + t.Fatalf("remote binding constraint = delete action %q, validated %t; want %q, %t", deleteAction, validated, want, wantValidated) + } +} diff --git a/internal/db/bot_workdir_remote_binding_migration_test.go b/internal/db/bot_workdir_remote_binding_migration_test.go new file mode 100644 index 0000000000..b09ce7e2f7 --- /dev/null +++ b/internal/db/bot_workdir_remote_binding_migration_test.go @@ -0,0 +1,59 @@ +package db + +import ( + "strings" + "testing" +) + +func TestBotWorkdirRemoteBindingDeletePolicyMigrations(t *testing.T) { + t.Parallel() + + baseline := botWorkdirsTableSQL(readEmbeddedMigration(t, "postgres/migrations/0001_init.up.sql")) + if !strings.Contains(baseline, "CONSTRAINT bot_workdirs_remote_binding_fkey") || + !strings.Contains(baseline, "REFERENCES public.bot_remote_runtime_bindings(team_id, id) ON DELETE RESTRICT") { + t.Fatal("canonical bot_workdirs schema must restrict deletion of referenced remote bindings") + } + if strings.Contains(baseline, "REFERENCES public.bot_remote_runtime_bindings(team_id, id) ON DELETE CASCADE") { + t.Fatal("canonical bot_workdirs schema still cascades remote binding deletion") + } + + // 0129 is already published and must remain byte-semantically historical; + // deployed databases receive the policy change only through 0142. + published := readEmbeddedMigration(t, "postgres/migrations/0129_bot_workdirs.up.sql") + if !strings.Contains(published, "REFERENCES public.bot_remote_runtime_bindings(team_id, id) ON DELETE CASCADE") { + t.Fatal("published 0129 migration no longer contains its original CASCADE constraint") + } + + up := readEmbeddedMigration(t, "postgres/migrations/0142_bot_workdirs_remote_binding_restrict.up.sql") + if !strings.HasPrefix(up, "-- 0142_bot_workdirs_remote_binding_restrict\n") || + !strings.Contains(up, "DROP CONSTRAINT IF EXISTS bot_workdirs_remote_binding_fkey") || + !strings.Contains(up, "ON DELETE RESTRICT") { + t.Fatal("0142 up migration must replace the published foreign key with RESTRICT") + } + if strings.Contains(up, "ON DELETE CASCADE") || !strings.Contains(up, "NOT VALID") { + t.Fatal("0142 up migration must install an RLS-safe NOT VALID RESTRICT constraint") + } + + down := readEmbeddedMigration(t, "postgres/migrations/0142_bot_workdirs_remote_binding_restrict.down.sql") + if !strings.HasPrefix(down, "-- 0142_bot_workdirs_remote_binding_restrict\n") || + !strings.Contains(down, "DROP CONSTRAINT IF EXISTS bot_workdirs_remote_binding_fkey") || + !strings.Contains(down, "ON DELETE CASCADE") { + t.Fatal("0142 down migration must restore the published CASCADE constraint") + } + if !strings.Contains(down, "NOT VALID") { + t.Fatal("0142 down migration must restore an RLS-safe NOT VALID CASCADE constraint") + } +} + +func botWorkdirsTableSQL(sql string) string { + start := strings.Index(sql, "CREATE TABLE IF NOT EXISTS public.bot_workdirs") + if start < 0 { + return "" + } + tail := sql[start:] + end := strings.Index(tail, "CREATE UNIQUE INDEX IF NOT EXISTS bot_workdirs_target_path_unique") + if end < 0 { + return tail + } + return tail[:end] +} diff --git a/internal/db/errors.go b/internal/db/errors.go index fe70324edb..be6cab8a99 100644 --- a/internal/db/errors.go +++ b/internal/db/errors.go @@ -3,8 +3,9 @@ package db import "errors" var ( - ErrNotFound = errors.New("database record not found") - ErrLastActiveAdmin = errors.New("team must retain at least one active admin") + ErrNotFound = errors.New("database record not found") + ErrLastActiveAdmin = errors.New("team must retain at least one active admin") + ErrWorkspaceTargetInUse = errors.New("workspace target is referenced by a workdir") // ErrCommitOutcomeUnknown means a transaction's COMMIT acknowledgement was // lost. Callers that would perform destructive compensation must reconcile // an idempotency/publication key on a fresh connection first. diff --git a/internal/db/postgres/store/bot_remote_runtime_bindings.go b/internal/db/postgres/store/bot_remote_runtime_bindings.go index b1df834aab..ec08f1a55b 100644 --- a/internal/db/postgres/store/bot_remote_runtime_bindings.go +++ b/internal/db/postgres/store/bot_remote_runtime_bindings.go @@ -2,8 +2,10 @@ package postgresstore import ( "context" + "errors" "strings" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/felinics/memoh/internal/db" @@ -167,6 +169,20 @@ func (s *Store) DeleteMount(ctx context.Context, botID, targetID string) error { _, err = s.queries.DeleteBotRemoteRuntimeMount(ctx, dbsqlc.DeleteBotRemoteRuntimeMountParams{ BotID: botUUID, TargetID: targetUUID, }) + return mapDeleteRemoteMountErr(err) +} + +func mapDeleteRemoteMountErr(err error) error { + var pgErr *pgconn.PgError + // PostgreSQL 18 reports an ON DELETE RESTRICT failure as + // restrict_violation (23001); older releases used + // foreign_key_violation (23503). Match both so self-hosted installs on an + // older PostgreSQL still get the mapped 409 instead of a generic 500. + if errors.As(err, &pgErr) && + (pgErr.Code == "23001" || pgErr.Code == "23503") && + pgErr.ConstraintName == "bot_workdirs_remote_binding_fkey" { + return db.ErrWorkspaceTargetInUse + } return mapQueryErr(err) } diff --git a/internal/db/postgres/store/bot_remote_runtime_bindings_test.go b/internal/db/postgres/store/bot_remote_runtime_bindings_test.go new file mode 100644 index 0000000000..f9e2c0c6e3 --- /dev/null +++ b/internal/db/postgres/store/bot_remote_runtime_bindings_test.go @@ -0,0 +1,43 @@ +package postgresstore + +import ( + "errors" + "fmt" + "testing" + + "github.com/jackc/pgx/v5/pgconn" + + "github.com/felinics/memoh/internal/db" +) + +func TestMapDeleteRemoteMountErrRecognizesOnlyWorkdirRestrictViolation(t *testing.T) { + t.Parallel() + + // PostgreSQL 18 reports RESTRICT as 23001; older releases used 23503. + for _, code := range []string{"23001", "23503"} { + inUse := fmt.Errorf("delete remote mount: %w", &pgconn.PgError{ + Code: code, + ConstraintName: "bot_workdirs_remote_binding_fkey", + }) + if err := mapDeleteRemoteMountErr(inUse); !errors.Is(err, db.ErrWorkspaceTargetInUse) { + t.Fatalf("mapDeleteRemoteMountErr(%s) = %v, want ErrWorkspaceTargetInUse", code, err) + } + } + + for name, pgErr := range map[string]*pgconn.PgError{ + "different foreign key": { + Code: "23001", + ConstraintName: "some_other_foreign_key", + }, + "different SQLSTATE": { + Code: "22000", + ConstraintName: "bot_workdirs_remote_binding_fkey", + }, + } { + t.Run(name, func(t *testing.T) { + if got := mapDeleteRemoteMountErr(pgErr); !errors.Is(got, pgErr) { + t.Fatalf("mapDeleteRemoteMountErr() = %v, want original database error", got) + } + }) + } +} diff --git a/internal/handlers/acp_claude_code_oauth.go b/internal/handlers/acp_claude_code_oauth.go index 89d8929c1f..2f80d0a91f 100644 --- a/internal/handlers/acp_claude_code_oauth.go +++ b/internal/handlers/acp_claude_code_oauth.go @@ -262,6 +262,7 @@ func (h *ACPClaudeCodeOAuthHandler) requireBotAccess(c echo.Context) (bots.Bot, func (h *ACPClaudeCodeOAuthHandler) ensureManagedWorkspace(ctx context.Context, botID string) error { // The OAuth token is persisted in bot metadata and injected via // CLAUDE_CODE_OAUTH_TOKEN when the workspace session starts. + ctx = nativeACPWorkspaceContext(ctx) if _, err := h.acpWorkspace.WorkspaceInfo(ctx, botID); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } diff --git a/internal/handlers/acp_claude_code_oauth_test.go b/internal/handlers/acp_claude_code_oauth_test.go index 273023478b..4fa4fc0f7e 100644 --- a/internal/handlers/acp_claude_code_oauth_test.go +++ b/internal/handlers/acp_claude_code_oauth_test.go @@ -149,3 +149,13 @@ func TestUpsertClaudeCodeOAuthMetadataStoresBotScopedToken(t *testing.T) { t.Fatalf("original metadata was mutated") } } + +func TestACPClaudeCodeEnsureManagedWorkspaceUsesNativeTarget(t *testing.T) { + workspaceProvider := &usersACPConfigWorkspace{} + handler := &ACPClaudeCodeOAuthHandler{acpWorkspace: workspaceProvider} + + if err := handler.ensureManagedWorkspace(context.Background(), "bot-1"); err != nil { + t.Fatalf("ensureManagedWorkspace() error = %v", err) + } + assertWorkspaceTargetsNative(t, "Claude Code WorkspaceInfo", workspaceProvider.workspaceInfoTargetsSnapshot()) +} diff --git a/internal/handlers/acp_codex_oauth.go b/internal/handlers/acp_codex_oauth.go index 1cdf7a8d9b..a1b73ff456 100644 --- a/internal/handlers/acp_codex_oauth.go +++ b/internal/handlers/acp_codex_oauth.go @@ -169,7 +169,8 @@ func (h *ACPCodexOAuthHandler) Status(c echo.Context) error { if !status.Configured { return c.JSON(http.StatusOK, status) } - if err := h.ensureManagedWorkspace(c.Request().Context(), botID); err != nil { + ctx := nativeACPWorkspaceContext(c.Request().Context()) + if err := h.ensureManagedWorkspace(ctx, botID); err != nil { var httpErr *echo.HTTPError if errors.As(err, &httpErr) && httpErr.Code == http.StatusBadRequest { status.Configured = false @@ -178,14 +179,14 @@ func (h *ACPCodexOAuthHandler) Status(c echo.Context) error { return err } - client, err := h.acpWorkspace.MCPClient(c.Request().Context(), botID) + client, err := h.acpWorkspace.MCPClient(ctx, botID) if err != nil { return c.JSON(http.StatusOK, status) } - if !acpclient.IsCodexManagedOAuthConfig(c.Request().Context(), client) { + if !acpclient.IsCodexManagedOAuthConfig(ctx, client) { return c.JSON(http.StatusOK, status) } - auth, err := acpclient.CheckCodexManagedOAuthAuth(c.Request().Context(), client) + auth, err := acpclient.CheckCodexManagedOAuthAuth(ctx, client) if err != nil { return c.JSON(http.StatusOK, status) } @@ -262,6 +263,7 @@ func (h *ACPCodexOAuthHandler) requireBotAccess(c echo.Context) (string, string, func (h *ACPCodexOAuthHandler) ensureManagedWorkspace(ctx context.Context, botID string) error { // Managed Codex auth is stored in the bot-scoped CODEX_HOME inside the // workspace rather than a server host account. + ctx = nativeACPWorkspaceContext(ctx) if _, err := h.acpWorkspace.WorkspaceInfo(ctx, botID); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } @@ -272,6 +274,7 @@ func (h *ACPCodexOAuthHandler) writeCodexOAuthAuth(ctx context.Context, botID st if h.acpWorkspace == nil { return errors.New("workspace manager is not configured") } + ctx = nativeACPWorkspaceContext(ctx) if h.runtimeResets == nil { return apperror.Wrap( apperror.CodeSessionHistoryInconsistent, diff --git a/internal/handlers/acp_runtime.go b/internal/handlers/acp_runtime.go index 2329986588..1d86428a6d 100644 --- a/internal/handlers/acp_runtime.go +++ b/internal/handlers/acp_runtime.go @@ -20,6 +20,9 @@ import ( "github.com/felinics/memoh/internal/bots" session "github.com/felinics/memoh/internal/chat/thread" "github.com/felinics/memoh/internal/db" + "github.com/felinics/memoh/internal/workdir" + "github.com/felinics/memoh/internal/workspace" + "github.com/felinics/memoh/internal/workspace/bridge" ) type ACPRuntimeHandler struct { @@ -27,6 +30,8 @@ type ACPRuntimeHandler struct { sessionService *session.Service botService *bots.Service accountService *accounts.Service + workdirs acpRuntimeWorkdirResolver + workspaces acpRuntimeWorkspaceInfoProvider } type acpRuntimePool interface { @@ -43,6 +48,14 @@ type acpRuntimePool interface { CloseRuntime(botID, runtimeID string) error } +type acpRuntimeWorkdirResolver interface { + ResolveForSession(ctx context.Context, botID, workdirID string) (workdir.Resolved, error) +} + +type acpRuntimeWorkspaceInfoProvider interface { + WorkspaceInfo(ctx context.Context, botID string) (bridge.WorkspaceInfo, error) +} + type acpRuntimeCreateRequest struct { AgentID string `json:"acp_agent_id"` ProjectPath string `json:"project_path,omitempty"` @@ -64,15 +77,30 @@ func NewACPRuntimeHandler(pool *acpagent.SessionPool, sessionService *session.Se return newACPRuntimeHandler(pool, sessionService, botService, accountService) } -func newACPRuntimeHandler(pool acpRuntimePool, sessionService *session.Service, botService *bots.Service, accountService *accounts.Service) *ACPRuntimeHandler { +func NewACPRuntimeHandlerWithWorkspaceAccess(pool *acpagent.SessionPool, sessionService *session.Service, botService *bots.Service, accountService *accounts.Service, workdirs *workdir.Service, workspaces *workspace.Manager) *ACPRuntimeHandler { + handler := newACPRuntimeHandler(pool, sessionService, botService, accountService, workdirs) + handler.SetWorkspaceInfoProvider(workspaces) + return handler +} + +func newACPRuntimeHandler(pool acpRuntimePool, sessionService *session.Service, botService *bots.Service, accountService *accounts.Service, workdirResolvers ...acpRuntimeWorkdirResolver) *ACPRuntimeHandler { + var workdirs acpRuntimeWorkdirResolver + if len(workdirResolvers) > 0 { + workdirs = workdirResolvers[0] + } return &ACPRuntimeHandler{ pool: pool, sessionService: sessionService, botService: botService, accountService: accountService, + workdirs: workdirs, } } +func (h *ACPRuntimeHandler) SetWorkspaceInfoProvider(provider acpRuntimeWorkspaceInfoProvider) { + h.workspaces = provider +} + func (h *ACPRuntimeHandler) Register(e *echo.Echo) { e.POST("/bots/:bot_id/acp-runtimes", h.CreateRuntime) e.GET("/bots/:bot_id/acp-runtimes/:runtime_id", h.GetRuntimeByID) @@ -124,6 +152,9 @@ func (h *ACPRuntimeHandler) CreateRuntime(c echo.Context) error { if projectPath == "" { projectPath = session.DefaultACPProjectPath } + if err := h.requirePrimaryWorkspaceRead(c, channelIdentityID, bot.ID); err != nil { + return err + } status, err := h.pool.CreateRuntime(c.Request().Context(), acpagent.CreateRuntimeInput{ BotID: bot.ID, AgentID: agentID, @@ -153,13 +184,18 @@ func (h *ACPRuntimeHandler) CreateRuntime(c echo.Context) error { // @Failure 500 {object} apperror.Problem // @Router /bots/{bot_id}/acp-runtimes/{runtime_id} [get]. func (h *ACPRuntimeHandler) GetRuntimeByID(c echo.Context) error { - _, _, status, err := h.authorizedRuntimeByID(c) + bot, _, status, err := h.authorizedRuntimeByID(c) if err != nil { if errors.Is(err, acpagent.ErrRuntimeNotFound) { return runtimePoolError(err) } return acpRuntimeHTTPError(err) } + // A remote runtime's project_path is an absolute path on the connected + // computer; reading it crosses the same boundary the Set endpoints gate. + if err := h.requireRemoteRuntimeRead(c, bot.ID, status); err != nil { + return err + } return c.JSON(http.StatusOK, status) } @@ -179,13 +215,16 @@ func (h *ACPRuntimeHandler) GetRuntimeByID(c echo.Context) error { // @Failure 502 {object} apperror.Problem // @Router /bots/{bot_id}/acp-runtimes/{runtime_id}/model [patch]. func (h *ACPRuntimeHandler) SetRuntimeModel(c echo.Context) error { - bot, runtimeID, _, err := h.authorizedRuntimeByID(c) + bot, runtimeID, runtimeStatus, err := h.authorizedRuntimeByID(c) if err != nil { if errors.Is(err, acpagent.ErrRuntimeNotFound) { return runtimePoolError(err) } return acpRuntimeHTTPError(err) } + if err := h.requireRemoteRuntimeRead(c, bot.ID, runtimeStatus); err != nil { + return err + } var req acpRuntimeModelRequest if err := c.Bind(&req); err != nil { return apperror.Wrap(apperror.CodeACPRequestInvalid, err, nil) @@ -212,13 +251,16 @@ func (h *ACPRuntimeHandler) SetRuntimeModel(c echo.Context) error { // @Failure 502 {object} apperror.Problem // @Router /bots/{bot_id}/acp-runtimes/{runtime_id}/reasoning [patch]. func (h *ACPRuntimeHandler) SetRuntimeReasoning(c echo.Context) error { - bot, runtimeID, _, err := h.authorizedRuntimeByID(c) + bot, runtimeID, runtimeStatus, err := h.authorizedRuntimeByID(c) if err != nil { if errors.Is(err, acpagent.ErrRuntimeNotFound) { return runtimePoolError(err) } return acpRuntimeHTTPError(err) } + if err := h.requireRemoteRuntimeRead(c, bot.ID, runtimeStatus); err != nil { + return err + } var req acpRuntimeReasoningRequest if err := c.Bind(&req); err != nil { return apperror.Wrap(apperror.CodeACPRequestInvalid, err, nil) @@ -246,13 +288,16 @@ func (h *ACPRuntimeHandler) SetRuntimeReasoning(c echo.Context) error { // @Failure 502 {object} apperror.Problem // @Router /bots/{bot_id}/acp-runtimes/{runtime_id}/mode [patch]. func (h *ACPRuntimeHandler) SetRuntimeMode(c echo.Context) error { - bot, runtimeID, _, err := h.authorizedRuntimeByID(c) + bot, runtimeID, runtimeStatus, err := h.authorizedRuntimeByID(c) if err != nil { if errors.Is(err, acpagent.ErrRuntimeNotFound) { return runtimePoolError(err) } return acpRuntimeHTTPError(err) } + if err := h.requireRemoteRuntimeRead(c, bot.ID, runtimeStatus); err != nil { + return err + } var req acpRuntimeModeRequest if err := c.Bind(&req); err != nil { return apperror.Wrap(apperror.CodeACPRequestInvalid, err, nil) @@ -313,10 +358,13 @@ func (h *ACPRuntimeHandler) CloseRuntime(c echo.Context) error { // @Failure 500 {object} apperror.Problem // @Router /bots/{bot_id}/sessions/{session_id}/acp-runtime [get]. func (h *ACPRuntimeHandler) GetRuntime(c echo.Context) error { - _, sessionID, sess, err := h.authorizedACPSession(c) + bot, sessionID, sess, err := h.authorizedACPSession(c) if err != nil { return err } + if err := h.requireRemoteSessionWorkdirRead(c, bot.ID, sess); err != nil { + return err + } acpMeta := acpRuntimeSessionMetadata(sess) status := h.pool.RuntimeStatus(sessionID, sessionMetadataString(acpMeta, "acp_agent_id"), sessionMetadataString(acpMeta, "project_path")) return c.JSON(http.StatusOK, status) @@ -340,6 +388,9 @@ func (h *ACPRuntimeHandler) EnsureRuntime(c echo.Context) error { if err != nil { return err } + if err := h.requireRemoteSessionWorkdirRead(c, bot.ID, sess); err != nil { + return err + } botID := bot.ID acpMeta := acpRuntimeSessionMetadata(sess) if err := acpAgentSetupHTTPError(bot.Metadata, sessionMetadataString(acpMeta, "acp_agent_id")); err != nil { @@ -381,6 +432,9 @@ func (h *ACPRuntimeHandler) SetModel(c echo.Context) error { if err != nil { return err } + if err := h.requireRemoteSessionWorkdirRead(c, bot.ID, sess); err != nil { + return err + } botID := bot.ID var req acpRuntimeModelRequest if err := c.Bind(&req); err != nil { @@ -430,6 +484,9 @@ func (h *ACPRuntimeHandler) SetReasoning(c echo.Context) error { if err != nil { return err } + if err := h.requireRemoteSessionWorkdirRead(c, bot.ID, sess); err != nil { + return err + } botID := bot.ID var req acpRuntimeReasoningRequest if err := c.Bind(&req); err != nil { @@ -480,6 +537,11 @@ func (h *ACPRuntimeHandler) SetMode(c echo.Context) error { if err != nil { return err } + // SetMode cold-starts a runtime on the session's target like Ensure does, + // so it crosses the same remote permission boundary. + if err := h.requireRemoteSessionWorkdirRead(c, bot.ID, sess); err != nil { + return err + } var req acpRuntimeModeRequest if err := c.Bind(&req); err != nil { return apperror.Wrap(apperror.CodeACPRequestInvalid, err, nil) @@ -647,6 +709,96 @@ func (h *ACPRuntimeHandler) authorizedACPSession(c echo.Context) (bots.Bot, stri return bot, sessionID, sess, nil } +// requireRemoteSessionWorkdirRead gates session-scoped runtime endpoints on +// workspace_read whenever the session's effective execution target is a +// connected computer. A folder-bound session pins its own target; a session +// without a folder inherits the bot's Primary workspace, so that target is +// checked instead — otherwise a workdir-less ACP session on a remote-Primary +// bot would reach the owner's computer with only chat permission. +func (h *ACPRuntimeHandler) requireRemoteSessionWorkdirRead(c echo.Context, botID string, sess session.Thread) error { + workdirID := strings.TrimSpace(sess.WorkdirID) + if workdirID == "" { + channelIdentityID, err := RequireChannelIdentityID(c) + if err != nil { + return err + } + return h.requirePrimaryWorkspaceRead(c, channelIdentityID, botID) + } + if h.workdirs == nil { + return echo.NewHTTPError(http.StatusInternalServerError, "workdir service not configured") + } + bound, err := h.workdirs.ResolveForSession(c.Request().Context(), botID, workdirID) + if err != nil { + return workdirHTTPError(nil, err) + } + if !strings.EqualFold(strings.TrimSpace(bound.Kind), workdir.TargetKindRemote) { + return nil + } + channelIdentityID, err := RequireChannelIdentityID(c) + if err != nil { + return err + } + permissions, err := h.resolveCurrentUserPermissions(c, channelIdentityID, botID) + if err != nil { + return err + } + return requireRemoteWorkdirReadPermission(bound.Kind, permissions) +} + +func (h *ACPRuntimeHandler) requirePrimaryWorkspaceRead(c echo.Context, channelIdentityID, botID string) error { + if h.workspaces == nil { + // The legacy constructor is retained for embedders and unit tests that + // have no remote-workspace surface. Production wires workspace access. + return nil + } + info, err := h.workspaces.WorkspaceInfo(c.Request().Context(), botID) + if err != nil { + return workdirHTTPError(nil, err) + } + targetKind := strings.TrimSpace(info.TargetKind) + if targetKind == "" && strings.EqualFold(strings.TrimSpace(info.Backend), bridge.WorkspaceBackendRemote) { + targetKind = workspace.WorkspaceTargetRemote + } + if !strings.EqualFold(targetKind, workspace.WorkspaceTargetRemote) { + return nil + } + permissions, err := h.resolveCurrentUserPermissions(c, channelIdentityID, botID) + if err != nil { + return err + } + return requireRemoteWorkdirReadPermission(targetKind, permissions) +} + +func (h *ACPRuntimeHandler) requireRemoteRuntimeRead(c echo.Context, botID string, status acpagent.RuntimeStatus) error { + if !strings.EqualFold(strings.TrimSpace(status.WorkspaceTargetKind), workspace.WorkspaceTargetRemote) { + return nil + } + channelIdentityID, err := RequireChannelIdentityID(c) + if err != nil { + return err + } + permissions, err := h.resolveCurrentUserPermissions(c, channelIdentityID, botID) + if err != nil { + return err + } + return requireRemoteWorkdirReadPermission(status.WorkspaceTargetKind, permissions) +} + +func (h *ACPRuntimeHandler) resolveCurrentUserPermissions(c echo.Context, channelIdentityID, botID string) ([]string, error) { + if h.botService == nil || h.accountService == nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "bot services not configured") + } + isAdmin, err := h.accountService.IsAdmin(c.Request().Context(), channelIdentityID) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + perms, err := h.botService.ResolveUserPermissions(c.Request().Context(), botID, channelIdentityID, isAdmin) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return perms, nil +} + func (h *ACPRuntimeHandler) authorizedRuntimeControlBot(c echo.Context, actorID, botID, runtimeOwnerID string) (bots.Bot, error) { runtimeOwnerID = strings.TrimSpace(runtimeOwnerID) if runtimeOwnerID == "" { diff --git a/internal/handlers/acp_runtime_test.go b/internal/handlers/acp_runtime_test.go index a5099266f2..e7f13358a1 100644 --- a/internal/handlers/acp_runtime_test.go +++ b/internal/handlers/acp_runtime_test.go @@ -22,6 +22,9 @@ import ( session "github.com/felinics/memoh/internal/chat/thread" "github.com/felinics/memoh/internal/db/postgres/sqlc" dbstore "github.com/felinics/memoh/internal/db/store" + "github.com/felinics/memoh/internal/workdir" + "github.com/felinics/memoh/internal/workspace" + "github.com/felinics/memoh/internal/workspace/bridge" ) type acpRuntimeQueries struct { @@ -32,32 +35,39 @@ type acpRuntimeQueries struct { } type fakeACPRuntimePool struct { - status acpagent.RuntimeStatus - statusErr error - ensureInput acpagent.PromptInput - setModelInput acpagent.PromptInput - setModelID string - setModelContextErr error - setReasoningInput acpagent.PromptInput - setReasoningEffort string - setReasoningCtxErr error - createInput acpagent.CreateRuntimeInput - createErr error - statusBotID string - statusRuntimeID string - modelBotID string - modelRuntimeID string - modelID string - reasoningBotID string - reasoningRuntimeID string - reasoningEffort string - modeBotID string - modeRuntimeID string - modeID string - modeContextErr error - closedBotID string - closedRuntimeID string - closeErr error + status acpagent.RuntimeStatus + statusErr error + ensureCalls int + ensureInput acpagent.PromptInput + setModelCalls int + setModelInput acpagent.PromptInput + setModelID string + setModelContextErr error + setReasoningCalls int + setReasoningInput acpagent.PromptInput + setReasoningEffort string + setReasoningCtxErr error + createCalls int + createInput acpagent.CreateRuntimeInput + createErr error + statusCalls int + statusBotID string + statusRuntimeID string + setRuntimeModelCalls int + modelBotID string + modelRuntimeID string + modelID string + setRuntimeReasoningCalls int + reasoningBotID string + reasoningRuntimeID string + reasoningEffort string + modeBotID string + modeRuntimeID string + modeID string + modeContextErr error + closedBotID string + closedRuntimeID string + closeErr error } func (*fakeACPRuntimePool) RuntimeStatus(sessionID, agentID, projectPath string) acpagent.RuntimeStatus { @@ -70,11 +80,13 @@ func (*fakeACPRuntimePool) RuntimeStatus(sessionID, agentID, projectPath string) } func (p *fakeACPRuntimePool) Ensure(_ context.Context, input acpagent.PromptInput) (acpagent.RuntimeStatus, error) { + p.ensureCalls++ p.ensureInput = input return p.status, nil } func (p *fakeACPRuntimePool) SetModel(ctx context.Context, input acpagent.PromptInput, modelID string) (acpagent.RuntimeStatus, error) { + p.setModelCalls++ p.setModelInput = input p.setModelID = modelID p.setModelContextErr = ctx.Err() @@ -82,6 +94,7 @@ func (p *fakeACPRuntimePool) SetModel(ctx context.Context, input acpagent.Prompt } func (p *fakeACPRuntimePool) SetReasoning(ctx context.Context, input acpagent.PromptInput, effort string) (acpagent.RuntimeStatus, error) { + p.setReasoningCalls++ p.setReasoningInput = input p.setReasoningEffort = effort p.setReasoningCtxErr = ctx.Err() @@ -93,17 +106,20 @@ func (p *fakeACPRuntimePool) SetMode(_ context.Context, _ acpagent.PromptInput, } func (p *fakeACPRuntimePool) CreateRuntime(_ context.Context, input acpagent.CreateRuntimeInput) (acpagent.RuntimeStatus, error) { + p.createCalls++ p.createInput = input return p.status, p.createErr } func (p *fakeACPRuntimePool) RuntimeStatusByID(botID, runtimeID string) (acpagent.RuntimeStatus, error) { + p.statusCalls++ p.statusBotID = botID p.statusRuntimeID = runtimeID return p.status, p.statusErr } func (p *fakeACPRuntimePool) SetRuntimeModel(_ context.Context, botID, runtimeID, modelID string) (acpagent.RuntimeStatus, error) { + p.setRuntimeModelCalls++ p.modelBotID = botID p.modelRuntimeID = runtimeID p.modelID = modelID @@ -111,6 +127,7 @@ func (p *fakeACPRuntimePool) SetRuntimeModel(_ context.Context, botID, runtimeID } func (p *fakeACPRuntimePool) SetRuntimeReasoning(_ context.Context, botID, runtimeID, effort string) (acpagent.RuntimeStatus, error) { + p.setRuntimeReasoningCalls++ p.reasoningBotID = botID p.reasoningRuntimeID = runtimeID p.reasoningEffort = effort @@ -131,6 +148,44 @@ func (p *fakeACPRuntimePool) CloseRuntime(botID, runtimeID string) error { return p.closeErr } +type fakeACPRuntimeWorkdirResolver struct { + resolved workdir.Resolved + err error + calls int + botID string + workdirID string +} + +// nativeWorkspaceInfo installs a native-Primary workspace provider so +// workdir-less session endpoints pass the remote gate without permissions. +func installNativeWorkspaceInfo(h *ACPRuntimeHandler) { + h.SetWorkspaceInfoProvider(&fakeACPRuntimeWorkspaceInfoProvider{info: bridge.WorkspaceInfo{ + Backend: bridge.WorkspaceBackendContainer, + TargetID: workspace.WorkspaceTargetNative, + TargetKind: workspace.WorkspaceTargetNative, + }}) +} + +type fakeACPRuntimeWorkspaceInfoProvider struct { + info bridge.WorkspaceInfo + err error + calls int + botID string +} + +func (p *fakeACPRuntimeWorkspaceInfoProvider) WorkspaceInfo(_ context.Context, botID string) (bridge.WorkspaceInfo, error) { + p.calls++ + p.botID = botID + return p.info, p.err +} + +func (r *fakeACPRuntimeWorkdirResolver) ResolveForSession(_ context.Context, botID, workdirID string) (workdir.Resolved, error) { + r.calls++ + r.botID = botID + r.workdirID = workdirID + return r.resolved, r.err +} + func (q acpRuntimeQueries) GetBotByID(_ context.Context, _ pgtype.UUID) (sqlc.GetBotByIDRow, error) { return q.bot, nil } @@ -170,6 +225,7 @@ func TestACPRuntimeHandlerReturnsIdleStatus(t *testing.T) { bots.NewService(nil, queries), newTestAdminAccountService("admin"), ) + installNativeWorkspaceInfo(handler) e := echo.New() req := httptest.NewRequest(http.MethodGet, "/bots/"+botID+"/sessions/"+sessionID+"/acp-runtime", nil) @@ -245,7 +301,7 @@ func TestACPRuntimeHandlerEnsureStartsRuntimeAndReturnsModels(t *testing.T) { bots.NewService(nil, queries), newTestAdminAccountService("admin"), ) - + installNativeWorkspaceInfo(handler) e := echo.New() req := httptest.NewRequest(http.MethodPost, "/bots/"+botID+"/sessions/"+sessionID+"/acp-runtime", nil) req.Header.Set("Authorization", "Bearer token-1") @@ -321,6 +377,57 @@ func TestACPRuntimeHandlerEnsureRejectsMissingRuntimeOwner(t *testing.T) { } } +func TestACPRuntimeEnsureRequiresWorkspaceReadForWorkdirLessSessionOnRemotePrimary(t *testing.T) { + const ( + botID = "11111111-1111-1111-1111-111111111111" + sessionID = "44444444-4444-4444-4444-444444444444" + actorID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + ) + queries := acpRuntimeQueries{ + bot: testBotRow(botID, acpEnabledBotMetadata()), + permissions: []byte(`["workspace_exec"]`), + session: sqlc.BotSession{ + ID: testUUID(sessionID), + BotID: testUUID(botID), + Type: session.TypeACPAgent, + Title: "Codex", + RuntimeMetadata: testJSON(map[string]any{ + "acp_agent_id": acpprofile.AgentCodexID, + "project_path": "/data/app", + "runtime_owner_account_id": actorID, + }), + }, + } + pool := &fakeACPRuntimePool{status: acpagent.RuntimeStatus{SessionID: sessionID, State: "idle"}} + handler := newACPRuntimeHandler( + pool, + session.NewService(nil, queries, nil), + bots.NewService(nil, queries), + newTestAdminAccountService("user"), + ) + handler.SetWorkspaceInfoProvider(&fakeACPRuntimeWorkspaceInfoProvider{info: bridge.WorkspaceInfo{ + Backend: bridge.WorkspaceBackendRemote, + TargetID: "44444444-4444-4444-8444-444444444444", + TargetKind: workspace.WorkspaceTargetRemote, + }}) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/bots/"+botID+"/sessions/"+sessionID+"/acp-runtime", nil) + rec := httptest.NewRecorder() + ctx := testAuthContext(e, req, rec, actorID) + ctx.SetPath("/bots/:bot_id/sessions/:session_id/acp-runtime") + ctx.SetParamNames("bot_id", "session_id") + ctx.SetParamValues(botID, sessionID) + + err := handler.EnsureRuntime(ctx) + if got := apperror.CodeOf(err); got != apperror.CodeWorkspaceReadPermissionRequired { + t.Fatalf("EnsureRuntime code = %q, want %q (error %v)", got, apperror.CodeWorkspaceReadPermissionRequired, err) + } + if pool.ensureCalls != 0 { + t.Fatalf("pool.Ensure calls = %d, want 0", pool.ensureCalls) + } +} + func TestACPRuntimeHandlerEnsureAllowsWorkspaceExecMember(t *testing.T) { botID := "11111111-1111-1111-1111-111111111111" sessionID := "77777777-7777-7777-7777-777777777777" @@ -348,6 +455,7 @@ func TestACPRuntimeHandlerEnsureAllowsWorkspaceExecMember(t *testing.T) { bots.NewService(nil, queries), newTestAdminAccountService("user"), ) + installNativeWorkspaceInfo(handler) e := echo.New() req := httptest.NewRequest(http.MethodPost, "/bots/"+botID+"/sessions/"+sessionID+"/acp-runtime", nil) @@ -365,6 +473,109 @@ func TestACPRuntimeHandlerEnsureAllowsWorkspaceExecMember(t *testing.T) { } } +func TestACPRuntimeSessionControlsRequireWorkspaceReadForRemoteWorkdir(t *testing.T) { + const ( + botID = "11111111-1111-1111-1111-111111111111" + sessionID = "22222222-2222-2222-2222-222222222222" + workdirID = "33333333-3333-4333-8333-333333333333" + actorID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + ) + tests := []struct { + name string + method string + path string + body string + call func(*ACPRuntimeHandler, echo.Context) error + }{ + { + name: "ensure runtime", + method: http.MethodPost, + path: "/bots/:bot_id/sessions/:session_id/acp-runtime", + call: (*ACPRuntimeHandler).EnsureRuntime, + }, + { + name: "set model", + method: http.MethodPatch, + path: "/bots/:bot_id/sessions/:session_id/acp-runtime/model", + body: `{"model_id":"gpt-5.1-codex"}`, + call: (*ACPRuntimeHandler).SetModel, + }, + { + name: "set reasoning", + method: http.MethodPatch, + path: "/bots/:bot_id/sessions/:session_id/acp-runtime/reasoning", + body: `{"reasoning_effort":"high"}`, + call: (*ACPRuntimeHandler).SetReasoning, + }, + { + name: "set mode", + method: http.MethodPatch, + path: "/bots/:bot_id/sessions/:session_id/acp-runtime/mode", + body: `{"mode_id":"acceptEdits"}`, + call: (*ACPRuntimeHandler).SetMode, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + queries := acpRuntimeQueries{ + bot: testBotRow(botID, acpEnabledBotMetadata()), + session: sqlc.BotSession{ + ID: testUUID(sessionID), + BotID: testUUID(botID), + WorkdirID: testUUID(workdirID), + Type: session.TypeACPAgent, + Title: "Codex", + RuntimeMetadata: testJSON(map[string]any{ + "acp_agent_id": acpprofile.AgentCodexID, + "project_path": "/Users/alice/project", + "runtime_owner_account_id": actorID, + }), + }, + permissions: []byte(`["workspace_exec"]`), + } + pool := &fakeACPRuntimePool{} + workdirs := &fakeACPRuntimeWorkdirResolver{resolved: workdir.Resolved{ + WorkdirID: workdirID, + TargetID: "44444444-4444-4444-8444-444444444444", + Kind: workdir.TargetKindRemote, + WorkDir: "/Users/alice/project", + }} + handler := newACPRuntimeHandler( + pool, + session.NewService(nil, queries, nil), + bots.NewService(nil, queries), + newTestAdminAccountService("user"), + workdirs, + ) + + e := echo.New() + req := httptest.NewRequest(tc.method, "/bots/"+botID+"/sessions/"+sessionID+"/acp-runtime", strings.NewReader(tc.body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + ctx := testAuthContext(e, req, rec, actorID) + ctx.SetPath(tc.path) + ctx.SetParamNames("bot_id", "session_id") + ctx.SetParamValues(botID, sessionID) + + err := tc.call(handler, ctx) + if got := apperror.CodeOf(err); got != apperror.CodeWorkspaceReadPermissionRequired { + t.Fatalf("control code = %q, want %q (error %v)", got, apperror.CodeWorkspaceReadPermissionRequired, err) + } + problem, ok := apperror.ProblemFrom(err, "req-control") + if !ok || problem.Status != http.StatusForbidden || problem.Code != string(apperror.CodeWorkspaceReadPermissionRequired) { + t.Fatalf("control problem = %#v, recognized = %v", problem, ok) + } + if workdirs.calls != 1 || workdirs.botID != botID || workdirs.workdirID != workdirID { + t.Fatalf("workdir resolution = calls %d, bot %q, workdir %q", workdirs.calls, workdirs.botID, workdirs.workdirID) + } + if pool.ensureCalls != 0 || pool.setModelCalls != 0 || pool.setReasoningCalls != 0 { + t.Fatalf("pool calls = ensure %d, model %d, reasoning %d; want zero", pool.ensureCalls, pool.setModelCalls, pool.setReasoningCalls) + } + }) + } +} + func TestAuthorizeACPRuntimeSessionAccess(t *testing.T) { t.Run("owner with workspace exec", func(t *testing.T) { err := authorizeACPRuntimeSessionAccess( @@ -468,6 +679,7 @@ func TestACPRuntimeHandlerSetModel(t *testing.T) { bots.NewService(nil, queries), newTestAdminAccountService("admin"), ) + installNativeWorkspaceInfo(handler) e := echo.New() req := httptest.NewRequest( @@ -547,6 +759,7 @@ func TestACPRuntimeHandlerSetReasoning(t *testing.T) { bots.NewService(nil, queries), newTestAdminAccountService("admin"), ) + installNativeWorkspaceInfo(handler) e := echo.New() req := httptest.NewRequest( @@ -621,6 +834,10 @@ func TestACPRuntimeHandlerCreateRuntime(t *testing.T) { bots.NewService(nil, queries), newTestAdminAccountService("admin"), ) + handler.SetWorkspaceInfoProvider(&fakeACPRuntimeWorkspaceInfoProvider{info: bridge.WorkspaceInfo{ + TargetID: workspace.WorkspaceTargetNative, + TargetKind: workspace.WorkspaceTargetNative, + }}) e := echo.New() req := httptest.NewRequest( @@ -660,6 +877,150 @@ func TestACPRuntimeHandlerCreateRuntime(t *testing.T) { } } +func TestACPRuntimeCreateRequiresWorkspaceReadOnlyForRemotePrimary(t *testing.T) { + const ( + botID = "11111111-1111-1111-1111-111111111111" + actorID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + ) + tests := []struct { + name string + targetKind string + wantDenied bool + wantCreates int + }{ + {name: "remote primary", targetKind: workspace.WorkspaceTargetRemote, wantDenied: true}, + {name: "native primary", targetKind: workspace.WorkspaceTargetNative, wantCreates: 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + queries := acpRuntimeQueries{ + bot: testBotRow(botID, acpEnabledBotMetadata()), + permissions: []byte(`["workspace_exec"]`), + } + pool := &fakeACPRuntimePool{status: acpagent.RuntimeStatus{RuntimeID: "rt_warm", State: "idle"}} + handler := newACPRuntimeHandler( + pool, + session.NewService(nil, queries, nil), + bots.NewService(nil, queries), + newTestAdminAccountService("user"), + ) + workspaceInfo := &fakeACPRuntimeWorkspaceInfoProvider{info: bridge.WorkspaceInfo{ + Backend: bridge.WorkspaceBackendContainer, + TargetID: workspace.WorkspaceTargetNative, + TargetKind: tc.targetKind, + }} + if tc.targetKind == workspace.WorkspaceTargetRemote { + workspaceInfo.info.Backend = bridge.WorkspaceBackendRemote + workspaceInfo.info.TargetID = "44444444-4444-4444-8444-444444444444" + } + handler.SetWorkspaceInfoProvider(workspaceInfo) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/bots/"+botID+"/acp-runtimes", bytes.NewBufferString(`{"acp_agent_id":"codex"}`)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + ctx := testAuthContext(e, req, rec, actorID) + ctx.SetPath("/bots/:bot_id/acp-runtimes") + ctx.SetParamNames("bot_id") + ctx.SetParamValues(botID) + + err := handler.CreateRuntime(ctx) + if tc.wantDenied { + if got := apperror.CodeOf(err); got != apperror.CodeWorkspaceReadPermissionRequired { + t.Fatalf("CreateRuntime code = %q, want %q (error %v)", got, apperror.CodeWorkspaceReadPermissionRequired, err) + } + problem, ok := apperror.ProblemFrom(err, "req-prewarm") + if !ok || problem.Status != http.StatusForbidden || problem.Code != string(apperror.CodeWorkspaceReadPermissionRequired) { + t.Fatalf("CreateRuntime problem = %#v, recognized = %v", problem, ok) + } + } else if err != nil { + t.Fatalf("CreateRuntime native primary error = %v", err) + } + if workspaceInfo.calls != 1 || workspaceInfo.botID != botID { + t.Fatalf("WorkspaceInfo calls = %d, bot %q", workspaceInfo.calls, workspaceInfo.botID) + } + if pool.createCalls != tc.wantCreates { + t.Fatalf("CreateRuntime pool calls = %d, want %d", pool.createCalls, tc.wantCreates) + } + }) + } +} + +func TestACPRuntimeByIDControlsRequireWorkspaceReadForRemoteRuntime(t *testing.T) { + const ( + botID = "11111111-1111-1111-1111-111111111111" + actorID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + ) + tests := []struct { + name string + path string + body string + call func(*ACPRuntimeHandler, echo.Context) error + }{ + { + name: "set model", + path: "/bots/:bot_id/acp-runtimes/:runtime_id/model", + body: `{"model_id":"gpt-5.1-codex-high"}`, + call: (*ACPRuntimeHandler).SetRuntimeModel, + }, + { + name: "set reasoning", + path: "/bots/:bot_id/acp-runtimes/:runtime_id/reasoning", + body: `{"reasoning_effort":"high"}`, + call: (*ACPRuntimeHandler).SetRuntimeReasoning, + }, + { + name: "set mode", + path: "/bots/:bot_id/acp-runtimes/:runtime_id/mode", + body: `{"mode_id":"acceptEdits"}`, + call: (*ACPRuntimeHandler).SetRuntimeMode, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + queries := acpRuntimeQueries{ + bot: testBotRow(botID, acpEnabledBotMetadata()), + permissions: []byte(`["workspace_exec"]`), + } + pool := &fakeACPRuntimePool{status: acpagent.RuntimeStatus{ + RuntimeID: "rt_remote", + RuntimeOwnerAccountID: actorID, + WorkspaceTargetID: "44444444-4444-4444-8444-444444444444", + WorkspaceTargetKind: workspace.WorkspaceTargetRemote, + State: "idle", + }} + handler := newACPRuntimeHandler( + pool, + session.NewService(nil, queries, nil), + bots.NewService(nil, queries), + newTestAdminAccountService("user"), + ) + + e := echo.New() + req := httptest.NewRequest(http.MethodPatch, "/bots/"+botID+"/acp-runtimes/rt_remote", strings.NewReader(tc.body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + ctx := testAuthContext(e, req, rec, actorID) + ctx.SetPath(tc.path) + ctx.SetParamNames("bot_id", "runtime_id") + ctx.SetParamValues(botID, "rt_remote") + + err := tc.call(handler, ctx) + if got := apperror.CodeOf(err); got != apperror.CodeWorkspaceReadPermissionRequired { + t.Fatalf("control code = %q, want %q (error %v)", got, apperror.CodeWorkspaceReadPermissionRequired, err) + } + if pool.statusCalls != 1 { + t.Fatalf("status calls = %d, want 1", pool.statusCalls) + } + if pool.setRuntimeModelCalls != 0 || pool.setRuntimeReasoningCalls != 0 { + t.Fatalf("runtime setter calls = model %d, reasoning %d; want zero", pool.setRuntimeModelCalls, pool.setRuntimeReasoningCalls) + } + }) + } +} + func TestACPRuntimeHandlerCreateRuntimeRejectsDisabledAgent(t *testing.T) { botID := "11111111-1111-1111-1111-111111111111" queries := acpRuntimeQueries{ @@ -750,6 +1111,10 @@ func TestACPRuntimeHandlerCreateRuntimeMapsCapToTooManyRequests(t *testing.T) { bots.NewService(nil, queries), newTestAdminAccountService("admin"), ) + handler.SetWorkspaceInfoProvider(&fakeACPRuntimeWorkspaceInfoProvider{info: bridge.WorkspaceInfo{ + TargetID: workspace.WorkspaceTargetNative, + TargetKind: workspace.WorkspaceTargetNative, + }}) e := echo.New() req := httptest.NewRequest( @@ -783,6 +1148,10 @@ func TestACPRuntimeHandlerCreateRuntimeRedactsStartFailure(t *testing.T) { bots.NewService(nil, queries), newTestAdminAccountService("admin"), ) + handler.SetWorkspaceInfoProvider(&fakeACPRuntimeWorkspaceInfoProvider{info: bridge.WorkspaceInfo{ + TargetID: workspace.WorkspaceTargetNative, + TargetKind: workspace.WorkspaceTargetNative, + }}) e := echo.New() req := httptest.NewRequest( diff --git a/internal/handlers/bot_remote_runtime.go b/internal/handlers/bot_remote_runtime.go index 0e65ca20f1..9655f6a5b9 100644 --- a/internal/handlers/bot_remote_runtime.go +++ b/internal/handlers/bot_remote_runtime.go @@ -10,6 +10,8 @@ import ( "github.com/labstack/echo/v4" "github.com/felinics/memoh/internal/accounts" + acpagent "github.com/felinics/memoh/internal/agent/runtime/acp" + "github.com/felinics/memoh/internal/apperror" "github.com/felinics/memoh/internal/bots" "github.com/felinics/memoh/internal/db" "github.com/felinics/memoh/internal/settings" @@ -34,11 +36,16 @@ type workspaceTargetSettings interface { UpsertBot(ctx context.Context, botID string, req settings.UpsertRequest) (settings.Settings, error) } +type workspaceTargetRuntimeCloser interface { + CloseBotWorkspaceTargetRuntimes(botID, targetID string) error +} + type BotRemoteRuntimeHandler struct { log *slog.Logger service botRemoteRuntimeService workspaces workspaceTargetManager settings workspaceTargetSettings + runtimes workspaceTargetRuntimeCloser bots *bots.Service accounts *accounts.Service } @@ -50,6 +57,7 @@ func NewBotRemoteRuntimeHandler( settingsService *settings.Service, botService *bots.Service, accountService *accounts.Service, + acpPool *acpagent.SessionPool, ) *BotRemoteRuntimeHandler { if log == nil { log = slog.Default() @@ -59,6 +67,7 @@ func NewBotRemoteRuntimeHandler( service: service, workspaces: manager, settings: settingsService, + runtimes: acpPool, bots: botService, accounts: accountService, } @@ -127,21 +136,36 @@ func (h *BotRemoteRuntimeHandler) Mount(c echo.Context) error { // @Failure 400 {object} ErrorResponse // @Failure 403 {object} ErrorResponse // @Failure 404 {object} ErrorResponse +// @Failure 409 {object} apperror.Problem // @Router /bots/{bot_id}/workspace-targets/{target_id} [delete]. func (h *BotRemoteRuntimeHandler) Delete(c echo.Context) error { botID, err := h.requirePermission(c, bots.PermissionManage) if err != nil { return err } - if strings.TrimSpace(c.Param("target_id")) == workspace.WorkspaceTargetNative { + targetID := strings.TrimSpace(c.Param("target_id")) + if targetID == workspace.WorkspaceTargetNative { return echo.NewHTTPError(http.StatusBadRequest, "native workspace target cannot be deleted") } - if err := h.service.DeleteMount(c.Request().Context(), botID, c.Param("target_id")); err != nil { + if err := h.service.DeleteMount(c.Request().Context(), botID, targetID); err != nil { return workspaceTargetHTTPError(h.log, err) } + h.closeDeletedTargetRuntimes(botID, targetID) return c.NoContent(http.StatusNoContent) } +func (h *BotRemoteRuntimeHandler) closeDeletedTargetRuntimes(botID, targetID string) { + if h == nil || h.runtimes == nil { + return + } + if err := h.runtimes.CloseBotWorkspaceTargetRuntimes(botID, targetID); err != nil { + h.log.Warn("failed to close ACP runtimes for deleted workspace target", + slog.Any("error", err), + slog.String("bot_id", botID), + slog.String("workspace_target_id", targetID)) + } +} + // SetPrimary godoc // @Summary Set a Bot's Primary workspace target // @Tags workspace-targets @@ -276,6 +300,8 @@ func workspaceTargetHTTPError(log *slog.Logger, err error) error { errors.Is(err, workspace.ErrWorkspaceTargetNotFound), errors.Is(err, db.ErrNotFound): return echo.NewHTTPError(http.StatusNotFound, "workspace target not found") + case errors.Is(err, workspace.ErrWorkspaceTargetInUse): + return apperror.New(apperror.CodeWorkspaceTargetInUse, nil) case errors.Is(err, workspace.ErrRemoteRuntimeRevoked), errors.Is(err, workspace.ErrRemoteRuntimeOwnerMismatch), errors.Is(err, workspace.ErrRemoteRuntimeClientUpdateNeeded): diff --git a/internal/handlers/bot_remote_runtime_test.go b/internal/handlers/bot_remote_runtime_test.go index 9c6cc94bde..c98ba4ef5e 100644 --- a/internal/handlers/bot_remote_runtime_test.go +++ b/internal/handlers/bot_remote_runtime_test.go @@ -3,11 +3,14 @@ package handlers import ( "context" "errors" + "fmt" + "log/slog" "net/http" "testing" "github.com/labstack/echo/v4" + "github.com/felinics/memoh/internal/apperror" "github.com/felinics/memoh/internal/settings" "github.com/felinics/memoh/internal/workspace" ) @@ -34,10 +37,36 @@ func TestWorkspaceTargetHTTPError(t *testing.T) { } } +func TestWorkspaceTargetHTTPErrorMapsTargetInUseToStableConflict(t *testing.T) { + err := workspaceTargetHTTPError(nil, fmt.Errorf("delete mount: %w", workspace.ErrWorkspaceTargetInUse)) + problem, ok := apperror.ProblemFrom(err, "request-1") + if !ok { + t.Fatalf("workspaceTargetHTTPError() = %v, want application error", err) + } + if problem.Status != http.StatusConflict || problem.Code != string(apperror.CodeWorkspaceTargetInUse) { + t.Fatalf("problem = %#v, want 409 %q", problem, apperror.CodeWorkspaceTargetInUse) + } + if problem.Detail != "This computer is still used by a folder and cannot be disconnected." { + t.Fatalf("problem detail = %q", problem.Detail) + } +} + type fakeWorkspaceTargetService struct { target workspace.WorkspaceTarget } +type fakeWorkspaceTargetRuntimeCloser struct { + botID string + targetID string + err error +} + +func (c *fakeWorkspaceTargetRuntimeCloser) CloseBotWorkspaceTargetRuntimes(botID, targetID string) error { + c.botID = botID + c.targetID = targetID + return c.err +} + func (*fakeWorkspaceTargetService) Mount(context.Context, string, string) (workspace.WorkspaceTarget, error) { return workspace.WorkspaceTarget{}, nil } @@ -54,6 +83,15 @@ func (*fakeWorkspaceTargetService) UpdateToolApprovalConfig(context.Context, str func (*fakeWorkspaceTargetService) DeleteMount(context.Context, string, string) error { return nil } +func TestDeletedWorkspaceTargetClosesPinnedACPRuntimes(t *testing.T) { + closer := &fakeWorkspaceTargetRuntimeCloser{} + handler := &BotRemoteRuntimeHandler{log: slog.Default(), runtimes: closer} + handler.closeDeletedTargetRuntimes("bot-1", "computer-1") + if closer.botID != "bot-1" || closer.targetID != "computer-1" { + t.Fatalf("runtime close = bot %q target %q", closer.botID, closer.targetID) + } +} + func TestModeShortcutPreservesAdvancedToolApprovalRules(t *testing.T) { config := settings.DefaultToolApprovalConfig() config.Enabled = false diff --git a/internal/handlers/mcp_tools.go b/internal/handlers/mcp_tools.go index 9b9160ede8..bf13636b11 100644 --- a/internal/handlers/mcp_tools.go +++ b/internal/handlers/mcp_tools.go @@ -7,6 +7,7 @@ import ( "github.com/labstack/echo/v4" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/felinics/memoh/internal/apperror" "github.com/felinics/memoh/internal/auth" mcpgw "github.com/felinics/memoh/internal/mcp" ) @@ -56,6 +57,13 @@ func (h *ContainerdHandler) SetACPRuntimeResolver(resolver acpRuntimeContextReso // @Failure 500 {object} ErrorResponse // @Router /bots/{bot_id}/tools [post]. func (h *ContainerdHandler) HandleMCPTools(c echo.Context) error { + // A runtime credential is an authentication mechanism independent of a + // user JWT. Resolve it before the ordinary bot-access path, but fail closed + // whenever either runtime header is present: malformed or stale runtime + // credentials must never fall back to public header-derived tool identity. + if hasAnyRuntimeToolCredential(c.Request()) { + return h.handleMCPToolsWithBotID(c, strings.TrimSpace(c.Param("bot_id"))) + } if h.toolGateway == nil { return echo.NewHTTPError(http.StatusServiceUnavailable, "tool gateway not configured") } @@ -71,22 +79,46 @@ func (h *ContainerdHandler) handleMCPToolsWithBotID(c echo.Context, botID string // identity; their per-prompt context resolves from the live handle and // the bot in the path must own the runtime. Fails closed: a dead or // foreign runtime never falls back to header-supplied identity. - if runtimeID := strings.TrimSpace(c.Request().Header.Get(mcpgw.ToolHeaderRuntimeID)); runtimeID != "" { - if h.acpRuntimes == nil { - return echo.NewHTTPError(http.StatusNotFound, "runtime not found") + if hasAnyRuntimeToolCredential(c.Request()) { + session, err := h.resolveRuntimeToolContext(c, botID) + if err != nil { + return err } - session, ok := h.acpRuntimes.ResolveRuntimeToolContext(botID, runtimeID, c.Request().Header.Get(mcpgw.ToolHeaderRuntimeToken)) - if !ok { - return echo.NewHTTPError(http.StatusNotFound, "runtime not found") + if h.toolGateway == nil { + return echo.NewHTTPError(http.StatusServiceUnavailable, "tool gateway not configured") } mcpgw.ServeToolMCPHTTP(c.Response().Writer, c.Request(), h.logger, h.toolGateway, h.toolContexts, session) return nil } + if h.toolGateway == nil { + return echo.NewHTTPError(http.StatusServiceUnavailable, "tool gateway not configured") + } session := h.buildToolSessionContext(c, botID) mcpgw.ServeToolMCPHTTP(c.Response().Writer, c.Request(), h.logger, h.toolGateway, h.toolContexts, session) return nil } +func hasAnyRuntimeToolCredential(req *http.Request) bool { + if req == nil { + return false + } + return strings.TrimSpace(req.Header.Get(mcpgw.ToolHeaderRuntimeID)) != "" || + strings.TrimSpace(req.Header.Get(mcpgw.ToolHeaderRuntimeToken)) != "" +} + +func (h *ContainerdHandler) resolveRuntimeToolContext(c echo.Context, botID string) (mcpgw.ToolSessionContext, error) { + runtimeID := strings.TrimSpace(c.Request().Header.Get(mcpgw.ToolHeaderRuntimeID)) + runtimeToken := strings.TrimSpace(c.Request().Header.Get(mcpgw.ToolHeaderRuntimeToken)) + if botID == "" || runtimeID == "" || runtimeToken == "" || h.acpRuntimes == nil { + return mcpgw.ToolSessionContext{}, apperror.New(apperror.CodeACPRuntimeNotFound, nil) + } + session, ok := h.acpRuntimes.ResolveRuntimeToolContext(botID, runtimeID, runtimeToken) + if !ok || strings.TrimSpace(session.BotID) != botID || strings.TrimSpace(session.RuntimeID) != runtimeID { + return mcpgw.ToolSessionContext{}, apperror.New(apperror.CodeACPRuntimeNotFound, nil) + } + return session, nil +} + func buildToolCallPayloadFromRaw(params *sdkmcp.CallToolParamsRaw) (mcpgw.ToolCallPayload, error) { return mcpgw.BuildToolCallPayloadFromRaw(params) } diff --git a/internal/handlers/mcp_tools_test.go b/internal/handlers/mcp_tools_test.go index ac48e2d9ca..d21c3adfdc 100644 --- a/internal/handlers/mcp_tools_test.go +++ b/internal/handlers/mcp_tools_test.go @@ -13,6 +13,7 @@ import ( "github.com/labstack/echo/v4" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/felinics/memoh/internal/apperror" mcpgw "github.com/felinics/memoh/internal/mcp" ) @@ -111,6 +112,12 @@ func (r mcpToolsRuntimeResolver) ResolveRuntimeToolContext(botID, runtimeID, too return r.session, r.ok } +type mismatchedMCPToolsRuntimeResolver struct{} + +func (mismatchedMCPToolsRuntimeResolver) ResolveRuntimeToolContext(_, _, _ string) (mcpgw.ToolSessionContext, bool) { + return mcpgw.ToolSessionContext{BotID: "other-bot", RuntimeID: "other-runtime"}, true +} + func TestHandleMCPToolsWithGatewayAcceptCompatibility(t *testing.T) { e := echo.New() executor := &mcpToolsTestExecutor{} @@ -257,9 +264,41 @@ func TestHandleMCPToolsRuntimeIDRequiresRuntimeToolToken(t *testing.T) { if err == nil { t.Fatal("runtime tool request without token should fail") } - httpErr := &echo.HTTPError{} - if !errors.As(err, &httpErr) || httpErr.Code != http.StatusNotFound { - t.Fatalf("runtime tool request without token error = %v, want 404", err) + if got := apperror.CodeOf(err); got != apperror.CodeACPRuntimeNotFound { + t.Fatalf("runtime tool request without token error = %v, code = %q", err, got) + } +} + +func TestHandleMCPToolsRuntimeCredentialFailsBeforeGatewayAvailability(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/bots/bot-1/tools", nil) + req.Header.Set(mcpgw.ToolHeaderRuntimeID, "runtime-1") + req.Header.Set(mcpgw.ToolHeaderRuntimeToken, "wrong-token") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("bot_id") + c.SetParamValues("bot-1") + + err := (&ContainerdHandler{}).HandleMCPTools(c) + if got := apperror.CodeOf(err); got != apperror.CodeACPRuntimeNotFound { + t.Fatalf("invalid runtime credential error = %v, code = %q", err, got) + } +} + +func TestHandleMCPToolsRejectsMismatchedResolvedRuntimeIdentity(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/bots/bot-1/tools", nil) + req.Header.Set(mcpgw.ToolHeaderRuntimeID, "runtime-1") + req.Header.Set(mcpgw.ToolHeaderRuntimeToken, "runtime-token-1") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("bot_id") + c.SetParamValues("bot-1") + + handler := &ContainerdHandler{acpRuntimes: mismatchedMCPToolsRuntimeResolver{}} + err := handler.HandleMCPTools(c) + if got := apperror.CodeOf(err); got != apperror.CodeACPRuntimeNotFound { + t.Fatalf("mismatched runtime identity error = %v, code = %q", err, got) } } diff --git a/internal/handlers/session.go b/internal/handlers/session.go index ed64d7485b..edb8773e0b 100644 --- a/internal/handlers/session.go +++ b/internal/handlers/session.go @@ -45,7 +45,7 @@ type sessionWorkdirService interface { // warm agent process and binding a session to a runtime. type acpSessionRuntimeService interface { CloseSession(sessionID string) error - BindRuntime(ctx context.Context, botID, runtimeID, sessionID, agentID, projectPath, runtimeOwnerAccountID string) error + BindRuntime(ctx context.Context, botID, runtimeID, sessionID, agentID, projectPath, workspaceTargetID, runtimeOwnerAccountID string) error } // sessionResetService is the runtime-agnostic history reset boundary. It is a @@ -223,10 +223,19 @@ func (h *SessionHandler) CreateSession(c echo.Context) error { req.Metadata = mergeSessionMetadata(req.Metadata, map[string]any{"acp_agent_id": descriptor.Provider}) req.RuntimeMetadata = mergeSessionMetadata(req.RuntimeMetadata, map[string]any{"acp_agent_id": descriptor.Provider}) } - boundWorkdir, err := h.resolveCreateSessionWorkdir(c.Request().Context(), bot.ID, req.WorkdirID, targetRuntimeType) + boundWorkdir, err := h.resolveCreateSessionWorkdir(c.Request().Context(), bot.ID, req.WorkdirID) if err != nil { return err } + if boundWorkdir != nil && strings.EqualFold(strings.TrimSpace(boundWorkdir.TargetKind), workdir.TargetKindRemote) { + perms, resolveErr := h.resolveCurrentUserPermissions(c, channelIdentityID, bot.ID) + if resolveErr != nil { + return resolveErr + } + if permissionErr := requireRemoteWorkdirReadPermission(boundWorkdir.TargetKind, perms); permissionErr != nil { + return permissionErr + } + } if targetRuntimeType == session.RuntimeACPAgent { req.Metadata = session.ApplyACPMetadataDefaults(mergeSessionMetadata(req.Metadata, req.RuntimeMetadata)) req.RuntimeMetadata = session.ApplyACPMetadataDefaults(mergeSessionMetadata(req.RuntimeMetadata, req.Metadata)) @@ -263,13 +272,20 @@ func (h *SessionHandler) CreateSession(c echo.Context) error { // after a successful create), not transactional. A failed bind keeps the // session — the first prompt simply cold starts a runtime. if runtimeID := strings.TrimSpace(req.ACPRuntimeID); runtimeID != "" && session.IsACPRuntime(sess) && h.acpRuntimes != nil { + workspaceTargetID := "" + projectPath := sessionMetadataString(sess.Metadata, "project_path") + if boundWorkdir != nil { + workspaceTargetID = strings.TrimSpace(boundWorkdir.WorkspaceTargetID) + projectPath = strings.TrimSpace(boundWorkdir.Path) + } if bindErr := h.acpRuntimes.BindRuntime( c.Request().Context(), bot.ID, runtimeID, sess.ID, sessionMetadataString(sess.Metadata, "acp_agent_id"), - sessionMetadataString(sess.Metadata, "project_path"), + projectPath, + workspaceTargetID, sessionMetadataString(sess.Metadata, "runtime_owner_account_id"), ); bindErr != nil { h.logger.Warn("failed to bind ACP runtime to new session; first prompt will cold start", @@ -943,6 +959,14 @@ func (h *SessionHandler) resolveCurrentUserPermissions(c echo.Context, channelId return perms, nil } +func requireRemoteWorkdirReadPermission(targetKind string, permissions []string) error { + if !strings.EqualFold(strings.TrimSpace(targetKind), workdir.TargetKindRemote) || + bots.HasPermission(permissions, bots.PermissionWorkspaceRead) { + return nil + } + return apperror.New(apperror.CodeWorkspaceReadPermissionRequired, nil) +} + func requiredReadPermissionForSessionType(sessionType string) string { switch strings.TrimSpace(sessionType) { case session.TypeChat: @@ -1035,12 +1059,10 @@ func filterSessionsForPermissions(items []session.Thread, userID string, perms [ return out } -// resolveCreateSessionWorkdir validates a requested workdir binding: the -// workdir must exist on this bot and be live, and ACP sessions can only bind -// native-workspace workdirs — the ACP runtime cannot reach a remote computer -// yet, so accepting the binding would create a session that fails on its -// first prompt. -func (h *SessionHandler) resolveCreateSessionWorkdir(ctx context.Context, botID, workdirID, runtimeType string) (*workdir.Workdir, error) { +// resolveCreateSessionWorkdir validates that a requested workdir binding +// exists on this bot and is active. Its immutable target and path are copied +// into the session by CreateSession for every supported runtime. +func (h *SessionHandler) resolveCreateSessionWorkdir(ctx context.Context, botID, workdirID string) (*workdir.Workdir, error) { workdirID = strings.TrimSpace(workdirID) if workdirID == "" { return nil, nil @@ -1052,10 +1074,6 @@ func (h *SessionHandler) resolveCreateSessionWorkdir(ctx context.Context, botID, if err != nil { return nil, workdirHTTPError(h.logger, err) } - if runtimeType == session.RuntimeACPAgent && bound.TargetKind == workdir.TargetKindRemote { - return nil, echo.NewHTTPError(http.StatusBadRequest, - "ACP sessions cannot use a remote computer workdir yet; bind a native workspace workdir instead") - } return &bound, nil } diff --git a/internal/handlers/session_create_test.go b/internal/handlers/session_create_test.go index a5551eb773..760cb2afaf 100644 --- a/internal/handlers/session_create_test.go +++ b/internal/handlers/session_create_test.go @@ -316,9 +316,9 @@ func (*recordingRuntimeBinder) BeginSessionHistoryReset(ctx context.Context, _, return ctx, func() {}, nil } -func (b *recordingRuntimeBinder) BindRuntime(ctx context.Context, botID, runtimeID, sessionID, agentID, projectPath, runtimeOwnerAccountID string) error { +func (b *recordingRuntimeBinder) BindRuntime(ctx context.Context, botID, runtimeID, sessionID, agentID, projectPath, workspaceTargetID, runtimeOwnerAccountID string) error { b.bindCtx = ctx - b.bindArgs = []string{botID, runtimeID, sessionID, agentID, projectPath, runtimeOwnerAccountID} + b.bindArgs = []string{botID, runtimeID, sessionID, agentID, projectPath, workspaceTargetID, runtimeOwnerAccountID} return b.bindErr } @@ -350,7 +350,7 @@ func TestCreateSessionBindsWarmACPRuntime(t *testing.T) { if err := callCreateSessionRequest(handler, botID, req); err != nil { t.Fatalf("CreateSession() error = %v", err) } - want := []string{botID, "rt_warm", "22222222-2222-2222-2222-222222222222", "codex", session.DefaultACPProjectPath, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"} + want := []string{botID, "rt_warm", "22222222-2222-2222-2222-222222222222", "codex", session.DefaultACPProjectPath, "", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"} if len(binder.bindArgs) != len(want) { t.Fatalf("bind args = %#v, want %#v", binder.bindArgs, want) } diff --git a/internal/handlers/session_delete_test.go b/internal/handlers/session_delete_test.go index a7a70660e8..fc921985fe 100644 --- a/internal/handlers/session_delete_test.go +++ b/internal/handlers/session_delete_test.go @@ -81,7 +81,7 @@ func (c *recordingACPSessionCloser) BeginSessionHistoryReset(ctx context.Context return context.WithValue(ctx, sessionDeleteResetCtxKey{}, true), release, nil } -func (*recordingACPSessionCloser) BindRuntime(context.Context, string, string, string, string, string, string) error { +func (*recordingACPSessionCloser) BindRuntime(context.Context, string, string, string, string, string, string, string) error { return nil } diff --git a/internal/handlers/users.go b/internal/handlers/users.go index 1df4950791..9beb4dd6e1 100644 --- a/internal/handlers/users.go +++ b/internal/handlers/users.go @@ -37,6 +37,10 @@ type acpWorkspaceConfigProvider interface { WorkspaceInfo(ctx context.Context, botID string) (bridge.WorkspaceInfo, error) } +func nativeACPWorkspaceContext(ctx context.Context) context.Context { + return workspace.WithWorkspaceTarget(ctx, workspace.WorkspaceTargetNative) +} + type botCreateWorkspace interface { acpWorkspaceConfigProvider SetupBotContainerWithProgress(ctx context.Context, botID string, progress workspace.ContainerSetupProgress) error @@ -1000,6 +1004,7 @@ func (h *UsersHandler) prepareACPWorkspaceConfig(ctx context.Context, bot bots.B if len(targets) == 0 { return nil } + ctx = nativeACPWorkspaceContext(ctx) workspaceInfo, err := h.acpWorkspace.WorkspaceInfo(ctx, bot.ID) if err != nil { return err diff --git a/internal/handlers/users_acp_config_test.go b/internal/handlers/users_acp_config_test.go index 5c480d02bd..748a4d3992 100644 --- a/internal/handlers/users_acp_config_test.go +++ b/internal/handlers/users_acp_config_test.go @@ -22,11 +22,12 @@ import ( func TestPrepareACPWorkspaceConfigWritesCodexAPIKeyConfig(t *testing.T) { client, recorder := newUsersACPConfigBridgeClient(t) + workspaceProvider := &usersACPConfigWorkspace{ + backend: bridge.WorkspaceBackendContainer, + client: client, + } handler := &UsersHandler{ - acpWorkspace: &usersACPConfigWorkspace{ - backend: bridge.WorkspaceBackendContainer, - client: client, - }, + acpWorkspace: workspaceProvider, } err := handler.prepareACPWorkspaceConfig(context.Background(), bots.Bot{ @@ -49,6 +50,8 @@ func TestPrepareACPWorkspaceConfigWritesCodexAPIKeyConfig(t *testing.T) { if err != nil { t.Fatalf("prepareACPWorkspaceConfig() error = %v", err) } + assertWorkspaceTargetsNative(t, "WorkspaceInfo", workspaceProvider.workspaceInfoTargetsSnapshot()) + assertWorkspaceTargetsNative(t, "MCPClient", workspaceProvider.mcpClientTargetsSnapshot()) writes := recorder.writes() if len(writes) != 2 { @@ -396,13 +399,20 @@ func TestPrepareACPWorkspaceConfigWritesHermesManagedConfig(t *testing.T) { } type usersACPConfigWorkspace struct { + mu sync.Mutex backend string defaultWorkDir string client *bridge.Client mcpErr error + + workspaceInfoTargets []string + mcpClientTargets []string } -func (w *usersACPConfigWorkspace) WorkspaceInfo(context.Context, string) (bridge.WorkspaceInfo, error) { +func (w *usersACPConfigWorkspace) WorkspaceInfo(ctx context.Context, _ string) (bridge.WorkspaceInfo, error) { + w.mu.Lock() + w.workspaceInfoTargets = append(w.workspaceInfoTargets, workspace.WorkspaceTargetFromContext(ctx)) + w.mu.Unlock() defaultWorkDir := w.defaultWorkDir if defaultWorkDir == "" { defaultWorkDir = "/data" @@ -410,13 +420,40 @@ func (w *usersACPConfigWorkspace) WorkspaceInfo(context.Context, string) (bridge return bridge.WorkspaceInfo{Backend: w.backend, DefaultWorkDir: defaultWorkDir}, nil } -func (w *usersACPConfigWorkspace) MCPClient(context.Context, string) (*bridge.Client, error) { +func (w *usersACPConfigWorkspace) MCPClient(ctx context.Context, _ string) (*bridge.Client, error) { + w.mu.Lock() + w.mcpClientTargets = append(w.mcpClientTargets, workspace.WorkspaceTargetFromContext(ctx)) + w.mu.Unlock() if w.mcpErr != nil { return nil, w.mcpErr } return w.client, nil } +func (w *usersACPConfigWorkspace) workspaceInfoTargetsSnapshot() []string { + w.mu.Lock() + defer w.mu.Unlock() + return append([]string(nil), w.workspaceInfoTargets...) +} + +func (w *usersACPConfigWorkspace) mcpClientTargetsSnapshot() []string { + w.mu.Lock() + defer w.mu.Unlock() + return append([]string(nil), w.mcpClientTargets...) +} + +func assertWorkspaceTargetsNative(t *testing.T, operation string, targets []string) { + t.Helper() + if len(targets) == 0 { + t.Fatalf("%s was not called", operation) + } + for _, target := range targets { + if target != workspace.WorkspaceTargetNative { + t.Fatalf("%s workspace target = %q, want %q", operation, target, workspace.WorkspaceTargetNative) + } + } +} + func (*usersACPConfigWorkspace) SetupBotContainerWithProgress(context.Context, string, workspace.ContainerSetupProgress) error { return nil } diff --git a/internal/server/server.go b/internal/server/server.go index b086182ccd..b71e87ca5c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "log/slog" + "net/http" neturl "net/url" "strings" @@ -13,6 +14,7 @@ import ( "github.com/felinics/memoh/internal/auth" "github.com/felinics/memoh/internal/channel/publicmedia" "github.com/felinics/memoh/internal/httpx" + "github.com/felinics/memoh/internal/mcp" ) type Server struct { @@ -52,7 +54,7 @@ func newServer(log *slog.Logger, addr string, jwtSecret string, e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{ Limit: "1M", Skipper: func(c echo.Context) bool { - return !shouldLimitPublicRequestBody(c.Request().URL.Path) + return !shouldLimitRequestBody(c.Request()) }, })) e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ @@ -79,7 +81,7 @@ func newServer(log *slog.Logger, addr string, jwtSecret string, }, })) e.Use(auth.JWTMiddleware(jwtSecret, func(c echo.Context) bool { - return shouldSkipJWT(c.Request().URL.Path) + return shouldSkipJWTRequest(c.Request()) }, validateSession)) for _, h := range handlers { @@ -103,6 +105,34 @@ func (s *Server) Stop(ctx context.Context) error { return s.echo.Shutdown(ctx) } +// shouldSkipJWTRequest admits the ACP runtime credential only on the exact +// stateless MCP tools route. The handler still authenticates both values +// against a live runtime before serving a request; this check merely lets that +// independent credential reach the handler instead of being rejected as a +// missing user JWT. +func shouldSkipJWTRequest(req *http.Request) bool { + if req == nil || req.URL == nil { + return false + } + if shouldSkipJWT(req.URL.Path) { + return true + } + return isRuntimeToolsCredentialRequest(req) +} + +func isRuntimeToolsCredentialRequest(req *http.Request) bool { + return req != nil && req.URL != nil && req.Method == http.MethodPost && + isExactBotToolsPath(req.URL.Path) && + strings.TrimSpace(req.Header.Get(mcp.ToolHeaderRuntimeID)) != "" && + strings.TrimSpace(req.Header.Get(mcp.ToolHeaderRuntimeToken)) != "" +} + +func isExactBotToolsPath(requestPath string) bool { + parts := strings.Split(requestPath, "/") + return len(parts) == 4 && parts[0] == "" && parts[1] == "bots" && + strings.TrimSpace(parts[2]) != "" && parts[3] == "tools" +} + func shouldSkipJWT(path string) bool { if path == "/" || path == "/ping" || path == "/health" || path == "/api/swagger.json" || path == "/auth/login" || path == "/runtimes/connect" { return true @@ -153,6 +183,13 @@ func shouldLimitPublicRequestBody(path string) bool { return isPublicChannelWebhookPath(path) } +func shouldLimitRequestBody(req *http.Request) bool { + if req == nil || req.URL == nil { + return false + } + return shouldLimitPublicRequestBody(req.URL.Path) || isRuntimeToolsCredentialRequest(req) +} + func isPublicChannelWebhookPath(path string) bool { if !strings.HasPrefix(path, "/channels/") { return false diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a7195b362d..48cd2d2cdb 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -2,6 +2,7 @@ package server import ( "bytes" + "context" "encoding/json" "errors" "log/slog" @@ -14,6 +15,9 @@ import ( "github.com/labstack/echo/v4" "github.com/felinics/memoh/internal/apperror" + "github.com/felinics/memoh/internal/config" + apphandlers "github.com/felinics/memoh/internal/handlers" + mcpgw "github.com/felinics/memoh/internal/mcp" ) func TestShouldSkipJWT_ChannelWebhookPaths(t *testing.T) { @@ -63,6 +67,52 @@ func TestShouldLimitPublicRequestBody(t *testing.T) { } } +func TestShouldLimitRuntimeToolsRequestBody(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodPost, "/bots/bot-1/tools", nil) + req.Header.Set(mcpgw.ToolHeaderRuntimeID, "runtime-1") + req.Header.Set(mcpgw.ToolHeaderRuntimeToken, "token-1") + if !shouldLimitRequestBody(req) { + t.Fatal("complete runtime tool credential must enable the public request body limit") + } + + req.Header.Del(mcpgw.ToolHeaderRuntimeToken) + if shouldLimitRequestBody(req) { + t.Fatal("ordinary authenticated tools request must preserve its existing body-limit behavior") + } +} + +func TestShouldSkipJWTRequestForExactRuntimeToolsCredential(t *testing.T) { + t.Parallel() + tests := []struct { + name string + method string + path string + id string + token string + want bool + }{ + {name: "exact", method: http.MethodPost, path: "/bots/bot-1/tools", id: "runtime-1", token: "token-1", want: true}, + {name: "missing token", method: http.MethodPost, path: "/bots/bot-1/tools", id: "runtime-1"}, + {name: "missing runtime", method: http.MethodPost, path: "/bots/bot-1/tools", token: "token-1"}, + {name: "get", method: http.MethodGet, path: "/bots/bot-1/tools", id: "runtime-1", token: "token-1"}, + {name: "trailing slash", method: http.MethodPost, path: "/bots/bot-1/tools/", id: "runtime-1", token: "token-1"}, + {name: "nested", method: http.MethodPost, path: "/api/bots/bot-1/tools", id: "runtime-1", token: "token-1"}, + {name: "empty bot", method: http.MethodPost, path: "/bots//tools", id: "runtime-1", token: "token-1"}, + {name: "ordinary tools request", method: http.MethodPost, path: "/bots/bot-1/tools"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.path, nil) + req.Header.Set(mcpgw.ToolHeaderRuntimeID, tt.id) + req.Header.Set(mcpgw.ToolHeaderRuntimeToken, tt.token) + if got := shouldSkipJWTRequest(req); got != tt.want { + t.Fatalf("shouldSkipJWTRequest() = %v, want %v", got, tt.want) + } + }) + } +} + func TestSafeRequestLogURIStripsPublicMediaQuery(t *testing.T) { t.Parallel() @@ -187,6 +237,138 @@ func TestShouldSkipJWTOnlyForRuntimeConnectEndpoint(t *testing.T) { } } +type runtimeMCPRouteOnly struct { + handler *apphandlers.ContainerdHandler +} + +func (h runtimeMCPRouteOnly) Register(e *echo.Echo) { + e.POST("/bots/:bot_id/tools", h.handler.HandleMCPTools) +} + +type runtimeMCPResolver struct { + session mcpgw.ToolSessionContext + calls int +} + +func (r *runtimeMCPResolver) ResolveRuntimeToolContext(botID, runtimeID, toolToken string) (mcpgw.ToolSessionContext, bool) { + r.calls++ + if botID != r.session.BotID || runtimeID != r.session.RuntimeID || toolToken != r.session.RuntimeToken { + return mcpgw.ToolSessionContext{}, false + } + return r.session, true +} + +type runtimeMCPToolSource struct { + lastSession mcpgw.ToolSessionContext +} + +func (s *runtimeMCPToolSource) ListTools(_ context.Context, session mcpgw.ToolSessionContext) ([]mcpgw.ToolDescriptor, error) { + s.lastSession = session + return []mcpgw.ToolDescriptor{{ + Name: "runtime_probe", + Description: "runtime authentication integration probe", + InputSchema: map[string]any{"type": "object"}, + }}, nil +} + +func (s *runtimeMCPToolSource) CallTool(_ context.Context, session mcpgw.ToolSessionContext, _ string, _ map[string]any) (map[string]any, error) { + s.lastSession = session + return mcpgw.BuildToolSuccessResult(map[string]any{"ok": true}), nil +} + +func TestRuntimeCredentialAuthenticatesExactMCPRouteWithoutUserJWT(t *testing.T) { + log := slog.New(slog.DiscardHandler) + trusted := mcpgw.ToolSessionContext{ + BotID: "bot-1", + ChatID: "trusted-chat", + RuntimeID: "runtime-1", + RuntimeToken: "runtime-token-1", + SessionID: "trusted-session", + RuntimeActive: true, + } + resolver := &runtimeMCPResolver{session: trusted} + source := &runtimeMCPToolSource{} + handler := apphandlers.NewContainerdHandler(log, nil, config.WorkspaceConfig{}, "", nil, nil, nil) + handler.SetToolGatewayService(mcpgw.NewToolGatewayService(log, []mcpgw.ToolSource{source})) + handler.SetACPRuntimeResolver(resolver) + server := NewServer(log, ":0", "test-secret", runtimeMCPRouteOnly{handler: handler}) + + request := func(method, path, runtimeID, runtimeToken string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(`{"jsonrpc":"2.0","id":"1","method":"tools/list"}`)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set(echo.HeaderAccept, echo.MIMEApplicationJSON) + req.Header.Set(mcpgw.ToolHeaderRuntimeID, runtimeID) + req.Header.Set(mcpgw.ToolHeaderRuntimeToken, runtimeToken) + rec := httptest.NewRecorder() + server.echo.ServeHTTP(rec, req) + return rec + } + + rec := request(http.MethodPost, "/bots/bot-1/tools", trusted.RuntimeID, trusted.RuntimeToken) + if rec.Code != http.StatusOK { + t.Fatalf("valid runtime MCP status = %d, body = %s", rec.Code, rec.Body.String()) + } + if resolver.calls != 1 { + t.Fatalf("runtime resolver calls = %d, want 1", resolver.calls) + } + if source.lastSession.BotID != trusted.BotID || source.lastSession.RuntimeID != trusted.RuntimeID || + source.lastSession.SessionID != trusted.SessionID || !source.lastSession.RuntimeActive { + t.Fatalf("MCP source received untrusted context: %#v", source.lastSession) + } + + rec = request(http.MethodPost, "/bots/bot-1/tools", trusted.RuntimeID, "wrong-token") + if rec.Code != http.StatusNotFound { + t.Fatalf("invalid runtime MCP status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get(echo.HeaderContentType); got != "application/problem+json" { + t.Fatalf("invalid runtime MCP content type = %q", got) + } + var problem apperror.Problem + if err := json.Unmarshal(rec.Body.Bytes(), &problem); err != nil { + t.Fatalf("decode invalid runtime MCP problem: %v", err) + } + if problem.Code != string(apperror.CodeACPRuntimeNotFound) || problem.RequestID == "" { + t.Fatalf("invalid runtime MCP problem = %#v", problem) + } + + resolverCalls := resolver.calls + oversizedReq := httptest.NewRequest(http.MethodPost, "/bots/bot-1/tools", strings.NewReader(strings.Repeat("x", 2<<20))) + oversizedReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + oversizedReq.Header.Set(mcpgw.ToolHeaderRuntimeID, trusted.RuntimeID) + oversizedReq.Header.Set(mcpgw.ToolHeaderRuntimeToken, trusted.RuntimeToken) + oversizedRec := httptest.NewRecorder() + server.echo.ServeHTTP(oversizedRec, oversizedReq) + if oversizedRec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized runtime MCP status = %d, want %d", oversizedRec.Code, http.StatusRequestEntityTooLarge) + } + if resolver.calls != resolverCalls { + t.Fatalf("oversized runtime request reached resolver: calls = %d, want %d", resolver.calls, resolverCalls) + } + + for _, test := range []struct { + name string + method string + path string + id string + token string + }{ + {name: "ordinary request", method: http.MethodPost, path: "/bots/bot-1/tools"}, + {name: "partial credential", method: http.MethodPost, path: "/bots/bot-1/tools", id: trusted.RuntimeID}, + {name: "wrong method", method: http.MethodGet, path: "/bots/bot-1/tools", id: trusted.RuntimeID, token: trusted.RuntimeToken}, + {name: "non-exact path", method: http.MethodPost, path: "/bots/bot-1/tools/extra", id: trusted.RuntimeID, token: trusted.RuntimeToken}, + } { + t.Run(test.name, func(t *testing.T) { + unauthorized := request(test.method, test.path, test.id, test.token) + if unauthorized.Code < http.StatusBadRequest { + t.Fatalf("status = %d, body = %s; request bypassed user JWT", unauthorized.Code, unauthorized.Body.String()) + } + }) + } + if resolver.calls != resolverCalls { + t.Fatalf("non-runtime requests reached runtime resolver: calls = %d, want %d", resolver.calls, resolverCalls) + } +} + func TestShouldSkipJWTOnlyForDigestAddressedSupermarketSkillIcons(t *testing.T) { t.Parallel() digest := strings.Repeat("a", 64) diff --git a/internal/toolcontext/context.go b/internal/toolcontext/context.go index a42ff8883a..91f12cc2ab 100644 --- a/internal/toolcontext/context.go +++ b/internal/toolcontext/context.go @@ -29,6 +29,13 @@ type Session struct { CurrentPlatform string ReplyTarget string ConversationType string + // Workspace routing pins tool execution to the session's resolved target. + // The remote kind marks a connected computer rather than a managed + // container workspace. + WorkspaceTargetID string + WorkspaceTargetKind string + WorkspaceTargetName string + WorkdirPath string // ReasoningStoredEffort and ReasoningRequestedEffort are unresolved turn // inputs. A tool that selects another model must resolve them against that // model instead of inheriting the parent runtime's provider-specific result. @@ -146,6 +153,18 @@ func Merge(base, latest Session) Session { if value := strings.TrimSpace(latest.ConversationType); value != "" { merged.ConversationType = value } + if value := strings.TrimSpace(latest.WorkspaceTargetID); value != "" { + merged.WorkspaceTargetID = value + } + if value := strings.TrimSpace(latest.WorkspaceTargetKind); value != "" { + merged.WorkspaceTargetKind = value + } + if value := strings.TrimSpace(latest.WorkspaceTargetName); value != "" { + merged.WorkspaceTargetName = value + } + if value := strings.TrimSpace(latest.WorkdirPath); value != "" { + merged.WorkdirPath = value + } if value := strings.TrimSpace(latest.ReasoningStoredEffort); value != "" { merged.ReasoningStoredEffort = value } diff --git a/internal/toolcontext/context_test.go b/internal/toolcontext/context_test.go index 447dc004ef..4fa0865d13 100644 --- a/internal/toolcontext/context_test.go +++ b/internal/toolcontext/context_test.go @@ -124,3 +124,29 @@ func TestMergePreservesRuntimeLifecycle(t *testing.T) { t.Fatalf("runtime lifecycle = context:%v guard:%v", merged.RunContext, merged.RuntimeGuard != nil) } } + +func TestMergePreservesWorkspaceBinding(t *testing.T) { + base := Session{ + WorkspaceTargetID: "old-target", + WorkspaceTargetKind: "native", + WorkspaceTargetName: "Server Workspace", + WorkdirPath: "/data/old", + } + merged := Merge(base, Session{ + WorkspaceTargetID: " remote-target ", + WorkspaceTargetKind: " remote ", + WorkspaceTargetName: " Office Mac ", + WorkdirPath: " /Users/alice/project ", + }) + + if merged.WorkspaceTargetID != "remote-target" || + merged.WorkspaceTargetKind != "remote" || + merged.WorkspaceTargetName != "Office Mac" || + merged.WorkdirPath != "/Users/alice/project" { + t.Fatalf("workspace binding = %#v", merged) + } + kept := Merge(base, Session{}) + if kept.WorkspaceTargetID != "old-target" || kept.WorkdirPath != "/data/old" { + t.Fatalf("empty overlay dropped binding = %#v", kept) + } +} diff --git a/internal/userruntime/metadata.go b/internal/userruntime/metadata.go index a673281871..88b5082b2b 100644 --- a/internal/userruntime/metadata.go +++ b/internal/userruntime/metadata.go @@ -13,11 +13,13 @@ import ( ) const ( - RuntimeMetadataHeader = "X-Memoh-Runtime-Metadata" - maxMetadataBytes = 8 * 1024 - CapabilityFS = "fs" - CapabilityExec = "exec" - CapabilityHostFS = "host_fs" + RuntimeMetadataHeader = "X-Memoh-Runtime-Metadata" + maxMetadataBytes = 8 * 1024 + CapabilityFS = "fs" + CapabilityExec = "exec" + CapabilityHostFS = "host_fs" + CapabilityACPCodex = "acp_codex" + CapabilityACPClaudeCode = "acp_claude_code" ) var ( @@ -114,7 +116,7 @@ func validateHandshakeInfo(info *HandshakeInfo) error { for _, capability := range info.Capabilities { capability = strings.ToLower(strings.TrimSpace(capability)) switch capability { - case CapabilityFS, CapabilityExec, CapabilityHostFS: + case CapabilityFS, CapabilityExec, CapabilityHostFS, CapabilityACPCodex, CapabilityACPClaudeCode: default: // A newer client may declare capabilities this server predates. // Drop them instead of rejecting: routing only ever consults the diff --git a/internal/userruntime/metadata_test.go b/internal/userruntime/metadata_test.go index 9eacbf3873..5e70f8e939 100644 --- a/internal/userruntime/metadata_test.go +++ b/internal/userruntime/metadata_test.go @@ -26,7 +26,18 @@ func TestParseHandshakeMetadataUnicodeAndCanonicalCapabilities(t *testing.T) { "arch": "arm64", "client_version": "1.2.3", "workspace_base": "/Users/张三/项目", - "capabilities": []string{"exec", "fs", "host_fs", "exec", "workspace_scope", "tunnel_v9"}, + "capabilities": []string{ + "exec", + "fs", + "host_fs", + "exec", + " ACP_CODEX ", + "acp_codex", + "ACP_CLAUDE_CODE", + "acp_claude_code", + "workspace_scope", + "tunnel_v9", + }, }) info, err := ParseHandshakeMetadata(encoded) @@ -36,8 +47,8 @@ func TestParseHandshakeMetadataUnicodeAndCanonicalCapabilities(t *testing.T) { if info.Hostname != "工作站.local" || info.WorkspaceBase != "/Users/张三/项目" { t.Fatalf("unicode metadata changed: %#v", info) } - if got := strings.Join(info.Capabilities, ","); got != "exec,fs,host_fs" { - t.Fatalf("capabilities = %q, want exec,fs,host_fs (unknown capability must be dropped, not rejected)", got) + if got := strings.Join(info.Capabilities, ","); got != "acp_claude_code,acp_codex,exec,fs,host_fs" { + t.Fatalf("capabilities = %q, want canonical ACP and workspace capabilities (unknown capability must be dropped, not rejected)", got) } } diff --git a/internal/workspace/bridge/workspace_info.go b/internal/workspace/bridge/workspace_info.go index c143f5eb62..d593329a3b 100644 --- a/internal/workspace/bridge/workspace_info.go +++ b/internal/workspace/bridge/workspace_info.go @@ -14,6 +14,10 @@ type WorkspaceInfo struct { OS string DefaultWorkDir string ACPToolsHTTPURL string + Capabilities []string + TargetID string + TargetKind string + TargetName string } type WorkspaceInfoProvider interface { diff --git a/internal/workspace/manager.go b/internal/workspace/manager.go index 01f2c5420f..4f49777781 100644 --- a/internal/workspace/manager.go +++ b/internal/workspace/manager.go @@ -333,9 +333,9 @@ func (m *Manager) ResolveWorkspaceTarget(ctx context.Context, botID, targetID st return ResolvedWorkspaceTarget{}, err } return ResolvedWorkspaceTarget{ - TargetID: WorkspaceTargetNative, - Kind: WorkspaceTargetNative, - Name: "Server Workspace", + TargetID: info.TargetID, + Kind: info.TargetKind, + Name: info.TargetName, Primary: primary, Client: client, Info: info, @@ -415,7 +415,7 @@ func (m *Manager) nativeWorkspaceInfo(ctx context.Context, botID string) (bridge if provider, ok := m.service.(bridge.WorkspaceInfoProvider); ok { info, err := provider.WorkspaceInfo(ctx, botID) if err == nil { - return withACPToolsEndpoint(info), nil + return withNativeWorkspaceTarget(withACPToolsEndpoint(info)), nil } if !errors.Is(err, ctr.ErrNotSupported) && !ctr.IsNotFound(err) { return bridge.WorkspaceInfo{}, err @@ -425,7 +425,7 @@ func (m *Manager) nativeWorkspaceInfo(ctx context.Context, botID string) (bridge Backend: bridge.WorkspaceBackendContainer, DefaultWorkDir: config.DefaultDataMount, } - return withACPToolsEndpoint(info), nil + return withNativeWorkspaceTarget(withACPToolsEndpoint(info)), nil } func (m *Manager) nativeToolApprovalConfig(ctx context.Context, botID string) (settings.ToolApprovalConfig, error) { @@ -501,6 +501,13 @@ func withACPToolsEndpoint(info bridge.WorkspaceInfo) bridge.WorkspaceInfo { return info } +func withNativeWorkspaceTarget(info bridge.WorkspaceInfo) bridge.WorkspaceInfo { + info.TargetID = WorkspaceTargetNative + info.TargetKind = WorkspaceTargetNative + info.TargetName = "Server Workspace" + return info +} + func (m *Manager) Init(ctx context.Context) error { image := m.imageRef() result, err := m.PrepareImageForCreate(ctx, image, &ctr.PullImageOptions{ diff --git a/internal/workspace/manager_legacy_test.go b/internal/workspace/manager_legacy_test.go index d431543c19..f4c2ce0e17 100644 --- a/internal/workspace/manager_legacy_test.go +++ b/internal/workspace/manager_legacy_test.go @@ -44,13 +44,18 @@ type legacyRouteTestService struct { type workspaceInfoProviderTestService struct { legacyRouteTestService - info bridge.WorkspaceInfo + info bridge.WorkspaceInfo + client *bridge.Client } func (s *workspaceInfoProviderTestService) WorkspaceInfo(context.Context, string) (bridge.WorkspaceInfo, error) { return s.info, nil } +func (s *workspaceInfoProviderTestService) MCPClient(context.Context, string) (*bridge.Client, error) { + return s.client, nil +} + func (s *legacyRouteTestService) PullImage(_ context.Context, ref string, _ *ctr.PullImageOptions) (ctr.ImageInfo, error) { s.pullCalls++ s.pullRefs = append(s.pullRefs, ref) @@ -366,10 +371,13 @@ func TestStartWithImageDoesNotRecreateExistingContainer(t *testing.T) { } func TestWorkspaceInfoAddsACPToolsEndpointForProviderContainer(t *testing.T) { + client, _ := newRemoteScopeTestClient(t) svc := &workspaceInfoProviderTestService{ + client: client, info: bridge.WorkspaceInfo{ Backend: bridge.WorkspaceBackendContainer, DefaultWorkDir: "/data", + Capabilities: []string{"native_capability"}, }, } m := newLegacyRouteTestManager(t, svc, config.WorkspaceConfig{DataRoot: t.TempDir()}) @@ -381,6 +389,21 @@ func TestWorkspaceInfoAddsACPToolsEndpointForProviderContainer(t *testing.T) { if info.ACPToolsHTTPURL != ACPToolsProxyHTTPURL { t.Fatalf("ACPToolsHTTPURL = %q", info.ACPToolsHTTPURL) } + if got := strings.Join(info.Capabilities, ","); got != "native_capability" { + t.Fatalf("native provider capabilities = %q, want native_capability", got) + } + if info.TargetID != WorkspaceTargetNative || info.TargetKind != WorkspaceTargetNative || info.TargetName != "Server Workspace" { + t.Fatalf("native WorkspaceInfo target identity = %#v", info) + } + + target, err := m.ResolveWorkspaceTarget(context.Background(), "bot-1", WorkspaceTargetNative) + if err != nil { + t.Fatalf("ResolveWorkspaceTarget native: %v", err) + } + if target.TargetID != WorkspaceTargetNative || target.Kind != WorkspaceTargetNative || target.Name != "Server Workspace" || + target.Info.TargetID != target.TargetID || target.Info.TargetKind != target.Kind || target.Info.TargetName != target.Name { + t.Fatalf("resolved native target identity = %#v", target) + } } func TestDeleteClearsLegacyRoute(t *testing.T) { diff --git a/internal/workspace/remote.go b/internal/workspace/remote.go index 5cf5ecc0e8..0dbd204071 100644 --- a/internal/workspace/remote.go +++ b/internal/workspace/remote.go @@ -30,6 +30,7 @@ const ( var ( ErrWorkspaceTargetNotFound = errors.New("workspace target not found") + ErrWorkspaceTargetInUse = errors.New("workspace target is referenced by a workdir") ErrRemoteWorkspaceNotBound = errors.New("remote workspace is not bound") ErrRemoteRuntimeNotUsable = errors.New("remote runtime not found, revoked, or owned by another user") ErrRemoteRuntimeOffline = errors.New("remote runtime is offline") @@ -261,7 +262,11 @@ func (s *RemoteWorkspaceService) DeleteMount(ctx context.Context, botID, targetI if _, err := s.getRecord(ctx, botID, targetID); err != nil { return err } - return s.store.DeleteMount(ctx, botID, targetID) + if err := s.store.DeleteMount(ctx, botID, targetID); errors.Is(err, db.ErrWorkspaceTargetInUse) { + return ErrWorkspaceTargetInUse + } else { + return err + } } func (s *RemoteWorkspaceService) ResolveMount(ctx context.Context, botID, targetID string) (ResolvedWorkspaceTarget, error) { @@ -287,6 +292,10 @@ func (s *RemoteWorkspaceService) resolveRecord(record dbstore.BotRemoteRuntimeBi Backend: bridge.WorkspaceBackendRemote, OS: connection.Info.OS, DefaultWorkDir: connection.Info.WorkspaceBase, + Capabilities: append([]string(nil), connection.Info.Capabilities...), + TargetID: record.ID, + TargetKind: WorkspaceTargetRemote, + TargetName: record.RuntimeName, }, Approval: toolApprovalConfig(record.ToolApproval), }, nil diff --git a/internal/workspace/remote_test.go b/internal/workspace/remote_test.go index 87bb37305a..bd0f20cfa7 100644 --- a/internal/workspace/remote_test.go +++ b/internal/workspace/remote_test.go @@ -7,6 +7,7 @@ import ( "log/slog" "net" "slices" + "strings" "testing" "google.golang.org/grpc" @@ -305,6 +306,24 @@ func TestOwnerMismatchIsRedactedButTargetCanBeDeleted(t *testing.T) { } } +func TestRemoteWorkspaceTargetInUseCannotBeDeleted(t *testing.T) { + store := &fakeRemoteBindingStore{ + records: []dbstore.BotRemoteRuntimeBindingRecord{{ + ID: remoteTestTargetID, BotID: remoteTestBotID, RuntimeID: remoteTestRuntimeID, + }}, + deleteErr: db.ErrWorkspaceTargetInUse, + } + service := &RemoteWorkspaceService{store: store} + + err := service.DeleteMount(context.Background(), remoteTestBotID, remoteTestTargetID) + if !errors.Is(err, ErrWorkspaceTargetInUse) { + t.Fatalf("DeleteMount() error = %v, want ErrWorkspaceTargetInUse", err) + } + if len(store.records) != 1 || store.records[0].ID != remoteTestTargetID { + t.Fatalf("referenced target was removed: %#v", store.records) + } +} + func TestRemotePrimaryOfflineNeverFallsBackToNative(t *testing.T) { store := &fakeRemoteBindingStore{records: []dbstore.BotRemoteRuntimeBindingRecord{{ ID: remoteTestTargetID, BotID: remoteTestBotID, RuntimeID: remoteTestRuntimeID, @@ -347,8 +366,16 @@ func TestRemotePrimaryDoesNotHideNativeContainerStatus(t *testing.T) { func TestRemoteWorkspaceClientUsesHostFilesystemCapability(t *testing.T) { rootClient, captured := newRemoteScopeTestClient(t) + capabilities := []string{ + userruntime.CapabilityFS, + userruntime.CapabilityExec, + userruntime.CapabilityHostFS, + userruntime.CapabilityACPCodex, + userruntime.CapabilityACPClaudeCode, + } store := &fakeRemoteBindingStore{records: []dbstore.BotRemoteRuntimeBindingRecord{{ ID: remoteTestTargetID, BotID: remoteTestBotID, RuntimeID: remoteTestRuntimeID, + RuntimeName: "Office Mac", IsPrimary: true, RuntimeUserID: remoteTestOwnerID, BotOwnerUserID: remoteTestOwnerID, }}} @@ -360,7 +387,7 @@ func TestRemoteWorkspaceClientUsesHostFilesystemCapability(t *testing.T) { Info: userruntime.RuntimeInfo{ WorkspaceBase: "/Users/alice", OS: "darwin", - Capabilities: []string{userruntime.CapabilityFS, userruntime.CapabilityExec, userruntime.CapabilityHostFS}, + Capabilities: capabilities, }, }}, } @@ -368,9 +395,18 @@ func TestRemoteWorkspaceClientUsesHostFilesystemCapability(t *testing.T) { if err != nil { t.Fatalf("ResolveMount: %v", err) } - if target.Info.Backend != bridge.WorkspaceBackendRemote || target.Info.DefaultWorkDir != "/Users/alice" { + if target.Info.Backend != bridge.WorkspaceBackendRemote || target.Info.DefaultWorkDir != "/Users/alice" || + target.Info.TargetID != remoteTestTargetID || target.Info.TargetKind != WorkspaceTargetRemote || target.Info.TargetName != "Office Mac" { t.Fatalf("workspace info = %#v", target.Info) } + if got := strings.Join(target.Info.Capabilities, ","); got != strings.Join(capabilities, ",") { + t.Fatalf("workspace capabilities = %q, want %q", got, strings.Join(capabilities, ",")) + } + target.Info.Capabilities[0] = "mutated" + connection, ok := service.runtimes.Connection(remoteTestRuntimeID) + if !ok || connection.Info.Capabilities[0] != userruntime.CapabilityFS { + t.Fatalf("resolved WorkspaceInfo aliases runtime capabilities: %#v", connection) + } if _, err := target.Client.Stat(context.Background(), "/Users/alice"); err != nil { t.Fatalf("Stat: %v", err) } diff --git a/internal/workspace/target_context_test.go b/internal/workspace/target_context_test.go index 40b58492a3..561ccca253 100644 --- a/internal/workspace/target_context_test.go +++ b/internal/workspace/target_context_test.go @@ -185,7 +185,8 @@ func TestManagerWorkspaceTargetOverridePrecedenceAndConcurrentIsolation(t *testi if err != nil { t.Fatalf("WorkspaceInfo request override: %v", err) } - if info.Backend != "remote" || info.OS != "win32" || info.DefaultWorkDir != `C:\Users\alice\workspaces` { + if info.Backend != "remote" || info.OS != "win32" || info.DefaultWorkDir != `C:\Users\alice\workspaces` || + info.TargetID != remoteTestTargetID2 || info.TargetKind != WorkspaceTargetRemote || info.TargetName != "Request PC" { t.Fatalf("request WorkspaceInfo = %#v", info) } client, err := manager.MCPClient(requestCtx, remoteTestBotID) @@ -210,9 +211,11 @@ func TestManagerWorkspaceTargetOverridePrecedenceAndConcurrentIsolation(t *testi defer wg.Done() ctx := WithWorkspaceTarget(context.Background(), remoteTestTargetID) wantOS := "darwin" + wantTargetID := remoteTestTargetID if index%2 == 1 { ctx = WithWorkspaceTarget(context.Background(), remoteTestTargetID2) wantOS = "win32" + wantTargetID = remoteTestTargetID2 } <-start for range 50 { @@ -225,6 +228,10 @@ func TestManagerWorkspaceTargetOverridePrecedenceAndConcurrentIsolation(t *testi errs <- fmt.Errorf("worker %d OS = %q, want %q", index, info.OS, wantOS) return } + if info.TargetID != wantTargetID { + errs <- fmt.Errorf("worker %d target = %q, want %q", index, info.TargetID, wantTargetID) + return + } } }(i) } diff --git a/packages/runtime/src/core/acp-launchers.ts b/packages/runtime/src/core/acp-launchers.ts new file mode 100644 index 0000000000..31870c046f --- /dev/null +++ b/packages/runtime/src/core/acp-launchers.ts @@ -0,0 +1,396 @@ +import { constants } from 'node:fs' +import { + access, + chmod, + lstat, + mkdtemp, + open, + readdir, + rm, + stat, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { isAbsolute, join } from 'node:path' + +export const trustedACPAdapterNames = [ + 'codex-acp', + 'claude-agent-acp', +] as const + +export type TrustedACPAdapterName = typeof trustedACPAdapterNames[number] + +interface TrustedACPLauncherBase { + /** Absolute Electron or Node executable used only for Runtime's bootstrap. */ + nodeExecutable: string + /** Absolute JavaScript entry file for the pinned ACP adapter package. */ + adapterEntry: string +} + +export interface TrustedCodexACPLauncher extends TrustedACPLauncherBase { + /** Absolute local Codex CLI entry; exposed only as CODEX_PATH. */ + codexExecutable: string +} + +export interface TrustedClaudeCodeACPLauncher extends TrustedACPLauncherBase { + /** Absolute local Claude Code entry; exposed only to this adapter. */ + claudeCodeExecutable: string +} + +/** Main-process-only descriptors for the two fixed Desktop ACP adapters. */ +export type TrustedACPLaunchers = Readonly<{ + /** false explicitly disables this alias and blocks ambient PATH fallback. */ + 'codex-acp'?: TrustedCodexACPLauncher | false + /** false explicitly disables this alias and blocks ambient PATH fallback. */ + 'claude-agent-acp'?: TrustedClaudeCodeACPLauncher | false +}> + +export interface PreparedTrustedACPLaunchers { + configuredAdapters: readonly TrustedACPAdapterName[] + availableAdapters: readonly TrustedACPAdapterName[] + executableDirectories: readonly string[] + close(): Promise +} + +interface PrepareTrustedACPLaunchersOptions { + os?: NodeJS.Platform + warn?: (message: string) => void +} + +interface NormalizedLauncher { + nodeExecutable: string + adapterEntry: string + agentExecutable: string +} + +const launcherDirectoryPrefix = 'memoh-runtime-acp-' + +/** + * Copies and validates the embedding-process configuration before a session + * starts. There are no generic aliases, arbitrary argv, or arbitrary env + * values: each fixed alias has one strongly typed local-Agent path. + */ +export function normalizeTrustedACPLaunchers( + launchers: TrustedACPLaunchers | undefined, +): TrustedACPLaunchers { + if (launchers === undefined) { + return Object.freeze({}) + } + assertRecord(launchers, 'trustedACPLaunchers must be an object') + + const normalized: { + 'codex-acp'?: TrustedCodexACPLauncher | false + 'claude-agent-acp'?: TrustedClaudeCodeACPLauncher | false + } = {} + for (const key of Object.keys(launchers)) { + if (!isTrustedACPAdapterName(key)) { + throw new Error(`unsupported trusted ACP adapter alias: ${key}`) + } + const launcher = launchers[key] + if (launcher === false) { + normalized[key] = false + continue + } + assertRecord(launcher, `trusted ACP launcher ${key} must be an object`) + const allowedFields = key === 'codex-acp' + ? new Set(['nodeExecutable', 'adapterEntry', 'codexExecutable']) + : new Set(['nodeExecutable', 'adapterEntry', 'claudeCodeExecutable']) + for (const field of Object.keys(launcher)) { + if (!allowedFields.has(field)) { + throw new Error(`trusted ACP launcher ${key} field ${field} is not allowed`) + } + } + const nodeExecutable = absoluteLauncherPath(launcher.nodeExecutable, `${key} nodeExecutable`) + const adapterEntry = absoluteLauncherPath(launcher.adapterEntry, `${key} adapterEntry`) + if (key === 'codex-acp') { + normalized[key] = Object.freeze({ + nodeExecutable, + adapterEntry, + codexExecutable: absoluteLauncherPath(launcher.codexExecutable, `${key} codexExecutable`), + }) + } else { + normalized[key] = Object.freeze({ + nodeExecutable, + adapterEntry, + claudeCodeExecutable: absoluteLauncherPath( + launcher.claudeCodeExecutable, + `${key} claudeCodeExecutable`, + ), + }) + } + } + return Object.freeze(normalized) +} + +/** + * Verifies every fixed file before materializing mode-0700 POSIX shims. An + * explicitly configured but invalid alias remains configured, so capability + * detection cannot fall back to a same-name executable from the ambient GUI + * PATH. + */ +export async function prepareTrustedACPLaunchers( + launchers: TrustedACPLaunchers | undefined, + options: PrepareTrustedACPLaunchersOptions = {}, +): Promise { + const normalized = normalizeTrustedACPLaunchers(launchers) + const configuredAdapters = trustedACPAdapterNames.filter(name => ( + Object.prototype.hasOwnProperty.call(normalized, name) + )) + const os = options.os ?? process.platform + if (configuredAdapters.length === 0) { + return emptyPreparedLaunchers(configuredAdapters) + } + if (os !== 'darwin' && os !== 'linux') { + for (const name of configuredAdapters) { + options.warn?.(`trusted ACP launcher ${name} is unavailable on ${os}`) + } + return emptyPreparedLaunchers(configuredAdapters) + } + + const verified = new Map() + for (const name of configuredAdapters) { + const launcher = normalized[name] + if (!launcher) continue + try { + verified.set(name, { + nodeExecutable: await verifiedFile(launcher.nodeExecutable, constants.X_OK, 'node executable'), + adapterEntry: await verifiedFile(launcher.adapterEntry, constants.R_OK, 'adapter entry'), + agentExecutable: await verifiedFile( + name === 'codex-acp' + ? (normalized['codex-acp'] as TrustedCodexACPLauncher).codexExecutable + : (normalized['claude-agent-acp'] as TrustedClaudeCodeACPLauncher).claudeCodeExecutable, + constants.X_OK, + 'agent executable', + ), + }) + } catch (error) { + options.warn?.(`trusted ACP launcher ${name} is unavailable: ${errorMessage(error)}`) + } + } + if (verified.size === 0) { + return emptyPreparedLaunchers(configuredAdapters) + } + + const directory = await mkdtemp(join(tmpdir(), `${launcherDirectoryPrefix}${process.pid}-`)) + let closed = false + const close = async () => { + if (closed) return + closed = true + await rm(directory, { recursive: true, force: true }) + } + try { + await chmod(directory, 0o700) + const availableAdapters: TrustedACPAdapterName[] = [] + for (const name of trustedACPAdapterNames) { + const launcher = verified.get(name) + if (!launcher) continue + // One broken adapter degrades to a warning like verification failures + // do; it must not fail the whole connection loop for the other alias. + try { + const agentCommand = await materializeAgentCommand(directory, name, launcher) + const bootstrap = join(directory, `${name}-bootstrap.mjs`) + const shim = join(directory, name) + await writePrivateFile(bootstrap, launcherBootstrap(name, { + ...launcher, + agentExecutable: agentCommand, + })) + await writePrivateFile(shim, launcherShim(launcher.nodeExecutable, bootstrap)) + availableAdapters.push(name) + } catch (error) { + options.warn?.(`trusted ACP launcher ${name} could not be prepared: ${errorMessage(error)}`) + } + } + if (availableAdapters.length === 0) { + await close() + return emptyPreparedLaunchers(configuredAdapters) + } + return { + configuredAdapters: Object.freeze([...configuredAdapters]), + availableAdapters: Object.freeze(availableAdapters), + executableDirectories: Object.freeze([directory]), + close, + } + } catch (error) { + await close().catch(() => undefined) + throw error + } +} + +/** Removes private launcher state owned by Runtime processes that no longer exist. */ +export async function cleanupStaleTrustedACPLaunchers(): Promise { + let names: string[] + try { + names = await readdir(tmpdir()) + } catch { + return + } + await Promise.all(names.map(async name => { + const match = /^memoh-runtime-acp-(\d+)-[0-9A-Za-z_-]{6}$/.exec(name) + if (!match) return + const ownerPID = Number(match[1]) + if (!Number.isSafeInteger(ownerPID) || ownerPID <= 0 || processIsAlive(ownerPID)) return + const path = join(tmpdir(), name) + try { + const entry = await lstat(path) + if (!entry.isDirectory() || entry.isSymbolicLink()) return + await rm(path, { recursive: true, force: true }) + } catch { + // Best effort: another Runtime or cleanup pass may win this race. + } + })) +} + +function emptyPreparedLaunchers( + configuredAdapters: readonly TrustedACPAdapterName[], +): PreparedTrustedACPLaunchers { + return { + configuredAdapters: Object.freeze([...configuredAdapters]), + availableAdapters: Object.freeze([]), + executableDirectories: Object.freeze([]), + close: async () => undefined, + } +} + +function absoluteLauncherPath(value: unknown, label: string): string { + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > 4_096 + || value !== value.trim() + || /[\0\r\n]/.test(value) + || !isAbsolute(value) + ) { + throw new Error(`trusted ACP launcher ${label} must be a safe absolute path`) + } + return value +} + +async function verifiedFile( + path: string, + mode: number, + description: string, +): Promise { + try { + // stat/access follow symlinks, so the target is fully validated — but the + // returned path stays the original: version managers like volta dispatch + // on the symlink's basename, and resolving it would launch the shim + // binary under the wrong name. + const entry = await stat(path) + if (!entry.isFile()) { + throw new Error(`${description} is not a regular file`) + } + await access(path, mode) + return path + } catch (error) { + if (error instanceof Error && error.message === `${description} is not a regular file`) { + throw error + } + throw new Error(`${description} is not accessible`) + } +} + +async function writePrivateFile(path: string, content: string): Promise { + await writeFile(path, content, { + encoding: 'utf8', + flag: 'wx', + mode: 0o700, + }) + await chmod(path, 0o700) +} + +function launcherShim(nodeExecutable: string, bootstrap: string): string { + const invocation = [nodeExecutable, bootstrap].map(quotePOSIXShellArgument).join(' ') + return `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec ${invocation} "$@"\n` +} + +async function materializeAgentCommand( + directory: string, + name: TrustedACPAdapterName, + launcher: NormalizedLauncher, +): Promise { + if (!await isNodeScript(launcher.agentExecutable)) { + return launcher.agentExecutable + } + const bootstrap = join(directory, `${name}-agent-bootstrap.mjs`) + const shim = join(directory, `${name}-agent`) + await writePrivateFile(bootstrap, agentBootstrap(launcher.agentExecutable)) + await writePrivateFile(shim, launcherShim(launcher.nodeExecutable, bootstrap)) + return shim +} + +async function isNodeScript(file: string): Promise { + const handle = await open(file, 'r') + try { + const buffer = Buffer.alloc(256) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) + const firstLine = buffer.subarray(0, bytesRead).toString('utf8').split(/\r?\n/, 1)[0] + return /^#!\s*(?:\/usr\/bin\/env(?:\s+-S)?\s+node(?:\s|$)|\S*\/node(?:\s|$))/.test(firstLine) + } finally { + await handle.close() + } +} + +function agentBootstrap(agentEntry: string): string { + const entry = javascriptString(agentEntry) + return `import { pathToFileURL } from 'node:url' + +const agentEntry = ${entry} +const agentArgs = process.argv.slice(2) +delete process.env.ELECTRON_RUN_AS_NODE +delete process.env.CODEX_PATH +delete process.env.CLAUDE_CODE_EXECUTABLE +process.argv = [process.execPath, agentEntry, ...agentArgs] +await import(pathToFileURL(agentEntry).href) +` +} + +function launcherBootstrap(name: TrustedACPAdapterName, launcher: NormalizedLauncher): string { + const adapterEntry = javascriptString(launcher.adapterEntry) + const agentExecutable = javascriptString(launcher.agentExecutable) + const agentEnvironment = name === 'codex-acp' + ? `process.env.CODEX_PATH = ${agentExecutable}` + : `process.env.CLAUDE_CODE_EXECUTABLE = ${agentExecutable}` + return `import { pathToFileURL } from 'node:url' + +delete process.env.ELECTRON_RUN_AS_NODE +delete process.env.CODEX_PATH +delete process.env.CLAUDE_CODE_EXECUTABLE +${agentEnvironment} + +const adapterEntry = ${adapterEntry} +const adapterArgs = process.argv.slice(2) +process.argv = [process.execPath, adapterEntry, ...adapterArgs] +await import(pathToFileURL(adapterEntry).href) +` +} + +function javascriptString(value: string): string { + return JSON.stringify(value).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029') +} + +function quotePOSIXShellArgument(value: string): string { + return `'${value.replaceAll('\u0027', '\u0027"\u0027"\u0027')}'` +} + +function isTrustedACPAdapterName(value: string): value is TrustedACPAdapterName { + return (trustedACPAdapterNames as readonly string[]).includes(value) +} + +function assertRecord(value: unknown, message: string): asserts value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(message) + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'validation failed' +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} diff --git a/packages/runtime/src/core/exec.ts b/packages/runtime/src/core/exec.ts index e8c3f95a81..40c5c16171 100644 --- a/packages/runtime/src/core/exec.ts +++ b/packages/runtime/src/core/exec.ts @@ -31,6 +31,7 @@ export class WorkspaceExecService { private readonly paths: ExecPathResolver, private readonly children: ExecChildSupervisor, private readonly acceptingRPCs: () => boolean = () => true, + private readonly trustedExecutableDirectories: readonly string[] = [], ) {} exec(call: ServerDuplexStream): void { @@ -39,6 +40,11 @@ export class WorkspaceExecService { let cancelled = false let terminalSent = false let child: ChildProcessWithoutNullStreams | undefined + // stdin frames must reach the child in arrival order. Everything queues + // until start() has flushed the queue; only then do writes go direct. + // Writing directly as soon as `child` exists would let frames arriving in + // the async spawn window overtake the queued first-message bytes. + let stdinReady = false const queuedInput: Buffer[] = [] const admissionActive = () => this.acceptingRPCs() && !cancelled && !call.cancelled && !call.destroyed @@ -65,7 +71,12 @@ export class WorkspaceExecService { call.emit('error', error) return } - child?.stdin.end() + // Before the flush point, start() observes inputEnded via + // shouldEndInput() and ends stdin after flushing the queue; ending it + // here would drop the still-queued bytes. + if (stdinReady) { + child?.stdin.end() + } }) call.on('data', (message: ExecInput) => { if (first) { @@ -86,6 +97,7 @@ export class WorkspaceExecService { void this.children.terminate(spawned) } }, + () => { stdinReady = true }, ) .catch(error => { if (admissionActive()) { @@ -97,7 +109,7 @@ export class WorkspaceExecService { } if (message.stdin_data?.length) { const data = Buffer.from(message.stdin_data) - if (child) { + if (child && stdinReady) { child.stdin.write(data) } else { queuedInput.push(data) @@ -114,6 +126,7 @@ export class WorkspaceExecService { markTerminalSent: () => void, admissionActive: () => boolean, onSpawn: (child: ChildProcessWithoutNullStreams) => void, + onStdinReady: () => void, ): Promise { assertExecAdmissionActive(call, admissionActive) if (request.pty) { @@ -135,6 +148,7 @@ export class WorkspaceExecService { const environment = guardedEnvironment(request.env, { clean: request.clean_env, unset: request.unset_env, + trustedExecutableDirectories: this.trustedExecutableDirectories, }) assertExecAdmissionActive(call, admissionActive) @@ -178,9 +192,12 @@ export class WorkspaceExecService { throw rpcError(status.CANCELLED, 'exec was cancelled before process admission completed') } + // Flush and hand over stdin in one synchronous block: no data event can + // interleave between the queue drain and the switch to direct writes. for (const data of queuedInput.splice(0)) { child.stdin.write(data) } + onStdinReady() if (shouldEndInput()) { child.stdin.end() } diff --git a/packages/runtime/src/core/guards.ts b/packages/runtime/src/core/guards.ts index 364ab06b17..6c84cf5dbe 100644 --- a/packages/runtime/src/core/guards.ts +++ b/packages/runtime/src/core/guards.ts @@ -1,8 +1,11 @@ +import { constants } from 'node:fs' +import { access, stat } from 'node:fs/promises' import { posix, win32 } from 'node:path' import { status } from '@grpc/grpc-js' import { rpcError } from '../rpc' +import type { TrustedACPAdapterName } from './acp-launchers.js' const blockedNames = new Set([ 'NODE_OPTIONS', @@ -16,6 +19,9 @@ const blockedNames = new Set([ 'PATHEXT', 'IFS', 'MEMOH_RUNTIME_KEY', + 'ELECTRON_RUN_AS_NODE', + 'CODEX_PATH', + 'CLAUDE_CODE_EXECUTABLE', ]) const inheritedExactNames = new Set([ @@ -49,13 +55,76 @@ const inheritedExactNames = new Set([ const validEnvironmentName = /^[A-Za-z_][A-Za-z0-9_]*$/ -export function runtimeCapabilities(): Array<'fs' | 'exec' | 'host_fs'> { - return ['fs', 'exec', 'host_fs'] +const baseRuntimeCapabilities = ['fs', 'exec', 'host_fs'] as const + +const acpAdapterCapabilities = [ + { command: 'codex-acp', capability: 'acp_codex' }, + { command: 'claude-agent-acp', capability: 'acp_claude_code' }, +] as const + +export type RuntimeCapability = + | typeof baseRuntimeCapabilities[number] + | typeof acpAdapterCapabilities[number]['capability'] + +type ACPAdapterProbe = ( + candidate: string, + adapter: TrustedACPAdapterName, +) => Promise + +export interface ACPAdapterAvailability { + configuredAdapters: readonly TrustedACPAdapterName[] + availableAdapters: readonly TrustedACPAdapterName[] +} + +export function runtimeCapabilities(): RuntimeCapability[] { + return [...baseRuntimeCapabilities] +} + +// ACP capabilities are advisory. The Server still rechecks the adapter when +// it starts a session, while this probe lets it avoid offering agents that are +// absent from the connected computer. Reuse the same narrowed PATH exposed to +// Remote Runtime commands rather than inspecting arbitrary process entries. +export async function detectRuntimeCapabilities( + source: NodeJS.ProcessEnv = process.env, + os: NodeJS.Platform = process.platform, + isAvailable: ACPAdapterProbe = isAvailableACPAdapter, + trustedAvailability?: ACPAdapterAvailability, +): Promise { + const capabilities = runtimeCapabilities() + if (os !== 'darwin' && os !== 'linux') { + return capabilities + } + + const safePath = inheritedEnvironment(source, os).PATH ?? '' + const directories = safePath.split(posix.delimiter).filter(Boolean) + const configured = new Set(trustedAvailability?.configuredAdapters ?? []) + const available = new Set(trustedAvailability?.availableAdapters ?? []) + for (const adapter of acpAdapterCapabilities) { + if (configured.has(adapter.command)) { + if (available.has(adapter.command)) { + capabilities.push(adapter.capability) + } + continue + } + for (const directory of directories) { + if (await isAvailable(posix.join(directory, adapter.command), adapter.command)) { + capabilities.push(adapter.capability) + break + } + } + } + return capabilities } export interface GuardedEnvironmentOptions { clean?: boolean unset?: readonly string[] + // Trusted launcher directories are prepended to PATH so the fixed ACP + // aliases resolve to Runtime-owned shims. This is name resolution, not + // confinement: Exec still runs any server-supplied command as the user, so + // Remote ACP inherits the full-shell trust model of the exec capability + // (see docs/design/remote-acp.md, "Trust model"). + trustedExecutableDirectories?: readonly string[] } export function guardedEnvironment( @@ -77,6 +146,11 @@ export function guardedEnvironment( assertSafeEnvironmentName(name) environment[process.platform === 'win32' ? name.toUpperCase() : name] = value } + prependTrustedExecutableDirectories( + environment, + options.trustedExecutableDirectories ?? [], + process.platform, + ) return environment } @@ -131,6 +205,8 @@ export function assertSafeEnvironmentName(name: string): void { const normalized = name.toUpperCase() if ( blockedNames.has(normalized) + || normalized.startsWith('NODE_') + || normalized.startsWith('ELECTRON_') || normalized.startsWith('LD_') || normalized.startsWith('DYLD_') ) { @@ -149,6 +225,34 @@ function safeInheritedPath(value: string | undefined, os: NodeJS.Platform): stri return entries.length > 0 ? [...new Set(entries)].join(paths.delimiter) : defaultPath(os) } +function prependTrustedExecutableDirectories( + environment: NodeJS.ProcessEnv, + directories: readonly string[], + os: NodeJS.Platform, +): void { + if (directories.length === 0) { + return + } + const paths = os === 'win32' ? win32 : posix + const trusted: string[] = [] + for (const directory of directories) { + if ( + !directory + || directory.includes('\0') + || directory.includes('\r') + || directory.includes('\n') + || !paths.isAbsolute(directory) + ) { + throw new Error('trusted executable directories must be safe absolute paths') + } + trusted.push(directory) + } + const inherited = (environment.PATH ?? '') + .split(paths.delimiter) + .filter(Boolean) + environment.PATH = [...new Set([...trusted, ...inherited])].join(paths.delimiter) +} + function defaultPath(os: NodeJS.Platform): string { if (os === 'win32') { return String.raw`C:\Windows\System32;C:\Windows` @@ -157,3 +261,23 @@ function defaultPath(os: NodeJS.Platform): string { ? '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' : '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' } + +async function isExecutableFile(candidate: string): Promise { + try { + const entry = await stat(candidate) + if (!entry.isFile()) { + return false + } + await access(candidate, constants.X_OK) + return true + } catch { + return false + } +} + +async function isAvailableACPAdapter( + candidate: string, + _adapter: TrustedACPAdapterName, +): Promise { + return await isExecutableFile(candidate) +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 5be80941df..7338c78e81 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -3,3 +3,9 @@ export * from './config.js' export * from './session.js' export * from './server-url.js' export * from './version.js' +export type { + TrustedACPAdapterName, + TrustedACPLaunchers, + TrustedClaudeCodeACPLauncher, + TrustedCodexACPLauncher, +} from './core/acp-launchers.js' diff --git a/packages/runtime/src/service.ts b/packages/runtime/src/service.ts index 58297ce4ed..045953230f 100644 --- a/packages/runtime/src/service.ts +++ b/packages/runtime/src/service.ts @@ -18,8 +18,13 @@ import { import { loadSync } from '@grpc/proto-loader' import { ChildSupervisor } from './children' +import { + prepareTrustedACPLaunchers, + type TrustedACPLaunchers, +} from './core/acp-launchers.js' import { WorkspaceExecService } from './core/exec' import { rawChunkSize, WorkspaceFileService } from './core/fs' +import { detectRuntimeCapabilities, type RuntimeCapability } from './core/guards' import { HostPathResolver } from './core/paths' import { mapNodeError, rpcError } from './rpc' import type { @@ -41,9 +46,11 @@ export const grpcMessageLimit = 16 * 1024 * 1024 export interface RuntimeGrpcServerOptions { workspaceBase: string warn?: (message: string) => void + trustedACPLaunchers?: TrustedACPLaunchers } export interface RunningRuntimeGrpcServer { + capabilities: readonly RuntimeCapability[] acceptConnection(connection: Duplex): void close(): Promise } @@ -58,22 +65,54 @@ export async function startRuntimeGrpcServer( options: RuntimeGrpcServerOptions, ): Promise { const paths = await HostPathResolver.create(options.workspaceBase) + const launchers = await prepareTrustedACPLaunchers(options.trustedACPLaunchers, { + warn: options.warn, + }) const children = new ChildSupervisor({ warn: options.warn }) let acceptingRPCs = true - const implementation = createContainerService(paths, children, () => acceptingRPCs) - const server = new Server({ - 'grpc.max_receive_message_length': grpcMessageLimit, - 'grpc.max_send_message_length': grpcMessageLimit, - }) - server.addService(await loadContainerServiceDefinition(), implementation) - // grpc-js 1.14.4 exposes a public connection injector that hands an - // existing Duplex directly to its HTTP/2 server. This deliberately avoids - // opening a second loopback TCP, Unix-socket, or named-pipe listener. - const injector = server.createConnectionInjector(ServerCredentials.createInsecure()) + let capabilities: RuntimeCapability[] + let server: Server | undefined + let injector: ReturnType | undefined + try { + capabilities = await detectRuntimeCapabilities( + process.env, + process.platform, + undefined, + launchers, + ) + server = new Server({ + 'grpc.max_receive_message_length': grpcMessageLimit, + 'grpc.max_send_message_length': grpcMessageLimit, + }) + const implementation = createContainerService( + paths, + children, + () => acceptingRPCs, + launchers.executableDirectories, + ) + server.addService(await loadContainerServiceDefinition(), implementation) + // grpc-js 1.14.4 exposes a public connection injector that hands an + // existing Duplex directly to its HTTP/2 server. This deliberately avoids + // opening a second loopback TCP, Unix-socket, or named-pipe listener. + injector = server.createConnectionInjector(ServerCredentials.createInsecure()) + } catch (error) { + server?.forceShutdown() + await children.close().catch(() => undefined) + await launchers.close().catch(() => undefined) + throw error + } + if (!server || !injector) { + await children.close().catch(() => undefined) + await launchers.close().catch(() => undefined) + throw new Error('runtime gRPC server initialization did not complete') + } + const activeServer = server + const connectionInjector = injector const connections = new Set() let closing = false let closePromise: Promise | undefined return { + capabilities: Object.freeze([...capabilities]), acceptConnection(connection) { if (closing || connection.destroyed) { connection.destroy() @@ -82,7 +121,7 @@ export async function startRuntimeGrpcServer( connections.add(connection) connection.once('close', () => connections.delete(connection)) try { - injector.injectConnection(connection) + connectionInjector.injectConnection(connection) } catch (error) { connections.delete(connection) connection.destroy() @@ -99,20 +138,27 @@ export async function startRuntimeGrpcServer( connection.destroy() } connections.clear() - await children.close() - // tryShutdown closes the injector-owned HTTP/2 server and releases - // the channelz reference created by createConnectionInjector(). - await new Promise(resolve => { - const timer = setTimeout(() => { - server.forceShutdown() - resolve() - }, 2_000) - timer.unref() - server.tryShutdown(() => { - clearTimeout(timer) - resolve() - }) - }) + try { + await children.close() + } finally { + // tryShutdown closes the injector-owned HTTP/2 server and releases + // the channelz reference created by createConnectionInjector(). + try { + await new Promise(resolve => { + const timer = setTimeout(() => { + activeServer.forceShutdown() + resolve() + }, 2_000) + timer.unref() + activeServer.tryShutdown(() => { + clearTimeout(timer) + resolve() + }) + }) + } finally { + await launchers.close() + } + } })() return closePromise }, @@ -143,9 +189,15 @@ export function createContainerService( paths: HostPathResolver, children: ChildSupervisor, acceptingRPCs: () => boolean = () => true, + trustedExecutableDirectories: readonly string[] = [], ): UntypedServiceImplementation { const files = new WorkspaceFileService(paths) - const commands = new WorkspaceExecService(paths, children, acceptingRPCs) + const commands = new WorkspaceExecService( + paths, + children, + acceptingRPCs, + trustedExecutableDirectories, + ) const ReadFile: handleUnaryCall = unary(async call => files.readFile(call.request)) const WriteFile: handleUnaryCall = unary(async call => files.writeFile(call.request)) diff --git a/packages/runtime/src/session.ts b/packages/runtime/src/session.ts index 74a861e87f..7709377994 100644 --- a/packages/runtime/src/session.ts +++ b/packages/runtime/src/session.ts @@ -7,7 +7,15 @@ import WebSocket from 'ws' import { normalizeRuntimeTeamId, validateConfig, type RuntimeClientConfig } from './config.js' import { bridgeWebSocketToGrpc } from './pipe/grpc-websocket' import { grpcMessageLimit, startRuntimeGrpcServer } from './service' -import { runtimeCapabilities } from './core/guards' +import { + runtimeCapabilities, + type RuntimeCapability, +} from './core/guards' +import { + cleanupStaleTrustedACPLaunchers, + normalizeTrustedACPLaunchers, + type TrustedACPLaunchers, +} from './core/acp-launchers.js' import { runtimeClientVersion } from './version' export const runtimeProtocolGrpc = 'memoh.runtime.v1.grpc' @@ -26,7 +34,7 @@ export interface RuntimeHandshakeMetadataV1 { arch: string client_version: string workspace_base: string - capabilities: Array<'fs' | 'exec' | 'host_fs'> + capabilities: RuntimeCapability[] } export interface RuntimeHandshakeHeaders { @@ -36,11 +44,21 @@ export interface RuntimeHandshakeHeaders { 'X-Team-ID'?: string } +/** + * Trusted launchers may be given as a value or as a provider. A provider is + * re-resolved on every connection attempt, so hosts like Desktop can pick up + * a CLI the user installed after the session started without a restart. + */ +export type TrustedACPLaunchersSource = + | TrustedACPLaunchers + | (() => Promise | TrustedACPLaunchers | undefined) + export interface RuntimeSessionOptions { version?: string random?: () => number onStatus?: (status: RuntimeSessionStatus, error?: string) => void warn?: (message: string) => void + trustedACPLaunchers?: TrustedACPLaunchersSource } export type RuntimeSessionStatus = 'connecting' | 'connected' | 'disconnected' | 'stopped' @@ -82,6 +100,7 @@ export function createHandshakeMetadata( os: platform() as RuntimeHandshakeMetadataV1['os'], arch: arch(), }, + capabilities: readonly RuntimeCapability[] = runtimeCapabilities(), ): RuntimeHandshakeMetadataV1 { if (!['darwin', 'linux', 'win32'].includes(machine.os)) { throw new Error(`unsupported runtime operating system: ${machine.os}`) @@ -101,7 +120,7 @@ export function createHandshakeMetadata( arch: requiredMetadataString(machine.arch, 'arch', 64), client_version: requiredMetadataString(version, 'client_version', 128), workspace_base: workspace, - capabilities: runtimeCapabilities(), + capabilities: [...capabilities], } } @@ -134,6 +153,7 @@ export class RuntimeSession { private readonly random: () => number private readonly onStatus: ((status: RuntimeSessionStatus, error?: string) => void) | undefined private readonly warn: ((message: string) => void) | undefined + private readonly trustedACPLaunchers: TrustedACPLaunchersSource | undefined private stopped = false private activeController: AbortController | undefined @@ -146,6 +166,9 @@ export class RuntimeSession { this.random = options.random ?? Math.random this.onStatus = options.onStatus this.warn = options.warn + this.trustedACPLaunchers = typeof options.trustedACPLaunchers === 'function' + ? options.trustedACPLaunchers + : normalizeTrustedACPLaunchers(options.trustedACPLaunchers) } async start(signal?: AbortSignal): Promise { @@ -163,6 +186,7 @@ export class RuntimeSession { const url = runtimeConnectUrl(this.config.serverUrl) assertSecureRuntimeUrl(url, this.config.insecureLocalhost) const workspaceBase = await realpath(this.config.workspaceBase) + await cleanupStaleTrustedACPLaunchers() let retry = 1_000 let lastError: string | undefined @@ -198,6 +222,21 @@ export class RuntimeSession { this.activeController?.abort() } + private async resolveTrustedACPLaunchers(): Promise { + if (typeof this.trustedACPLaunchers !== 'function') { + return this.trustedACPLaunchers + } + try { + return normalizeTrustedACPLaunchers(await this.trustedACPLaunchers()) + } catch (error) { + // A broken discovery must not take down the whole computer connection. + // Explicitly disabling both aliases also blocks ambient PATH fallback, + // so a failed probe cannot silently widen what the server may launch. + this.warn?.(`trusted ACP launcher discovery failed: ${error instanceof Error ? error.message : String(error)}`) + return { 'codex-acp': false, 'claude-agent-acp': false } + } + } + private async runConnection(url: URL, workspaceBase: string, signal?: AbortSignal): Promise { let grpc: Awaited> | undefined let websocket: WebSocket | undefined @@ -206,8 +245,9 @@ export class RuntimeSession { grpc = await startRuntimeGrpcServer({ workspaceBase, warn: this.warn, + trustedACPLaunchers: await this.resolveTrustedACPLaunchers(), }) - const metadata = createHandshakeMetadata(workspaceBase, this.version) + const metadata = createHandshakeMetadata(workspaceBase, this.version, undefined, grpc.capabilities) const headers = handshakeHeaders(this.config, this.version, metadata) const websocketHeaders: Record = { Authorization: headers.Authorization, diff --git a/packages/runtime/test/acp-launchers.test.ts b/packages/runtime/test/acp-launchers.test.ts new file mode 100644 index 0000000000..5cd6aaee98 --- /dev/null +++ b/packages/runtime/test/acp-launchers.test.ts @@ -0,0 +1,163 @@ +import { execFile } from 'node:child_process' +import { constants } from 'node:fs' +import { + access, + chmod, + mkdtemp, + rm, + stat, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' + +import { afterEach, describe, expect, it } from 'vitest' + +import { + cleanupStaleTrustedACPLaunchers, + normalizeTrustedACPLaunchers, + prepareTrustedACPLaunchers, +} from '../src/core/acp-launchers' + +const execFileAsync = promisify(execFile) +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe('trusted ACP launchers', () => { + it('starts the fixed adapter with only its local CLI path and caller argv', async () => { + if (process.platform === 'win32') return + const root = await temporaryDirectory() + const adapter = join(root, 'adapter.cjs') + await writeFile(adapter, ` +process.stdout.write(JSON.stringify({ + argv: process.argv.slice(2), + electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE, + codexPath: process.env.CODEX_PATH, + claudePath: process.env.CLAUDE_CODE_EXECUTABLE, +})) +`) + const codex = await executableFixture(root, 'codex') + const prepared = await prepareTrustedACPLaunchers({ + 'codex-acp': { + nodeExecutable: process.execPath, + adapterEntry: adapter, + codexExecutable: codex, + }, + }) + try { + expect(prepared.configuredAdapters).toEqual(['codex-acp']) + expect(prepared.availableAdapters).toEqual(['codex-acp']) + const directory = prepared.executableDirectories[0] + expect((await stat(directory)).mode & 0o777).toBe(0o700) + const shim = join(directory, 'codex-acp') + await expect(access(shim, constants.X_OK)).resolves.toBeUndefined() + + const result = await execFileAsync(shim, ['argument from server'], { + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: 'ambient', + CODEX_PATH: '/ambient/codex', + CLAUDE_CODE_EXECUTABLE: '/ambient/claude', + }, + }) + expect(JSON.parse(result.stdout)).toEqual({ + argv: ['argument from server'], + codexPath: codex, + }) + } finally { + const directory = prepared.executableDirectories[0] + await prepared.close() + await prepared.close() + await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' }) + } + }) + + it('runs an npm-style local CLI entry with Runtime-owned Node', async () => { + if (process.platform === 'win32') return + const root = await temporaryDirectory() + const adapter = join(root, 'adapter.cjs') + await writeFile(adapter, 'process.stdout.write(process.env.CODEX_PATH ?? \'\')\n') + const codex = join(root, 'codex.js') + await writeFile(codex, `#!/usr/bin/env node +process.stdout.write(JSON.stringify({ + argv: process.argv.slice(2), + electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE, + codexPath: process.env.CODEX_PATH, + claudePath: process.env.CLAUDE_CODE_EXECUTABLE, +})) +`, { mode: 0o700 }) + await chmod(codex, 0o700) + + const prepared = await prepareTrustedACPLaunchers({ + 'codex-acp': { + nodeExecutable: process.execPath, + adapterEntry: adapter, + codexExecutable: codex, + }, + }) + try { + const adapterResult = await execFileAsync(join(prepared.executableDirectories[0], 'codex-acp')) + const localCLI = adapterResult.stdout.trim() + const cliResult = await execFileAsync(localCLI, ['app-server'], { + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: 'ambient', + CODEX_PATH: '/ambient/codex', + CLAUDE_CODE_EXECUTABLE: '/ambient/claude', + }, + }) + expect(JSON.parse(cliResult.stdout)).toEqual({ argv: ['app-server'] }) + } finally { + await prepared.close() + } + }) + + it('rejects arbitrary aliases, fields, environment, and relative paths', () => { + const absolute = process.execPath + const valid = { + nodeExecutable: absolute, + adapterEntry: absolute, + codexExecutable: absolute, + } + const invalid = [ + { 'other-acp': valid }, + { 'codex-acp': { ...valid, nodeExecutable: 'relative-command' } }, + { 'codex-acp': { ...valid, claudeCodeExecutable: absolute } }, + { 'codex-acp': { ...valid, env: { CODEX_PATH: '/tmp/codex' } } }, + ] + for (const launchers of invalid) { + expect(() => normalizeTrustedACPLaunchers(launchers as never)).toThrow() + } + }) + + it('cleans only launcher directories owned by dead Runtime processes', async () => { + const stale = await mkdtemp(join(tmpdir(), 'memoh-runtime-acp-99999999-')) + const live = await mkdtemp(join(tmpdir(), `memoh-runtime-acp-${process.pid}-`)) + const unrelated = await mkdtemp(join(tmpdir(), 'memoh-runtime-other-')) + roots.push(stale, live, unrelated) + + await cleanupStaleTrustedACPLaunchers() + + await expect(access(stale)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(access(live)).resolves.toBeUndefined() + await expect(access(unrelated)).resolves.toBeUndefined() + }) +}) + +async function temporaryDirectory(): Promise { + const root = await mkdtemp(join(tmpdir(), 'memoh-runtime-launcher-test-')) + roots.push(root) + await chmod(root, 0o700) + return root +} + +async function executableFixture(root: string, name: string): Promise { + const path = join(root, name) + await writeFile(path, '#!/bin/sh\nexit 0\n', { mode: 0o700 }) + await chmod(path, 0o700) + return path +} diff --git a/packages/runtime/test/exec.test.ts b/packages/runtime/test/exec.test.ts new file mode 100644 index 0000000000..6da60484f4 --- /dev/null +++ b/packages/runtime/test/exec.test.ts @@ -0,0 +1,100 @@ +import { EventEmitter } from 'node:events' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import type { ChildProcessWithoutNullStreams } from 'node:child_process' + +import { WorkspaceExecService } from '../src/core/exec' +import type { ExecOutput } from '../src/types' + +class FakeExecCall extends EventEmitter { + cancelled = false + destroyed = false + ended = false + frames: ExecOutput[] = [] + + write(frame: ExecOutput): boolean { + this.frames.push(frame) + return true + } + + end(): void { + this.ended = true + } + + stdout(): string { + return Buffer.concat(this.frames.filter(frame => frame.stream === 0).map(frame => Buffer.from(frame.data))).toString() + } +} + +async function waitFor(condition: () => boolean, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + while (!condition()) { + if (Date.now() > deadline) { + throw new Error('waitFor timed out') + } + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +describe.skipIf(process.platform === 'win32')('WorkspaceExecService stdin ordering', () => { + let root = '' + let script = '' + + beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'memoh-exec-order-')) + script = join(root, 'stdin-echo.cjs') + await writeFile(script, ` +let input = '' +process.stdin.setEncoding('utf8') +process.stdin.on('data', chunk => { input += chunk }) +process.stdin.on('end', () => process.stdout.write(input)) +`) + }) + + afterAll(async () => { + await rm(root, { recursive: true, force: true }) + }) + + it('keeps frames arriving in the spawn window behind queued first-frame bytes', async () => { + // The dangerous window is after spawn() has set `child` but before + // start() flushes the queued first-frame stdin bytes. A controllable + // register() promise holds start() inside exactly that window. + let releaseRegistration: (() => void) | undefined + const children = { + register: (_child: ChildProcessWithoutNullStreams) => + new Promise(resolve => { releaseRegistration = resolve }), + terminate: async (child: ChildProcessWithoutNullStreams) => { + child.kill('SIGKILL') + }, + } + const paths = { + defaultDirectory: root, + resolve: async (path: string) => path, + revalidate: async (path: string) => path, + } + const service = new WorkspaceExecService(paths, children) + const call = new FakeExecCall() + service.exec(call as never) + + call.emit('data', { + command: `node ${JSON.stringify(script)}`, + stdin_data: Buffer.from('one'), + }) + await waitFor(() => releaseRegistration !== undefined) + + // These frames land while start() awaits registration: the child exists, + // but the first frame's bytes are still queued. + call.emit('data', { stdin_data: Buffer.from('-two') }) + call.emit('data', { stdin_data: Buffer.from('-three') }) + call.emit('end') + releaseRegistration?.() + + await waitFor(() => call.ended) + expect(call.stdout()).toBe('one-two-three') + expect(call.frames.at(-1)).toMatchObject({ stream: 2, exit_code: 0 }) + }) +}) diff --git a/packages/runtime/test/guards.test.ts b/packages/runtime/test/guards.test.ts index dea51c761d..7330091fa9 100644 --- a/packages/runtime/test/guards.test.ts +++ b/packages/runtime/test/guards.test.ts @@ -3,10 +3,78 @@ import { describe, expect, it } from 'vitest' import { assertSafeEnvironmentName, + detectRuntimeCapabilities, + guardedEnvironment, inheritedEnvironment, } from '../src/core/guards' describe('runtime guards', () => { + it('advertises ACP adapters found on the narrowed POSIX PATH', async () => { + const probed: string[] = [] + const capabilities = await detectRuntimeCapabilities({ + PATH: '/safe/bin:relative:/other/bin:/safe/bin', + }, 'linux', async candidate => { + probed.push(candidate) + return candidate === '/safe/bin/codex-acp' + || candidate === '/other/bin/claude-agent-acp' + }) + + expect(capabilities).toEqual([ + 'fs', + 'exec', + 'host_fs', + 'acp_codex', + 'acp_claude_code', + ]) + expect(probed).not.toContain('relative/codex-acp') + expect(probed).not.toContain('relative/claude-agent-acp') + }) + + it('does not advertise missing ACP adapters or probe them on Windows', async () => { + const missing = await detectRuntimeCapabilities({ PATH: '/safe/bin' }, 'darwin', async () => false) + expect(missing).toEqual(['fs', 'exec', 'host_fs']) + + let probes = 0 + const windows = await detectRuntimeCapabilities({ + Path: String.raw`C:\Tools;C:\Windows`, + PATHEXT: '.COM;.EXE;.BAT;.CMD', + }, 'win32', async () => { + probes++ + return true + }) + expect(windows).toEqual(['fs', 'exec', 'host_fs']) + expect(probes).toBe(0) + }) + + it('uses trusted adapter availability instead of ambient PATH for configured aliases', async () => { + const probed: string[] = [] + const capabilities = await detectRuntimeCapabilities( + { PATH: '/ambient/bin' }, + 'linux', + async candidate => { + probed.push(candidate) + return true + }, + { + configuredAdapters: ['codex-acp', 'claude-agent-acp'], + availableAdapters: ['codex-acp'], + }, + ) + + expect(capabilities).toContain('acp_codex') + expect(capabilities).not.toContain('acp_claude_code') + expect(probed).toEqual([]) + }) + + it('keeps a Runtime-owned executable path in clean and unset environments', () => { + if (process.platform === 'win32') return + expect(guardedEnvironment([], { + clean: true, + unset: ['PATH'], + trustedExecutableDirectories: ['/private/runtime-shims'], + })).toEqual({ PATH: '/private/runtime-shims' }) + }) + it.each([ 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES', @@ -21,6 +89,12 @@ describe('runtime guards', () => { 'PATHEXT', 'IFS', 'MEMOH_RUNTIME_KEY', + 'ELECTRON_RUN_AS_NODE', + 'CODEX_PATH', + 'CLAUDE_CODE_EXECUTABLE', + 'NODE_PATH', + 'NODE_DEBUG', + 'ELECTRON_NO_ATTACH_CONSOLE', ])( 'rejects dangerous environment variable %s', name => { diff --git a/packages/runtime/test/service.test.ts b/packages/runtime/test/service.test.ts index bfb4c62925..64c03dd455 100644 --- a/packages/runtime/test/service.test.ts +++ b/packages/runtime/test/service.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -144,6 +144,12 @@ process.exitCode = 7 await expect(exec({ command: 'echo ok', work_dir: '/data', env: ['PATH=/tmp'] })) .rejects.toMatchObject({ code: status.PERMISSION_DENIED }) + await expect(exec({ command: 'echo ok', work_dir: '/data', env: ['ELECTRON_RUN_AS_NODE=1'] })) + .rejects.toMatchObject({ code: status.PERMISSION_DENIED }) + await expect(exec({ command: 'echo ok', work_dir: '/data', env: ['CODEX_PATH=/tmp/codex'] })) + .rejects.toMatchObject({ code: status.PERMISSION_DENIED }) + await expect(exec({ command: 'echo ok', work_dir: '/data', env: ['CLAUDE_CODE_EXECUTABLE=/tmp/claude'] })) + .rejects.toMatchObject({ code: status.PERMISSION_DENIED }) await expect(exec({ command: 'echo ok', work_dir: '/data', pty: true })) .rejects.toMatchObject({ code: status.UNIMPLEMENTED }) @@ -156,6 +162,90 @@ process.exitCode = 7 } }) + it('keeps concurrent bidirectional Exec streams independent', async () => { + const echoScript = await writeNodeFixture('stream-echo.cjs', ` +const [label, delay] = process.argv.slice(2) +let input = '' +process.stdin.setEncoding('utf8') +process.stdin.on('data', chunk => { input += chunk }) +process.stdin.on('end', () => { + setTimeout(() => process.stdout.write(label + ':' + input), Number(delay)) +}) +`) + + const [first, second] = await Promise.all([ + execWithStdin({ + command: nodeScriptCommand(echoScript, 'first', '30'), + work_dir: '/data', + }, ['alpha', '-one']), + execWithStdin({ + command: nodeScriptCommand(echoScript, 'second', '0'), + work_dir: '/data', + }, ['beta', '-two']), + ]) + + expect(stdout(first)).toBe('first:alpha-one') + expect(stdout(second)).toBe('second:beta-two') + expect(first.at(-1)).toMatchObject({ stream: 2, exit_code: 0 }) + expect(second.at(-1)).toMatchObject({ stream: 2, exit_code: 0 }) + }) + + it('launches a fixed ACP shim through the Runtime-owned PATH', async () => { + if (process.platform === 'win32') return + client.close() + await transport.close() + await running.close() + + const entry = await writeNodeFixture('trusted-codex-entry.cjs', ` +process.stdout.write(JSON.stringify({ + argv: process.argv.slice(2), + electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE, + codexPath: process.env.CODEX_PATH, + claudePath: process.env.CLAUDE_CODE_EXECUTABLE, +})) +`) + const codex = join(root, 'trusted-codex') + await writeFile(codex, '#!/bin/sh\nexit 0\n', { mode: 0o700 }) + await chmod(codex, 0o700) + running = await startRuntimeGrpcServer({ + workspaceBase: root, + warn: () => undefined, + trustedACPLaunchers: { + 'codex-acp': { + nodeExecutable: process.execPath, + adapterEntry: entry, + codexExecutable: codex, + }, + }, + }) + transport = await createGrpcWebSocketTestHarness(running) + const ClientConstructor = loadTestClientConstructor() + client = new ClientConstructor( + transport.target, + credentials.createInsecure(), + { + 'grpc.max_receive_message_length': grpcMessageLimit, + 'grpc.max_send_message_length': grpcMessageLimit, + }, + ) + + expect(running.capabilities).toContain('acp_codex') + const output = await exec({ + command: 'command -v codex-acp && codex-acp \'argument from server\'', + work_dir: '/data', + clean_env: true, + unset_env: ['PATH'], + }) + const lines = stdout(output).trim().split('\n') + expect(lines[0]).toMatch(/memoh-runtime-acp-.+\/codex-acp$/) + expect(lines[0]).not.toContain(process.execPath) + expect(lines[0]).not.toContain(entry) + expect(JSON.parse(lines[1])).toEqual({ + argv: ['argument from server'], + codexPath: codex, + }) + }) + it('terminates connection-owned process trees on server close', async () => { const pidPath = join(root, 'pid') const childScript = await writeNodeFixture('child.cjs', 'setInterval(() => {}, 1_000)\n') @@ -189,6 +279,7 @@ setInterval(() => {}, 1_000) stream.cancel() }) + it('uses the home directory by default and allows host paths outside it', async () => { const cwdScript = await writeNodeFixture('cwd.cjs', 'process.stdout.write(process.cwd())\n') const defaultExec = await exec({ command: nodeScriptCommand(cwdScript) }) @@ -270,6 +361,21 @@ function exec(first: ExecInput, metadata = new Metadata()): Promise { + const stream = client.Exec(new Metadata()) + const frames: ExecOutput[] = [] + stream.on('data', frame => frames.push(frame)) + stream.write(first) + for (const chunk of chunks) { + stream.write({ stdin_data: Buffer.from(chunk) }) + } + stream.end() + return new Promise((resolve, reject) => { + stream.once('end', () => resolve(frames)) + stream.once('error', reject) + }) +} + function emptyWriteRaw(metadata: Metadata): Promise<{ bytes_written: string }> { return new Promise((resolve, reject) => { const writer = client.WriteRaw(metadata, (error, response) => error ? reject(error) : resolve(response)) diff --git a/packages/runtime/test/session.test.ts b/packages/runtime/test/session.test.ts index 20e0cbd0fd..d8b87b45d2 100644 --- a/packages/runtime/test/session.test.ts +++ b/packages/runtime/test/session.test.ts @@ -1,5 +1,5 @@ import { Buffer } from 'node:buffer' -import { mkdtemp, realpath, rm } from 'node:fs/promises' +import { chmod, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -69,6 +69,31 @@ describe('runtime handshake', () => { expect(windowsMetadata.workspace_base).toBe(String.raw`C:\Users\alice\Memoh`) }) + it('accepts detected ACP capabilities without changing legacy metadata calls', () => { + const machine = { + hostname: 'alice.local', + os: 'darwin' as const, + arch: 'arm64', + } + const legacy = createHandshakeMetadata('/Users/alice', '1.2.3', machine) + expect(legacy.capabilities).toEqual(['fs', 'exec', 'host_fs']) + + const detected = createHandshakeMetadata('/Users/alice', '1.2.3', machine, [ + 'fs', + 'exec', + 'host_fs', + 'acp_codex', + 'acp_claude_code', + ]) + expect(detected.capabilities).toEqual([ + 'fs', + 'exec', + 'host_fs', + 'acp_codex', + 'acp_claude_code', + ]) + }) + it('allows plaintext only for an explicitly enabled loopback target', () => { expect(() => assertSecureRuntimeUrl(new URL('ws://127.0.0.1:8080/runtimes/connect'), true)).not.toThrow() expect(() => assertSecureRuntimeUrl(new URL('ws://localhost:8080/runtimes/connect'), false)).toThrow() @@ -95,6 +120,11 @@ describe('runtime handshake', () => { }) }) const key = runtimeKey + const adapterEntry = join(root, 'codex-entry.cjs') + await writeFile(adapterEntry, '// fixed adapter fixture\n') + const codexExecutable = join(root, 'codex') + await writeFile(codexExecutable, '#!/bin/sh\nexit 0\n', { mode: 0o700 }) + await chmod(codexExecutable, 0o700) const session = new RuntimeSession({ serverUrl: `http://127.0.0.1:${port}/api`, key, @@ -104,6 +134,15 @@ describe('runtime handshake', () => { }, { random: () => 0.5, onStatus: status => statuses.push(status), + trustedACPLaunchers: process.platform === 'win32' + ? undefined + : { + 'codex-acp': { + nodeExecutable: process.execPath, + adapterEntry, + codexExecutable, + }, + }, }) running = session.start(controller.signal) const request = await requestPromise @@ -113,10 +152,16 @@ describe('runtime handshake', () => { expect(request.headers['sec-websocket-protocol']).toBe(runtimeProtocolGrpc) const encoded = request.headers['x-memoh-runtime-metadata'] expect(typeof encoded).toBe('string') - expect(JSON.parse(Buffer.from(String(encoded), 'base64url').toString('utf8'))).toMatchObject({ + const decoded = JSON.parse(Buffer.from(String(encoded), 'base64url').toString('utf8')) + expect(decoded).toMatchObject({ version: 1, workspace_base: await realpath(root), }) + if (process.platform !== 'win32') { + expect(decoded.capabilities).toContain('acp_codex') + } + expect(JSON.stringify(decoded)).not.toContain(process.execPath) + expect(JSON.stringify(decoded)).not.toContain(adapterEntry) controller.abort() await running expect(statuses).toContain('connected') diff --git a/packages/sdk/src/types.gen.ts b/packages/sdk/src/types.gen.ts index 638dac8ec8..537f0ee1a8 100644 --- a/packages/sdk/src/types.gen.ts +++ b/packages/sdk/src/types.gen.ts @@ -11177,6 +11177,10 @@ export type DeleteBotsByBotIdWorkspaceTargetsByTargetIdErrors = { * Not Found */ 404: HandlersErrorResponse; + /** + * Conflict + */ + 409: ApperrorProblem; }; export type DeleteBotsByBotIdWorkspaceTargetsByTargetIdError = DeleteBotsByBotIdWorkspaceTargetsByTargetIdErrors[keyof DeleteBotsByBotIdWorkspaceTargetsByTargetIdErrors]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0818eabd6..8dfce8a070 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,12 @@ importers: apps/desktop: dependencies: + '@agentclientprotocol/claude-agent-acp': + specifier: 0.66.0 + version: 0.66.0(@anthropic-ai/sdk@0.122.0(zod@4.4.1))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.1)) + '@agentclientprotocol/codex-acp': + specifier: 1.2.0 + version: 1.2.0 '@electron-toolkit/preload': specifier: ^3.0.1 version: 3.0.2(electron@42.5.0) @@ -493,9 +499,89 @@ packages: '@acemir/cssom@0.9.31': resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + '@agentclientprotocol/claude-agent-acp@0.66.0': + resolution: {integrity: sha512-BwalxKsxZzHZGEs+X9hV3biErLE7PHWoao2hmyP3QBWXxvMHbc1F1tzDE95ZA47Fle+KBYf2gKpgy1MJ+ZmVlw==} + engines: {node: '>=22'} + hasBin: true + + '@agentclientprotocol/codex-acp@1.2.0': + resolution: {integrity: sha512-nj23pM4OfaCOB5Du5rsSq0kcyki5H8D5ql96+AwIv7lnu0YEKj9R2YDsxbOKzwLlt+5QD6s0mn4Grkm7SSAUUA==} + hasBin: true + + '@agentclientprotocol/sdk@1.3.0': + resolution: {integrity: sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + '@agentclientprotocol/sdk@1.4.0': + resolution: {integrity: sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220': + resolution: {integrity: sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220': + resolution: {integrity: sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220': + resolution: {integrity: sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220': + resolution: {integrity: sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220': + resolution: {integrity: sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220': + resolution: {integrity: sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220': + resolution: {integrity: sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220': + resolution: {integrity: sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.220': + resolution: {integrity: sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.122.0': + resolution: {integrity: sha512-GGPNftt0caaz9MDlmNQGHX8855Ojaduyy5pm9Sm1h7HalCn0cWNb5/bweadJF+4yzbal+QL6ztBa09WAAOzLmQ==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@asamuzakjp/css-color@4.1.2': resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} @@ -669,6 +755,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -1359,6 +1449,12 @@ packages: '@hey-api/types@0.1.4': resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==} + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1628,6 +1724,16 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/wasm-runtime@1.2.0': resolution: {integrity: sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -1643,6 +1749,47 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} + '@openai/codex@0.147.0': + resolution: {integrity: sha512-EQLEXecAG2ptxI7UpBMo2TR/ga5596/c/OsYF/0LoUDh5JANZ7IoGqlzBEWbuEVQ76JePIbtTW/ihCkp1a7Z3w==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.147.0-darwin-arm64': + resolution: {integrity: sha512-BEUVkiOW7kLcRyrMLfAr/h9wF8sRVJyZDy6OHtVn6QGDXiv3BvAZVTY1Pu9xF7KdIdkYXbp4uayN0aDQQaAUJw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.147.0-darwin-x64': + resolution: {integrity: sha512-Tb8McE5SvJIH0Vs5R6sq7u+quiC931yan2KOOl6km1OdZ82+Wi7eF5XrSFPs5CF7xCgoIK4Vs+byMbT5hN+ZUw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.147.0-linux-arm64': + resolution: {integrity: sha512-SLC1JXw2TYfr/c3HhrJubyyLelq7vTOLWVmiThFA+z0+WgzCPmaseJ/kzDD3Gge/TO7fCnnj7UcPmC0d2c8XAg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.147.0-linux-x64': + resolution: {integrity: sha512-0W9MBxPpWW0cSkNqrTDN2jR7rzzT7oNMhQY5446lT2Lw5cz5yhDTck4Va9rjkQEm+HlFzP/dmEMSZbXfJsINmw==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.147.0-win32-arm64': + resolution: {integrity: sha512-e2ZstJ8zT8Rm1nvR7CUVO+Gr3cTChE41+VfOzGhynzDXEoW0wfbjUQbc2bWbh1arG94LMm4y3dqBtUIbSrfeGA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.147.0-win32-x64': + resolution: {integrity: sha512-oT7Ss5fAPf2fiWE9QNURqZcQGAAawSVxmIUdgPzckq4KFZAM+pRz9JbM4Rr498CjtbNgTOjWvDJ+DXvIBSfOPA==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -2002,6 +2149,9 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2647,6 +2797,10 @@ packages: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} engines: {node: ^20.17.0 || >=22.9.0} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2817,6 +2971,10 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -2866,6 +3024,10 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + bytestreamjs@2.0.1: resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} engines: {node: '>=6.0.0'} @@ -2902,6 +3064,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} @@ -3029,9 +3195,29 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} @@ -3039,6 +3225,10 @@ packages: core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -3299,6 +3489,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -3320,6 +3514,10 @@ packages: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} @@ -3373,6 +3571,9 @@ packages: echarts@6.1.0: resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -3421,6 +3622,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -3511,6 +3716,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -3589,6 +3797,18 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3596,6 +3816,16 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@8.7.0: + resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -3608,6 +3838,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.4: resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} @@ -3627,6 +3860,10 @@ packages: filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -3658,6 +3895,14 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -3782,6 +4027,10 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hono@4.13.5: + resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} + engines: {node: '>=16.9.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -3799,6 +4048,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -3820,6 +4073,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3850,6 +4107,14 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ip-address@10.7.0: + resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -3883,6 +4148,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -3929,6 +4197,9 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + jose@6.2.10: + resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3957,12 +4228,19 @@ packages: resolution: {integrity: sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==} engines: {node: ^18.17.0 || >=20.5.0} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -4282,10 +4560,18 @@ packages: mdurl@2.1.0: resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + memorystream@0.3.1: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + mermaid@11.16.0: resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} @@ -4308,10 +4594,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@2.6.0: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} engines: {node: '>=4.0.0'} @@ -4397,6 +4691,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + node-abi@4.28.0: resolution: {integrity: sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==} engines: {node: '>=22.12.0'} @@ -4447,6 +4745,14 @@ packages: engines: {node: '>=18'} hasBin: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -4457,6 +4763,10 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4508,6 +4818,10 @@ packages: parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -4529,6 +4843,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -4584,6 +4901,10 @@ packages: typescript: optional: true + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -4667,6 +4988,10 @@ packages: resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -4693,6 +5018,10 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -4700,6 +5029,14 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -4800,6 +5137,10 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -4858,13 +5199,24 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4898,6 +5250,22 @@ packages: shiki@3.23.0: resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -4942,10 +5310,17 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + stat-mode@1.0.0: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -5085,6 +5460,10 @@ packages: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} @@ -5106,6 +5485,9 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -5142,6 +5524,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript-eslint@8.65.0: resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5213,6 +5599,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unplugin-dts@1.0.0-beta.6: resolution: {integrity: sha512-+xbFv5aVFtLZFNBAKI4+kXmd2h+T42/AaP8Bsp0YP/je/uOTN94Ame2Xt3e9isZS+Z7/hrLCLbsVJh+saqFMfQ==} peerDependencies: @@ -5310,6 +5700,10 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vee-validate@4.15.1: resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==} peerDependencies: @@ -5436,6 +5830,10 @@ packages: jsdom: optional: true + vscode-jsonrpc@9.0.2: + resolution: {integrity: sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==} + engines: {node: '>=14.0.0'} + vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} @@ -5655,6 +6053,11 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -5677,11 +6080,83 @@ snapshots: '@acemir/cssom@0.9.31': optional: true + '@agentclientprotocol/claude-agent-acp@0.66.0(@anthropic-ai/sdk@0.122.0(zod@4.4.1))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.1))': + dependencies: + '@agentclientprotocol/sdk': 1.3.0(zod@4.4.1) + '@anthropic-ai/claude-agent-sdk': 0.3.220(@anthropic-ai/sdk@0.122.0(zod@4.4.1))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.1))(zod@4.4.1) + zod: 4.4.1 + transitivePeerDependencies: + - '@anthropic-ai/sdk' + - '@modelcontextprotocol/sdk' + + '@agentclientprotocol/codex-acp@1.2.0': + dependencies: + '@agentclientprotocol/sdk': 1.4.0(zod@4.4.1) + '@openai/codex': 0.147.0 + diff: 9.0.0 + open: 11.0.0 + vscode-jsonrpc: 9.0.2 + zod: 4.4.1 + + '@agentclientprotocol/sdk@1.3.0(zod@4.4.1)': + dependencies: + zod: 4.4.1 + + '@agentclientprotocol/sdk@1.4.0(zod@4.4.1)': + dependencies: + zod: 4.4.1 + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 tinyexec: 1.0.4 + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.220(@anthropic-ai/sdk@0.122.0(zod@4.4.1))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.1))(zod@4.4.1)': + dependencies: + '@anthropic-ai/sdk': 0.122.0(zod@4.4.1) + '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.1) + zod: 4.4.1 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.220 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.220 + + '@anthropic-ai/sdk@0.122.0(zod@4.4.1)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 + optionalDependencies: + zod: 4.4.1 + '@asamuzakjp/css-color@4.1.2': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -5901,6 +6376,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/runtime@7.29.7': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -6466,6 +6943,10 @@ snapshots: '@hey-api/types@0.1.4': {} + '@hono/node-server@2.1.1(hono@4.13.5)': + dependencies: + hono: 4.13.5 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -6702,6 +7183,28 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@modelcontextprotocol/sdk@1.30.0(zod@4.4.1)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.5) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.7.0(express@5.2.1) + hono: 4.13.5 + jose: 6.2.10 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.1 + zod-to-json-schema: 3.25.2(zod@4.4.1) + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -6713,6 +7216,33 @@ snapshots: '@noble/hashes@2.2.0': {} + '@openai/codex@0.147.0': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.147.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.147.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.147.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.147.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.147.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.147.0-win32-x64' + + '@openai/codex@0.147.0-darwin-arm64': + optional: true + + '@openai/codex@0.147.0-darwin-x64': + optional: true + + '@openai/codex@0.147.0-linux-arm64': + optional: true + + '@openai/codex@0.147.0-linux-x64': + optional: true + + '@openai/codex@0.147.0-win32-arm64': + optional: true + + '@openai/codex@0.147.0-win32-x64': + optional: true + '@opentelemetry/api@1.9.0': optional: true @@ -6996,6 +7526,8 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.18': @@ -7818,6 +8350,11 @@ snapshots: abbrev@4.0.0: {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -7840,6 +8377,10 @@ snapshots: optionalDependencies: ajv: 8.13.0 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv-keywords@3.5.2(ajv@6.15.0): dependencies: ajv: 6.15.0 @@ -8050,6 +8591,20 @@ snapshots: bluebird@3.7.2: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} boolean@3.2.0: @@ -8152,6 +8707,8 @@ snapshots: dependencies: run-applescript: 7.1.0 + bytes@3.1.2: {} + bytestreamjs@2.0.1: {} c12@3.3.3: @@ -8203,6 +8760,11 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + camelcase@5.3.1: {} caniuse-lite@1.0.30001762: {} @@ -8299,14 +8861,29 @@ snapshots: consola@3.4.2: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + copy-anything@4.0.5: dependencies: is-what: 5.5.0 core-util-is@1.0.2: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -8590,6 +9167,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} destr@2.0.5: {} @@ -8605,6 +9184,8 @@ snapshots: diff@8.0.2: {} + diff@9.0.0: {} + dijkstrajs@1.0.3: {} dir-compare@4.2.0: @@ -8671,6 +9252,8 @@ snapshots: tslib: 2.3.0 zrender: 6.1.0 + ee-first@1.1.1: {} + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -8776,6 +9359,8 @@ snapshots: emoji-regex@8.0.0: {} + encodeurl@2.0.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -8918,6 +9503,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-plugin-vue@10.10.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0))): @@ -9018,10 +9605,59 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} + express-rate-limit@8.7.0(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.7.0 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.8: {} fast-deep-equal@3.1.3: {} @@ -9030,6 +9666,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.4: {} fdir@6.5.0(picomatch@4.0.3): @@ -9048,6 +9686,17 @@ snapshots: dependencies: minimatch: 5.1.9 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -9081,6 +9730,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -9254,6 +9907,8 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hono@4.13.5: {} + hookable@5.5.3: {} hosted-git-info@4.1.0: @@ -9271,6 +9926,14 @@ snapshots: http-cache-semantics@4.2.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -9296,6 +9959,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -9315,6 +9982,10 @@ snapshots: internmap@2.0.3: {} + ip-address@10.7.0: {} + + ipaddr.js@1.9.1: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -9338,6 +10009,8 @@ snapshots: is-potential-custom-element-name@1.0.1: optional: true + is-promise@4.0.0: {} + is-what@5.5.0: {} is-wsl@3.1.0: @@ -9368,6 +10041,8 @@ snapshots: jju@1.4.0: {} + jose@6.2.10: {} + js-tokens@4.0.0: {} js-yaml@4.2.0: @@ -9409,10 +10084,17 @@ snapshots: json-parse-even-better-errors@4.0.0: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: @@ -9678,8 +10360,12 @@ snapshots: mdurl@2.1.0: {} + media-typer@1.1.1: {} + memorystream@0.3.1: {} + merge-descriptors@2.0.0: {} + mermaid@11.16.0: dependencies: '@braintree/sanitize-url': 7.1.2 @@ -9723,10 +10409,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@2.6.0: {} mimic-response@1.0.1: {} @@ -9797,6 +10489,10 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + node-abi@4.28.0: dependencies: semver: 7.8.4 @@ -9855,6 +10551,10 @@ snapshots: pathe: 2.0.3 tinyexec: 1.0.2 + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + object-keys@1.1.1: optional: true @@ -9862,6 +10562,10 @@ snapshots: ohash@2.0.11: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -9926,6 +10630,8 @@ snapshots: entities: 6.0.1 optional: true + parseurl@1.3.3: {} + path-browserify@1.0.1: {} path-data-parser@0.1.0: {} @@ -9938,6 +10644,8 @@ snapshots: path-parse@1.0.7: {} + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} pe-library@0.4.1: {} @@ -9969,6 +10677,8 @@ snapshots: optionalDependencies: typescript: 6.0.3 + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -10072,6 +10782,11 @@ snapshots: '@types/node': 25.9.5 long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: optional: true @@ -10096,10 +10811,24 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + quansync@0.2.11: {} quick-lru@5.1.1: {} + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + rc9@2.1.2: dependencies: defu: 6.1.4 @@ -10263,6 +10992,16 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-applescript@7.1.0: {} rw@1.3.3: {} @@ -10301,13 +11040,40 @@ snapshots: semver@7.8.5: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serialize-error@7.0.1: dependencies: type-fest: 0.13.1 optional: true + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-blocking@2.0.0: {} + setprototypeof@1.2.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -10365,6 +11131,34 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -10401,8 +11195,15 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + stat-mode@1.0.0: {} + statuses@2.0.2: {} + std-env@4.2.0: {} stream-markdown-parser@1.1.4: @@ -10557,6 +11358,8 @@ snapshots: tmp@0.2.5: {} + toidentifier@1.0.1: {} + toml@3.0.0: {} totalist@3.0.1: {} @@ -10577,6 +11380,8 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 + ts-algebra@2.0.0: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -10605,6 +11410,12 @@ snapshots: type-fest@4.41.0: {} + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typescript-eslint@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) @@ -10666,6 +11477,8 @@ snapshots: universalify@2.0.1: {} + unpipe@1.0.0: {} + unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.55.2(@types/node@25.9.5))(@vue/language-core@3.2.2)(esbuild@0.27.2)(rolldown@1.1.5)(rollup@4.54.0)(typescript@6.0.3)(vite@8.1.5(@types/node@25.9.5)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.54.0) @@ -10739,6 +11552,8 @@ snapshots: uuid@11.1.0: {} + vary@1.1.2: {} + vee-validate@4.15.1(vue@3.5.26(typescript@6.0.3)): dependencies: '@vue/devtools-api': 7.7.9 @@ -10888,6 +11703,8 @@ snapshots: transitivePeerDependencies: - msw + vscode-jsonrpc@9.0.2: {} + vscode-uri@3.1.0: {} vue-demi@0.14.10(vue@3.5.26(typescript@6.0.3)): @@ -11120,6 +11937,10 @@ snapshots: yocto-queue@0.1.0: {} + zod-to-json-schema@3.25.2(zod@4.4.1): + dependencies: + zod: 4.4.1 + zod@3.25.76: {} zod@4.3.6: {} diff --git a/spec/docs.go b/spec/docs.go index 5c709816df..d8687ae784 100644 --- a/spec/docs.go +++ b/spec/docs.go @@ -9447,6 +9447,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/handlers.ErrorResponse" } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } } } } diff --git a/spec/swagger.json b/spec/swagger.json index fac4a1de71..3897bf0829 100644 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -9438,6 +9438,12 @@ "schema": { "$ref": "#/definitions/handlers.ErrorResponse" } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/apperror.Problem" + } } } } diff --git a/spec/swagger.yaml b/spec/swagger.yaml index aff3cb707a..caa18c0968 100644 --- a/spec/swagger.yaml +++ b/spec/swagger.yaml @@ -12304,6 +12304,10 @@ paths: description: Not Found schema: $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/apperror.Problem' summary: Delete a Remote Runtime workspace target tags: - workspace-targets