diff --git a/bun.lock b/bun.lock index 779bffa..6200e80 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "apps/server": { "name": "@openagentpack/server", - "version": "0.0.4", + "version": "0.0.6", "dependencies": { "@hono/zod-openapi": "^1.4.0", "@openagentpack/playbooks": "workspace:*", @@ -63,7 +63,7 @@ }, "packages/cli": { "name": "@openagentpack/cli", - "version": "0.1.1", + "version": "0.3.0", "bin": { "agents": "dist/bin/agents.js", }, @@ -90,12 +90,12 @@ }, "packages/playground": { "name": "@openagentpack/playground", - "version": "0.1.1", + "version": "0.3.0", "bin": { "agents-playground": "dist/bin/playground.js", }, "dependencies": { - "@hono/node-server": "^1.19.0", + "@hono/node-server": "^2.0.5", "@hono/zod-openapi": "^1.4.0", "@openagentpack/sdk": "workspace:*", "hono": "^4.12.28", @@ -111,7 +111,7 @@ }, "packages/sdk": { "name": "@openagentpack/sdk", - "version": "0.1.1", + "version": "0.3.0", "dependencies": { "jszip": "^3.10.1", "yaml": "^2.9.0", @@ -276,7 +276,7 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@hono/node-server": ["@hono/node-server@2.0.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA=="], "@hono/zod-openapi": ["@hono/zod-openapi@1.4.0", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", "@hono/zod-validator": "^0.8.0", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.10.0", "zod": "^4.0.0" } }, "sha512-AFchqR1N/NxfI4hUOSGI2/g8zLROxA1OE7Oh5JJFlTaGxhrdRyH+93gd0tIBpb0z8s9r8hUoNnaOBfHbdb4NMw=="], diff --git a/docs/guides/run-sessions.md b/docs/guides/run-sessions.md index a02d550..fc7a348 100644 --- a/docs/guides/run-sessions.md +++ b/docs/guides/run-sessions.md @@ -63,9 +63,11 @@ agents: 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. +When a new Session starts, OpenAgentPack includes the resolved Git working-tree paths in the first user message. If exactly one repository is mounted, the Agent is instructed to prefix every shell command with a safely quoted `cd -- &&` and to use absolute paths beneath the mount for non-shell file tools. With multiple repositories, all paths are listed and the Agent chooses the appropriate working tree from the task. Repository URLs and credentials are never included in this path hint. + 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 | +| Provider | Required mount root | Default Git repository path | | --- | --- | --- | | Qoder | `/data` | `/data/workspace/` | | Claude | `/workspace` | `/workspace/` | diff --git a/examples/claude/github-session/agents.yaml b/examples/claude/github-session/agents.yaml index be7d991..0bfc8b2 100644 --- a/examples/claude/github-session/agents.yaml +++ b/examples/claude/github-session/agents.yaml @@ -29,4 +29,3 @@ agents: - type: github_repository url: ${GITHUB_REPOSITORY_URL} authorization_token: ${GITHUB_TOKEN} - mount_path: /workspace/repository diff --git a/packages/playground/package.json b/packages/playground/package.json index e1a21cc..6f7688c 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -40,7 +40,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@hono/node-server": "^1.19.0", + "@hono/node-server": "^2.0.5", "@hono/zod-openapi": "^1.4.0", "@openagentpack/sdk": "workspace:*", "hono": "^4.12.28", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 5388797..8eea119 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -163,7 +163,13 @@ export { uploadFile, } from "./internal/core/session-runtime.ts"; export { resolveSessionProvider } from "./internal/session/session-manager.ts"; -export { prependFileHint, preparePromptForProvider, rewriteFileMentions } from "./internal/utils/sandbox-mount.ts"; +export { + prependFileHint, + prepareInitialSessionPrompt, + preparePromptForProvider, + resolveRepositoryMountPath, + rewriteFileMentions, +} from "./internal/utils/sandbox-mount.ts"; export { resolveProviderConfigFromEnv } from "./internal/providers/registry.ts"; export { applyProviderConfigToEnv, diff --git a/packages/sdk/src/internal/core/session-runtime.ts b/packages/sdk/src/internal/core/session-runtime.ts index e6767bc..b108553 100644 --- a/packages/sdk/src/internal/core/session-runtime.ts +++ b/packages/sdk/src/internal/core/session-runtime.ts @@ -11,7 +11,7 @@ import type { ProviderSessionEventList, } from "../types/session-event.ts"; import type { ProviderSkillInfo } from "../types/skill-info.ts"; -import { preparePromptForProvider } from "../utils/sandbox-mount.ts"; +import { prepareInitialSessionPrompt } from "../utils/sandbox-mount.ts"; import { resolveAgentMaterialization } from "./agent-materialization.ts"; import type { ProjectRuntimeContext } from "./project-runtime.ts"; import { getRuntimeProvider } from "./project-runtime.ts"; @@ -184,7 +184,7 @@ export async function startSessionRun( agentName, provider, session, - events: streamMessageEvents(adapter, session.id, preparePromptForProvider(prompt, bindings.files, provider)), + events: streamMessageEvents(adapter, session.id, prepareInitialSessionPrompt(prompt, bindings, provider)), }; } @@ -214,15 +214,12 @@ export async function startSessionRunPolling( prompt: string, options: SessionRuntimeRunOptions = {}, ): Promise { - const run = await createSessionForAgent(ctx, options); - const adapter = getRuntimeProvider(ctx, run.provider); - const hintedPrompt = preparePromptForProvider( - prompt, - options.files?.map((f) => ({ mount_path: f.mountPath })), - run.provider, - ); - const collected = await sendSessionMessageAndCollectEvents(adapter, run.session.id, hintedPrompt, options); - return { ...run, ...collected }; + const { agentName, provider, adapter } = resolveSessionRuntime(ctx, options); + const bindings = buildSessionBindings(agentName, ctx.config, provider, ctx.state, options); + const session = await adapter.createSession(bindings); + const initialPrompt = prepareInitialSessionPrompt(prompt, bindings, provider); + const collected = await sendSessionMessageAndCollectEvents(adapter, session.id, initialPrompt, options); + return { agentName, provider, session, ...collected }; } export async function sendSessionMessageAndCollectEvents( diff --git a/packages/sdk/src/internal/providers/session-resource-mapper.ts b/packages/sdk/src/internal/providers/session-resource-mapper.ts index 0c8c329..7e651ab 100644 --- a/packages/sdk/src/internal/providers/session-resource-mapper.ts +++ b/packages/sdk/src/internal/providers/session-resource-mapper.ts @@ -1,28 +1,11 @@ -import { UserError } from "../errors.ts"; import type { SessionGithubRepositoryResource } from "../types/session.ts"; -import { providerMountPrefix } from "../utils/sandbox-mount.ts"; +import { resolveRepositoryMountPath } 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}`; + return resolveRepositoryMountPath(provider, resource); } -/** Map the provider-neutral GitHub resource declaration to the shared CAS wire shape. */ +/** Map the provider-neutral Git repository declaration to the provider's legacy wire discriminator. */ export function mapGithubRepositorySessionResource( resource: SessionGithubRepositoryResource, options: { diff --git a/packages/sdk/src/internal/utils/sandbox-mount.ts b/packages/sdk/src/internal/utils/sandbox-mount.ts index 596624e..89d675e 100644 --- a/packages/sdk/src/internal/utils/sandbox-mount.ts +++ b/packages/sdk/src/internal/utils/sandbox-mount.ts @@ -6,6 +6,7 @@ // Pure string ops only (no `node:path`): this module ships to the browser via the SDK bundle. import { UserError } from "../errors.ts"; +import type { SessionBindings, SessionGithubRepositoryResource } from "../types/session.ts"; const PROVIDER_MOUNT_PREFIXES: Readonly> = { qoder: "/data", @@ -20,6 +21,31 @@ function joinAbsolute(prefix: string, sub: string): string { return `${left}/${right}`; } +/** Quote an arbitrary path as one POSIX shell word. */ +function quoteShellWord(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function stripUrlQueryAndFragment(value: string): string { + const queryIndex = value.indexOf("?"); + const fragmentIndex = value.indexOf("#"); + if (queryIndex === -1) { + return fragmentIndex === -1 ? value : value.slice(0, fragmentIndex); + } + if (fragmentIndex === -1) return value.slice(0, queryIndex); + return value.slice(0, Math.min(queryIndex, fragmentIndex)); +} + +function stripGitSuffix(value: string): string { + return value.toLowerCase().endsWith(".git") ? value.slice(0, -4) : value; +} + +function lastRemotePathSegment(value: string): string { + const lastSlash = value.lastIndexOf("/"); + const lastColon = value.lastIndexOf(":"); + return value.slice(Math.max(lastSlash, lastColon) + 1).trim(); +} + export function providerMountPrefix(provider: string): string | undefined { return PROVIDER_MOUNT_PREFIXES[provider]; } @@ -45,6 +71,24 @@ export interface MountedFile { mount_path: string; } +/** Resolve the provider path used for a mounted Git repository. */ +export function resolveRepositoryMountPath(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} Git repository Session resource mount_path must start with '${prefix}/'.`); + } + return resource.mount_path; + } + // Accept URL remotes as well as scp-like SSH remotes such as git@host:team/repo.git. + const repositoryName = stripGitSuffix(lastRemotePathSegment(stripUrlQueryAndFragment(resource.url))); + if (!repositoryName) { + throw new UserError(`Cannot derive a ${provider} Git repository mount path from URL '${resource.url}'.`); + } + return provider === "qoder" ? `${prefix}/workspace/${repositoryName}` : `${prefix}/${repositoryName}`; +} + /** * Build an English hint listing the real sandbox paths of the uploaded files, so the model * knows where to read them. Returns "" when there are no files. @@ -66,16 +110,61 @@ export function prependFileHint(prompt: string, files: MountedFile[] | undefined return `${hint}\n\n${prompt}`; } -const FILE_MENTION_SENTINEL_RE = /\u27E6file:(.+?)\u27E7/g; +const FILE_MENTION_START = "\u27E6file:"; +const FILE_MENTION_END = "\u27E7"; /** 将 prompt 内 ⟦file:mountPath⟧ 占位符替换为 provider 感知的真实 sandbox 路径 */ export function rewriteFileMentions(prompt: string, provider: string): string { - return prompt.replace(FILE_MENTION_SENTINEL_RE, (_match, mountPath: string) => - resolveSandboxMountPath(provider, mountPath), - ); + let cursor = 0; + let rewritten = ""; + + while (cursor < prompt.length) { + const start = prompt.indexOf(FILE_MENTION_START, cursor); + if (start === -1) { + rewritten += prompt.slice(cursor); + break; + } + const mountPathStart = start + FILE_MENTION_START.length; + const end = prompt.indexOf(FILE_MENTION_END, mountPathStart); + if (end === -1) { + rewritten += prompt.slice(cursor); + break; + } + rewritten += prompt.slice(cursor, start); + rewritten += resolveSandboxMountPath(provider, prompt.slice(mountPathStart, end)); + cursor = end + FILE_MENTION_END.length; + } + + return rewritten; } /** 发送给 provider 前统一处理:先替换 mention 占位符,再 prepend 文件 hint */ export function preparePromptForProvider(prompt: string, files: MountedFile[] | undefined, provider: string): string { return prependFileHint(rewriteFileMentions(prompt, provider), files, provider); } + +/** Prepare only the first user message for a newly created Session. */ +export function prepareInitialSessionPrompt(prompt: string, bindings: SessionBindings, provider: string): string { + const prepared = preparePromptForProvider(prompt, bindings.files, provider); + const repositories = (bindings.resources ?? []).filter( + (resource): resource is SessionGithubRepositoryResource => resource.type === "github_repository", + ); + if (repositories.length === 0) return prepared; + + const paths = repositories.map((resource) => resolveRepositoryMountPath(provider, resource)); + if (paths.length === 1) { + const path = paths[0]!; + return [ + `The Git working tree for this task is mounted at \`${path}\`.`, + "Work only inside this directory unless the user explicitly requests otherwise.", + "Prefix every shell command with:", + `cd -- ${quoteShellWord(path)} &&`, + `Use absolute paths under \`${path}\` for non-shell file tools.`, + "", + prepared, + ].join("\n"); + } + const lines = ["Git working trees for this task are mounted at:", ...paths.map((path) => `- ${path}`)]; + lines.push("Choose the appropriate working tree for the task before inspecting or modifying files."); + return `${lines.join("\n")}\n\n${prepared}`; +} diff --git a/packages/sdk/tests/unit/core-session-runtime.test.ts b/packages/sdk/tests/unit/core-session-runtime.test.ts index ff16040..f901642 100644 --- a/packages/sdk/tests/unit/core-session-runtime.test.ts +++ b/packages/sdk/tests/unit/core-session-runtime.test.ts @@ -183,6 +183,28 @@ describe("core session runtime", () => { expect(calls).toEqual(["createSession", "send:do work", "stream:evt_user"]); }); + test("startRun tells the first message to work inside the mounted Git repository", async () => { + const calls: string[] = []; + const provider = adapter("qoder", calls, true); + const runtime = ctx(provider); + runtime.config.agents!.assistant!.resources = [ + { + type: "github_repository", + url: "git@code.example.com:team/project.git", + authorization_token: "never-send-this", + }, + ]; + const run = await startSessionRun(runtime, "do work", { agent: "assistant" }); + for await (const _event of run.events) { + // Consume the lazy stream so the message is sent. + } + + const sent = calls.find((call) => call.startsWith("send:")); + expect(sent).toContain("/data/workspace/project"); + expect(sent).toContain("cd -- '/data/workspace/project' &&"); + expect(sent).not.toContain("never-send-this"); + }); + test("forwards explicit tunnel overrides when creating a session", async () => { let tunnelId: string | undefined; const provider = { diff --git a/packages/sdk/tests/unit/map-session.test.ts b/packages/sdk/tests/unit/map-session.test.ts index 69e0c25..8b3d2ed 100644 --- a/packages/sdk/tests/unit/map-session.test.ts +++ b/packages/sdk/tests/unit/map-session.test.ts @@ -29,7 +29,7 @@ function minimalBindings(): SessionBindings { } describe("Qoder mapSession", () => { - test("maps a GitHub repository resource", () => { + test("maps a Git repository resource", () => { const body = mapQoderSession({ ...minimalBindings(), resources: [ @@ -69,7 +69,7 @@ describe("Qoder mapSession", () => { mount_path: "/data/workspace/my-repo", }); }); - test("rejects an explicit GitHub mount path outside /data", () => { + test("rejects an explicit Git repository mount path outside /data", () => { expect(() => mapQoderSession({ ...minimalBindings(), @@ -82,7 +82,7 @@ describe("Qoder mapSession", () => { }, ], }), - ).toThrow("qoder GitHub Session resource mount_path must start with '/data/'."); + ).toThrow("qoder Git repository Session resource mount_path must start with '/data/'."); }); test("full bindings produce correct body with resources array", () => { const body = mapQoderSession(fullBindings()) as Record; @@ -129,7 +129,7 @@ describe("Qoder mapSession", () => { }); describe("Claude mapSession", () => { - test("maps a GitHub repository resource", () => { + test("maps a Git repository resource", () => { const body = mapClaudeSession({ ...minimalBindings(), resources: [ diff --git a/packages/sdk/tests/unit/sandbox-mount.test.ts b/packages/sdk/tests/unit/sandbox-mount.test.ts index 85d2693..f74ba43 100644 --- a/packages/sdk/tests/unit/sandbox-mount.test.ts +++ b/packages/sdk/tests/unit/sandbox-mount.test.ts @@ -1,12 +1,24 @@ import { describe, expect, test } from "bun:test"; +import type { SessionBindings } from "../../src/internal/types/session.ts"; import { composeFileMountHint, + prepareInitialSessionPrompt, preparePromptForProvider, prependFileHint, resolveSandboxMountPath, rewriteFileMentions, } from "../../src/internal/utils/sandbox-mount.ts"; +function bindings(resources: SessionBindings["resources"] = []): SessionBindings { + return { + agent_id: "agent_1", + environment_id: "env_1", + vault_ids: [], + memory_store_ids: [], + resources, + }; +} + describe("resolveSandboxMountPath", () => { test("uses each provider's fixed mount prefix and preserves subdirs", () => { expect(resolveSandboxMountPath("qoder", "uploads/report.pdf")).toBe("/data/uploads/report.pdf"); @@ -78,6 +90,19 @@ describe("rewriteFileMentions", () => { expect(rewriteFileMentions(prompt, "bailian")).toBe("see /mnt/uploads/a.png here"); }); + test("rewrites multiple sentinels with a linear scanner", () => { + const prompt = [`first ${"\u27E6file:uploads/a.png\u27E7"}`, `second ${"\u27E6file:uploads/b.png\u27E7"}`].join( + " and ", + ); + expect(rewriteFileMentions(prompt, "qoder")).toBe("first /data/uploads/a.png and second /data/uploads/b.png"); + }); + + test("leaves unterminated sentinels unchanged", () => { + const longTail = "x".repeat(10_000); + const prompt = `before ${"\u27E6file:uploads/a.png"}${longTail}`; + expect(rewriteFileMentions(prompt, "bailian")).toBe(prompt); + }); + test("no sentinel → unchanged", () => { expect(rewriteFileMentions("hello", "bailian")).toBe("hello"); }); @@ -91,3 +116,86 @@ describe("preparePromptForProvider", () => { expect(out).toContain("The user uploaded files"); }); }); + +describe("prepareInitialSessionPrompt", () => { + test("uses a single Git repository as the task working directory", () => { + const out = prepareInitialSessionPrompt( + "implement the feature", + bindings([ + { + type: "github_repository", + url: "https://gitlab.example.com/team/repo.git", + authorization_token: "do-not-leak", + }, + ]), + "qoder", + ); + expect(out).toContain("The Git working tree for this task is mounted at `/data/workspace/repo`."); + expect(out).toContain("Prefix every shell command with:\ncd -- '/data/workspace/repo' &&"); + expect(out).toContain("Use absolute paths under `/data/workspace/repo` for non-shell file tools."); + expect(out).not.toContain("gitlab.example.com"); + expect(out).not.toContain("do-not-leak"); + expect(out.endsWith("implement the feature")).toBe(true); + }); + + test("derives repository names without regex query or fragment stripping", () => { + const out = prepareInitialSessionPrompt( + "inspect it", + bindings([ + { + type: "github_repository", + url: "https://git.example.com/team/repo.git?token=abc#readme", + authorization_token: "do-not-leak", + }, + ]), + "qoder", + ); + expect(out).toContain("`/data/workspace/repo`"); + expect(out).not.toContain("token=abc"); + expect(out).not.toContain("readme"); + }); + + test("shell-quotes an explicit repository mount path", () => { + const out = prepareInitialSessionPrompt( + "inspect it", + bindings([ + { + type: "github_repository", + url: "https://code.example.com/team/repo.git", + authorization_token: "secret", + mount_path: "/data/workspace/team's repo", + }, + ]), + "qoder", + ); + expect(out).toContain(`cd -- '/data/workspace/team'"'"'s repo' &&`); + }); + + test("supports scp-like Git remotes without naming a single working directory when several are mounted", () => { + const out = prepareInitialSessionPrompt( + "coordinate the change", + bindings([ + { + type: "github_repository", + url: "git@host.example.com:team/api.git", + authorization_token: "secret-a", + }, + { + type: "github_repository", + url: "https://gitea.example.com/team/web.git", + authorization_token: "secret-b", + mount_path: "/data/projects/web", + }, + ]), + "qoder", + ); + expect(out).toContain("- /data/workspace/api"); + expect(out).toContain("- /data/projects/web"); + expect(out).toContain("Choose the appropriate working tree"); + expect(out).not.toContain(" as the working directory"); + }); + + test("leaves prompts unchanged when the Session has no files or repositories", () => { + expect(prepareInitialSessionPrompt("hello", bindings(), "qoder")).toBe("hello"); + }); +});