diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index e88a49a716..5764e44b7f 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -1357,6 +1357,36 @@ limitations under the License. ================================================================================ +Package: @openai/agents-core@0.14.3 +Declared license: MIT +Selected license: MIT +Repository: https://github.com/openai/openai-agents-js + +--- LICENSE --- +MIT License + +Copyright (c) 2025 OpenAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: @protobufjs/aspromise@1.1.2 Declared license: BSD-3-Clause Selected license: BSD-3-Clause @@ -11441,6 +11471,216 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================================================ +Package: openai@6.49.0 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: github:openai/openai-node + +--- LICENSE --- +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 2026 OpenAI + + 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. + +================================================================================ + Package: p-finally@1.0.0 Declared license: MIT Selected license: MIT diff --git a/package-lock.json b/package-lock.json index 3cde617136..4d58972bc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2430,6 +2430,27 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@openai/agents-core": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/@openai/agents-core/-/agents-core-0.14.3.tgz", + "integrity": "sha512-5tJnFL4Ei34GtArVatM9TUtILDYUXJ2/H0+9jIU8B7y5eINjrQtajmeaudFHmxA4/t7blRgG1SuKQfoA0FPy7A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "openai": "^6.46.0" + }, + "optionalDependencies": { + "@modelcontextprotocol/sdk": "^1.26.0" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@oxc-parser/binding-android-arm-eabi": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz", @@ -10551,6 +10572,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/oxc-parser": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz", @@ -13520,6 +13571,7 @@ "@maka/code-mode": "0.1.0", "@maka/core": "0.1.0", "@mozilla/readability": "^0.6.0", + "@openai/agents-core": "0.14.3", "@slack/socket-mode": "^3.0.0", "@slack/web-api": "^8.0.0", "@wecom/aibot-node-sdk": "1.0.7", diff --git a/packages/core/src/__tests__/permission-compatibility.test.ts b/packages/core/src/__tests__/permission-compatibility.test.ts index d1ecf67e66..1092a4f5e3 100644 --- a/packages/core/src/__tests__/permission-compatibility.test.ts +++ b/packages/core/src/__tests__/permission-compatibility.test.ts @@ -18,6 +18,7 @@ describe('legacy permission payload classification', () => { test('keeps plan-mode tool availability classification independent of authorization', () => { expect(classifyToolUse({ toolName: 'Read', args: {} })).toBe('read'); expect(classifyToolUse({ toolName: 'Write', args: {} })).toBe('file_write'); + expect(classifyToolUse({ toolName: 'apply_patch', args: {} })).toBe('file_write'); expect(classifyToolUse({ toolName: 'ExploreAgent', args: {}, categoryHint: 'subagent' })).toBe( 'subagent', ); diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index ae84456ebb..8c5e3d7c9c 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -101,6 +101,7 @@ export const BUILTIN_TOOL_CATEGORY: Record = { // file write Write: 'file_write', Edit: 'file_write', + apply_patch: 'file_write', patch: 'file_write', // shell — default unsafe; categorizeBash() may downgrade or upgrade Bash: 'shell_unsafe', diff --git a/packages/core/src/tool-catalog.ts b/packages/core/src/tool-catalog.ts index 46ea07ec96..315d6baea4 100644 --- a/packages/core/src/tool-catalog.ts +++ b/packages/core/src/tool-catalog.ts @@ -86,6 +86,7 @@ export const MAKA_CATALOG_TOOLS: readonly CatalogToolDef[] = Object.freeze( { name: 'ArchiveRead' }, { name: 'Write' }, { name: 'Edit' }, + { name: 'apply_patch' }, { name: 'FormatJson' }, { name: 'Glob' }, { name: 'Grep' }, diff --git a/packages/runtime/package.json b/packages/runtime/package.json index f6dd2e0581..1b4aa46cb4 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -76,6 +76,7 @@ "@ai-sdk/google": "4.0.39", "@ai-sdk/openai": "4.0.36", "@ai-sdk/openai-compatible": "3.0.27", + "@openai/agents-core": "0.14.3", "@larksuiteoapi/node-sdk": "1.72.0", "@maka/code-mode": "0.1.0", "@maka/core": "0.1.0", diff --git a/packages/runtime/scripts/build-filesystem-worker.mjs b/packages/runtime/scripts/build-filesystem-worker.mjs index c09c051bd9..5370630514 100644 --- a/packages/runtime/scripts/build-filesystem-worker.mjs +++ b/packages/runtime/scripts/build-filesystem-worker.mjs @@ -5,6 +5,7 @@ import { build } from 'esbuild'; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const outfile = resolve(packageRoot, 'dist', 'workers', 'filesystem-worker.js'); +const agentsCoreUtils = fileURLToPath(import.meta.resolve('@openai/agents-core/utils')); await mkdir(dirname(outfile), { recursive: true }); await build({ @@ -14,6 +15,9 @@ await build({ platform: 'node', format: 'esm', target: 'node22', + alias: { + '@openai/agents-core/utils': resolve(dirname(agentsCoreUtils), 'applyDiff.mjs'), + }, sourcemap: false, legalComments: 'none', }); diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 3acce87fa4..6db6742610 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -86,6 +86,105 @@ import type { MemoryExtractionSourceSnapshot } from '../memory-extraction.js'; import type { OpenAiResponsesSemanticBaseline } from '../openai-responses-continuation.js'; import type { OpenAiResponsesTransportState } from '../openai-responses-websocket.js'; +describe('AiSdkBackend native ApplyPatch routing', () => { + test('advertises apply_patch only to supported native OpenAI models', async () => { + for (const [providerType, modelId, expected] of [ + ['openai', 'gpt-5.4', true], + ['openai', 'gpt-5', false], + ['anthropic', connection().defaultModel, false], + ] as const) { + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: + providerType === 'openai' + ? { ...connection(), slug: 'openai', providerType } + : connection(), + apiKey: 'sk-test', + modelId, + modelFactory: () => model, + tools: [nativeApplyPatchTool()], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain(backend.send({ turnId: 'turn-1', text: 'edit', context: [] })); + assert.equal(modelToolNames(model).includes('apply_patch'), expected); + } + }); + + test('replays a durable apply_patch failure as native provider JSON', async () => { + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: { ...connection(), slug: 'openai', providerType: 'openai' }, + apiKey: 'sk-test', + modelId: 'gpt-5.4', + modelFactory: () => model, + tools: [nativeApplyPatchTool()], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-user', + turnId: 'turn-previous', + role: 'user', + author: 'user', + text: 'patch it', + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-previous', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'call-1', + name: 'apply_patch', + args: { + callId: 'call-1', + operation: { type: 'update_file', path: 'file.txt', diff: '@@' }, + }, + }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-previous', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'call-1', + name: 'apply_patch', + result: { status: 'failed', output: 'diff rejected' }, + isError: true, + }, + }), + ], + }), + ); + + const toolResult = (compactPrompt(model) as Array<{ role: string; content: any[] }>) + .find((message) => message.role === 'tool') + ?.content.find((part) => part.type === 'tool-result'); + assert.deepEqual(toolResult?.output, { + type: 'json', + value: { status: 'failed', output: 'diff rejected' }, + }); + }); +}); + describe('AiSdkBackend Memory Extraction triggers', () => { test('exposes explicitly unsupported Memory triggers on the native OpenAI Responses lane', async () => { const model = completionModel(); @@ -15683,6 +15782,16 @@ function testTool(name: string, parameters: unknown): MakaTool { }; } +function nativeApplyPatchTool(): MakaTool { + return { + name: 'apply_patch', + description: 'Apply one patch operation', + parameters: z.object({}), + providerTool: { kind: 'openai-apply-patch' }, + impl: async () => ({ status: 'completed' }), + }; +} + function permissionTool(onExecute?: () => void): MakaTool { return { name: 'Bash', diff --git a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts index b9966013e7..0166bb4eec 100644 --- a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts @@ -85,6 +85,8 @@ describe('builtin file tools use the sandboxed worker', () => { return { kind: 'read', content: 'worker-content' }; case 'write': return { kind: 'write', ok: true, path: input.operation.path, bytes: 7 }; + case 'apply_patch': + return { kind: 'apply_patch', ok: true, path: input.operation.path }; case 'edit': return { kind: 'edit', @@ -118,6 +120,16 @@ describe('builtin file tools use the sandboxed worker', () => { }); await runTool(tools, 'Read', { path: 'read.txt' }, cwd); + await writeFile(join(cwd, 'patch.txt'), 'old\n', 'utf8'); + await runTool( + tools, + 'apply_patch', + { + callId: 'patch-1', + operation: { type: 'update_file', path: 'patch.txt', diff: '@@\n-old\n+new\n' }, + }, + cwd, + ); await runTool(tools, 'Write', { path: 'write.txt', content: 'content' }, cwd); await runTool(tools, 'Edit', { path: 'edit.txt', old_string: 'a', new_string: 'b' }, cwd); await runTool(tools, 'FormatJson', { path: 'data.json' }, cwd); @@ -126,7 +138,7 @@ describe('builtin file tools use the sandboxed worker', () => { assert.deepEqual( calls.map((call) => call.operation.kind), - ['read', 'write', 'edit', 'format_json', 'glob', 'grep'], + ['read', 'apply_patch', 'write', 'edit', 'format_json', 'glob', 'grep'], ); assert.equal( calls.every((call) => call.executionBoundary?.kind === 'managed'), diff --git a/packages/runtime/src/__tests__/builtin-tools.test.ts b/packages/runtime/src/__tests__/builtin-tools.test.ts index d75d4e646d..865e38a6ca 100644 --- a/packages/runtime/src/__tests__/builtin-tools.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools.test.ts @@ -56,6 +56,7 @@ describe('builtin tool activity kinds', () => { expect(kinds).toEqual({ Bash: 'command', Read: 'read', + apply_patch: 'edit', Write: 'edit', Edit: 'edit', FormatJson: 'edit', @@ -99,6 +100,19 @@ describe('builtin tool activity kinds', () => { ); assert.ok(tools.some((tool) => tool.name === 'Write')); }); + + test('includes apply_patch when a custom workspace exposes the capability', () => { + const tools = buildBuiltinTools({ + executor: fakeExecutor({ + applyPatch: async ({ path }) => ({ ok: true, path }), + }), + }); + + assert.equal( + tools.some((tool) => tool.name === 'apply_patch'), + true, + ); + }); }); describe('builtin Read capabilities', () => { diff --git a/packages/runtime/src/__tests__/filesystem-apply-patch.test.ts b/packages/runtime/src/__tests__/filesystem-apply-patch.test.ts new file mode 100644 index 0000000000..a0c8b8ab9a --- /dev/null +++ b/packages/runtime/src/__tests__/filesystem-apply-patch.test.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test, type TestContext } from 'node:test'; +import { createBoundaryFilesystemExecutor } from '../filesystem-executor.js'; +import { createLocalWorkspaceExecutor } from '../workspace-executor.js'; + +test('applies one native update operation through the filesystem authority', async (t) => { + const cwd = await temporaryDirectory(t); + await writeFile(join(cwd, 'file.txt'), 'before\n', 'utf8'); + const filesystem = localFilesystem(); + + const result = await filesystem.applyPatch({ + cwd, + operation: { + type: 'update_file', + path: 'file.txt', + diff: '@@\n-before\n+after\n', + }, + }); + + assert.deepEqual(result, { status: 'completed' }); + assert.equal(await readFile(join(cwd, 'file.txt'), 'utf8'), 'after\n'); +}); + +test('deletes a self-referential symlink entry without following it', { + skip: process.platform === 'win32', +}, async (t) => { + const cwd = await temporaryDirectory(t); + const link = join(cwd, 'loop'); + await symlink('loop', link); + const filesystem = localFilesystem(); + + assert.deepEqual( + await filesystem.applyPatch({ + cwd, + operation: { type: 'delete_file', path: 'loop' }, + }), + { status: 'completed' }, + ); + await assert.rejects(readFile(link, 'utf8'), { code: 'ENOENT' }); +}); + +test('creates nested files exclusively and deletes their entries', async (t) => { + const cwd = await temporaryDirectory(t); + await writeFile(join(cwd, 'existing.txt'), 'keep\n', 'utf8'); + const filesystem = localFilesystem(); + + assert.deepEqual( + await filesystem.applyPatch({ + cwd, + operation: { type: 'create_file', path: 'nested/file.txt', diff: '+created\n' }, + }), + { status: 'completed' }, + ); + assert.equal(await readFile(join(cwd, 'nested/file.txt'), 'utf8'), 'created'); + + await assert.rejects( + filesystem.applyPatch({ + cwd, + operation: { type: 'create_file', path: 'existing.txt', diff: '+replacement\n' }, + }), + ); + assert.equal(await readFile(join(cwd, 'existing.txt'), 'utf8'), 'keep\n'); + + assert.deepEqual( + await filesystem.applyPatch({ + cwd, + operation: { type: 'delete_file', path: 'nested/file.txt' }, + }), + { status: 'completed' }, + ); + await assert.rejects(readFile(join(cwd, 'nested/file.txt'), 'utf8'), { code: 'ENOENT' }); +}); + +test('does not report an invalid backend result as completed', async (t) => { + const cwd = await temporaryDirectory(t); + await writeFile(join(cwd, 'file.txt'), 'before\n', 'utf8'); + const filesystem = createBoundaryFilesystemExecutor({ + workspace: createLocalWorkspaceExecutor(), + worker: { + execute: async () => ({ kind: 'read', content: 'wrong operation' }), + }, + }); + + await assert.rejects( + filesystem.applyPatch({ + cwd, + operation: { + type: 'update_file', + path: 'file.txt', + diff: '@@\n-before\n+after\n', + }, + }), + /backend returned/, + ); + assert.equal(await readFile(join(cwd, 'file.txt'), 'utf8'), 'before\n'); +}); + +test('reports a rejected diff without changing the file', async (t) => { + const cwd = await temporaryDirectory(t); + await writeFile(join(cwd, 'file.txt'), 'before\n', 'utf8'); + const filesystem = localFilesystem(); + + await assert.rejects( + filesystem.applyPatch({ + cwd, + operation: { + type: 'update_file', + path: 'file.txt', + diff: '@@\n-missing\n+after\n', + }, + }), + /context|missing|match|find/i, + ); + assert.equal(await readFile(join(cwd, 'file.txt'), 'utf8'), 'before\n'); +}); + +test('reports missing mutation targets as rejected operations', async (t) => { + const cwd = await temporaryDirectory(t); + const filesystem = localFilesystem(); + + for (const operation of [ + { type: 'update_file' as const, path: 'missing.txt', diff: '@@\n-old\n+new\n' }, + { type: 'delete_file' as const, path: 'missing.txt' }, + ]) { + await assert.rejects(filesystem.applyPatch({ cwd, operation }), { code: 'ENOENT' }); + } +}); + +async function temporaryDirectory(t: TestContext): Promise { + const path = await mkdtemp(join(tmpdir(), 'maka-native-apply-patch-')); + t.after(() => rm(path, { recursive: true, force: true })); + return path; +} + +function localFilesystem() { + return createBoundaryFilesystemExecutor({ workspace: createLocalWorkspaceExecutor() }); +} diff --git a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts index 1ccad44731..8b182df28e 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { rmSync } from 'node:fs'; -import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, test } from 'node:test'; @@ -52,6 +52,29 @@ test('Read image payloads fit within the filesystem worker response limit', () = }); describe('filesystem worker client permission snapshots', () => { + test('authorizes delete against the symlink entry', async () => { + const workspace = await temporaryDirectory('maka-worker-client-delete-link-'); + const target = join(workspace, 'target.txt'); + const link = join(workspace, 'link.txt'); + await writeFile(target, 'keep', 'utf8'); + await symlink(target, link); + const { client, requests } = fakeClient(); + + await client.execute({ + operation: { kind: 'apply_patch', path: link, action: 'delete' }, + cwd: workspace, + mode: 'ask', + }); + + assert.equal(requests[0]?.operation.path, link); + assert.deepEqual(requests[0]?.expectedTarget, { + enforcementPath: link, + access: 'write', + scope: 'exact', + targetType: 'symlink', + }); + }); + for (const kind of ['bypass', 'external'] as const) { test(`rejects an authoritative ${kind} boundary instead of falling back to legacy mode`, async () => { const workspace = await temporaryDirectory(`maka-worker-client-${kind}-`); @@ -506,6 +529,8 @@ function fakeResult(request: FilesystemWorkerRequest): FilesystemWorkerResult { path: request.operation.path, bytes: Buffer.byteLength(request.operation.content, 'utf8'), }; + case 'apply_patch': + return { kind: 'apply_patch', ok: true, path: request.operation.path }; case 'grep': return { kind: 'grep', matches: ['file.ts:1:value'] }; case 'glob': diff --git a/packages/runtime/src/__tests__/filesystem-worker-linux-smoke.test.ts b/packages/runtime/src/__tests__/filesystem-worker-linux-smoke.test.ts index cb5d1b55f6..4cdc3bc984 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-linux-smoke.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-linux-smoke.test.ts @@ -120,6 +120,23 @@ describe('Linux filesystem worker smoke', { skip }, () => { } }); + test('creates a nested patch file inside bubblewrap', async () => { + const target = join(workspace, 'nested', 'created.txt'); + + await client.execute({ + operation: { + kind: 'apply_patch', + path: target, + action: 'create', + diff: '+created\n', + }, + cwd: workspace, + mode: 'ask', + }); + + assert.equal(await readFile(target, 'utf8'), 'created'); + }); + test('applies one exact outside grant without opening its sibling', async () => { const allowedPath = join(outside, 'allowed.txt'); const siblingPath = join(outside, 'sibling.txt'); diff --git a/packages/runtime/src/__tests__/filesystem-worker-smoke.test.ts b/packages/runtime/src/__tests__/filesystem-worker-smoke.test.ts index d62b3e7659..34497ef831 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-smoke.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-smoke.test.ts @@ -62,6 +62,38 @@ describe('macOS filesystem worker smoke', { skip: process.platform !== 'darwin' ); }); + test('creates a nested patch file inside the workspace', async () => { + const target = join(workspace, 'nested', 'created.txt'); + + await client.execute({ + operation: { + kind: 'apply_patch', + path: target, + action: 'create', + diff: '+created\n', + }, + cwd: workspace, + mode: 'ask', + }); + + assert.equal(await readFile(target, 'utf8'), 'created'); + }); + + test('deletes an entry through the absolute macOS path alias', async () => { + const target = join(workspace, 'aliased.txt'); + const aliasedTarget = target.replace(/^\/private(?=\/)/, ''); + assert.notEqual(aliasedTarget, target); + await writeFile(target, 'delete me', 'utf8'); + + await client.execute({ + operation: { kind: 'apply_patch', path: aliasedTarget, action: 'delete' }, + cwd: workspace, + mode: 'ask', + }); + + await assert.rejects(readFile(target, 'utf8'), { code: 'ENOENT' }); + }); + test('applies one exact boundary expansion without opening a sibling path', async () => { const allowedPath = join(outside, 'allowed.txt'); const siblingPath = join(outside, 'sibling.txt'); diff --git a/packages/runtime/src/__tests__/filesystem-worker.test.ts b/packages/runtime/src/__tests__/filesystem-worker.test.ts index e310881321..cebb08dde7 100644 --- a/packages/runtime/src/__tests__/filesystem-worker.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker.test.ts @@ -23,6 +23,95 @@ afterEach(async () => { }); describe('filesystem worker operations', () => { + test('creates nested patch files exclusively', async () => { + const root = await temporaryDirectory('maka-worker-create-patch-'); + const target = join(root, 'nested', 'file.txt'); + const operation = { + kind: 'apply_patch' as const, + cwd: root, + path: target, + action: 'create' as const, + diff: '+created\n', + }; + + const created = await executeFilesystemWorkerRequest( + requestFor(operation, { + enforcementPath: target, + access: 'write', + scope: 'exact', + targetType: 'missing', + }), + ); + assert.equal(created.ok, true); + assert.equal(await readFile(target, 'utf8'), 'created'); + + const conflict = await executeFilesystemWorkerRequest( + requestFor(operation, { + enforcementPath: target, + access: 'write', + scope: 'exact', + targetType: 'file', + }), + ); + assert.equal(conflict.ok, false); + assert.equal(await readFile(target, 'utf8'), 'created'); + }); + + test('returns a recoverable patch conflict without changing the file', async () => { + const root = await temporaryDirectory('maka-worker-patch-conflict-'); + const target = join(root, 'file.txt'); + await writeFile(target, 'before\n', 'utf8'); + + const response = await executeFilesystemWorkerRequest( + requestFor( + { + kind: 'apply_patch', + cwd: root, + path: target, + action: 'update', + diff: '@@\n-missing\n+after\n', + }, + { + enforcementPath: target, + access: 'write', + scope: 'exact', + targetType: 'file', + }, + ), + ); + + assert.equal(response.ok, false); + if (!response.ok) { + assert.equal(response.error.code, 'edit_conflict'); + assert.match(response.error.message, /Invalid Context/); + } + assert.equal(await readFile(target, 'utf8'), 'before\n'); + }); + + test('deletes a symlink entry without deleting its target', async () => { + const root = await temporaryDirectory('maka-worker-delete-link-'); + const target = join(root, 'target.txt'); + const link = join(root, 'link.txt'); + await writeFile(target, 'keep', 'utf8'); + await symlink(target, link); + + const response = await executeFilesystemWorkerRequest( + requestFor( + { kind: 'apply_patch', cwd: root, path: link, action: 'delete' }, + { + enforcementPath: link, + access: 'write', + scope: 'exact', + targetType: 'symlink', + }, + ), + ); + + assert.equal(response.ok, true); + assert.equal(await readFile(target, 'utf8'), 'keep'); + await assert.rejects(readFile(link, 'utf8'), { code: 'ENOENT' }); + }); + test('runs Grep from the filesystem root without broadening its target permission', async () => { const root = await temporaryDirectory('maka-worker-grep-root-'); const target = join(root, 'file.ts'); diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 896a0f3540..2b6703cca6 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -2,10 +2,26 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { RetryError } from 'ai'; -import { ModelAdapter, normalizeAiSdkUsage } from '../model-adapter.js'; +import { lowerModelTools, ModelAdapter, normalizeAiSdkUsage } from '../model-adapter.js'; import type { ModelStreamEvent } from '../model-protocol.js'; describe('ModelAdapter stream and error normalization', () => { + test('lowers apply_patch to the client-executed OpenAI provider tool', () => { + const tools = lowerModelTools({ + apply_patch: { kind: 'provider', providerTool: { kind: 'openai-apply-patch' } }, + }); + + assert.deepEqual( + { + type: (tools.apply_patch as { type?: unknown }).type, + id: (tools.apply_patch as { id?: unknown }).id, + isProviderExecuted: (tools.apply_patch as { isProviderExecuted?: unknown }) + .isProviderExecuted, + }, + { type: 'provider', id: 'openai.apply_patch', isProviderExecuted: false }, + ); + }); + test('resolves optional-key LocalAI without fabricating a credential', () => { const model = {}; let observedApiKey: string | undefined; diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index 6d6d5e089c..548443d64a 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -8,6 +8,7 @@ import { } from '@maka/core'; import { buildProviderOptions, getAIModel } from '@maka/runtime'; import { resolveModelRuntime } from '../model-runtime.js'; +import { lowerModelTools } from '../model-adapter.js'; function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmConnection { return { @@ -83,6 +84,64 @@ describe('responses wire contract', () => { }); describe('responses wire request body', () => { + test('returns native apply_patch results with the provider output item', async () => { + let body: Record | undefined; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + return Response.json({ + id: 'r', + object: 'response', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }) as unknown as typeof globalThis.fetch; + const connection = conn('openai'); + const model = getAIModel({ connection, apiKey: 'test-key', modelId: 'gpt-5.4', fetch }); + const tools = lowerModelTools({ + apply_patch: { kind: 'provider', providerTool: { kind: 'openai-apply-patch' } }, + }); + + await model.doGenerate({ + prompt: [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'apply_patch', + input: { + callId: 'call-1', + operation: { type: 'delete_file', path: 'old.txt' }, + }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'apply_patch', + output: { type: 'json', value: { status: 'failed', output: 'conflict' } }, + }, + ], + }, + ], + tools: [tools.apply_patch as never], + providerOptions: { openai: { store: false } }, + }); + + assert.deepEqual((body?.input as unknown[] | undefined)?.at(-1), { + type: 'apply_patch_call_output', + call_id: 'call-1', + status: 'failed', + output: 'conflict', + }); + }); + test('a non-OpenAI-named Responses model still asks for encrypted reasoning', async () => { // The options shape alone does not prove the wire: the SDK only adds the // include when it also believes the model reasons, and it decides that by diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index b31c1d3a8a..4b9c7430c3 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -257,6 +257,38 @@ describe('ToolRuntime settlement', () => { }); }); + it('records apply_patch failures without changing their provider output shape', async () => { + const events: Array<{ type: string; isError?: boolean }> = []; + const runtime = makeRuntime(); + const settlement = await runtime.settleToolCall({ + tool: { + ...tool(() => ({ error: 'dispatch failed' })), + name: 'apply_patch', + providerTool: { kind: 'openai-apply-patch' }, + }, + turnId: 'turn-1', + stepId: 'step-1', + toolCallId: 'call-1', + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: (event) => events.push(event), + pushAndWaitUntilConsumed: async (event) => { + events.push(event); + }, + }, + }); + + assert.equal( + events.some((event) => event.type === 'tool_result' && event.isError === true), + true, + ); + assert.deepEqual(settlement.modelOutput, { + type: 'json', + value: { status: 'failed', output: 'dispatch failed' }, + }); + }); + it('falls back from provider text to the raw error message', async () => { const runtime = makeRuntime(); for (const [result, expected] of [ diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 66b9a421e2..95fe39a9f7 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -208,6 +208,7 @@ import { type MemoryExtractionTrigger, } from './memory-extraction.js'; import { modelUsesNativeOpenAiResponses } from './model-runtime.js'; +import { openAiModelSupportsApplyPatch } from './openai-apply-patch.js'; import { applyRuntimeEventContextBudget, buildContextBudgetDiagnosticShell, @@ -822,6 +823,25 @@ function toolResultText(text: string): ToolResultOutput { return { type: 'content', value: [{ type: 'text', text }] }; } +function nativeApplyPatchFailureOutput(output: ToolResultOutput): ToolResultOutput { + const value = output.type === 'json' || output.type === 'error-json' ? output.value : undefined; + const record = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined; + const message = + output.type === 'text' || output.type === 'error-text' + ? output.value + : typeof record?.output === 'string' + ? record.output + : typeof record?.text === 'string' + ? record.text + : typeof record?.error === 'string' + ? record.error + : undefined; + return { + type: 'json', + value: { status: 'failed', ...(message ? { output: message } : {}) }, + }; +} + const MAX_PROVIDER_ATTEMPTS_PER_STEP = 10; const MAX_IDLE_WATCHDOG_RETRIES_PER_STEP = 1; const PROVIDER_RETRY_BASE_DELAY_MS = 1_000; @@ -1038,10 +1058,15 @@ export class AiSdkBackend implements AgentBackend { : {}), }) : []; + const modelTools = + modelUsesNativeOpenAiResponses(input.connection, input.modelId) && + openAiModelSupportsApplyPatch(input.modelId) + ? input.tools + : input.tools.filter((tool) => tool.providerTool?.kind !== 'openai-apply-patch'); this.toolAvailabilityRuntime = new ToolAvailabilityRuntime( // The archive decoder is a runtime protocol tool, not a host binding: // this session's placeholders name it, so this session advertises it. - bindToolResultArchiveDecoder([...input.tools, ...memoryTools], input.toolResultArchive), + bindToolResultArchiveDecoder([...modelTools, ...memoryTools], input.toolResultArchive), input.toolAvailability, buildInvalidMakaTool(), ); @@ -3606,14 +3631,22 @@ export class AiSdkBackend implements AgentBackend { // back — the plan flags them as `unmatched_tool_result` (a non-blocking // diagnostic precisely so this drop path is reachable; see // hasBlockingReplayDiagnostics). - const materializeReplayToolResult = async (result: ToolResultItem): Promise => - settledModelOutputs?.get(result.toolCallId) ?? - (await this.materializeToolResultOutput( - budget, - result.output, - result.isError, - `runtime-event:${result.eventId}:tool-result`, - )); + const materializeReplayToolResult = async ( + result: ToolResultItem, + toolName: string, + ): Promise => { + const output = + settledModelOutputs?.get(result.toolCallId) ?? + (await this.materializeToolResultOutput( + budget, + result.output, + result.isError, + `runtime-event:${result.eventId}:tool-result`, + )); + return toolName === 'apply_patch' && result.isError + ? nativeApplyPatchFailureOutput(output) + : output; + }; const pushClientToolResults = async (calls: readonly ToolCallItem[]) => { for (const call of calls) { const result = results.get(call.toolCallId); @@ -3627,7 +3660,7 @@ export class AiSdkBackend implements AgentBackend { type: 'tool-result', toolCallId: result.toolCallId, toolName: result.toolName, - output: await materializeReplayToolResult(result), + output: await materializeReplayToolResult(result, call.toolName), }, ], }, @@ -3676,7 +3709,7 @@ export class AiSdkBackend implements AgentBackend { type: 'tool-result', toolCallId: result.toolCallId, toolName: result.toolName, - output: await materializeReplayToolResult(result), + output: await materializeReplayToolResult(result, call.toolName), }); } if (text && text.content.length > 0) { diff --git a/packages/runtime/src/apply-patch-file.ts b/packages/runtime/src/apply-patch-file.ts new file mode 100644 index 0000000000..156a77ed71 --- /dev/null +++ b/packages/runtime/src/apply-patch-file.ts @@ -0,0 +1,39 @@ +import { promises as fs } from 'node:fs'; +import { dirname } from 'node:path'; +import { applyDiff } from '@openai/agents-core/utils'; + +export class ApplyPatchRejectedError extends Error { + constructor(message: string) { + super(message); + this.name = 'ApplyPatchRejectedError'; + } +} + +export async function createPatchedFile(path: string, diff: string): Promise { + const content = patchContent('', diff, 'create'); + await fs.mkdir(dirname(path), { recursive: true }); + try { + await fs.writeFile(path, content, { encoding: 'utf8', flag: 'wx' }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new ApplyPatchRejectedError('ApplyPatch create target already exists.'); + } + throw error; + } +} + +export async function updatePatchedFile(path: string, diff: string): Promise { + const content = await fs.readFile(path, 'utf8'); + const updated = patchContent(content, diff); + if (updated !== content) await fs.writeFile(path, updated, 'utf8'); +} + +function patchContent(content: string, diff: string, mode?: 'create'): string { + try { + return applyDiff(content, diff, mode); + } catch (error) { + throw new ApplyPatchRejectedError( + error instanceof Error ? error.message : 'ApplyPatch could not be applied.', + ); + } +} diff --git a/packages/runtime/src/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts index 266c6f033b..d47d355ced 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -27,6 +27,7 @@ import { } from '@maka/core'; import { bashToolResultToModelOutput } from './bash-model-output.js'; import { fileWriteToolResultToModelOutput } from './file-tool-model-output.js'; +import { openAiApplyPatchInputSchema } from './openai-apply-patch.js'; import { buildManagedBashTool, buildStopBackgroundTaskTool, @@ -299,6 +300,17 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT ...(options.backgroundTasks ? [buildStopBackgroundTaskTool(options.backgroundTasks)] : []), ...(options.ptyControls ? [buildWriteStdinTool(options.ptyControls)] : []), ]; + const applyPatchTool = { + name: 'apply_patch', + activityKind: 'edit', + categoryHint: 'file_write', + description: 'Apply one OpenAI V4A file operation.', + parameters: openAiApplyPatchInputSchema, + providerTool: { kind: 'openai-apply-patch' }, + executionFacts, + impl: async ({ operation }, ctx) => + await filesystem.applyPatch({ operation, ...filesystemCall(ctx) }), + } satisfies MakaTool; const tools: MakaTool[] = [ ...bashTools, ...backgroundTools, @@ -368,6 +380,7 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT return { content: result.content }; }, }, + ...(executor.applyPatch ? [applyPatchTool] : []), { name: 'Write', activityKind: 'edit', diff --git a/packages/runtime/src/filesystem-executor.ts b/packages/runtime/src/filesystem-executor.ts index 3aa6e2ea6c..ed811c1043 100644 --- a/packages/runtime/src/filesystem-executor.ts +++ b/packages/runtime/src/filesystem-executor.ts @@ -22,17 +22,20 @@ import type { } from './filesystem-worker/client.js'; import type { ImageMimeType } from './image-file.js'; import type { FilesystemWorkerResult } from './filesystem-worker/protocol.js'; +import { resolveCanonicalDirectoryEntryTarget } from './path-containment.js'; import { normalizeSandboxBoundaryPath } from './sandbox-boundary-path.js'; import { SandboxCommandError } from './sandbox/errors.js'; import type { WorkspaceEditExecutor, + WorkspaceApplyPatchExecutor, WorkspacePathScope, WorkspaceSearchExecutor, WorkspaceWriteExecutor, } from './workspace-executor.js'; /** A file operation, named the same way on every backend. `cwd` is supplied per call. */ -export type FilesystemOperation = FilesystemWorkerClientOperation; +type FilesystemBackendOperation = FilesystemWorkerClientOperation; +export type FilesystemOperation = Exclude; /** * The result shape every backend answers with. @@ -55,6 +58,23 @@ export interface FilesystemExecuteInput { abortSignal?: AbortSignal; } +type FilesystemBackendExecuteInput = Omit & { + operation: FilesystemBackendOperation; +}; + +export type ApplyPatchOperation = + | { type: 'create_file'; path: string; diff: string } + | { type: 'delete_file'; path: string } + | { type: 'update_file'; path: string; diff: string }; + +export interface FilesystemApplyPatchInput extends Omit { + operation: ApplyPatchOperation; +} + +export interface ApplyPatchResult { + status: 'completed'; +} + export interface FilesystemExecutor { /** * Run one operation under the authority of the boundary it carries. A mutating @@ -62,11 +82,13 @@ export interface FilesystemExecutor { * no caller has to know that a lock exists or how its key is spelled. */ execute(input: FilesystemExecuteInput): Promise; + applyPatch(input: FilesystemApplyPatchInput): Promise; } /** The workspace primitives the host-local backend drives. */ export type FilesystemWorkspaceExecutor = WorkspaceWriteExecutor & WorkspaceEditExecutor & + Partial & WorkspaceSearchExecutor; export interface BoundaryFilesystemExecutorInput { @@ -126,7 +148,7 @@ export function createBoundaryFilesystemExecutor( 'Managed filesystem execution is unavailable because the sandboxed worker cannot be enforced.', }); }; - async function run(call: FilesystemExecuteInput): Promise { + async function run(call: FilesystemBackendExecuteInput): Promise { const worker = workerFor(call.executionBoundary); if (!worker) return await local.execute(call, pathScopeForBoundary(call.executionBoundary)); const result = await worker.execute({ @@ -151,6 +173,32 @@ export function createBoundaryFilesystemExecutor( } return result; } + async function writeLockTarget( + call: Omit, + path: string, + semantics: 'target' | 'entry' = 'target', + ): Promise { + const worker = workerFor(call.executionBoundary); + if (!worker) + return ( + await input.workspace.writeLockKey({ + cwd: call.cwd, + path, + semantics, + }) + ).key; + if (semantics === 'entry') { + return (await resolveCanonicalDirectoryEntryTarget(call.cwd, path)).path; + } + return ( + await normalizeSandboxBoundaryPath({ + path, + access: 'write', + scope: 'exact', + cwd: await canonicalExistingPath(call.cwd), + }) + ).enforcementPath; + } return { async execute(call) { if (!mutates(call.operation)) return await run(call); @@ -158,24 +206,38 @@ export function createBoundaryFilesystemExecutor( // goes on to reject still takes the same lock as its other spellings. The // key is derived from the same canonicalisation the backend will resolve // with, or the lock-key space and the resolved-path space drift apart. - const worker = workerFor(call.executionBoundary); - const key = worker - ? ( - await normalizeSandboxBoundaryPath({ - path: call.operation.path, - access: 'write', - scope: 'exact', - cwd: await canonicalExistingPath(call.cwd), - }) - ).enforcementPath - : (await input.workspace.writeLockKey({ cwd: call.cwd, path: call.operation.path })).key; + const key = await writeLockTarget(call, call.operation.path); return await withFileWriteLock(key, () => run(call)); }, + async applyPatch(call) { + const { operation, ...common } = call; + const semantics = operation.type === 'update_file' ? 'target' : 'entry'; + const key = await writeLockTarget(common, operation.path, semantics); + return await withFileWriteLock(key, async () => { + const backendOperation: FilesystemWorkerClientOperation = + operation.type === 'delete_file' + ? { kind: 'apply_patch', path: operation.path, action: 'delete' } + : { + kind: 'apply_patch', + path: operation.path, + action: operation.type === 'create_file' ? 'create' : 'update', + diff: operation.diff, + }; + const result = await run({ ...common, operation: backendOperation }); + if (result.kind !== 'apply_patch') { + throw new Error(`ApplyPatch backend returned ${JSON.stringify(result.kind)}.`); + } + return { status: 'completed' }; + }); + }, }; } interface WorkspaceFilesystemBackend { - execute(input: FilesystemExecuteInput, scope: WorkspacePathScope): Promise; + execute( + input: FilesystemBackendExecuteInput, + scope: WorkspacePathScope, + ): Promise; } /** @@ -244,6 +306,16 @@ function createWorkspaceFilesystemExecutor( ...(diff !== undefined ? { diff } : {}), }; } + case 'apply_patch': { + if (!workspace.applyPatch) throw new Error('Workspace does not support ApplyPatch'); + const common = { cwd, path: operation.path, label: 'ApplyPatch', scope }; + const patched = await workspace.applyPatch( + operation.action === 'delete' + ? { ...common, action: 'delete' } + : { ...common, action: operation.action, diff: operation.diff }, + ); + return { kind: 'apply_patch', ok: true, path: patched.path }; + } case 'edit': { const { path } = await workspace.resolveExistingPath({ cwd, diff --git a/packages/runtime/src/filesystem-worker/client.ts b/packages/runtime/src/filesystem-worker/client.ts index b1c18be422..07046af882 100644 --- a/packages/runtime/src/filesystem-worker/client.ts +++ b/packages/runtime/src/filesystem-worker/client.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { realpath } from 'node:fs/promises'; +import { lstat, realpath } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname } from 'node:path'; import { @@ -13,6 +13,7 @@ import { } from '@maka/core'; import { normalizeSandboxBoundaryPath } from '../sandbox-boundary-path.js'; +import { resolveCanonicalDirectoryEntryTarget } from '../path-containment.js'; import { pinExistingLinuxProfilePath } from '../sandbox/linux-profile-path.js'; import type { SandboxManager } from '../sandbox/sandbox-manager.js'; import type { SandboxPlatform } from '../sandbox/types.js'; @@ -25,6 +26,7 @@ import { import { FILESYSTEM_WORKER_PROTOCOL_VERSION, FilesystemWorkerOperationSchema, + operationUsesDirectoryEntry, parseFilesystemWorkerResponse, type FilesystemWorkerErrorCode, type FilesystemWorkerOperation, @@ -150,12 +152,20 @@ export class FilesystemWorkerClient { if (!parsedOperation.success) throw clientError('invalid_operation', 'validation', requestId); const access = operationAccess(parsedOperation.data.kind); - const target = await normalizeSandboxBoundaryPath({ - path: parsedOperation.data.path, - access, - scope: operationScope(parsedOperation.data.kind), - cwd: canonicalCwd, - }).catch(() => { + const entryMode = operationUsesDirectoryEntry(parsedOperation.data); + const target: FilesystemWorkerTarget & { writableAncestor?: string } = await (entryMode + ? normalizeDirectoryEntryTarget({ + path: parsedOperation.data.path, + access, + cwd: canonicalCwd, + }) + : normalizeSandboxBoundaryPath({ + path: parsedOperation.data.path, + access, + scope: operationScope(parsedOperation.data.kind), + cwd: canonicalCwd, + }) + ).catch(() => { throw clientError('invalid_operation', 'validation', requestId); }); const compiled = @@ -177,6 +187,8 @@ export class FilesystemWorkerClient { access, enforcementPath: target.enforcementPath, targetType: target.targetType, + entryMode, + writableAncestor: target.writableAncestor, }); const pathContext = { workspaceRoots: compiled.workspaceRoots, @@ -213,9 +225,12 @@ export class FilesystemWorkerClient { ); } + const boundaryTarget = target.writableAncestor + ? { path: target.writableAncestor, access: 'write' as const, scope: 'subtree' as const } + : { path: target.enforcementPath, access, scope: target.scope }; const operationBoundary = { filesystem: { - entries: [{ path: target.enforcementPath, access, scope: target.scope }], + entries: [boundaryTarget], }, } as const; const operation = FilesystemWorkerOperationSchema.parse({ @@ -243,13 +258,13 @@ export class FilesystemWorkerClient { if (!launch.ok) throw clientError(launch.reason, 'launch', requestId, launch.message); const workerProfile = deriveWorkerProfile(effectiveProfile, operationBoundary); const pinnedTarget = - platform === 'linux' && target.targetType !== 'missing' + platform === 'linux' && !entryMode && target.targetType !== 'missing' ? (() => { try { return pinExistingLinuxProfilePath({ path: target.enforcementPath, access, - targetType: target.targetType, + targetType: target.targetType as 'file' | 'directory' | 'other', childFd: 4, }); } catch { @@ -262,7 +277,7 @@ export class FilesystemWorkerClient { } })() : undefined; - if (platform === 'linux' && target.targetType !== 'missing' && !pinnedTarget) { + if (platform === 'linux' && !entryMode && target.targetType !== 'missing' && !pinnedTarget) { throw clientError( 'path_changed', 'validation', @@ -271,7 +286,9 @@ export class FilesystemWorkerClient { ); } const pinnedRuntimeWritableRoot = - platform === 'linux' && target.targetType === 'missing' && runtimeWritableRoots?.[0] + platform === 'linux' && + runtimeWritableRoots?.[0] && + (entryMode || target.targetType === 'missing') ? (() => { try { return pinExistingLinuxProfilePath({ @@ -292,8 +309,8 @@ export class FilesystemWorkerClient { : undefined; if ( platform === 'linux' && - target.targetType === 'missing' && runtimeWritableRoots && + (entryMode || target.targetType === 'missing') && !pinnedRuntimeWritableRoot ) { throw clientError( @@ -425,11 +442,12 @@ export function filesystemWorkerRuntimeWritableRoots(input: { access: 'read' | 'write'; enforcementPath: string; targetType: FilesystemWorkerTarget['targetType']; + entryMode?: boolean; + writableAncestor?: string; }): readonly string[] | undefined { - if (input.platform !== 'linux' || input.access !== 'write' || input.targetType !== 'missing') { - return undefined; - } - return [dirname(input.enforcementPath)]; + if (input.platform !== 'linux' || input.access !== 'write') return undefined; + if (input.entryMode) return input.writableAncestor ? [input.writableAncestor] : undefined; + return input.targetType === 'missing' ? [dirname(input.enforcementPath)] : undefined; } function deriveWorkerProfile( @@ -467,7 +485,9 @@ function deriveWorkerProfile( } function operationAccess(kind: FilesystemWorkerOperation['kind']): 'read' | 'write' { - return kind === 'write' || kind === 'edit' || kind === 'format_json' ? 'write' : 'read'; + return kind === 'write' || kind === 'apply_patch' || kind === 'edit' || kind === 'format_json' + ? 'write' + : 'read'; } function operationScope(kind: FilesystemWorkerOperation['kind']): 'exact' | 'subtree' | 'auto' { @@ -479,6 +499,36 @@ async function canonicalPath(path: string): Promise { return await realpath(path).catch(() => path); } +async function normalizeDirectoryEntryTarget(input: { + path: string; + cwd: string; + access: 'read' | 'write'; +}): Promise { + const target = await resolveCanonicalDirectoryEntryTarget(input.cwd, input.path); + let targetType: FilesystemWorkerTarget['targetType']; + try { + const metadata = await lstat(target.path); + targetType = metadata.isSymbolicLink() + ? 'symlink' + : metadata.isFile() + ? 'file' + : metadata.isDirectory() + ? 'directory' + : 'other'; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error; + targetType = 'missing'; + } + return { + enforcementPath: target.path, + access: input.access, + scope: 'exact', + targetType, + writableAncestor: target.existingAncestor, + }; +} + function clientError( reason: FilesystemWorkerClientErrorReason, stage: FilesystemWorkerClientError['stage'], diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index 82e7a90eb9..35d9981523 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -2,14 +2,24 @@ import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; import { glob as nodeGlob } from 'node:fs/promises'; import { dirname, isAbsolute, parse, resolve } from 'node:path'; -import { isPathInside, realpathAllowMissing } from '../path-containment.js'; +import { + isPathInside, + realpathAllowMissing, + resolveCanonicalDirectoryEntryTarget, +} from '../path-containment.js'; import { sandboxBoundaryExpansionAllowsPath } from '@maka/core'; +import { + ApplyPatchRejectedError, + createPatchedFile, + updatePatchedFile, +} from '../apply-patch-file.js'; import { computeEditedSource } from '../edit-replace.js'; import { createUnifiedDiff } from '../unified-diff.js'; import { isSupportedImagePath, readWorkspaceImage } from '../image-file.js'; import { FILESYSTEM_WORKER_PROTOCOL_VERSION, + operationUsesDirectoryEntry, type FilesystemWorkerErrorCode, type FilesystemWorkerOperation, type FilesystemWorkerRequest, @@ -50,7 +60,12 @@ export async function executeFilesystemWorkerRequest( dependencies: FilesystemWorkerOperationDependencies = {}, ): Promise { try { - await assertTargetUnchanged(request.operation.path, request.expectedTarget); + await assertTargetUnchanged( + request.operation.cwd, + request.operation.path, + request.expectedTarget, + operationUsesDirectoryEntry(request.operation), + ); return { version: FILESYSTEM_WORKER_PROTOCOL_VERSION, requestId: request.requestId, @@ -140,6 +155,28 @@ export async function executeFilesystemOperation( ...(diff !== undefined ? { diff } : {}), }; } + case 'apply_patch': { + if (operation.action !== 'update') { + const path = await resolveDirectoryEntryAllowed( + operation.cwd, + operation.path, + operation.action === 'create' ? 'ApplyPatch create' : 'ApplyPatch delete', + operationBoundary, + ); + if (operation.action === 'create') await createPatchedFile(path, operation.diff); + else await fs.unlink(path); + return { kind: 'apply_patch', ok: true, path }; + } + const path = await resolveExistingAllowed( + operation.cwd, + operation.path, + 'ApplyPatch update', + 'write', + operationBoundary, + ); + await updatePatchedFile(path, operation.diff); + return { kind: 'apply_patch', ok: true, path }; + } case 'edit': { const path = await resolveExistingAllowed( operation.cwd, @@ -307,6 +344,9 @@ function sortKeysDeep(value: unknown): unknown { function normalizeOperationError(error: unknown): FilesystemOperationError { if (error instanceof FilesystemOperationError) return error; + if (error instanceof ApplyPatchRejectedError) { + return operationError('edit_conflict', error.message); + } const code = nodeErrorCode(error); if (code === 'ENOENT' || code === 'ENOTDIR') return operationError('not_found', 'The requested path was not found.'); @@ -316,11 +356,17 @@ function normalizeOperationError(error: unknown): FilesystemOperationError { } async function assertTargetUnchanged( + cwd: string, path: string, expected: FilesystemWorkerTarget, + noFollowFinalSymlink = false, ): Promise { - const enforcementPath = await realpathAllowMissing(path); - const targetType = await targetTypeOf(enforcementPath); + const enforcementPath = noFollowFinalSymlink + ? (await resolveCanonicalDirectoryEntryTarget(cwd, path)).path + : await realpathAllowMissing(path); + const targetType = noFollowFinalSymlink + ? await lstatTargetTypeOf(enforcementPath) + : await targetTypeOf(enforcementPath); if (enforcementPath !== expected.enforcementPath || targetType !== expected.targetType) { throw operationError( 'path_changed', @@ -360,6 +406,17 @@ async function resolveWritableAllowed( return followed; } +async function resolveDirectoryEntryAllowed( + cwd: string, + inputPath: string, + label: string, + permission: FilesystemWorkerRequest['operationBoundary'], +): Promise { + const target = await resolveCanonicalDirectoryEntryTarget(cwd, inputPath); + assertAllowed(target.root, target.path, label, 'write', permission); + return target.path; +} + async function resolveExistingAllowed( cwd: string, inputPath: string, @@ -437,6 +494,19 @@ async function targetTypeOf(path: string): Promise { + try { + const metadata = await fs.lstat(path); + if (metadata.isSymbolicLink()) return 'symlink'; + if (metadata.isFile()) return 'file'; + if (metadata.isDirectory()) return 'directory'; + return 'other'; + } catch (error) { + if (nodeErrorCode(error) === 'ENOENT') return 'missing'; + throw error; + } +} + function nodeErrorCode(error: unknown): string | undefined { if (!error || typeof error !== 'object' || !('code' in error)) return undefined; return typeof error.code === 'string' ? error.code : undefined; diff --git a/packages/runtime/src/filesystem-worker/protocol.ts b/packages/runtime/src/filesystem-worker/protocol.ts index c697844b08..f8b01e9f2c 100644 --- a/packages/runtime/src/filesystem-worker/protocol.ts +++ b/packages/runtime/src/filesystem-worker/protocol.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; import { validateSandboxBoundaryExpansion } from '@maka/core'; -export const FILESYSTEM_WORKER_PROTOCOL_VERSION = 4 as const; +// v5 adds the provider-native single-file ApplyPatch operation. +export const FILESYSTEM_WORKER_PROTOCOL_VERSION = 5 as const; const path = z.string().min(1).max(4096); const cwd = z.string().min(1).max(4096); @@ -40,11 +41,11 @@ export const FilesystemWorkerTargetSchema = z enforcementPath: path, access: z.enum(['read', 'write']), scope: z.enum(['exact', 'subtree']), - targetType: z.enum(['file', 'directory', 'other', 'missing']), + targetType: z.enum(['file', 'directory', 'symlink', 'other', 'missing']), }) .strict(); -export const FilesystemWorkerOperationSchema = z.discriminatedUnion('kind', [ +export const FilesystemWorkerOperationSchema = z.union([ z .object({ kind: z.literal('read'), @@ -55,6 +56,16 @@ export const FilesystemWorkerOperationSchema = z.discriminatedUnion('kind', [ }) .strict(), z.object({ kind: z.literal('write'), cwd, path, content: z.string() }).strict(), + z + .object({ + kind: z.literal('apply_patch'), + cwd, + path, + action: z.enum(['create', 'update']), + diff: z.string(), + }) + .strict(), + z.object({ kind: z.literal('apply_patch'), cwd, path, action: z.literal('delete') }).strict(), z .object({ kind: z.literal('edit'), @@ -123,6 +134,7 @@ export const FilesystemWorkerResultSchema = z.discriminatedUnion('kind', [ diff: z.string().optional(), }) .strict(), + z.object({ kind: z.literal('apply_patch'), ok: z.literal(true), path: z.string() }).strict(), z .object({ kind: z.literal('edit'), @@ -190,6 +202,13 @@ export const FilesystemWorkerResponseSchema = z.discriminatedUnion('ok', [ ]); export type FilesystemWorkerOperation = z.infer; + +export function operationUsesDirectoryEntry(operation: FilesystemWorkerOperation): boolean { + return ( + operation.kind === 'apply_patch' && + (operation.action === 'create' || operation.action === 'delete') + ); +} export type FilesystemWorkerTarget = z.infer; export type FilesystemWorkerRequest = z.infer; export type FilesystemWorkerResult = z.infer; diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 4b58ecd0b3..fb6b39edd9 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -60,6 +60,7 @@ import { OPENAI_RESPONSES_LANE_HEADER, type OpenAiResponsesTransportState, } from './openai-responses-websocket.js'; +import { openAiApplyPatchProviderTool } from './openai-apply-patch.js'; /** * Build an ai-sdk LanguageModel from a single input object. @@ -814,6 +815,8 @@ function compileProviderTool( tool: NonNullable, ): unknown { switch (tool.kind) { + case 'openai-apply-patch': + return openAiApplyPatchProviderTool; case 'openai-web-search': return openai.tools.webSearch({ ...(tool.searchContextSize ? { searchContextSize: tool.searchContextSize } : {}), diff --git a/packages/runtime/src/openai-apply-patch.ts b/packages/runtime/src/openai-apply-patch.ts new file mode 100644 index 0000000000..f95ce52f85 --- /dev/null +++ b/packages/runtime/src/openai-apply-patch.ts @@ -0,0 +1,16 @@ +import { openai } from '@ai-sdk/openai'; + +export const openAiApplyPatchProviderTool = openai.tools.applyPatch({}); +const inputSchema = openAiApplyPatchProviderTool.inputSchema; +export const openAiApplyPatchInputSchema = + typeof inputSchema === 'function' ? inputSchema() : inputSchema; + +/** Models documented by OpenAI as supporting the native Apply Patch tool. */ +export function openAiModelSupportsApplyPatch(modelId: string): boolean { + const id = modelId.trim().toLowerCase(); + return ( + /^gpt-5\.(?:1|2|5)(?:-\d{4}-\d{2}-\d{2})?$/.test(id) || + /^gpt-5\.4(?:-(?:mini|nano|pro))?(?:-\d{4}-\d{2}-\d{2})?$/.test(id) || + /^gpt-5\.6(?:-(?:sol|terra|luna))?(?:-\d{4}-\d{2}-\d{2})?$/.test(id) + ); +} diff --git a/packages/runtime/src/path-containment.ts b/packages/runtime/src/path-containment.ts index 616e159689..fa1bca9b4c 100644 --- a/packages/runtime/src/path-containment.ts +++ b/packages/runtime/src/path-containment.ts @@ -115,6 +115,43 @@ export async function realpathAllowMissing(target: string): Promise { } } +export interface CanonicalDirectoryEntryTarget { + root: string; + path: string; + existingAncestor: string; +} + +/** + * Resolve a directory entry without following its final component. This is the + * shared identity for create/delete locking, permission checks, sandbox grants, + * and execution; content updates continue to use the followed target instead. + */ +export async function resolveCanonicalDirectoryEntryTarget( + cwd: string, + inputPath: string, +): Promise { + const root = await realpath(cwd); + const requested = isAbsolute(inputPath) ? resolve(inputPath) : resolve(root, inputPath); + const parent = await realpathAllowMissing(dirname(requested)); + let existingAncestor = parent; + while (true) { + try { + if ((await lstat(existingAncestor)).isDirectory()) break; + } catch (error) { + if (!isMissingPathError(error)) throw error; + } + const next = dirname(existingAncestor); + if (next === existingAncestor) + throw new Error(`No directory contains ${JSON.stringify(inputPath)}`); + existingAncestor = next; + } + return { + root, + path: resolve(parent, basename(requested)), + existingAncestor, + }; +} + /** Dangling links followed by {@link realpathAllowMissing} before it gives up. */ const MAX_DANGLING_SYMLINK_HOPS = 32; diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index c55e58cc3f..024e002c4f 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -125,11 +125,11 @@ export interface MakaTool

