diff --git a/docs/guides/configure-an-agent.zh-CN.md b/docs/guides/configure-an-agent.zh-CN.md index 5fc706a..0b55a98 100644 --- a/docs/guides/configure-an-agent.zh-CN.md +++ b/docs/guides/configure-an-agent.zh-CN.md @@ -190,6 +190,18 @@ agents: | `allow` | 自动允许,Agent 使用时无需人工确认 | | `ask` | 每次使用前需要人工确认 | +`tools.default_permission` 控制所有已启用内置工具的默认权限,省略时为 `allow`。 +`permissions` 只覆盖其中明确列出的工具;工具名匹配不区分大小写和分隔符, +例如 `WebSearch` 与 `web_search` 等价。引用未在 `tools.builtin` 中启用的工具会在校验时报错。 + +```yaml +tools: + builtin: [Read, Bash, WebSearch] + default_permission: allow + permissions: + bash: ask +``` + ## Drift Detection 与 Refresh `plan` 和 `apply` 默认会先刷新远端状态。对于支持完整 drift detection 的资源,OpenAgentPack 会读取远端可比较内容;如果有人绕过 OpenAgentPack 在控制台或 API 中修改了资源,`plan` 会显示 `Remote drift detected`。 @@ -517,7 +529,9 @@ Deployment 运行时可挂载的资源: |---------|------|------| | `file` | `file_id` / `source` / `mount_path` | 文件资源;`file_id` 引用已上传文件;`source` 为本地路径,Qoder 和 Claude 在 `apply` 时上传 | | `memory_store` | `memory_store` / `access` / `instructions` | 引用 Memory Store;`access` 为 `read_write`(默认)或 `read_only` | -| `github_repository` | `url` / `checkout` / `mount_path` / `authorization_token` | 检出 Git 仓库(Qoder 和 Claude) | +| `github_repository` | `url` / `checkout` / `mount_path` / `authorization_token` | 检出 Git 仓库(Qoder 和 Claude)。Qoder 的挂载路径必须以 `/data/` 开头;省略时自动传递 `/data/workspace/<仓库名>` | + +各 Provider 的挂载根目录是固定约束:Qoder 为 `/data`,Claude 为 `/workspace`,百炼和 Ark 为 `/mnt`。上传文件使用相对路径时,OpenAgentPack 会在目标 Provider 的根目录下解析;显式绝对路径必须已经位于对应根目录,并保持原样传递,前缀不匹配时直接报错,不做静默改写。GitHub Session 资源省略 `mount_path` 时,Qoder 自动使用 `/data/workspace/<仓库名>`,Claude 自动使用 `/workspace/<仓库名>`。 ### 运行与查看 diff --git a/docs/guides/run-sessions.md b/docs/guides/run-sessions.md index 16cbd51..a02d550 100644 --- a/docs/guides/run-sessions.md +++ b/docs/guides/run-sessions.md @@ -46,7 +46,33 @@ agents session delete ## What a session binds -A Managed Session binds an Agent + environment + vaults + memory stores + files. A Qoder Forward Session binds a Template + Identity; the Template already owns its environment, tunnel, vault, and MCP configuration. `session create` lets callers override the relevant runtime bindings. +A Managed Session binds an Agent + environment + vaults + memory stores + files + declared resources. A Qoder Forward Session binds a Template + Identity; the Template already owns its environment, tunnel, vault, and MCP configuration. `session create` lets callers override the relevant runtime bindings. + +To mount a private GitHub repository in every managed Session, declare a provider-neutral resource on the Agent. Qoder and Claude receive their respective API wire shapes from the same configuration: + +```yaml +agents: + assistant: + # model, instructions, environment, ... + resources: + - type: github_repository + url: ${GITHUB_REPOSITORY_URL} + authorization_token: ${GITHUB_TOKEN} + checkout: { branch: main } +``` + +Store both variables in `.env` (which is gitignored). The token must be able to read the repository. `checkout` and `mount_path` are optional in the portable declaration. For Qoder, OpenAgentPack always sends a path under `/data`: when omitted it derives `/data/workspace/` from `url`; an explicit Qoder `mount_path` must start with `/data/`. This is required for Qoder to materialize the repository in the Session environment. Other providers retain their own path semantics. + +Provider mount roots are fixed and OpenAgentPack applies the same policy to wire requests and prompt file hints: + +| Provider | Required mount root | Default GitHub repository path | +| --- | --- | --- | +| Qoder | `/data` | `/data/workspace/` | +| Claude | `/workspace` | `/workspace/` | +| Bailian | `/mnt` | GitHub Session resources unsupported | +| Ark | `/mnt` | GitHub Session resources unsupported | + +An explicit absolute path must already use the target provider's root and is passed through unchanged; an absolute path under another root is a validation error. A relative uploaded-file path is resolved under the provider root. Portable configurations should normally omit repository `mount_path` and let the provider adapter derive it. ## Programmatic usage diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 699e695..f06101d 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -241,11 +241,12 @@ agents: environment: tunnel: # optional; Qoder BYOC tunnel name provider: - tools: { builtin: [...], mcp: [...], permissions: {...} } + tools: { builtin: [...], default_permission: allow, mcp: [...], permissions: {...} } mcp_servers: [ { name, type?, url? } ] skills: [ | { type, skill_id, version? } ] vault: memory_stores: [ ] + resources: [ SessionResource ] multiagent: { type: "coordinator", agents: [...] } metadata: { : } ``` @@ -258,16 +259,38 @@ agents: | `tunnel` | string | no | Qoder BYOC tunnel name from `tunnels`; unsupported for other providers. | | `provider` | string | no | Pin the agent to one provider. | | `tools.builtin` | string[] | yes (in `tools`) | Lowercase tool names. | -| `tools.permissions` | map | no | Per-tool permission policy. | +| `tools.default_permission` | `"allow"` \| `"ask"` | no | Permission inherited by enabled builtins; defaults to `allow`. | +| `tools.permissions` | map | no | Case- and separator-insensitive overrides for enabled builtins. Unknown and duplicate normalized names are rejected. | | `tools.mcp[]` | McpToolkitDecl[] | no | Select tools from an official MCP server. | | `mcp_servers[]` | `{ name, type?, url? }` | no | URL (`url`/`http`) or `official` MCP server. | | `skills[]` | string \| AgentSkillRef | no | Skill name or `{ type: "official"\|"custom", skill_id, version? }`. | | `vault` | string | no | Vault name. | | `memory_stores` | string[] | no | Bound memory stores. | +| `resources` | SessionResource[] | no | Resources attached to every managed Session created for the Agent. | | `multiagent.type` | `"coordinator"` | no | Declare a coordinator agent. | | `multiagent.agents` | string[] | yes (with multiagent) | Agents it orchestrates. | | `metadata` | map | no | Free-form metadata. | +### Session resources + +Qoder and Claude managed Sessions support a provider-neutral GitHub repository resource: + +```yaml +agents: + reviewer: + # ... + resources: + - type: github_repository + url: https://github.com/acme/private-repo.git + authorization_token: ${GITHUB_TOKEN} + checkout: { branch: main } # or: { commit: } + mount_path: /data/workspace/private-repo # optional +``` + +Keep `authorization_token` in `.env`; never put its value directly in `agents.yaml`. Qoder mount paths must start with `/data/`. If the field is omitted for Qoder, OpenAgentPack sends `/data/workspace/` automatically; for the URL above that is `/data/workspace/private-repo`. Qoder requires this `/data` path for the repository mount to take effect. Other providers retain their own path semantics. + +Mount roots are provider invariants: Qoder uses `/data`, Claude uses `/workspace`, and Bailian and Ark use `/mnt`. A relative uploaded-file path is resolved under the target root. An explicit absolute path must already use the matching root and is passed through unchanged; OpenAgentPack rejects mismatched absolute paths instead of silently rewriting them. For GitHub Session resources, when the path is omitted Qoder derives `/data/workspace/` and Claude derives `/workspace/`. + ### MCP toolkit (`tools.mcp[]`) ```yaml diff --git a/examples/README.md b/examples/README.md index e4105dd..847c24c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -21,6 +21,7 @@ examples/ │ ├── with-skills/ skill + file-referenced instructions │ ├── with-mcp/ MCP server + vault + restricted network │ ├── with-memory/ persistent memory store (beta) +│ ├── github-session/ private GitHub repository mounted into each Session │ ├── multiagent/ coordinator multi-agent (Claude only) │ ├── multi-provider/ same agent on both Claude + Qoder │ ├── deployment/ schedule + outcome rubric (native) @@ -31,6 +32,7 @@ examples/ │ ├── with-mcp/ MCP server + vault + restricted network │ ├── with-memory/ persistent memory store │ ├── with-vault/ vault only +│ ├── github-session/ private GitHub repository mounted into each Session │ ├── vault-only/ vault-only project │ ├── multi-provider/ same agent on both Claude + Qoder │ ├── deployment/ schedule + memory_store (native) @@ -65,6 +67,7 @@ extensions, and live-test commands. | Multi-Agent | unsupported | unsupported | native | native | Claude and Volcengine Ark support coordinator. | | Deployment | emulated | native | native | emulated | Qoder and Claude schedule server-side; Bailian and Ark expand into a session at `run` time. | | Session | native | native | native | native | All four support runtime sessions. | +| GitHub Session resource | unsupported | native | native | unsupported | Qoder and Claude clone and mount repositories at Session creation. | ## Tool naming differences @@ -103,3 +106,10 @@ agents session list --agent assistant # filter by agent agents session get sess_abc123 # session detail agents session delete sess_abc123 # delete a session ``` + +GitHub Session examples require `GITHUB_REPOSITORY_URL` and a read-capable +`GITHUB_TOKEN` in `.env`. See `qoder/github-session` and `claude/github-session`. + +Qoder repository mounts must live under `/data`. Its example uses an explicit +`/data/workspace/repository`; when `mount_path` is omitted, OpenAgentPack sends +`/data/workspace/` automatically. diff --git a/examples/claude/github-session/agents.yaml b/examples/claude/github-session/agents.yaml new file mode 100644 index 0000000..be7d991 --- /dev/null +++ b/examples/claude/github-session/agents.yaml @@ -0,0 +1,32 @@ +version: "1" + +providers: + claude: + api_key: ${ANTHROPIC_API_KEY} + +defaults: + provider: claude + +environments: + dev: + config: + type: cloud + networking: + type: unrestricted + +agents: + repository-reader: + description: "Read a private GitHub repository in a Claude managed Session" + model: claude-sonnet-4-6 + instructions: | + Inspect the mounted repository without modifying it. Report the current + branch, latest commit, and top-level files. + environment: dev + tools: + builtin: [read, glob, grep, bash] + default_permission: allow + resources: + - type: github_repository + url: ${GITHUB_REPOSITORY_URL} + authorization_token: ${GITHUB_TOKEN} + mount_path: /workspace/repository diff --git a/examples/qoder/github-session/agents.yaml b/examples/qoder/github-session/agents.yaml new file mode 100644 index 0000000..d97aac5 --- /dev/null +++ b/examples/qoder/github-session/agents.yaml @@ -0,0 +1,32 @@ +version: "1" + +providers: + qoder: + api_key: ${QODER_PAT} + +defaults: + provider: qoder + +environments: + dev: + config: + type: cloud + networking: + type: unrestricted + +agents: + repository-reader: + description: "Read a private GitHub repository in a Qoder managed Session" + model: ultimate + instructions: | + Inspect the mounted repository without modifying it. Report the current + branch, latest commit, and top-level files. + environment: dev + tools: + builtin: [read, glob, grep, bash] + default_permission: allow + resources: + - type: github_repository + url: ${GITHUB_REPOSITORY_URL} + authorization_token: ${GITHUB_TOKEN} + mount_path: /data/workspace/repository diff --git a/packages/sdk/src/internal/core/session-runtime.ts b/packages/sdk/src/internal/core/session-runtime.ts index 6140687..f765302 100644 --- a/packages/sdk/src/internal/core/session-runtime.ts +++ b/packages/sdk/src/internal/core/session-runtime.ts @@ -150,6 +150,7 @@ export async function createSessionForAgent( vaultIds: options.vaultIds, memoryStores: options.memoryStores, files: options.files, + resources: options.resources, title: options.title, metadata: options.metadata, }); @@ -173,6 +174,7 @@ export async function startSessionRun( vaultIds: options.vaultIds, memoryStores: options.memoryStores, files: options.files, + resources: options.resources, title: options.title, metadata: options.metadata, }); diff --git a/packages/sdk/src/internal/core/validate-config.ts b/packages/sdk/src/internal/core/validate-config.ts index 2ff289f..2bf9490 100644 --- a/packages/sdk/src/internal/core/validate-config.ts +++ b/packages/sdk/src/internal/core/validate-config.ts @@ -9,6 +9,7 @@ import { getProvider } from "../providers/registry.ts"; import type { ProjectConfig } from "../types/config.ts"; import type { Diagnostic } from "../types/plan.ts"; import type { ResourceAddress } from "../types/state.ts"; +import { providerMountPrefix } from "../utils/sandbox-mount.ts"; import { findMissingBailianMcpToolConfigs } from "../validation/bailian.ts"; export interface ValidateProjectConfigOptions { @@ -226,6 +227,50 @@ export function collectProviderCapabilities( for (const [name, agent] of Object.entries(config.agents ?? {})) { if (agent.provider && agent.provider !== providerName) continue; const delivery = agent.delivery?.[providerName]?.type ?? "managed"; + const address: ResourceAddress = { + type: delivery === "forward" ? "template" : "agent", + name, + provider: providerName, + }; + const asksForApproval = + agent.tools?.default_permission === "ask" || + Object.values(agent.tools?.permissions ?? {}).some((permission) => permission === "ask"); + if (asksForApproval && !def.features.tool_permissions) { + diagnostics.error( + `${providerName}.agent.tool_permissions.unsupported`, + `agent.${name}: provider '${providerName}' cannot enforce interactive tool permission 'ask'.`, + address, + ); + } + for (const resource of agent.resources ?? []) { + if (!def.features.session_resources.includes(resource.type)) { + diagnostics.error( + `${providerName}.agent.session_resource.${resource.type}.unsupported`, + `agent.${name}: provider '${providerName}' does not support Session resource type '${resource.type}'.`, + address, + ); + } + const mountPrefix = providerMountPrefix(providerName); + if ( + mountPrefix && + resource.mount_path && + resource.mount_path !== mountPrefix && + !resource.mount_path.startsWith(`${mountPrefix}/`) + ) { + diagnostics.error( + `${providerName}.agent.session_resource.mount_path.invalid`, + `agent.${name}: ${providerName} Session resource mount_path must start with '${mountPrefix}/'.`, + address, + ); + } + } + if (delivery === "forward" && agent.resources?.length) { + diagnostics.error( + `${providerName}.template.session_resources.unsupported`, + `agent.${name}: Forward delivery cannot attach Agent Session resources; use managed delivery.`, + address, + ); + } if (delivery === "forward" && !isSupported(caps, "template")) { diagnostics.error( `${providerName}.agent.delivery.forward.unsupported`, diff --git a/packages/sdk/src/internal/parser/schema.ts b/packages/sdk/src/internal/parser/schema.ts index 0eabae0..f25a735 100644 --- a/packages/sdk/src/internal/parser/schema.ts +++ b/packages/sdk/src/internal/parser/schema.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { canonicalToolName } from "../utils/tool-permissions.ts"; const networkingSchema = z.object({ type: z.enum(["unrestricted", "limited"]), @@ -168,11 +169,37 @@ const mcpToolkitSchema = z configs: t.configs, })); -const toolsSchema = z.object({ - builtin: z.array(z.string()), - mcp: z.array(mcpToolkitSchema).optional(), - permissions: z.record(z.string(), z.enum(["allow", "ask"])).optional(), -}); +const toolsSchema = z + .object({ + builtin: z.array(z.string()), + default_permission: z.enum(["allow", "ask"]).optional(), + mcp: z.array(mcpToolkitSchema).optional(), + permissions: z.record(z.string(), z.enum(["allow", "ask"])).optional(), + }) + .superRefine((tools, ctx) => { + const enabled = new Set(tools.builtin.map(canonicalToolName)); + const seen = new Map(); + for (const key of Object.keys(tools.permissions ?? {})) { + const canonical = canonicalToolName(key); + const previous = seen.get(canonical); + if (previous) { + ctx.addIssue({ + code: "custom", + path: ["permissions", key], + message: `duplicates permission key '${previous}' after tool-name normalization`, + }); + } else { + seen.set(canonical, key); + } + if (!enabled.has(canonical)) { + ctx.addIssue({ + code: "custom", + path: ["permissions", key], + message: `references tool '${key}' which is not enabled in tools.builtin`, + }); + } + } + }); const multiagentSchema = z.object({ type: z.literal("coordinator"), @@ -199,6 +226,19 @@ const agentDeliverySchema = z.object({ type: z.enum(["managed", "forward"]), }); +const sessionGithubRepoResourceSchema = z.object({ + type: z.literal("github_repository"), + url: z.string().url(), + checkout: z + .object({ branch: z.string().min(1).optional(), commit: z.string().min(1).optional() }) + .refine((value) => !(value.branch && value.commit), { + message: "checkout accepts either branch or commit, not both", + }) + .optional(), + mount_path: z.string().optional(), + authorization_token: z.string().min(1), +}); + const agentSchema = z.object({ name: z.string().optional(), description: z.string().optional(), @@ -212,6 +252,7 @@ const agentSchema = z.object({ skills: z.array(z.union([z.string(), agentSkillRefSchema])).optional(), vault: z.string().optional(), memory_stores: z.array(z.string()).optional(), + resources: z.array(sessionGithubRepoResourceSchema).optional(), multiagent: multiagentSchema.optional(), metadata: z.record(z.string(), z.string()).optional(), delivery: z.record(z.string(), agentDeliverySchema).optional(), diff --git a/packages/sdk/src/internal/providers/ark/index.ts b/packages/sdk/src/internal/providers/ark/index.ts index ceb67a9..e5d7115 100644 --- a/packages/sdk/src/internal/providers/ark/index.ts +++ b/packages/sdk/src/internal/providers/ark/index.ts @@ -7,6 +7,7 @@ registerProvider({ name: "ark", configSchema: arkConfigSchema, capabilities: ARK_CAPABILITIES, + features: { tool_permissions: true, session_resources: [] }, createAdapter: (config, projectName) => { const c = config as ArkConfig; return new ArkAdapter(c.api_key, projectName); diff --git a/packages/sdk/src/internal/providers/ark/mapper.ts b/packages/sdk/src/internal/providers/ark/mapper.ts index 471c529..265b3ef 100644 --- a/packages/sdk/src/internal/providers/ark/mapper.ts +++ b/packages/sdk/src/internal/providers/ark/mapper.ts @@ -11,6 +11,8 @@ import type { SessionEventType } from "../../types/dto.ts"; import type { ManagedSessionBindings } from "../../types/session.ts"; import type { ProviderSessionEvent } from "../../types/session-event.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; +import { resolveSandboxMountPath } from "../../utils/sandbox-mount.ts"; +import { permissionOverridesFromWire, resolveBuiltinTools, toPermissionPolicy } from "../../utils/tool-permissions.ts"; import type { ResolvedAgentRefs, ResolvedDeploymentRefs } from "../interface.ts"; import { normalizeWireResourceName } from "../resource-naming.ts"; import { injectMetadata, secretPlaceholder } from "../sync-mapping.ts"; @@ -111,6 +113,7 @@ export function agentToDecl(raw: Record): Record | undefined; let builtinTools: string[] | undefined; + let builtinPermissions: Record | undefined; let allToolsEnabled = false; if (tools?.length) { const toolset = tools.find((t) => t.type === "agent_toolset_20260701"); @@ -119,9 +122,11 @@ export function agentToDecl(raw: Record): Record; if (configs.length > 0) { builtinTools = configs.filter((c) => c.enabled !== false).map((c) => c.name); + builtinPermissions = permissionOverridesFromWire(configs); } else if (defaultConfig?.enabled) { allToolsEnabled = true; } @@ -158,7 +163,7 @@ export function agentToDecl(raw: Record): Record | undefined; if (builtinTools?.length) { - toolsDecl = { builtin: builtinTools }; + toolsDecl = { builtin: builtinTools, permissions: builtinPermissions }; } else if (allToolsEnabled) { toolsDecl = { builtin: ["read", "write", "edit", "bash", "glob", "grep", "web_search", "web_fetch"], @@ -237,18 +242,11 @@ export function mapAgent( // Tools if (decl.tools) { - const toolConfigs = decl.tools.builtin - .filter((toolName) => ARK_BUILTINS.has(toolName)) - .map((toolName) => { - const permission = decl.tools?.permissions?.[toolName] ?? "allow"; - return { - name: toolName, - enabled: true, - permission_policy: { - type: permission === "ask" ? "always_ask" : "always_allow", - }, - }; - }); + const toolConfigs = resolveBuiltinTools(decl.tools, { supportedWireNames: ARK_BUILTINS }).map((tool) => ({ + name: tool.wireName, + enabled: true, + permission_policy: toPermissionPolicy(tool.permission), + })); body.tools = [ { type: "agent_toolset_20260701", @@ -437,7 +435,7 @@ export function mapSession(bindings: ManagedSessionBindings): unknown { resources.push({ type: "file", file_id: f.file_id, - mount_path: f.mount_path, + mount_path: resolveSandboxMountPath("ark", f.mount_path), }); if (resources.length) body.resources = resources; diff --git a/packages/sdk/src/internal/providers/bailian/index.ts b/packages/sdk/src/internal/providers/bailian/index.ts index dafdb61..60d41bc 100644 --- a/packages/sdk/src/internal/providers/bailian/index.ts +++ b/packages/sdk/src/internal/providers/bailian/index.ts @@ -7,6 +7,7 @@ registerProvider({ name: "bailian", configSchema: bailianConfigSchema, capabilities: BAILIAN_CAPABILITIES, + features: { tool_permissions: false, session_resources: [] }, createAdapter: (config, projectName) => { const c = config as BailianConfig; return new BailianAdapter(c.api_key, c.workspace_id, c.base_url, projectName); diff --git a/packages/sdk/src/internal/providers/bailian/mapper.ts b/packages/sdk/src/internal/providers/bailian/mapper.ts index ce0fee2..e0c302f 100644 --- a/packages/sdk/src/internal/providers/bailian/mapper.ts +++ b/packages/sdk/src/internal/providers/bailian/mapper.ts @@ -10,6 +10,8 @@ import type { } from "../../types/config.ts"; import type { ManagedSessionBindings } from "../../types/session.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; +import { resolveSandboxMountPath } from "../../utils/sandbox-mount.ts"; +import { resolveBuiltinTools } from "../../utils/tool-permissions.ts"; import type { ResolvedAgentRefs, ResolvedDeploymentRefs } from "../interface.ts"; import { injectMetadata, secretPlaceholder } from "../sync-mapping.ts"; @@ -234,12 +236,10 @@ export function mapAgent( // Tools: builtin_toolkit + mcp_toolkit blocks const BAILIAN_BUILTINS = new Set(["bash", "read", "write", "edit", "glob", "grep", "download_file"]); if (decl.tools) { - const toolConfigs = decl.tools.builtin - .filter((t) => BAILIAN_BUILTINS.has(t)) - .map((toolName) => ({ - name: toolName, - enabled: true, - })); + const toolConfigs = resolveBuiltinTools(decl.tools, { supportedWireNames: BAILIAN_BUILTINS }).map((tool) => ({ + name: tool.wireName, + enabled: true, + })); body.tools = [ { type: "builtin_toolkit", @@ -310,7 +310,13 @@ export function mapSession(bindings: ManagedSessionBindings): unknown { // User-uploaded files mount through the `resources` array as `{type:"file",file_id}`. if (bindings.vault_ids.length) body.vault_ids = bindings.vault_ids; const resources: Record[] = []; - for (const f of bindings.files ?? []) resources.push({ type: "file", file_id: f.file_id, mount_path: f.mount_path }); + for (const f of bindings.files ?? []) { + resources.push({ + type: "file", + file_id: f.file_id, + mount_path: resolveSandboxMountPath("bailian", f.mount_path), + }); + } if (resources.length) body.resources = resources; if (bindings.memory_store_ids.length) body.memory_store_ids = bindings.memory_store_ids; diff --git a/packages/sdk/src/internal/providers/capabilities.ts b/packages/sdk/src/internal/providers/capabilities.ts index 67a332e..7e906a3 100644 --- a/packages/sdk/src/internal/providers/capabilities.ts +++ b/packages/sdk/src/internal/providers/capabilities.ts @@ -22,6 +22,12 @@ export interface CapabilityEntry { export type ProviderCapabilities = Record; +/** Fine-grained behavior nested inside resource kinds, used for early config validation. */ +export interface ProviderFeatures { + tool_permissions: boolean; + session_resources: readonly "github_repository"[]; +} + /** * The lowest supported/unsupported decision, derived from the capability matrix. * A kind is supported when its tier is `native` or `emulated`. This is the single diff --git a/packages/sdk/src/internal/providers/claude/index.ts b/packages/sdk/src/internal/providers/claude/index.ts index 80afbc6..9ecb0ff 100644 --- a/packages/sdk/src/internal/providers/claude/index.ts +++ b/packages/sdk/src/internal/providers/claude/index.ts @@ -7,6 +7,7 @@ registerProvider({ name: "claude", configSchema: claudeConfigSchema, capabilities: CLAUDE_CAPABILITIES, + features: { tool_permissions: true, session_resources: ["github_repository"] }, createAdapter: (config, projectName) => { const c = config as ClaudeConfig; return new ClaudeAdapter(c.api_key, c.beta, projectName); diff --git a/packages/sdk/src/internal/providers/claude/mapper.ts b/packages/sdk/src/internal/providers/claude/mapper.ts index 42a0ba7..322320b 100644 --- a/packages/sdk/src/internal/providers/claude/mapper.ts +++ b/packages/sdk/src/internal/providers/claude/mapper.ts @@ -11,7 +11,10 @@ import type { SessionEventType } from "../../types/dto.ts"; import type { ManagedSessionBindings } from "../../types/session.ts"; import type { ProviderSessionEvent } from "../../types/session-event.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; +import { resolveSandboxMountPath } from "../../utils/sandbox-mount.ts"; +import { permissionOverridesFromWire, resolveBuiltinTools, toPermissionPolicy } from "../../utils/tool-permissions.ts"; import type { ResolvedAgentRefs, ResolvedDeploymentRefs } from "../interface.ts"; +import { mapGithubRepositorySessionResource, resolveGithubRepositoryMountPath } from "../session-resource-mapper.ts"; import { injectMetadata, secretPlaceholder } from "../sync-mapping.ts"; // Claude's builtin tool vocabulary. Generic/bailian-native tool names not in this set @@ -19,6 +22,11 @@ import { injectMetadata, secretPlaceholder } from "../sync-mapping.ts"; // rather than forwarded — the API rejects unknown tool names. const CLAUDE_BUILTINS = new Set(["read", "write", "edit", "bash", "glob", "grep", "web_search", "web_fetch"]); +/** Claude rejects the otherwise-valid GitHub clone URL form ending in `.git`. */ +export function normalizeGithubRepositoryUrlForClaude(url: string): string { + return url.replace(/\.git\/?$/, ""); +} + // --- Reverse mapping (remote -> agents.yaml decl), used by `agents sync` --- /** @@ -119,6 +127,7 @@ export function agentToDecl(raw: Record): Record | undefined; let allToolsEnabled = false; if (tools?.length) { const toolset = tools.find((t) => t.type === "agent_toolset_20260401"); @@ -127,9 +136,11 @@ export function agentToDecl(raw: Record): Record; if (configs.length > 0) { builtinTools = configs.filter((c) => c.enabled !== false).map((c) => c.name); + builtinPermissions = permissionOverridesFromWire(configs); } else if (defaultConfig?.enabled) { // All tools enabled by default, no specific configs listed allToolsEnabled = true; @@ -165,7 +176,7 @@ export function agentToDecl(raw: Record): Record | undefined; if (builtinTools?.length) { - toolsDecl = { builtin: builtinTools }; + toolsDecl = { builtin: builtinTools, permissions: builtinPermissions }; } else if (allToolsEnabled) { toolsDecl = { builtin: ["read", "write", "edit", "bash", "glob", "grep", "web_search", "web_fetch"], @@ -239,18 +250,11 @@ export function mapAgent( // Tools if (decl.tools) { - const toolConfigs = decl.tools.builtin - .filter((toolName) => CLAUDE_BUILTINS.has(toolName)) - .map((toolName) => { - const permission = decl.tools?.permissions?.[toolName] ?? "allow"; - return { - name: toolName, - enabled: true, - permission_policy: { - type: permission === "ask" ? "always_ask" : "always_allow", - }, - }; - }); + const toolConfigs = resolveBuiltinTools(decl.tools, { supportedWireNames: CLAUDE_BUILTINS }).map((tool) => ({ + name: tool.wireName, + enabled: true, + permission_policy: toPermissionPolicy(tool.permission), + })); body.tools = [ { type: "agent_toolset_20260401", @@ -406,7 +410,7 @@ function mapDeploymentResources( } else if (r.type === "github_repository") { const entry: Record = { type: "github_repository", - url: r.url, + url: normalizeGithubRepositoryUrlForClaude(r.url), }; if (r.authorization_token) entry.authorization_token = r.authorization_token; if (r.checkout?.branch) { @@ -559,8 +563,16 @@ export function mapSession(bindings: ManagedSessionBindings): unknown { resources.push({ type: "file", file_id: f.file_id, - mount_path: f.mount_path, + mount_path: resolveSandboxMountPath("claude", f.mount_path), }); + for (const resource of bindings.resources ?? []) { + resources.push( + mapGithubRepositorySessionResource(resource, { + mapUrl: normalizeGithubRepositoryUrlForClaude, + mapMountPath: (item) => resolveGithubRepositoryMountPath("claude", item), + }), + ); + } if (resources.length) body.resources = resources; return body; diff --git a/packages/sdk/src/internal/providers/qoder/index.ts b/packages/sdk/src/internal/providers/qoder/index.ts index 600c3e4..e96b4cc 100644 --- a/packages/sdk/src/internal/providers/qoder/index.ts +++ b/packages/sdk/src/internal/providers/qoder/index.ts @@ -7,6 +7,7 @@ registerProvider({ name: "qoder", configSchema: qoderConfigSchema, capabilities: QODER_CAPABILITIES, + features: { tool_permissions: true, session_resources: ["github_repository"] }, createAdapter: (config, projectName) => { const c = config as QoderConfig; return new QoderAdapter(c.api_key, c.gateway, projectName, c.forward_gateway); diff --git a/packages/sdk/src/internal/providers/qoder/mapper.ts b/packages/sdk/src/internal/providers/qoder/mapper.ts index 4c625f1..e06d552 100644 --- a/packages/sdk/src/internal/providers/qoder/mapper.ts +++ b/packages/sdk/src/internal/providers/qoder/mapper.ts @@ -14,7 +14,9 @@ import type { ManagedSessionBindings } from "../../types/session.ts"; import type { ProviderSessionEvent } from "../../types/session-event.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; import { resolveSandboxMountPath } from "../../utils/sandbox-mount.ts"; +import { permissionOverridesFromWire, resolveBuiltinTools, toPermissionPolicy } from "../../utils/tool-permissions.ts"; import type { ResolvedAgentRefs, ResolvedDeploymentRefs, ResolvedTemplateRefs } from "../interface.ts"; +import { mapGithubRepositorySessionResource, resolveGithubRepositoryMountPath } from "../session-resource-mapper.ts"; import { injectMetadata, secretPlaceholder, slug } from "../sync-mapping.ts"; // Qoder's API expects builtin tool names in PascalCase. The configuration layer @@ -179,13 +181,19 @@ export function agentToDecl(raw: Record): Record | undefined; if (tools?.length) { const toolset = tools.find((t) => t.type === "agent_toolset_20260401"); if (toolset && Array.isArray(toolset.enabled_tools)) { builtinTools = (toolset.enabled_tools as string[]).map((t) => normalizeToolNameFromQoder(t)); } else if (toolset) { - const configs = (toolset.configs ?? []) as Array<{ name: string; enabled?: boolean }>; + const configs = (toolset.configs ?? []) as Array<{ + name: string; + enabled?: boolean; + permission_policy?: unknown; + }>; builtinTools = configs.filter((c) => c.enabled !== false).map((c) => normalizeToolNameFromQoder(c.name)); + builtinPermissions = permissionOverridesFromWire(configs, normalizeToolNameFromQoder); } } @@ -212,7 +220,7 @@ export function agentToDecl(raw: Record): Record normalizeToolNameForQoder(t)); body.tools = [ { type: "agent_toolset_20260401", - enabled_tools: enabledTools, + configs: resolveBuiltinTools(decl.tools, { toWireName: normalizeToolNameForQoder }).map((tool) => ({ + name: tool.wireName, + enabled: true, + permission_policy: toPermissionPolicy(tool.permission), + })), }, ]; } else { @@ -470,19 +481,14 @@ export function mapForwardTemplate( else body.metadata = decl.metadata ?? {}; if (decl.tools) { - const permissions = decl.tools.permissions ?? {}; body.tools = [ { type: "agent_toolset_20260401", - configs: decl.tools.builtin.map((tool) => { - const normalized = normalizeToolNameForQoder(tool); - const policy = permissions[tool] ?? permissions[tool.toLowerCase()] ?? permissions[normalized]; - return { - name: normalized, - enabled: true, - ...(policy ? { permission_policy: { type: policy === "ask" ? "always_ask" : "always_allow" } } : {}), - }; - }), + configs: resolveBuiltinTools(decl.tools, { toWireName: normalizeToolNameForQoder }).map((tool) => ({ + name: tool.wireName, + enabled: true, + permission_policy: toPermissionPolicy(tool.permission), + })), }, ]; } else { @@ -646,6 +652,13 @@ export function mapSession(bindings: ManagedSessionBindings): unknown { for (const id of bindings.memory_store_ids) resources.push({ type: "memory_store", memory_store_id: id }); for (const f of bindings.files ?? []) resources.push({ type: "file", file_id: f.file_id, mount_path: resolveSandboxMountPath("qoder", f.mount_path) }); + for (const resource of bindings.resources ?? []) { + resources.push( + mapGithubRepositorySessionResource(resource, { + mapMountPath: (item) => resolveGithubRepositoryMountPath("qoder", item), + }), + ); + } if (resources.length) body.resources = resources; return body; diff --git a/packages/sdk/src/internal/providers/registry.ts b/packages/sdk/src/internal/providers/registry.ts index 308f8e8..1c75e8c 100644 --- a/packages/sdk/src/internal/providers/registry.ts +++ b/packages/sdk/src/internal/providers/registry.ts @@ -1,6 +1,6 @@ import type { z } from "zod"; import { UserError } from "../errors.ts"; -import type { ResourceKind } from "./capabilities.ts"; +import type { ProviderFeatures, ResourceKind } from "./capabilities.ts"; import { isSupported, type ProviderCapabilities, REQUIRED_METHODS_BY_KIND } from "./capabilities.ts"; import type { ProviderAdapter } from "./interface.ts"; @@ -8,6 +8,7 @@ export interface ProviderDefinition { name: string; configSchema: z.ZodType; capabilities: ProviderCapabilities; + features: ProviderFeatures; createAdapter(config: unknown, projectName?: string): ProviderAdapter; } diff --git a/packages/sdk/src/internal/providers/session-resource-mapper.ts b/packages/sdk/src/internal/providers/session-resource-mapper.ts new file mode 100644 index 0000000..0c8c329 --- /dev/null +++ b/packages/sdk/src/internal/providers/session-resource-mapper.ts @@ -0,0 +1,43 @@ +import { UserError } from "../errors.ts"; +import type { SessionGithubRepositoryResource } from "../types/session.ts"; +import { providerMountPrefix } from "../utils/sandbox-mount.ts"; + +export function resolveGithubRepositoryMountPath(provider: string, resource: SessionGithubRepositoryResource): string { + const prefix = providerMountPrefix(provider); + if (!prefix) throw new UserError(`Provider '${provider}' has no declared mount path prefix.`); + if (resource.mount_path) { + if (resource.mount_path !== prefix && !resource.mount_path.startsWith(`${prefix}/`)) { + throw new UserError(`${provider} GitHub Session resource mount_path must start with '${prefix}/'.`); + } + return resource.mount_path; + } + const repositoryName = new URL(resource.url).pathname + .split("/") + .filter(Boolean) + .at(-1) + ?.replace(/\.git$/i, ""); + if (!repositoryName) { + throw new UserError(`Cannot derive a ${provider} GitHub mount path from repository URL '${resource.url}'.`); + } + return provider === "qoder" ? `${prefix}/workspace/${repositoryName}` : `${prefix}/${repositoryName}`; +} + +/** Map the provider-neutral GitHub resource declaration to the shared CAS wire shape. */ +export function mapGithubRepositorySessionResource( + resource: SessionGithubRepositoryResource, + options: { + mapUrl?: (url: string) => string; + mapMountPath?: (resource: SessionGithubRepositoryResource) => string | undefined; + } = {}, +): Record { + const entry: Record = { + type: "github_repository", + url: options.mapUrl?.(resource.url) ?? resource.url, + authorization_token: resource.authorization_token, + }; + if (resource.checkout?.branch) entry.checkout = { type: "branch", name: resource.checkout.branch }; + else if (resource.checkout?.commit) entry.checkout = { type: "commit", sha: resource.checkout.commit }; + const mountPath = options.mapMountPath?.(resource) ?? resource.mount_path; + if (mountPath) entry.mount_path = mountPath; + return entry; +} diff --git a/packages/sdk/src/internal/session/session-manager.ts b/packages/sdk/src/internal/session/session-manager.ts index dc9a872..a6f7af0 100644 --- a/packages/sdk/src/internal/session/session-manager.ts +++ b/packages/sdk/src/internal/session/session-manager.ts @@ -1,8 +1,9 @@ import { resolveAgentMaterialization } from "../core/agent-materialization.ts"; import { UserError } from "../errors.ts"; import { requireRef } from "../executor/resolver.ts"; +import { getProvider } from "../providers/registry.ts"; import type { IStateManager } from "../state/state-manager.ts"; -import type { AgentDecl, ProjectConfig } from "../types/config.ts"; +import type { AgentDecl, ProjectConfig, SessionResourceDecl } from "../types/config.ts"; import type { SessionBindings } from "../types/session.ts"; export interface SessionCreateOptions { @@ -31,6 +32,8 @@ export interface SessionCreateOptions { memoryStores?: string[]; /** Uploaded files to mount as session resources, so the task can read the user's files. */ files?: { fileId: string; mountPath: string }[]; + /** Explicit resources override the resources declared on the agent. */ + resources?: SessionResourceDecl[]; title?: string; provider?: string; metadata?: Record; @@ -67,7 +70,21 @@ export function buildSessionBindings( const available = Object.keys(config.agents ?? {}).join(", "); throw new UserError(`Agent '${agentName}' not found in config. Available agents: ${available || "(none)"}`); } + const sessionResources = options.resources ?? agent.resources; + const providerFeatures = getProvider(provider)?.features; + for (const resource of sessionResources ?? []) { + if (!providerFeatures?.session_resources.includes(resource.type)) { + throw new UserError( + `Provider '${provider}' does not support Session resource type '${resource.type}' for agent '${agentName}'.`, + ); + } + } if (resolveAgentMaterialization(provider, agent).resourceType === "template") { + if (sessionResources?.length) { + throw new UserError( + `Forward session for '${agentName}' cannot attach Agent resources. Use managed delivery for GitHub repositories.`, + ); + } const templateId = requireRef(state, { type: "template", name: agentName, provider }); const defaultIdentity = config.defaults?.identity; const identityId = @@ -137,6 +154,7 @@ export function buildSessionBindings( vault_ids: vaultIds, memory_store_ids: memoryStoreIds, files: (options.files ?? []).map((f) => ({ file_id: f.fileId, mount_path: f.mountPath })), + resources: sessionResources, title: options.title, metadata: options.metadata, }; diff --git a/packages/sdk/src/internal/types/config.ts b/packages/sdk/src/internal/types/config.ts index 9af2dca..038db52 100644 --- a/packages/sdk/src/internal/types/config.ts +++ b/packages/sdk/src/internal/types/config.ts @@ -169,6 +169,8 @@ export interface AgentDecl { skills?: AgentSkillDecl[]; vault?: string; memory_stores?: string[]; + /** Resources mounted into every managed session created for this agent. */ + resources?: SessionResourceDecl[]; multiagent?: MultiagentDecl; metadata?: Record; /** Provider-specific remote materialization. Omitted means the existing managed Agent resource. */ @@ -204,6 +206,8 @@ export interface AgentSkillRefDecl { export interface AgentToolsDecl { builtin: string[]; + /** Permission inherited by every enabled builtin without an explicit override. */ + default_permission?: "allow" | "ask"; mcp?: AgentMcpToolkitDecl[]; permissions?: Record; } @@ -250,6 +254,14 @@ export type DeploymentResourceDecl = | DeploymentMemoryStoreResource | DeploymentGithubRepoResource; +/** Provider-neutral resources that can be attached directly when a session is created. */ +export interface SessionGithubRepoResourceDecl extends Omit { + /** Required for private repositories; interpolate this value from an environment variable. */ + authorization_token: string; +} + +export type SessionResourceDecl = SessionGithubRepoResourceDecl; + export interface DeploymentFileResource { type: "file"; file_id?: string; diff --git a/packages/sdk/src/internal/types/session.ts b/packages/sdk/src/internal/types/session.ts index bb96299..083da7f 100644 --- a/packages/sdk/src/internal/types/session.ts +++ b/packages/sdk/src/internal/types/session.ts @@ -5,9 +5,21 @@ export interface SessionFileResource { mount_path: string; } +export interface SessionGithubRepositoryResource { + type: "github_repository"; + url: string; + checkout?: { branch?: string; commit?: string }; + mount_path?: string; + authorization_token: string; +} + +export type SessionResource = SessionGithubRepositoryResource; + interface CommonSessionBindings { /** Uploaded files to mount in the session sandbox. */ files?: SessionFileResource[]; + /** Provider-neutral resources to mount when the session is created. */ + resources?: SessionResource[]; title?: string; metadata?: Record; } diff --git a/packages/sdk/src/internal/utils/sandbox-mount.ts b/packages/sdk/src/internal/utils/sandbox-mount.ts index 2a6ccd7..596624e 100644 --- a/packages/sdk/src/internal/utils/sandbox-mount.ts +++ b/packages/sdk/src/internal/utils/sandbox-mount.ts @@ -1,13 +1,18 @@ // Single source of truth for where an uploaded file actually lands inside the agent sandbox. // -// The mount_path the SDK *sends* is not the path the model *sees*: Agents backends (bailian, -// claude) prepend `/mnt/session` to it, while qoder mounts uploads under `/data/`. We resolve -// the real sandbox path here so both the wire mapper and the prompt hint stay in lockstep — -// the model is told the same path the file is actually written to. +// Every provider owns a fixed absolute mount root. Keep wire mapping and prompt hints on the +// same policy so the model is told the exact path sent to the provider. // // Pure string ops only (no `node:path`): this module ships to the browser via the SDK bundle. -const AGENTS_SESSION_PREFIX = "/mnt/session"; +import { UserError } from "../errors.ts"; + +const PROVIDER_MOUNT_PREFIXES: Readonly> = { + qoder: "/data", + claude: "/workspace", + bailian: "/mnt", + ark: "/mnt", +}; function joinAbsolute(prefix: string, sub: string): string { const left = prefix.replace(/\/+$/, ""); @@ -15,29 +20,24 @@ function joinAbsolute(prefix: string, sub: string): string { return `${left}/${right}`; } -function basename(p: string): string { - const trimmed = p.replace(/\/+$/, ""); - const idx = trimmed.lastIndexOf("/"); - return idx === -1 ? trimmed : trimmed.slice(idx + 1); +export function providerMountPrefix(provider: string): string | undefined { + return PROVIDER_MOUNT_PREFIXES[provider]; } /** * Map the SDK-sent `mount_path` to the absolute path the file occupies inside the sandbox. - * - ark / bailian / claude: backend self-prefixes `/mnt/session`, preserving subdirs. - * - qoder: uploads live under `/data/` (qoder's mount root; subdirs are flattened). + * Prefix provider-relative paths with the provider's fixed mount root. Paths that already + * use the correct root are preserved. * - unknown provider: return the path unchanged (conservative). */ export function resolveSandboxMountPath(provider: string, mountPath: string): string { - switch (provider) { - case "ark": - case "bailian": - case "claude": - return joinAbsolute(AGENTS_SESSION_PREFIX, mountPath); - case "qoder": - return joinAbsolute("/data", basename(mountPath)); - default: - return mountPath; + const prefix = providerMountPrefix(provider); + if (!prefix) return mountPath; + if (mountPath === prefix || mountPath.startsWith(`${prefix}/`)) return mountPath; + if (mountPath.startsWith("/")) { + throw new UserError(`${provider} mount_path must start with '${prefix}/'; received '${mountPath}'.`); } + return joinAbsolute(prefix, mountPath); } /** Files as carried by `SessionBindings.files` — only `mount_path` matters for the hint. */ diff --git a/packages/sdk/src/internal/utils/tool-permissions.ts b/packages/sdk/src/internal/utils/tool-permissions.ts new file mode 100644 index 0000000..f064931 --- /dev/null +++ b/packages/sdk/src/internal/utils/tool-permissions.ts @@ -0,0 +1,70 @@ +import type { AgentToolsDecl } from "../types/config.ts"; + +export type ToolPermission = "allow" | "ask"; + +export interface ResolvedBuiltinTool { + configuredName: string; + wireName: string; + permission: ToolPermission; +} + +/** Canonical comparison key: case- and separator-insensitive across provider vocabularies. */ +export function canonicalToolName(name: string): string { + return name + .trim() + .replace(/[^a-zA-Z0-9]+/g, "") + .toLowerCase(); +} + +/** Resolve provider wire names and permission overrides behind one shared interface. */ +export function resolveBuiltinTools( + tools: AgentToolsDecl, + options: { supportedWireNames?: ReadonlySet; toWireName?: (name: string) => string } = {}, +): ResolvedBuiltinTool[] { + const permissionByName = new Map(); + for (const [name, permission] of Object.entries(tools.permissions ?? {})) { + permissionByName.set(canonicalToolName(name), permission); + } + + const supportedByName = options.supportedWireNames + ? new Map([...options.supportedWireNames].map((name) => [canonicalToolName(name), name])) + : undefined; + + return tools.builtin.flatMap((configuredName) => { + const candidate = options.toWireName?.(configuredName) ?? configuredName; + const wireName = supportedByName?.get(canonicalToolName(candidate)) ?? (supportedByName ? undefined : candidate); + if (!wireName) return []; + return [ + { + configuredName, + wireName, + permission: permissionByName.get(canonicalToolName(configuredName)) ?? tools.default_permission ?? "allow", + }, + ]; + }); +} + +export function toPermissionPolicy(permission: ToolPermission): { type: "always_allow" | "always_ask" } { + return { type: permission === "ask" ? "always_ask" : "always_allow" }; +} + +/** Preserve remote permission policies when syncing provider tool configs back to YAML. */ +export function permissionOverridesFromWire( + configs: Array<{ name: string; enabled?: boolean; permission_policy?: unknown }>, + toConfigName: (name: string) => string = (name) => name, +): Record | undefined { + const permissions: Record = {}; + for (const config of configs) { + if (config.enabled === false) continue; + const rawPolicy = config.permission_policy; + const type = + typeof rawPolicy === "string" + ? rawPolicy + : rawPolicy && typeof rawPolicy === "object" + ? (rawPolicy as { type?: unknown }).type + : undefined; + if (type === "always_ask") permissions[toConfigName(config.name)] = "ask"; + else if (type === "always_allow") permissions[toConfigName(config.name)] = "allow"; + } + return Object.keys(permissions).length ? permissions : undefined; +} diff --git a/packages/sdk/tests/unit/map-session.test.ts b/packages/sdk/tests/unit/map-session.test.ts index 820e8dc..69e0c25 100644 --- a/packages/sdk/tests/unit/map-session.test.ts +++ b/packages/sdk/tests/unit/map-session.test.ts @@ -29,6 +29,61 @@ function minimalBindings(): SessionBindings { } describe("Qoder mapSession", () => { + test("maps a GitHub repository resource", () => { + const body = mapQoderSession({ + ...minimalBindings(), + resources: [ + { + type: "github_repository", + url: "https://github.com/acme/repo.git", + authorization_token: "secret", + checkout: { branch: "main" }, + mount_path: "/data/workspace/repo", + }, + ], + }) as Record; + expect(body.resources).toContainEqual({ + type: "github_repository", + url: "https://github.com/acme/repo.git", + authorization_token: "secret", + checkout: { type: "branch", name: "main" }, + mount_path: "/data/workspace/repo", + }); + }); + test("derives the required /data workspace mount when GitHub mount_path is omitted", () => { + const body = mapQoderSession({ + ...minimalBindings(), + resources: [ + { + type: "github_repository", + url: "https://github.com/acme/my-repo.git", + authorization_token: "secret", + }, + ], + }) as Record; + + expect(body.resources).toContainEqual({ + type: "github_repository", + url: "https://github.com/acme/my-repo.git", + authorization_token: "secret", + mount_path: "/data/workspace/my-repo", + }); + }); + test("rejects an explicit GitHub mount path outside /data", () => { + expect(() => + mapQoderSession({ + ...minimalBindings(), + resources: [ + { + type: "github_repository", + url: "https://github.com/acme/repo.git", + authorization_token: "secret", + mount_path: "/workspace/repo", + }, + ], + }), + ).toThrow("qoder GitHub Session resource mount_path must start with '/data/'."); + }); test("full bindings produce correct body with resources array", () => { const body = mapQoderSession(fullBindings()) as Record; @@ -74,6 +129,26 @@ describe("Qoder mapSession", () => { }); describe("Claude mapSession", () => { + test("maps a GitHub repository resource", () => { + const body = mapClaudeSession({ + ...minimalBindings(), + resources: [ + { + type: "github_repository", + url: "https://github.com/acme/repo.git", + authorization_token: "secret", + checkout: { commit: "0123456789abcdef" }, + }, + ], + }) as Record; + expect(body.resources).toContainEqual({ + type: "github_repository", + url: "https://github.com/acme/repo", + authorization_token: "secret", + checkout: { type: "commit", sha: "0123456789abcdef" }, + mount_path: "/workspace/repo", + }); + }); test("full bindings produce correct body with resources array", () => { const body = mapClaudeSession(fullBindings()) as Record; diff --git a/packages/sdk/tests/unit/provider-conformance.test.ts b/packages/sdk/tests/unit/provider-conformance.test.ts index 97754a4..248ab2d 100644 --- a/packages/sdk/tests/unit/provider-conformance.test.ts +++ b/packages/sdk/tests/unit/provider-conformance.test.ts @@ -91,6 +91,11 @@ for (const providerDef of conformingProviders) { } }); + test("fine-grained provider features are declared", () => { + expect(typeof providerDef.features.tool_permissions).toBe("boolean"); + expect(Array.isArray(providerDef.features.session_resources)).toBe(true); + }); + for (const kind of ALL_RESOURCE_KINDS) { const mapping = RESOURCE_KIND_METHODS[kind]; if (mapping.skip) continue; diff --git a/packages/sdk/tests/unit/qoder-examples.test.ts b/packages/sdk/tests/unit/qoder-examples.test.ts index 895ee43..2691205 100644 --- a/packages/sdk/tests/unit/qoder-examples.test.ts +++ b/packages/sdk/tests/unit/qoder-examples.test.ts @@ -3,7 +3,7 @@ import { resolve } from "node:path"; import { resolveAgentRefs } from "../../src/internal/executor/resolver.ts"; import { loadConfig } from "../../src/internal/parser/index.ts"; import { buildPlan } from "../../src/internal/planner/planner.ts"; -import { mapAgent } from "../../src/internal/providers/qoder/mapper.ts"; +import { agentToDecl, mapAgent } from "../../src/internal/providers/qoder/mapper.ts"; import { StateManager } from "../../src/internal/state/state-manager.ts"; import type { StateFile } from "../../src/internal/types/state.ts"; import "../../src/internal/providers/qoder/index.ts"; @@ -52,7 +52,9 @@ test("qoder bailian-cli example declares runnable core capabilities", async () = expect(body.tools).toEqual([ { type: "agent_toolset_20260401", - enabled_tools: ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch", "DeliverArtifacts"], + configs: ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch", "DeliverArtifacts"].map( + (name) => ({ name, enabled: true, permission_policy: { type: "always_allow" } }), + ), }, ]); expect(body.mcp_servers).toEqual([ @@ -72,3 +74,66 @@ test("qoder bailian-cli example declares runnable core capabilities", async () = "create:agent:bailian-cli", ]); }); + +test("qoder agent mapper preserves declared tool permission policies", () => { + const body = mapAgent( + "reader", + { + model: "auto", + instructions: "Read only", + tools: { + builtin: ["Read", "Bash"], + permissions: { Read: "allow", Bash: "ask" }, + }, + }, + { skill_ids: [], memory_store_ids: [], multiagent_agent_ids: [] }, + ) as Record; + expect(body.tools).toEqual([ + { + type: "agent_toolset_20260401", + configs: [ + { name: "Read", enabled: true, permission_policy: { type: "always_allow" } }, + { name: "Bash", enabled: true, permission_policy: { type: "always_ask" } }, + ], + }, + ]); +}); + +test("qoder permission overrides are case- and separator-insensitive", () => { + const body = mapAgent( + "reader", + { + model: "auto", + instructions: "Read only", + tools: { + builtin: ["Bash", "web_search"], + permissions: { bash: "ask", WebSearch: "ask" }, + }, + }, + { skill_ids: [] }, + ) as { tools: Array<{ configs: unknown[] }> }; + expect(body.tools[0]!.configs).toEqual([ + { name: "Bash", enabled: true, permission_policy: { type: "always_ask" } }, + { name: "WebSearch", enabled: true, permission_policy: { type: "always_ask" } }, + ]); +}); + +test("qoder sync preserves tool permission policies", () => { + const decl = agentToDecl({ + model: "auto", + system: "test", + tools: [ + { + type: "agent_toolset_20260401", + configs: [ + { name: "Read", enabled: true, permission_policy: { type: "always_allow" } }, + { name: "Bash", enabled: true, permission_policy: { type: "always_ask" } }, + ], + }, + ], + }); + expect(decl.tools).toEqual({ + builtin: ["read", "bash"], + permissions: { read: "allow", bash: "ask" }, + }); +}); diff --git a/packages/sdk/tests/unit/qoder-forward-template.test.ts b/packages/sdk/tests/unit/qoder-forward-template.test.ts index b486a9e..8ddd964 100644 --- a/packages/sdk/tests/unit/qoder-forward-template.test.ts +++ b/packages/sdk/tests/unit/qoder-forward-template.test.ts @@ -183,7 +183,7 @@ describe("Qoder Forward Template mapping and lifecycle", () => { enabled: true, permission_policy: { type: "always_ask" }, }, - { name: "Read", enabled: true }, + { name: "Read", enabled: true, permission_policy: { type: "always_allow" } }, ]); }); @@ -344,6 +344,18 @@ describe("Qoder Forward Template mapping and lifecycle", () => { }); describe("Forward delivery validation and runtime isolation", () => { + test("rejects Agent session resources because Forward sessions cannot attach them", () => { + const config = forwardConfig(); + config.agents!.assistant!.resources = [ + { + type: "github_repository", + url: "https://github.com/acme/repo.git", + authorization_token: "secret", + }, + ]; + const state = StateManager.initialize(tmpPath("forward-session-resources")); + expect(() => buildSessionBindings("assistant", config, "qoder", state)).toThrow(/managed delivery/); + }); test("rejects forward delivery on providers without the capability", () => { const config = forwardConfig(); config.providers = { bailian: {} }; diff --git a/packages/sdk/tests/unit/qoder-session-files.test.ts b/packages/sdk/tests/unit/qoder-session-files.test.ts index ecbbd1b..5ab606d 100644 --- a/packages/sdk/tests/unit/qoder-session-files.test.ts +++ b/packages/sdk/tests/unit/qoder-session-files.test.ts @@ -15,16 +15,16 @@ function bindings(overrides: Partial = {}): SessionBindings { } describe("qoder mapSession file resources", () => { - test("uploaded file mount_path is rewritten to /data/", () => { - const body = mapSession(bindings({ files: [{ file_id: "f1", mount_path: "/uploads/report.pdf" }] })) as { + test("uploaded file mount_path is prefixed with /data", () => { + const body = mapSession(bindings({ files: [{ file_id: "f1", mount_path: "uploads/report.pdf" }] })) as { resources?: { type: string; file_id: string; mount_path: string }[]; }; const file = body.resources?.find((r) => r.type === "file"); - expect(file?.mount_path).toBe("/data/report.pdf"); + expect(file?.mount_path).toBe("/data/uploads/report.pdf"); }); test("mapper and resolveSandboxMountPath agree (single source of truth)", () => { - const sent = "/uploads/nested/x.txt"; + const sent = "uploads/nested/x.txt"; const body = mapSession(bindings({ files: [{ file_id: "f1", mount_path: sent }] })) as { resources?: { type: string; mount_path: string }[]; }; diff --git a/packages/sdk/tests/unit/resolve-from-object.test.ts b/packages/sdk/tests/unit/resolve-from-object.test.ts index 12173b2..be9e6ce 100644 --- a/packages/sdk/tests/unit/resolve-from-object.test.ts +++ b/packages/sdk/tests/unit/resolve-from-object.test.ts @@ -40,3 +40,18 @@ test("inline instructions are a file-resolver no-op", async () => { const loaded = await resolveProjectConfigFromObject(raw, { projectName: "server" }); expect(loaded.config.agents?.designer?.instructions).toBe("inline instructions, no file reference"); }); + +test("rejects permission keys that do not identify an enabled builtin", async () => { + const raw = validRawConfig(); + (raw.agents.designer as Record).tools = { builtin: ["Read"], permissions: { Bash: "ask" } }; + await expect(resolveProjectConfigFromObject(raw, { projectName: "server" })).rejects.toThrow(/not enabled/); +}); + +test("rejects duplicate permission keys after normalization", async () => { + const raw = validRawConfig(); + (raw.agents.designer as Record).tools = { + builtin: ["WebSearch"], + permissions: { WebSearch: "allow", web_search: "ask" }, + }; + await expect(resolveProjectConfigFromObject(raw, { projectName: "server" })).rejects.toThrow(/duplicates/); +}); diff --git a/packages/sdk/tests/unit/sandbox-mount.test.ts b/packages/sdk/tests/unit/sandbox-mount.test.ts index 61b64b3..85d2693 100644 --- a/packages/sdk/tests/unit/sandbox-mount.test.ts +++ b/packages/sdk/tests/unit/sandbox-mount.test.ts @@ -8,19 +8,30 @@ import { } from "../../src/internal/utils/sandbox-mount.ts"; describe("resolveSandboxMountPath", () => { - test("bailian/claude prepend /mnt/session and preserve subdirs", () => { - expect(resolveSandboxMountPath("bailian", "/uploads/report.pdf")).toBe("/mnt/session/uploads/report.pdf"); - expect(resolveSandboxMountPath("claude", "/uploads/a/b.txt")).toBe("/mnt/session/uploads/a/b.txt"); + test("uses each provider's fixed mount prefix and preserves subdirs", () => { + expect(resolveSandboxMountPath("qoder", "uploads/report.pdf")).toBe("/data/uploads/report.pdf"); + expect(resolveSandboxMountPath("claude", "uploads/a/b.txt")).toBe("/workspace/uploads/a/b.txt"); + expect(resolveSandboxMountPath("bailian", "uploads/report.pdf")).toBe("/mnt/uploads/report.pdf"); + expect(resolveSandboxMountPath("ark", "uploads/report.pdf")).toBe("/mnt/uploads/report.pdf"); }); test("collapses duplicate slashes at the join", () => { - expect(resolveSandboxMountPath("bailian", "uploads/x.txt")).toBe("/mnt/session/uploads/x.txt"); - expect(resolveSandboxMountPath("claude", "//uploads//x.txt")).toBe("/mnt/session/uploads//x.txt"); + expect(resolveSandboxMountPath("bailian", "uploads/x.txt")).toBe("/mnt/uploads/x.txt"); + expect(resolveSandboxMountPath("claude", "uploads//x.txt")).toBe("/workspace/uploads//x.txt"); }); - test("qoder mounts under /data/, flattening subdirs", () => { - expect(resolveSandboxMountPath("qoder", "/uploads/report.pdf")).toBe("/data/report.pdf"); - expect(resolveSandboxMountPath("qoder", "/uploads/nested/x.txt")).toBe("/data/x.txt"); + test("rejects an absolute path under the wrong provider root", () => { + expect(() => resolveSandboxMountPath("qoder", "/workspace/report.pdf")).toThrow( + "qoder mount_path must start with '/data/'", + ); + expect(() => resolveSandboxMountPath("claude", "/mnt/report.pdf")).toThrow( + "claude mount_path must start with '/workspace/'", + ); + }); + + test("does not duplicate a correct prefix", () => { + expect(resolveSandboxMountPath("qoder", "/data/report.pdf")).toBe("/data/report.pdf"); + expect(resolveSandboxMountPath("claude", "/workspace/nested/x.txt")).toBe("/workspace/nested/x.txt"); expect(resolveSandboxMountPath("qoder", "report.pdf")).toBe("/data/report.pdf"); }); @@ -36,15 +47,15 @@ describe("composeFileMountHint", () => { }); test("lists resolved sandbox paths per provider", () => { - const hint = composeFileMountHint([{ mount_path: "/uploads/a.txt" }], "bailian"); - expect(hint).toContain("- /mnt/session/uploads/a.txt"); + const hint = composeFileMountHint([{ mount_path: "uploads/a.txt" }], "bailian"); + expect(hint).toContain("- /mnt/uploads/a.txt"); expect(hint).toContain("The user uploaded files"); }); test("multiple files each get a line", () => { - const hint = composeFileMountHint([{ mount_path: "/uploads/a.txt" }, { mount_path: "/uploads/b.txt" }], "qoder"); - expect(hint).toContain("- /data/a.txt"); - expect(hint).toContain("- /data/b.txt"); + const hint = composeFileMountHint([{ mount_path: "uploads/a.txt" }, { mount_path: "uploads/b.txt" }], "qoder"); + expect(hint).toContain("- /data/uploads/a.txt"); + expect(hint).toContain("- /data/uploads/b.txt"); }); }); @@ -55,16 +66,16 @@ describe("prependFileHint", () => { }); test("hint comes before the original prompt", () => { - const out = prependFileHint("summarize it", [{ mount_path: "/uploads/a.txt" }], "bailian"); + const out = prependFileHint("summarize it", [{ mount_path: "uploads/a.txt" }], "bailian"); expect(out.endsWith("\n\nsummarize it")).toBe(true); - expect(out.indexOf("/mnt/session/uploads/a.txt")).toBeLessThan(out.indexOf("summarize it")); + expect(out.indexOf("/mnt/uploads/a.txt")).toBeLessThan(out.indexOf("summarize it")); }); }); describe("rewriteFileMentions", () => { test("replaces sentinel with sandbox path", () => { - const prompt = `see ${"\u27E6file:/uploads/a.png\u27E7"} here`; - expect(rewriteFileMentions(prompt, "bailian")).toBe("see /mnt/session/uploads/a.png here"); + const prompt = `see ${"\u27E6file:uploads/a.png\u27E7"} here`; + expect(rewriteFileMentions(prompt, "bailian")).toBe("see /mnt/uploads/a.png here"); }); test("no sentinel → unchanged", () => { @@ -74,9 +85,9 @@ describe("rewriteFileMentions", () => { describe("preparePromptForProvider", () => { test("rewrites sentinel then prepends hint", () => { - const prompt = `use ${"\u27E6file:/uploads/a.txt\u27E7"}`; - const out = preparePromptForProvider(prompt, [{ mount_path: "/uploads/a.txt" }], "bailian"); - expect(out).toContain("/mnt/session/uploads/a.txt"); + const prompt = `use ${"\u27E6file:uploads/a.txt\u27E7"}`; + const out = preparePromptForProvider(prompt, [{ mount_path: "uploads/a.txt" }], "bailian"); + expect(out).toContain("/mnt/uploads/a.txt"); expect(out).toContain("The user uploaded files"); }); }); diff --git a/packages/sdk/tests/unit/session-manager.test.ts b/packages/sdk/tests/unit/session-manager.test.ts index 80ff351..8d461a3 100644 --- a/packages/sdk/tests/unit/session-manager.test.ts +++ b/packages/sdk/tests/unit/session-manager.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { buildSessionBindings, resolveSessionProvider } from "../../src/internal/session/session-manager.ts"; import { StateManager } from "../../src/internal/state/state-manager.ts"; import type { ProjectConfig } from "../../src/internal/types/config.ts"; +import "../../src/internal/providers/all.ts"; function makeConfig(overrides: Partial = {}): ProjectConfig { return { @@ -86,6 +87,32 @@ describe("buildSessionBindings", () => { expect(bindings.memory_store_ids).toEqual(["ms_docs"]); }); + test("inherits provider-neutral session resources from the agent declaration", () => { + const config = makeConfig(); + config.agents!.researcher!.resources = [ + { + type: "github_repository", + url: "https://github.com/acme/repo.git", + authorization_token: "secret", + checkout: { branch: "main" }, + }, + ]; + const bindings = buildSessionBindings("researcher", config, "qoder", makeState()); + expect(bindings.resources).toEqual(config.agents!.researcher!.resources); + }); + + test("rejects session resources before an unsupported provider can silently drop them", () => { + const config = makeConfig({ providers: { bailian: {} } }); + config.agents!.researcher!.resources = [ + { + type: "github_repository", + url: "https://github.com/acme/repo.git", + authorization_token: "secret", + }, + ]; + expect(() => buildSessionBindings("researcher", config, "bailian", makeState())).toThrow(/does not support/); + }); + test("CLI overrides replace agent declaration values", () => { const config = makeConfig(); const state = makeState(); diff --git a/packages/sdk/tests/unit/tool-permissions.test.ts b/packages/sdk/tests/unit/tool-permissions.test.ts new file mode 100644 index 0000000..f56d9e4 --- /dev/null +++ b/packages/sdk/tests/unit/tool-permissions.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { + canonicalToolName, + permissionOverridesFromWire, + resolveBuiltinTools, + toPermissionPolicy, +} from "../../src/internal/utils/tool-permissions.ts"; + +describe("tool permission resolution", () => { + test("defaults every enabled tool to allow", () => { + expect(resolveBuiltinTools({ builtin: ["Read", "Bash"] })).toEqual([ + { configuredName: "Read", wireName: "Read", permission: "allow" }, + { configuredName: "Bash", wireName: "Bash", permission: "allow" }, + ]); + }); + + test("matches permission overrides without case or separator sensitivity", () => { + const resolved = resolveBuiltinTools({ + builtin: ["WebSearch", "Bash"], + default_permission: "ask", + permissions: { web_search: "allow", bash: "allow" }, + }); + expect(resolved.map((tool) => tool.permission)).toEqual(["allow", "allow"]); + expect(canonicalToolName(" Web-Search ")).toBe("websearch"); + }); + + test("maps generic permissions to provider wire policies", () => { + expect(toPermissionPolicy("allow")).toEqual({ type: "always_allow" }); + expect(toPermissionPolicy("ask")).toEqual({ type: "always_ask" }); + }); + + test("preserves allow and ask policies when reverse-mapping provider configs", () => { + expect( + permissionOverridesFromWire([ + { name: "Read", enabled: true, permission_policy: { type: "always_allow" } }, + { name: "Bash", enabled: true, permission_policy: { type: "always_ask" } }, + ]), + ).toEqual({ Read: "allow", Bash: "ask" }); + }); +}); diff --git a/packages/sdk/tests/unit/validate-config.test.ts b/packages/sdk/tests/unit/validate-config.test.ts index 83f3967..a52b9a0 100644 --- a/packages/sdk/tests/unit/validate-config.test.ts +++ b/packages/sdk/tests/unit/validate-config.test.ts @@ -57,3 +57,83 @@ test("validates tunnel references and limits tunnels to Qoder", () => { const diagnostics = validateProjectConfig(config); expect(diagnostics.some((d) => d.code === "claude.agent.tunnel.unsupported")).toBe(true); }); + +test("rejects tool approval and GitHub Session resources on unsupported providers", () => { + const config: ProjectConfig = { + version: "1", + providers: { bailian: {} }, + defaults: { provider: "bailian" }, + agents: { + assistant: { + model: "qwen3.7-max", + instructions: "test", + tools: { builtin: ["bash"], default_permission: "ask" }, + resources: [ + { + type: "github_repository", + url: "https://github.com/acme/repo.git", + authorization_token: "secret", + }, + ], + }, + }, + }; + + const diagnostics = validateProjectConfig(config); + expect(diagnostics.some((item) => item.code === "bailian.agent.tool_permissions.unsupported")).toBe(true); + expect(diagnostics.some((item) => item.code === "bailian.agent.session_resource.github_repository.unsupported")).toBe( + true, + ); +}); + +test("rejects Qoder GitHub Session mount paths outside /data", () => { + const diagnostics = validateProjectConfig({ + version: "1", + providers: { qoder: { api_key: "test" } }, + defaults: { provider: "qoder" }, + environments: { dev: { config: { type: "cloud" } } }, + agents: { + reviewer: { + model: "auto", + instructions: "Review", + environment: "dev", + resources: [ + { + type: "github_repository", + url: "https://github.com/acme/repo.git", + authorization_token: "secret", + mount_path: "/workspace/repo", + }, + ], + }, + }, + }); + + expect(diagnostics.some((item) => item.code === "qoder.agent.session_resource.mount_path.invalid")).toBe(true); +}); + +test("rejects Claude GitHub Session mount paths outside /workspace", () => { + const diagnostics = validateProjectConfig({ + version: "1", + providers: { claude: { api_key: "test" } }, + defaults: { provider: "claude" }, + environments: { dev: { config: { type: "cloud" } } }, + agents: { + reviewer: { + model: "sonnet", + instructions: "Review", + environment: "dev", + resources: [ + { + type: "github_repository", + url: "https://github.com/acme/repo.git", + authorization_token: "secret", + mount_path: "/data/repo", + }, + ], + }, + }, + }); + + expect(diagnostics.some((item) => item.code === "claude.agent.session_resource.mount_path.invalid")).toBe(true); +});