diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d7fc755..562ae4f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -198,13 +198,13 @@ the same transaction as its Session and creation identity when this resource is implemented. Keep mutable connection/registration state out of immutable configuration; replacement ownership must fence stale observations. -The current delivery milestone is the Codex single-Agent path: Session +The accepted delivery baseline is the shared Codex/Claude single-Agent path: Session creation, environment preparation, native execution, files/artifacts, cancellation, reconnection/recovery queries, and standalone deployment acceptance. Select each bounded task from the complete board; nonblocking local improvements stay queued. Authentication, tenant/credential isolation, state consistency and data loss remain -material acceptance requirements. Other harnesses and protocol Subagent execution -are deferred without changing the complete pinned protocol target. +material acceptance requirements. Additional harness implementations and protocol Subagent execution +remain queued without changing the complete pinned protocol target. The current hosted architecture is V1: Core runs independently; each Environment sandbox contains its daemon, selected native harness, local tools and workspace. @@ -382,7 +382,7 @@ implementation behind its adapter. Core acts on verified capabilities and runtim conditions; a capability declaration alone never grants public feature admission. Extend existing interfaces during related functional work without introducing a second framework or a broad rewrite. The completed MVP remains Codex/Docker. -The next accepted engine profile is Claude SDK on the same dedicated Docker +Claude SDK is an equal first-class engine on the same dedicated Docker Runtime; qualify each image with the common full-loop acceptance before deploying. E2B, other engines and hosted remote-executor separation remain outside this batch. Later engines must satisfy the same applicable acceptance contract while keeping @@ -1928,6 +1928,34 @@ must close their query, await their native child and drain observations before publishing completion. Process groups are lifecycle supervision, not OS isolation or containment of descendants that deliberately leave the group. +### Harness qualification and onboarding + +Codex, Claude and future harnesses have equal architectural status. The common +Runtime wire protocol and Factory/Session/Prepared interfaces own lifecycle, +input receipts, cancellation, recovery and resource access; each native adapter +retains its implementation and model/tool loop. A new engine supplies an adapter, +a qualified profile in `services/agents-api/internal/engine`, registration and +an independently verified deployment. It does not add engine-name branches to +API handlers, persistence, dispatch or scheduling. + +The small static profile catalog owns engine-specific public admission and value +limits. Profile callbacks are pure and use existing public/protocol types; they +cannot query business data, decrypt credentials or control native processes. +Public schema validation, qualified engine support and actual Runtime capabilities +remain separate. Runtime advertisements alone never enable public operations. +Shared dispatch checks capability combinations, not a whitelist of engine names. +Use the same public acceptance assertions for both engines, retaining native +isolation tests where appropriate. Record unsupported or unverified combinations +as implementation gaps; do not reduce another engine's functionality to claim +parity or equate accepted parameters with applied native behavior. + +Claude hosted functions compose the existing SDK function bridge with the native +workspace sandbox. Only declared function tools and the verified native tool +inventory are available. The bundle advertises this combination separately from +basic workspace execution; older bundles cannot receive function preparations. +External hosted MCP remains unqualified. Function callbacks do not change file, +credential, history, subagent or network authority. + ### Claude dedicated Docker Runtime Build the pinned SDK bundle with `scripts/build-claude-sdk-runtime.sh`, then use @@ -1957,8 +1985,9 @@ The SDK adapter advertises `local_runtime_v1` only for its Linux bridge contract Registration combines that contract with the verified operator binding. Core uses an explicit accepted engine profile independently of advertisements. This profile supports native Bash/Read/Edit, preparation, shared Files/Artifacts, cancellation -and same-history continuation. Functions and MCP in this workspace profile remain -unqualified and reject before persistence; their existing `none` support is retained. +and same-history continuation. The separately advertised `workspace_functions` +combination supports declared public functions with text results. External HTTP +MCP remains unqualified here; its existing `none` support is retained. Native Bash network access uses the harness's HTTP proxy; no alternate networking or tool loop is implemented by Core. @@ -1987,8 +2016,8 @@ their streamed partial text. No phase is inferred from the final result. SDK/native child release and output draining precede daemon completion. `claudesdk.Config.Workspace` is a private, trusted operator binding for one -qualified placement. It enables only native Bash/Read/Edit in the existing SDK -loop. The entire factory must already run inside an outer mount/process boundary +qualified placement. It enables native Bash/Read/Edit and declared host functions +in the existing SDK loop. The entire factory must already run inside an outer mount/process boundary that excludes application, daemon and other-tenant credentials and host policy. The factory does not create that boundary. The dedicated Docker profile below selects this binding at startup; cwd and request options cannot select its policy. @@ -2011,7 +2040,7 @@ strict sandbox with no fallback or weaker isolation. Separate native file-tool permissions and a session tool hook restrict Read/Edit to the bound workspace and deny protected roots; background/unsandboxed Bash requests are rejected. The deployment must retain these controls, including the SDK-owned hook. -Functions, MCP and remote-environment combinations are rejected in this private +External MCP and remote-environment combinations remain rejected in this private profile until separately qualified. The existing `none` profile retains its behavior. Packaged `workspace_tools` establishes bridge support only, not host isolation or a public capability. The dedicated Runtime integration composes @@ -2213,7 +2242,7 @@ Operators may configure the daemon provider environment or the existing transien (`base_url` HTTPS and `bearer_token`). Core forwards these opaque options without persisting them in Session configuration. The adapter exclusively selects the provider environment and removes credentials from native tool environments. Product `claude_code` and product execution are unchanged. -The public profile accepts only +The `none` public profile accepts only text, explicit model/system instructions, managed state, exact native resume and declared functions with ordered text results, and the HTTP MCP subset described above. It rejects unsupported request diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation.go index c52cbba3..1a7eab65 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation.go @@ -56,6 +56,9 @@ func NewPreparationFactory(config Config) agent.PreparationFactory { if start.observeFunctions && !info.supportsWorkspaceCommands() { return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace command observations") } + if len(start.Functions) > 0 && !info.SupportsWorkspaceFunctions() { + return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace functions") + } s, err := launch(owner, snapshot, start, env) if err != nil { return nil, err diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go index 599dd8fe..d1deb39d 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go @@ -49,6 +49,10 @@ func (info RuntimeInfo) SupportsLocalRuntime() bool { return info.supportsWorkspaceCommands() && slices.Contains(info.Features, "local_runtime_v1") } +func (info RuntimeInfo) SupportsWorkspaceFunctions() bool { + return info.SupportsLocalRuntime() && slices.Contains(info.Features, "workspace_functions") +} + // CheckRuntime checks the packaged companion and exact execution entrypoint. // It does not create Session state, register an engine or make a model request. func CheckRuntime(ctx context.Context, config Config) (RuntimeInfo, error) { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index 63c6f841..1519beae 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -36,7 +36,7 @@ type workspaceProfile struct { } func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { - if req.DisableExecutionEnvironment || req.RemoteEnvironment != nil || len(req.FunctionTools) != 0 || req.MCPHTTPServers != nil { + if req.DisableExecutionEnvironment || req.RemoteEnvironment != nil || req.MCPHTTPServers != nil { return nil, nil, fmt.Errorf("claudesdk: workspace profile does not support the requested execution combination") } if req.WorkDir != "" && req.WorkDir != config.Workspace.Directory { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go index afa7d35f..5f6e9338 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go @@ -76,7 +76,7 @@ func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { } func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { - for _, name := range []string{"none", "remote", "work-dir", "function", "mcp", "caller-policy", "relative", "missing", "overlap", "symlink", "rule-pattern", "ambient-setting", "duplicate-env", "bad-env", "path-empty-component", "path-workspace", "code-in-workspace"} { + for _, name := range []string{"none", "remote", "work-dir", "mcp", "caller-policy", "relative", "missing", "overlap", "symlink", "rule-pattern", "ambient-setting", "duplicate-env", "bad-env", "path-empty-component", "path-workspace", "code-in-workspace"} { t.Run(name, func(t *testing.T) { config := workspaceFixture(t) req := workspaceRequest() @@ -87,8 +87,6 @@ func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { req.RemoteEnvironment = &proto.RemoteEnvironment{} case "work-dir": req.WorkDir = config.Workspace.ScratchDir - case "function": - req.FunctionTools = []proto.FunctionTool{{Name: "hello", Parameters: json.RawMessage(`{"type":"object"}`)}} case "mcp": req.MCPHTTPServers = &[]proto.MCPHTTPServer{} case "caller-policy": @@ -136,3 +134,16 @@ func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { }) } } + +func TestWorkspaceRetainsDeclaredFunctions(t *testing.T) { + config := workspaceFixture(t) + req := workspaceRequest() + req.FunctionTools = []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}} + start, _, err := prepare(config, req) + if err != nil { + t.Fatal(err) + } + if start.Workspace == nil || len(start.Functions) != 1 || start.Functions[0].Name != "lookup" || start.MCPHTTPServers != nil { + t.Fatal("workspace function declaration was not retained independently of external MCP") + } +} diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go index 544a76fe..c17aa75f 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -92,7 +92,7 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex return fail(fmt.Errorf("Claude SDK bundle does not support the local Runtime contract")) } caps := &out.Info.Capabilities - caps.EnvironmentNone, caps.FunctionTools = false, false + caps.EnvironmentNone, caps.FunctionTools = false, info.SupportsWorkspaceFunctions() caps.Preparation, caps.LocalEnvironment, caps.LocalEnvironmentNetworkPolicy = true, true, true caps.WorkspaceReadPreparation, caps.NativeSessionRecovery = true, true } diff --git a/apps/parsar-daemon/internal/dispatch/mcp_http.go b/apps/parsar-daemon/internal/dispatch/mcp_http.go index 68fdd129..5d754807 100644 --- a/apps/parsar-daemon/internal/dispatch/mcp_http.go +++ b/apps/parsar-daemon/internal/dispatch/mcp_http.go @@ -12,11 +12,11 @@ func validateMCPHTTP(req proto.PromptRequestPayload, caps proto.AgentKindCapabil if req.MCPHTTPServers == nil { return nil } - if req.RemoteEnvironment != nil && (req.AgentKind != "codex" || !caps.MCPHTTPTools || !caps.MCPHTTPRemoteEnvironment) { + if req.RemoteEnvironment != nil && (!caps.MCPHTTPTools || !caps.MCPHTTPRemoteEnvironment) { return errors.New("engine does not support service-side HTTP MCP with a remote environment") } for _, server := range *req.MCPHTTPServers { - if server.Required && (req.AgentKind != "codex" || !caps.MCPHTTPTools || !caps.MCPHTTPRequired || req.DisableExecutionEnvironment == (req.RemoteEnvironment != nil)) { + if server.Required && (!caps.MCPHTTPTools || !caps.MCPHTTPRequired || req.DisableExecutionEnvironment == (req.RemoteEnvironment != nil)) { return errors.New("engine does not support required service-side HTTP MCP initialization") } if server.BearerToken == nil { @@ -25,7 +25,7 @@ func validateMCPHTTP(req proto.PromptRequestPayload, caps proto.AgentKindCapabil if !caps.MCPHTTPTools || !caps.MCPHTTPBearerAuth { return errors.New("engine does not support authenticated HTTP MCP") } - if (req.AgentKind != "codex" && req.AgentKind != "claude_sdk") || req.DisableExecutionEnvironment == (req.RemoteEnvironment != nil) { + if req.DisableExecutionEnvironment == (req.RemoteEnvironment != nil) { return errors.New("authenticated HTTP MCP requires a supported service-side environment") } if req.RemoteEnvironment != nil { diff --git a/apps/parsar-daemon/internal/dispatch/mcp_http_test.go b/apps/parsar-daemon/internal/dispatch/mcp_http_test.go index 0265aedf..a5a1177d 100644 --- a/apps/parsar-daemon/internal/dispatch/mcp_http_test.go +++ b/apps/parsar-daemon/internal/dispatch/mcp_http_test.go @@ -72,7 +72,7 @@ func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { return nil, errors.New("controlled factory stop") }) err := h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "mcp-bearer", req)) - if called != (mode == "supported" || mode == "claude" || mode == "credential-free" || mode == "product" || mode == "required" || mode == "optional old peer") { + if called != (mode == "supported" || mode == "claude" || mode == "claude remote" || mode == "other engine" || mode == "credential-free" || mode == "product" || mode == "required" || mode == "optional old peer") { t.Fatal("wrong factory admission") } frames := h.sender.snapshot() @@ -143,7 +143,7 @@ func TestRemoteMCPRejectsBeforePreparationFactory(t *testing.T) { return nil, errors.New("controlled stop") }) err := h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "remote-mcp", req)) - allowed := mode == "supported" || mode == "no declaration" || mode == "bearer supported" || mode == "required" + allowed := mode == "supported" || mode == "other engine" || mode == "no declaration" || mode == "bearer supported" || mode == "required" if (err == nil) != allowed { t.Fatal("wrong preparation admission", err) } diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index d246810f..943b322d 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -24,6 +24,8 @@ in an execution adapter. The application core owns execution state and persisten engine-specific shapes stay at the adapter boundary. Codex uses its native app-server; Claude uses the maintained Agent SDK. Reuse native protocols and SDKs for further harnesses rather than adding another model/tool loop. +The [harness contract and parity baseline](harnesses.md) describes equal-engine +registration, qualification and shared acceptance. Verify configuration against actual execution: response defaults must not merely describe values the adapter never applied. diff --git a/contracts/agents-api/harnesses.md b/contracts/agents-api/harnesses.md new file mode 100644 index 00000000..c393e3cd --- /dev/null +++ b/contracts/agents-api/harnesses.md @@ -0,0 +1,95 @@ +# Native harness contract and qualification + +Codex, Claude Code and future harnesses are equal execution engines. Core owns +public protocol, authority and durable state. Each adapter owns its native +configuration, transport, process and model/tool loop. Supporting this contract +means implementing its observable semantics, not imitating Codex's native API. + +## Integration surface + +1. Implement the existing daemon `agent.Factory`/`Session` and, for prepared + environments, `PreparationFactory`/`Prepared` interfaces. Reuse the existing + `internal/agentdaemon/proto` requests, neutral events, input receipts and errors. +2. Register the factory, preparation factory and verified Runtime capabilities in + the daemon registry. Keep native translation inside the adapter. Dedicated local + environments reuse shared Files/write/export helpers and binding checks. +3. Add a pure qualified profile to `services/agents-api/internal/engine` and its + static catalog. Declare supported placements, public configuration/result limits + and required Runtime controls. A profile uses existing public/protocol types; + it has no database, credential-decryption or native-process responsibilities. +4. Supply the native deployment prerequisites and run common public acceptance. + No new handler, store table, scheduler, event projector or model loop is needed + for capabilities already represented by the contract. + +The catalog is the explicit service qualification boundary; a Runtime heartbeat +cannot authorize new public functionality. Unknown profiles fail closed. Schema +validity, qualified service support and the available Runtime remain independent +checks. Additional capability combinations require evidence, not an engine-name +exception. The static registry requires a build to add an implementation; dynamic +plugin loading and untrusted code execution are outside this design. + +New Session selection currently uses the operator's `AGENTS_API_ENGINE` setting; +existing Sessions retain their engine. The default is a deployment convenience, +not a different contract or authority level. There is no invented public `harness` +field. Future selection changes must respect the pinned public protocol. + +## Shared behavioral obligations + +- Preparation holds resources without consuming input; start transfers ownership + once. Unused preparation releases through `Close`; cancellation remains valid + across the transfer and reports settlement only after native effects stop. +- Confirm accepted/applied inputs separately. Preserve ordered public Items and + events, stable call identity and one terminal outcome. Never replay uncertain + work merely because a connection closed. +- Resume only the bound Session's native history. Missing, ambiguous or foreign + history fails closed. Device identity is not native Session ownership. +- Native tools and public Files operate on the same authorized workspace. + Generated code cannot access daemon/model credentials or foreign history. +- Emit verified measurements; absence of native usage detail is not a zero value. + Explicit unsupported operations remain implementation gaps in protocol coverage. + +## Current parity baseline + +The baseline is actual supported behavior on main, not everything Codex accepts +syntactically or everything either upstream harness can theoretically perform. + +| Operation | Codex | Claude Code | +| --- | --- | --- | +| Docker hosted text execution, native local tools | Qualified | Qualified | +| Files, immutable Artifacts, cancellation, restart/history recovery | Qualified | Qualified | +| Public functions in `none` | Qualified | Qualified; object-root schemas and text results | +| Public functions alongside hosted workspace tools | Qualified | Qualified; object-root schemas and text results | +| HTTP MCP and static-bearer Vault credentials in `none` | Qualified | Qualified subset | +| Required MCP initialization | Qualified | Gap; currently rejected | +| Hosted HTTP MCP | Gap | Gap | +| Function image results | Supported subset | Gap; currently rejected | +| Non-default verbosity | Native/model-dependent support | No equivalent qualified; medium only | +| Public detailed Usage | Supported native counters | Native raw usage retained; public breakdown gap | +| Official `self_hosted` remote executor path | Existing native Codex path | Not qualified; requires separate design | +| Explicit reasoning, structured output, enabled `multi_agent`, message images | Shared service gaps | Shared service gaps | + +Equal status does not justify silently dropping options, fabricating measurements, +weakening isolation or removing working Codex features. Native gaps must be solved +faithfully or remain explicit. User-managed colocated Runtime enrollment is a +separate queued feature; it is not a substitute for official `self_hosted`. + +## Common acceptance + +Use the pinned official Python SDK, raw HTTP and real model APIs. The common +`services/agents-api/tests/official_hosted_functions_native.py` assertions exercise +function success/error, native file output and public artifact bytes, same-history +continuation after restart, foreign result rejection and pending-call cancellation. +The operator fixture supplies only deployment/restart and model configuration; +public assertions are the same for both engines. Existing workflow, file, artifact +and interruption fixtures remain applicable. Native isolation canaries supplement +these tests; synthetic responses alone do not establish live qualification. + +The 2026-09-19 candidate passed the same hosted-function assertions with Codex +0.153.4 and Claude SDK 0.3.269/native 2.1.269 using real Kimi K3. Each run used +an independent Core, dedicated Agents API database and Docker Runtime. Cold +restart acceptance restores Core before restarting Runtime; daemon startup while +Core is unavailable is not qualified by this test. Evidence is retained under +`~/.parsar/remediation/20260919/harness-parity/` on the validation server. + +The broader protocol inventory remains in [README.md](README.md). Passing one +profile or these shared assertions does not establish complete compatibility. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index e94ba497..f55fe9b1 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -1935,10 +1935,11 @@ paths: events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace - profile currently rejects function and MCP tools. Idle Sessions provision - automatically; initial provisioning has no caller connection action. Network - defaults to enabled; disabled is also supported, while restricted domains - and populated startup installations are rejected. + profile supports non-deferred function tools with text results alongside native + workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; + initial provisioning has no caller connection action. Network defaults to + enabled; disabled is also supported, while restricted domains and populated + startup installations are rejected. parameters: - description: agents=v1 in: header diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index 55de2933..5c59507e 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -30,10 +30,12 @@ export type Event = | { type: "error"; code: "invalid_request" | "history_unavailable" | "execution_failed" | "cancelled" }; export async function execute(request: Start | Prepare, emit: (event: Event) => Promise, abort: AbortController, functions = new FunctionBridge(emit), inputs = new Inputs(request.type === "start" ? request.prompt : undefined), reads = new WorkspaceReads(emit, abort), directories = new WorkspaceDirectories(emit, abort)): Promise { - const workspace = request.workspace === undefined ? undefined : new WorkspaceProfile(request.cwd, request.workspace); + const definitions = (request.functions ?? []).map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.parameters })); + const names = definitions.map(tool => `mcp__functions__${tool.name}`); + const workspace = request.workspace === undefined ? undefined : new WorkspaceProfile(request.cwd, request.workspace, names); const commands = workspace ? new CommandObserver() : undefined; if (request.type === "prepare" && !workspace) throw new Error("invalid_request"); - if (workspace && ("functions" in request || "mcp_http_servers" in request)) throw new Error("invalid_request"); + if (workspace && "mcp_http_servers" in request) throw new Error("invalid_request"); if (request.require_history && !request.resume) { const recovered = await recoverSession(request.cwd); if (!recovered) { @@ -46,8 +48,6 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => await emit({ type: "error", code: "history_unavailable" }); return; } - const definitions = (request.functions ?? []).map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.parameters })); - const names = definitions.map(tool => `mcp__functions__${tool.name}`); const mcpServers: Record = Object.create(null); if (definitions.length) mcpServers.functions = createFunctionServer(definitions, functions.invoke); const profile = request.mcp_http_servers === undefined ? undefined : new MCPProfile(request.mcp_http_servers, names); @@ -72,7 +72,7 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => model: request.model, systemPrompt: request.system_prompt, ...(request.resume ? { resume: request.resume } : {}), - tools: [], mcpServers, allowedTools: profile?.allowed ?? names, strictMcpConfig: true, settingSources: [], + tools: [], allowedTools: profile?.allowed ?? names, strictMcpConfig: true, settingSources: [], ...(profile ? { agent: "parsar_root", disallowedTools: profile.denied, hooks: { PreToolUse: [{ hooks: [profile.beforeTool] }] }, @@ -82,6 +82,7 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => persistSession: true, includePartialMessages: true, abortController: abort, canUseTool: async () => ({ behavior: "deny", message: "Tools are unavailable in this execution profile." }), ...(workspace?.options ?? {}), + mcpServers, spawnClaudeCodeProcess: options => { const child = spawnNative(options); nativeAlive = true; diff --git a/packages/claude-sdk-adapter/src/request.ts b/packages/claude-sdk-adapter/src/request.ts index 84554fe3..90bf8469 100644 --- a/packages/claude-sdk-adapter/src/request.ts +++ b/packages/claude-sdk-adapter/src/request.ts @@ -38,7 +38,7 @@ export function parseRequest(line: string): Start | Prepare { parseHTTPServers(request.mcp_http_servers); const workspace = parseWorkspace(request.workspace, request.cwd); if (request.require_history && !workspace) throw new Error("invalid_request"); - if ((workspace && ("functions" in request || "mcp_http_servers" in request)) || + if ((workspace && "mcp_http_servers" in request) || (request.type === "prepare" && !workspace)) throw new Error("invalid_request"); return request as Start | Prepare; } diff --git a/packages/claude-sdk-adapter/src/runtime_check.ts b/packages/claude-sdk-adapter/src/runtime_check.ts index a351bb3d..39274768 100644 --- a/packages/claude-sdk-adapter/src/runtime_check.ts +++ b/packages/claude-sdk-adapter/src/runtime_check.ts @@ -45,7 +45,7 @@ try { assert.equal(smoke.error, undefined, "bridge_unavailable"); assert.equal(smoke.status, 0, "bridge_unavailable"); assert.deepEqual(JSON.parse(smoke.stdout), { type: "error", code: "invalid_request" }); - process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 1, features: [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); + process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 1, features: [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); } catch { // Native diagnostics can include operator environment; never forward them. process.stdout.write(JSON.stringify({ type: "runtime_unavailable" }) + "\n"); diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index 25352065..e866be98 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -61,7 +61,7 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin export class WorkspaceProfile { readonly options: Options; - constructor(private readonly cwd: string, config: Workspace) { + constructor(private readonly cwd: string, config: Workspace, private readonly functions: readonly string[] = []) { config = parseWorkspace(config, cwd)!; // SDK history lookup reads the bridge environment, independently of query.env. if (process.env.HOME !== config.home || process.env.CLAUDE_CONFIG_DIR !== config.state || @@ -78,7 +78,7 @@ export class WorkspaceProfile { } const protectedRoots = [config.home, config.state, ...config.protected_dirs]; this.options = { - env, tools: [...nativeTools], allowedTools: [], mcpServers: {}, strictMcpConfig: true, + env, tools: [...nativeTools], allowedTools: [...functions], mcpServers: {}, strictMcpConfig: true, settingSources: [], permissionMode: "default", persistSession: true, settings: { permissions: { @@ -105,8 +105,11 @@ export class WorkspaceProfile { } verify(tools: string[], servers: { name: string; status: string }[]): void { - if (servers.length || tools.length !== nativeTools.length || new Set(tools).size !== tools.length || - tools.some(name => !nativeTools.includes(name))) throw new Error("unexpected native workspace inventory"); + const expected = [...nativeTools, ...this.functions]; + if (servers.length !== (this.functions.length ? 1 : 0) || + servers.some(server => server.name !== "functions" || server.status !== "connected") || + tools.length !== expected.length || new Set(tools).size !== tools.length || + tools.some(name => !expected.includes(name))) throw new Error("unexpected native workspace inventory"); } readonly canUseTool: CanUseTool = async (name, input, { signal, agentID }) => { @@ -119,19 +122,20 @@ export class WorkspaceProfile { readonly beforeTool: HookCallback = async (input, id, { signal }) => { if (!signal.aborted && input.hook_event_name === "PreToolUse" && input.agent_id === undefined && (id === undefined || id === input.tool_use_id) && this.permits(input.tool_name, input.tool_input)) { - return input.tool_name === "Bash" ? {} : { hookSpecificOutput: { hookEventName: "PreToolUse", + return input.tool_name !== "Read" && input.tool_name !== "Edit" ? {} : { hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: this.absoluteInput(input.tool_name, input.tool_input as Record) } }; } return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: denial } }; }; private absoluteInput(name: string, input: Record): Record { - return name === "Bash" ? input : { ...input, file_path: resolve(this.cwd, input.file_path as string) }; + return name !== "Read" && name !== "Edit" ? input : { ...input, file_path: resolve(this.cwd, input.file_path as string) }; } private permits(name: string, value: unknown): boolean { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const input = value as Record; + if (this.functions.includes(name)) return true; if (name === "Bash") return typeof input.command === "string" && !!input.command.trim() && (input.run_in_background === undefined || input.run_in_background === false) && (input.dangerouslyDisableSandbox === undefined || input.dangerouslyDisableSandbox === false); diff --git a/packages/claude-sdk-adapter/tests/preparation.test.mjs b/packages/claude-sdk-adapter/tests/preparation.test.mjs index 93f0be8c..7de7c484 100644 --- a/packages/claude-sdk-adapter/tests/preparation.test.mjs +++ b/packages/claude-sdk-adapter/tests/preparation.test.mjs @@ -95,7 +95,8 @@ test("prepare validates a workspace-only immutable configuration and prompt-only const { root, request } = placement(); try { assert.deepEqual(parseRequest(JSON.stringify(request)), request); - for (const fields of [{ prompt: "" }, { prompt: "input" }, { workspace: undefined }, { functions: [] }, + assert.deepEqual(parseRequest(JSON.stringify({ ...request, functions: [] })), { ...request, functions: [] }); + for (const fields of [{ prompt: "" }, { prompt: "input" }, { workspace: undefined }, { mcp_http_servers: [] }, { env: {} }, { resume: "" }, { type: "prepared" }]) { assert.throws(() => parseRequest(JSON.stringify({ ...request, ...fields })), /invalid_request/); } diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs index 3bff5101..99331d8e 100644 --- a/packages/claude-sdk-adapter/tests/workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -24,10 +24,10 @@ function fixture(t) { return { root, dirs, config, request }; } -test("workspace is explicit, typed and incompatible with any function or MCP declaration", t => { +test("workspace is explicit, typed and rejects external MCP declarations", t => { const { config, request } = fixture(t); assert.deepEqual(parseStart(JSON.stringify(request)), request); - for (const fields of [{ functions: [] }, { mcp_http_servers: [] }, { functions: null }, { mcp_http_servers: null }]) { + for (const fields of [{ mcp_http_servers: [] }, { functions: null }, { mcp_http_servers: null }]) { assert.throws(() => parseStart(JSON.stringify({ ...request, ...fields })), /invalid_request/); } for (const workspace of [null, [], {}, { ...config, native: {} }, { ...config, env: {} }, @@ -175,3 +175,30 @@ test("dedicated Runtime carries an explicit native network policy", t => { const { workspace, ...none } = request; assert.throws(() => parseStart(JSON.stringify({ ...none, require_history: true })), /invalid_request/); }); + +test("workspace functions retain native sandbox and exact tool authority", async t => { + const { dirs, config, request } = fixture(t); + const functions = [{ name: "lookup", description: "Lookup", parameters: { type: "object" } }]; + assert.deepEqual(parseStart(JSON.stringify({ ...request, functions })).functions, functions); + const profile = new WorkspaceProfile(dirs.workspace, config, ["mcp__functions__lookup"]); + profile.verify(["Bash", "Read", "Edit", "mcp__functions__lookup"], [{ name: "functions", status: "connected" }]); + for (const servers of [[], [{ name: "functions", status: "failed" }], [{ name: "external", status: "connected" }]]) { + assert.throws(() => profile.verify(["Bash", "Read", "Edit", "mcp__functions__lookup"], servers)); + } + assert.throws(() => profile.verify(["Bash", "Read", "Edit", "mcp__functions__unknown"], [{ name: "functions", status: "connected" }])); + const controller = new AbortController(); + const input = { id: "fixture" }; + const options = { signal: controller.signal }; + assert.deepEqual(await profile.canUseTool("mcp__functions__lookup", input, options), { behavior: "allow", updatedInput: input }); + assert.equal((await profile.canUseTool("mcp__functions__unknown", input, options)).behavior, "deny"); + assert.equal((await profile.canUseTool("mcp__functions__lookup", input, { ...options, agentID: "child" })).behavior, "deny"); + const event = { hook_event_name: "PreToolUse", tool_name: "mcp__functions__lookup", tool_input: input, tool_use_id: "call" }; + assert.deepEqual(await profile.beforeTool(event, "call", options), {}); + assert.equal((await profile.beforeTool({ ...event, tool_name: "mcp__external__lookup" }, "call", options)).hookSpecificOutput.permissionDecision, "deny"); + assert.equal((await profile.canUseTool("Bash", { command: "true", dangerouslyDisableSandbox: true }, options)).behavior, "deny"); + assert.equal((await profile.canUseTool("Read", { file_path: dirs.state + "/history" }, options)).behavior, "deny"); + assert.equal(profile.options.sandbox.failIfUnavailable, true); + assert.equal(profile.options.sandbox.allowUnsandboxedCommands, false); + controller.abort(); + assert.equal((await profile.canUseTool("mcp__functions__lookup", input, options)).behavior, "deny"); +}); diff --git a/scripts/check-claude-sdk-runtime.mjs b/scripts/check-claude-sdk-runtime.mjs index 4751e066..a0db6e2f 100644 --- a/scripts/check-claude-sdk-runtime.mjs +++ b/scripts/check-claude-sdk-runtime.mjs @@ -23,7 +23,7 @@ assert.equal(probe.status, 0, "Exported runtime is unavailable"); const report = JSON.parse(probe.stdout); assert.equal(report.type, "runtime_ready"); assert.equal(report.protocol, 1); -assert.deepEqual(report.features, [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); +assert.deepEqual(report.features, [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); assert.equal(report.sdk, source.dependencies["@anthropic-ai/claude-agent-sdk"]); assert.equal(report.mcp, source.dependencies["@modelcontextprotocol/sdk"]); console.log(`Verified exported SDK ${report.sdk}, MCP ${report.mcp}, ${report.native}`); diff --git a/services/agents-api/deploy/claude/README.md b/services/agents-api/deploy/claude/README.md index 724494c5..10262274 100644 --- a/services/agents-api/deploy/claude/README.md +++ b/services/agents-api/deploy/claude/README.md @@ -42,8 +42,8 @@ Follow the existing Core setup for independent PostgreSQL credentials, migration API authentication and managed Runtime enrollment. Parsar is not a dependency. The supported hosted profile accepts text execution with medium verbosity and -native Bash/Read/Edit. Files and Artifacts use the shared public interfaces. -Workspace functions/MCP, multi-agent, structured output, user-managed enrollment +native Bash/Read/Edit and declared public functions with text results. Files and Artifacts use the shared public interfaces. +Workspace HTTP MCP, multi-agent, structured output, user-managed enrollment and other environment installations remain explicit gaps. The existing `environment:none` function/MCP profile is separate. This is not complete upstream protocol compatibility. @@ -71,8 +71,8 @@ confirms input receipts, and implements cancellation and preparation ownership. for output settlement. Reuse the shared process runner and existing error types. Native history belongs to the bound API Session, not the device descriptor. -Add the qualified placement and value constraints to the small execution -`acceptedEngine` declaration. That declaration gates public support independently +Add the qualified placement and value constraints to the qualified profile in +`services/agents-api/internal/engine/` and register it in its catalog. That declaration gates public support independently of Runtime capability flags. Do not add engine branches to handlers, stores, resource managers or scheduling. A genuinely new public capability may require a bounded extension of the shared contract; registration alone cannot make an diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index e99333e2..6d4dc415 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -111,7 +111,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, disabled multi_agent, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with required=false and separately advertised MCP/bearer runtime support. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile currently rejects function and MCP tools. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled is also supported, while restricted domains and populated startup installations are rejected. +// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, disabled multi_agent, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with required=false and separately advertised MCP/bearer runtime support. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled is also supported, while restricted domains and populated startup installations are rejected. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/engine/claude.go b/services/agents-api/internal/engine/claude.go new file mode 100644 index 00000000..95ae078e --- /dev/null +++ b/services/agents-api/internal/engine/claude.go @@ -0,0 +1,57 @@ +package engine + +import ( + "encoding/json" + "errors" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func claudeProfile() Profile { + return Profile{ + Placements: []string{"none", "openai_hosted"}, MCPBearer: true, + ValidateConfiguration: validateClaudeConfiguration, + ValidateTools: validateClaudeTools, + ValidateFunctionResult: func(content []proto.FunctionResultContent) error { + for _, part := range content { + if part.Type != "input_text" { + return ErrInvalidInput + } + } + return nil + }, + } +} + +func validateClaudeConfiguration(agent v1.Agent, environment *v1.Environment, hasDaemon bool) error { + if environment == nil || (environment.Type != "none" && environment.Type != "openai_hosted") || hasDaemon || strings.TrimSpace(agent.Model) == "" { + return ErrInvalidInput + } + if agent.Text.Verbosity != "" && agent.Text.Verbosity != "medium" { + return errors.New("The configured engine currently supports medium text verbosity only.") + } + if agent.MultiAgent.Enabled || agent.MultiAgent.MaxConcurrentSubagents != nil || agent.Reasoning.Effort != nil || agent.Reasoning.Summary != nil || (agent.ServiceTier != "" && agent.ServiceTier != "auto") || (agent.Text.Format.Type != "" && agent.Text.Format.Type != "text") { + return ErrInvalidInput + } + return nil +} + +func validateClaudeTools(environment *v1.Environment, _ bool, tools []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { + if environment.Type == "openai_hosted" && len(mcp) != 0 { + return errors.New("The configured workspace profile does not support HTTP MCP tools.") + } + if err := validateClaudeMCP(mcp); err != nil { + return err + } + for _, tool := range tools { + var schema struct { + Type string `json:"type"` + } + if json.Unmarshal(tool.Parameters, &schema) != nil || schema.Type != "object" { + return errors.New("The configured engine currently requires function schemas with root type object.") + } + } + return nil +} diff --git a/services/agents-api/internal/execution/claude_mcp.go b/services/agents-api/internal/engine/claude_mcp.go similarity index 98% rename from services/agents-api/internal/execution/claude_mcp.go rename to services/agents-api/internal/engine/claude_mcp.go index a879adb7..52db7c8b 100644 --- a/services/agents-api/internal/execution/claude_mcp.go +++ b/services/agents-api/internal/engine/claude_mcp.go @@ -1,4 +1,4 @@ -package execution +package engine import ( "errors" diff --git a/services/agents-api/internal/engine/codex.go b/services/agents-api/internal/engine/codex.go new file mode 100644 index 00000000..bb79af7d --- /dev/null +++ b/services/agents-api/internal/engine/codex.go @@ -0,0 +1,21 @@ +package engine + +import ( + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func codexProfile() Profile { + return Profile{ + Placements: []string{"none", "self_hosted", "openai_hosted"}, + WebSearchControl: true, TextVerbosity: true, MCPBearer: true, + ValidateTools: func(environment *v1.Environment, hasDaemon bool, _ []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { + if len(mcp) != 0 && (environment == nil || (environment.Type != "none" && environment.Type != "self_hosted") || hasDaemon) { + return errors.New("HTTP MCP execution currently requires the Codex service-side environment:none profile") + } + return nil + }, + } +} diff --git a/services/agents-api/internal/engine/profile.go b/services/agents-api/internal/engine/profile.go new file mode 100644 index 00000000..85cafe00 --- /dev/null +++ b/services/agents-api/internal/engine/profile.go @@ -0,0 +1,52 @@ +package engine + +import ( + "errors" + "slices" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +var ErrInvalidInput = errors.New("invalid engine configuration") + +// Profile records qualified public behavior, independently of Runtime advertisements. +type Profile struct { + Placements []string + WebSearchControl, TextVerbosity, MCPBearer bool + ValidateConfiguration func(agent v1.Agent, environment *v1.Environment, hasDaemon bool) error + ValidateTools func(environment *v1.Environment, hasDaemon bool, functions []proto.FunctionTool, mcp []proto.MCPHTTPServer) error + ValidateFunctionResult func([]proto.FunctionResultContent) error +} + +func (p Profile) Accepts(placement string) bool { + return slices.Contains(p.Placements, placement) +} + +type Catalog struct { + profiles map[string]Profile +} + +func NewCatalog(profiles map[string]Profile) Catalog { + c := Catalog{profiles: make(map[string]Profile, len(profiles))} + for kind, profile := range profiles { + profile.Placements = slices.Clone(profile.Placements) + c.profiles[kind] = profile + } + return c +} + +func (c Catalog) Lookup(kind string) (Profile, bool) { + profile, ok := c.profiles[kind] + profile.Placements = slices.Clone(profile.Placements) + return profile, ok +} + +var qualified = NewCatalog(map[string]Profile{ + "codex": codexProfile(), + "claude_sdk": claudeProfile(), +}) + +func Lookup(kind string) (Profile, bool) { + return qualified.Lookup(kind) +} diff --git a/services/agents-api/internal/engine/profile_test.go b/services/agents-api/internal/engine/profile_test.go new file mode 100644 index 00000000..1fbc1a13 --- /dev/null +++ b/services/agents-api/internal/engine/profile_test.go @@ -0,0 +1,28 @@ +package engine + +import "testing" + +func TestCatalogOwnsQualification(t *testing.T) { + placements := []string{"none"} + profiles := map[string]Profile{"fixture": {Placements: placements}} + catalog := NewCatalog(profiles) + placements[0] = "openai_hosted" + profiles["fixture"] = Profile{MCPBearer: true} + profiles["unqualified"] = Profile{Placements: []string{"none"}} + profile, ok := catalog.Lookup("fixture") + if !ok || !profile.Accepts("none") || profile.Accepts("openai_hosted") || profile.MCPBearer { + t.Fatal("caller changed catalog qualification") + } + profile.Placements[0] = "self_hosted" + profile.MCPBearer = true + again, _ := catalog.Lookup("fixture") + if !again.Accepts("none") || again.MCPBearer { + t.Fatal("lookup exposed mutable qualification") + } + if _, ok := catalog.Lookup("unqualified"); ok { + t.Fatal("caller registered an engine after catalog construction") + } + if _, ok := Lookup("fixture"); ok { + t.Fatal("fixture widened the service catalog") + } +} diff --git a/services/agents-api/internal/execution/engine_profile.go b/services/agents-api/internal/execution/engine_profile.go index 2e5e36d4..b4887388 100644 --- a/services/agents-api/internal/execution/engine_profile.go +++ b/services/agents-api/internal/execution/engine_profile.go @@ -3,68 +3,41 @@ package execution import ( "encoding/json" "errors" - "slices" - "strings" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) -// Accepted profiles are explicit public qualifications, not inferred from a -// Runtime advertisement. Native configuration and execution remain in adapters. -type engineProfile struct { - placements []string - validateConfiguration func(Snapshot) error - validateInputs func([]store.Input) error - webSearchControl, textVerbosity bool -} - -func (p engineProfile) accepts(placement string) bool { - return slices.Contains(p.placements, placement) -} -func acceptedEngine(engine string) (engineProfile, bool) { - switch engine { - case "codex": - return engineProfile{placements: []string{"none", "self_hosted", "openai_hosted"}, webSearchControl: true, textVerbosity: true}, true - case "claude_sdk": - return engineProfile{placements: []string{"none", "openai_hosted"}, validateConfiguration: validateClaudeConfiguration, validateInputs: validateClaudeInputs}, true - default: - return engineProfile{}, false +func profileError(err error) error { + if errors.Is(err, engine.ErrInvalidInput) { + return store.ErrInvalidInput } + return err } -func validateClaudeConfiguration(snapshot Snapshot) error { - agent := snapshot.Agent - if snapshot.Environment == nil || (snapshot.Environment.Type != "none" && snapshot.Environment.Type != "openai_hosted") || snapshot.Daemon != nil || strings.TrimSpace(agent.Model) == "" { - return store.ErrInvalidInput - } - if agent.Text.Verbosity != "" && agent.Text.Verbosity != "medium" { - return errors.New("The configured engine currently supports medium text verbosity only.") - } - if agent.MultiAgent.Enabled || agent.MultiAgent.MaxConcurrentSubagents != nil || agent.Reasoning.Effort != nil || agent.Reasoning.Summary != nil || (agent.ServiceTier != "" && agent.ServiceTier != "auto") || (agent.Text.Format.Type != "" && agent.Text.Format.Type != "text") { - return store.ErrInvalidInput - } - tools, mcp, err := executionTools(agent.Tools) - if err != nil { - return err - } - if snapshot.Environment.Type == "openai_hosted" && (len(tools) != 0 || len(mcp) != 0) { - return errors.New("The configured workspace profile does not support function or MCP tools.") +func validateProfileConfiguration(profile engine.Profile, snapshot Snapshot) error { + if profile.ValidateConfiguration != nil { + if err := profile.ValidateConfiguration(snapshot.Agent, snapshot.Environment, snapshot.Daemon != nil); err != nil { + return profileError(err) + } } - if err := validateClaudeMCP(mcp); err != nil { + functions, mcp, err := executionTools(snapshot.Agent.Tools) + // Preserve each profile's admission error precedence when tool decoding fails. + if profile.ValidateConfiguration != nil && err != nil { return err } - for _, tool := range tools { - var schema struct { - Type string `json:"type"` - } - if json.Unmarshal(tool.Parameters, &schema) != nil || schema.Type != "object" { - return errors.New("The configured engine currently requires function schemas with root type object.") + if profile.ValidateTools != nil { + if validationErr := profile.ValidateTools(snapshot.Environment, snapshot.Daemon != nil, functions, mcp); validationErr != nil { + return profileError(validationErr) } } - return nil + return err } -func validateClaudeInputs(inputs []store.Input) error { +func validateProfileInputs(profile engine.Profile, inputs []store.Input) error { + if profile.ValidateFunctionResult == nil { + return nil + } for _, input := range inputs { if input.Kind != "tool_result" { continue @@ -77,10 +50,8 @@ func validateClaudeInputs(inputs []store.Input) error { if err != nil { return store.ErrInvalidInput } - for _, part := range result.Content { - if part.Type != "input_text" { - return store.ErrInvalidInput - } + if err := profile.ValidateFunctionResult(result.Content); err != nil { + return profileError(err) } } return nil diff --git a/services/agents-api/internal/execution/engine_profile_test.go b/services/agents-api/internal/execution/engine_profile_test.go index 7ea0489a..1981c60e 100644 --- a/services/agents-api/internal/execution/engine_profile_test.go +++ b/services/agents-api/internal/execution/engine_profile_test.go @@ -2,7 +2,13 @@ package execution import ( "encoding/json" + "errors" "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) func TestAcceptedEnginePlacements(t *testing.T) { @@ -21,11 +27,55 @@ func TestAcceptedEnginePlacements(t *testing.T) { } } +func TestAdditionalProfileUsesCommonAdmission(t *testing.T) { + configurationChecked, toolsChecked, resultChecked := false, false, false + catalog := engine.NewCatalog(map[string]engine.Profile{"fixture": { + Placements: []string{"none"}, + ValidateConfiguration: func(agent v1.Agent, environment *v1.Environment, hasDaemon bool) error { + configurationChecked = true + if agent.Model != "fixture" || environment == nil || hasDaemon { + return engine.ErrInvalidInput + } + return nil + }, + ValidateTools: func(_ *v1.Environment, _ bool, tools []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { + toolsChecked = true + if len(tools) != 1 || tools[0].Name != "echo" || len(mcp) != 0 { + t.Fatal("common tool decoding did not reach profile") + } + return nil + }, + ValidateFunctionResult: func(content []proto.FunctionResultContent) error { + resultChecked = true + if len(content) != 1 || content[0].Text == nil || *content[0].Text != "response" { + t.Fatal("common result decoding did not reach profile") + } + return engine.ErrInvalidInput + }, + }}) + profile, ok := catalog.Lookup("fixture") + if !ok || !profile.Accepts("none") || profile.Accepts("openai_hosted") { + t.Fatal("fixture qualification was not selected") + } + snapshot := Snapshot{Agent: v1.Agent{Model: "fixture", Tools: []json.RawMessage{json.RawMessage(`{"type":"function","name":"echo","parameters":{"type":"object"}}`)}}, Environment: &v1.Environment{Type: "none"}} + if err := validateProfileConfiguration(profile, snapshot); err != nil || !configurationChecked || !toolsChecked { + t.Fatal("additional engine admission failed", err) + } + snapshot.Agent.Model = "invalid" + if err := validateProfileConfiguration(profile, snapshot); !errors.Is(err, store.ErrInvalidInput) { + t.Fatal("profile configuration lost public error mapping", err) + } + inputs := []store.Input{{Kind: "tool_result", Payload: json.RawMessage(`{"call_id":"call","result":{"success":true,"output":"response"}}`)}} + if err := validateProfileInputs(profile, inputs); !errors.Is(err, store.ErrInvalidInput) || !resultChecked { + t.Fatal("profile result lost public error mapping", err) + } +} + func TestClaudeHostedToolsRequireSeparateQualification(t *testing.T) { - for _, tools := range []string{`[{"type":"function","name":"f","parameters":{"type":"object"},"defer_loading":false}]`, `[{"type":"mcp","server_label":"s","transport":{"type":"http","server_url":"https://example.test/mcp"},"connection_origin":"service"}]`} { + for index, tools := range []string{`[{"type":"function","name":"f","parameters":{"type":"object"},"defer_loading":false}]`, `[{"type":"mcp","server_label":"s","transport":{"type":"http","server_url":"https://example.test/mcp"},"connection_origin":"service"}]`} { for _, placement := range []string{"none", "openai_hosted"} { raw := json.RawMessage(`{"agent":{"model":"fixture","tools":` + tools + `},"environment":{"type":"` + placement + `"}}`) - if err := ValidateSessionConfiguration("claude_sdk", raw); (err == nil) != (placement == "none") { + if err := ValidateSessionConfiguration("claude_sdk", raw); (err == nil) != (placement == "none" || index == 0) { t.Fatalf("%s: %v", placement, err) } } diff --git a/services/agents-api/internal/execution/mcp_support.go b/services/agents-api/internal/execution/mcp_support.go index c104ede4..e8a74e6d 100644 --- a/services/agents-api/internal/execution/mcp_support.go +++ b/services/agents-api/internal/execution/mcp_support.go @@ -5,22 +5,17 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + engineprofile "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) -// Public acceptance is separate from private adapter capability. Enabling a -// profile requires official-client and real execution validation for that path. -var publicMCPBearerProfiles = map[string]bool{ - "codex": true, - "claude_sdk": true, -} - func mcpCredentialBindings(engine string, snapshot Snapshot) (map[string]store.MCPCredentialBinding, error) { selected, err := selectedMCPCredentials(snapshot) if err != nil { return nil, err } - if len(selected) > 0 && !publicMCPBearerProfiles[engine] { + profile, qualified := engineprofile.Lookup(engine) + if len(selected) > 0 && (!qualified || !profile.MCPBearer) { return nil, errors.New("The configured engine currently supports anonymous HTTP MCP only.") } return selected, nil diff --git a/services/agents-api/internal/execution/support.go b/services/agents-api/internal/execution/support.go index 7ec4a0d0..0808f7b4 100644 --- a/services/agents-api/internal/execution/support.go +++ b/services/agents-api/internal/execution/support.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + engineprofile "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -17,8 +18,8 @@ func ValidateSessionConfiguration(engine string, configuration json.RawMessage) if json.Unmarshal(configuration, &snapshot) != nil { return store.ErrInvalidInput } - profile, ok := acceptedEngine(engine) - if !ok || (snapshot.Environment != nil && !profile.accepts(snapshot.Environment.Type)) { + profile, ok := engineprofile.Lookup(engine) + if !ok || (snapshot.Environment != nil && !profile.Accepts(snapshot.Environment.Type)) { return store.ErrInvalidInput } _, err := mcpCredentialBindings(engine, snapshot) @@ -29,8 +30,6 @@ func ValidateSessionConfiguration(engine string, configuration json.RawMessage) if snapshot.Daemon != nil || strings.TrimSpace(snapshot.Agent.Model) == "" || !path.IsAbs(snapshot.Environment.WorkspaceDirectory) || strings.ContainsAny(snapshot.Environment.WorkspaceDirectory, "\x00\r\n\\") || len(snapshot.Environment.CapabilityDirectories) != 0 { return store.ErrInvalidInput } - _, _, err := executionTools(snapshot.Agent.Tools) - return err } if snapshot.Environment != nil && snapshot.Environment.Type == "openai_hosted" { // Only this placement/engine combination has current native qualification. @@ -43,14 +42,7 @@ func ValidateSessionConfiguration(engine string, configuration json.RawMessage) return store.ErrInvalidInput } } - if profile.validateConfiguration == nil { - _, mcp, err := executionTools(snapshot.Agent.Tools) - if len(mcp) != 0 && (snapshot.Environment == nil || snapshot.Environment.Type != "none" || snapshot.Daemon != nil) { - return errors.New("HTTP MCP execution currently requires the Codex service-side environment:none profile") - } - return err - } - return profile.validateConfiguration(snapshot) + return validateProfileConfiguration(profile, snapshot) } func canAdmitInputs(engine string, configuration json.RawMessage) bool { @@ -62,14 +54,11 @@ func canAdmitInputs(engine string, configuration json.RawMessage) bool { } func validateEngineInputs(engine string, inputs []store.Input) error { - profile, ok := acceptedEngine(engine) + profile, ok := engineprofile.Lookup(engine) if !ok { return store.ErrInvalidInput } - if profile.validateInputs != nil { - return profile.validateInputs(inputs) - } - return nil + return validateProfileInputs(profile, inputs) } // engineCapabilities is shared by device selection and the final preclaim check. @@ -78,12 +67,12 @@ func engineCapabilities(peer *gateway.Session, engine string, snapshot Snapshot) fail := func(message string) (device.KindCapabilities, error) { return device.KindCapabilities{}, errors.New(message) } - profile, ok := acceptedEngine(engine) - if !ok || (snapshot.Environment != nil && !profile.accepts(snapshot.Environment.Type)) { + profile, ok := engineprofile.Lookup(engine) + if !ok || (snapshot.Environment != nil && !profile.Accepts(snapshot.Environment.Type)) { return fail("execution engine placement is not supported") } - if profile.validateConfiguration != nil { - if err := profile.validateConfiguration(snapshot); err != nil { + if profile.ValidateConfiguration != nil { + if err := validateProfileConfiguration(profile, snapshot); err != nil { return device.KindCapabilities{}, err } } @@ -95,10 +84,10 @@ func engineCapabilities(peer *gateway.Session, engine string, snapshot Snapshot) if !caps.ExecutionControls { return fail("device must advertise execution_controls") } - if profile.webSearchControl && !caps.WebSearchControl { + if profile.WebSearchControl && !caps.WebSearchControl { return fail("device must advertise web_search_control") } - if profile.textVerbosity && !caps.TextVerbosity { + if profile.TextVerbosity && !caps.TextVerbosity { return fail("device must advertise text_verbosity") } if !caps.ToolObservations { diff --git a/services/agents-api/tests/official_hosted_functions_native.py b/services/agents-api/tests/official_hosted_functions_native.py new file mode 100644 index 00000000..8c7becbc --- /dev/null +++ b/services/agents-api/tests/official_hosted_functions_native.py @@ -0,0 +1,109 @@ +"""Common real-harness acceptance for hosted functions and workspace execution.""" + +import importlib.metadata +import json +from pathlib import Path +import time +import uuid + + +def verify_hosted_functions(client, foreign, http, model, restart, evidence): + """restart(session_id, environment_id) restarts the operator-owned deployment.""" + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] and source["vcs_info"]["commit_id"] == pin["commit"] + sessions = client.beta.agents.sessions + endpoint = str(client.base_url).rstrip("/") + "/agents/sessions" + headers = {"Authorization": "Bearer " + client.api_key, "OpenAI-Beta": "agents=v1"} + marker = "function-memory-" + uuid.uuid4().hex + private_error = "private-handler-" + uuid.uuid4().hex + calls, observed, checks = [], [], [] + session = sessions.create(agent={"model": model, "instructions": "Follow the requested tool calls exactly. Never repeat a failed call.", "tools": [{ + "type": "function", "name": "lookup", "description": "Retrieve the requested test value.", + "parameters": {"type": "object", "properties": {"key": {"type": "string"}}, "required": ["key"], "additionalProperties": False}, + }]}, environment={"type": "openai_hosted"}) + + def until(predicate, timeout=120): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = predicate() + if value: + return value + time.sleep(0.2) + raise AssertionError("Public workflow did not reach expected state") + + def items(): + response = http.get(endpoint + "/" + session.id + "/items", headers=headers, params={"limit": 100, "order": "asc"}) + assert response.status_code == 200 + raw = response.json()["data"] + assert raw == [item.to_dict() for item in sessions.items.list(session.id, limit=100, order="asc").data] + return raw + + def invoke(text, key, handler): + with sessions.stream(session.id, input=text, tool_handlers={"lookup": handler}, idempotency_key=key, timeout=240) as stream: + events = [event.to_dict() for event in stream] + observed.append(events) + terminals = [e for e in events if e["type"] in ("agent.session.turn.completed", "agent.session.turn.failed", "agent.session.turn.cancelled")] + assert len(terminals) == 1 and terminals[0]["type"] == "agent.session.turn.completed" + assert events[-1]["type"] == "agent.session.idle" + function = [e for e in events if e["type"] == "agent.session.turn.item.added" and e["item"]["type"] == "function_call"] + result = [e for e in events if e["type"] == "agent.session.turn.item.added" and e["item"]["type"] == "function_call_output"] + assert len(function) == len(result) == 1 + assert function[0]["item"]["call_id"] == result[0]["item"]["call_id"] + assert events.index(function[0]) < events.index(result[0]) < events.index(terminals[0]) < len(events) - 1 + assert sessions.retrieve(session.id).required_actions == [] + return terminals[0]["turn"]["id"], result[0]["item"] + + try: + def success(arguments): + assert arguments == {"key": "success"} + calls.append(arguments) + return marker + + turn, result = invoke("Call lookup once with key success. Remember its returned string in conversation. Then use the native shell to create /workspace/outputs/function.txt containing exactly that string, with no newline. Do not call any other function.", "function-success", success) + assert result["output"] == marker and "error" not in result + artifacts = list(sessions.artifacts.list(session.id, limit=100)) + output = [a for a in artifacts if a.turn_id == turn and a.path == "/workspace/outputs/function.txt"] + assert len(output) == 1 + with sessions.artifacts.with_streaming_response.content(output[0].id, session_id=session.id) as response: + assert response.read() == marker.encode() + listing = client.beta.agents.environments.files.list(session.environment.id, path="/workspace/outputs") + assert any(f.path == "/workspace/outputs/function.txt" for f in listing) + assert any(i["type"] == "function_call_output" and i.get("output") == marker for i in items()) + checks.append("same_turn_function_native_file_and_public_artifact") + + restart(session.id, session.environment.id) + + def failure(arguments): + assert arguments == {"key": "failure"} + calls.append(arguments) + raise RuntimeError(private_error) + + _, result = invoke("Call lookup exactly once with key failure. If it fails, do not retry. Then reply with the exact string returned by lookup in our first turn, using conversation history and no file tools.", "function-failure", failure) + assert result["error"] == "Tool handler failed." and "output" not in result + messages = [i for i in items() if i["type"] == "message" and i["role"] == "assistant"] + assert marker in "\n".join(p.get("text", "") for p in messages[-1]["content"]) + assert len(calls) == 2 + checks.append("cold_history_continuation_and_public_handler_error") + + sessions.events.create(session.id, events=[{"type": "agent.session.input.message", "input": [{"role": "user", "content": [{"type": "input_text", "text": "Call lookup once with key pending and wait for the result."}]}]}], idempotency_key="function-pending") + until(lambda: sessions.retrieve(session.id).required_actions) + pending = [i for i in items() if i["type"] == "function_call"][-1] + foreign_headers = {**headers, "Authorization": "Bearer " + foreign.api_key} + result_event = {"type": "agent.session.input.tool_result", "call_id": pending["call_id"], "turn_id": sessions.turns.list(session.id).data[0].id, "success": True, "output": "foreign-value"} + response = http.post(endpoint + "/" + session.id + "/events", headers=foreign_headers, json={"events": [result_event]}) + assert response.status_code == 404 + cancel = [{"type": "agent.session.input.cancel"}] + sessions.events.create(session.id, events=cancel, idempotency_key="cancel-pending") + sessions.events.create(session.id, events=cancel, idempotency_key="cancel-pending") + until(lambda: len(sessions.turns.list(session.id).data) == 3 and sessions.turns.list(session.id).data[0].status == "cancelled") + assert sessions.retrieve(session.id).required_actions == [] + assert not any(i["type"] == "function_call_output" and i["call_id"] == pending["call_id"] for i in items()) + checks.append("pending_function_tenant_isolation_and_cancel_retry") + proof = {"checks": checks, "session": session.id, "calls": calls, "events": observed, "items": items()} + assert private_error not in json.dumps(proof) + Path(evidence).write_text(json.dumps(proof, indent=2)) + return checks + finally: + sessions.delete(session.id)