{ /** Optional trusted facts about the executor that runs this tool. */ executionFacts?: ToolExecutionFacts; /** - * Provider-native tool declaration. The provider executes this tool inside - * the primary model request; ToolRuntime must never dispatch `impl` for it. + * Provider-native tool declaration. Hosted tools execute at the provider; + * client-executed tools such as ApplyPatch still settle through ToolRuntime. */ providerTool?: { - readonly kind: 'openai-web-search' | 'anthropic-web-search-20250305'; + readonly kind: 'openai-apply-patch' | 'openai-web-search' | 'anthropic-web-search-20250305'; readonly searchContextSize?: 'low' | 'medium' | 'high'; readonly maxUses?: number; }; @@ -690,7 +690,12 @@ export class ToolRuntime { async settleToolCall(call: ResolvedMakaToolCall): Promise { const settlement = await this.settleToolCallRaw(call); const modelOutput = settlement.providerError - ? { type: 'error-text' as const, value: new Error(settlement.providerError).toString() } + ? call.tool.providerTool?.kind === 'openai-apply-patch' + ? { + type: 'json' as const, + value: { status: 'failed' as const, output: settlement.providerError }, + } + : { type: 'error-text' as const, value: new Error(settlement.providerError).toString() } : call.tool.toModelOutput ? await call.tool.toModelOutput({ toolCallId: call.toolCallId, diff --git a/packages/runtime/src/workspace-executor.ts b/packages/runtime/src/workspace-executor.ts index f161410e0a..ba544ad916 100644 --- a/packages/runtime/src/workspace-executor.ts +++ b/packages/runtime/src/workspace-executor.ts @@ -2,7 +2,12 @@ import { promises as fs } from 'node:fs'; import { exec } from 'node:child_process'; import { glob as nodeGlob } from 'node:fs/promises'; import { isAbsolute, resolve } from 'node:path'; -import { isPathInside, realpathAllowMissing } from './path-containment.js'; +import { + isPathInside, + realpathAllowMissing, + resolveCanonicalDirectoryEntryTarget, +} from './path-containment.js'; +import { createPatchedFile, updatePatchedFile } from './apply-patch-file.js'; import { promisify } from 'node:util'; import type { ToolExecutionFacts } from '@maka/core/permission'; import { runProcessWithBoundedTail, runShellWithBoundedTail } from './shell-exec.js'; @@ -81,6 +86,14 @@ export interface WorkspaceWriteFileResult { bytes: number; } +export type WorkspaceApplyPatchInput = WorkspaceResolvePathInput & + ({ action: 'create' | 'update'; diff: string } | { action: 'delete' }); + +export interface WorkspaceApplyPatchResult { + ok: true; + path: string; +} + /** * Which path space a resolution may land in. * @@ -106,6 +119,7 @@ export interface WorkspaceResolvePathResult { export interface WorkspaceWriteLockKeyInput { cwd: string; path: string; + semantics?: 'target' | 'entry'; } export interface WorkspaceWriteLockKeyResult { @@ -153,6 +167,10 @@ export interface WorkspaceWriteFileExecutor { writeFile(input: WorkspaceWriteFileInput): Promise; } +export interface WorkspaceApplyPatchExecutor { + applyPatch(input: WorkspaceApplyPatchInput): Promise; +} + export interface WorkspaceExistingPathResolver { resolveExistingPath(input: WorkspaceResolvePathInput): Promise; } @@ -206,7 +224,8 @@ export interface WorkspaceExecutor WorkspaceWriteExecutor, WorkspaceEditExecutor, WorkspaceGlobExecutor, - WorkspaceGrepExecutor {} + WorkspaceGrepExecutor, + Partial {} export class LocalWorkspaceExecutor implements WorkspaceExecutor { readonly facts = LOCAL_WORKSPACE_EXECUTOR_FACTS; @@ -256,6 +275,23 @@ export class LocalWorkspaceExecutor implements WorkspaceExecutor { }; } + async applyPatch(input: WorkspaceApplyPatchInput): Promise { + if (input.action !== 'update') { + const path = await resolveDirectoryEntryPathInScope( + input.cwd, + input.path, + input.label, + input.scope, + ); + if (input.action === 'create') await createPatchedFile(path, input.diff); + else await fs.unlink(path); + return { ok: true, path }; + } + const path = await resolveExistingPathInScope(input.cwd, input.path, input.label, input.scope); + await updatePatchedFile(path, input.diff); + return { ok: true, path }; + } + async resolveExistingPath(input: WorkspaceResolvePathInput): Promise { return { path: await resolveExistingPathInScope(input.cwd, input.path, input.label, input.scope), @@ -274,7 +310,11 @@ export class LocalWorkspaceExecutor implements WorkspaceExecutor { // the same lock. Escapes are rejected by the resolvers inside the lock, not // here. Sharing the canonicalisation is what keeps the lock-key space and // the resolved-path space from drifting apart. - return { key: (await canonicalPathUnderCwd(input.cwd, input.path)).path }; + const path = + input.semantics === 'entry' + ? (await resolveCanonicalDirectoryEntryTarget(input.cwd, input.path)).path + : (await canonicalPathUnderCwd(input.cwd, input.path)).path; + return { key: path }; } async globFiles(input: WorkspaceGlobInput): Promise { @@ -373,6 +413,18 @@ async function resolveExistingPathInScope( return assertInsideCwd(root, await fs.realpath(candidate), inputPath, label); } +async function resolveDirectoryEntryPathInScope( + cwd: string, + inputPath: string, + label: string, + scope: WorkspacePathScope, +): Promise { + const target = await resolveCanonicalDirectoryEntryTarget(cwd, inputPath); + return scope === 'host' + ? target.path + : assertInsideCwd(target.root, target.path, inputPath, label); +} + function assertInsideCwd( root: string, candidate: string,