Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion docs/guides/run-sessions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<repo-name>` 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 -- <mount-path> &&` 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/<repo-name>` |
| Claude | `/workspace` | `/workspace/<repo-name>` |
Expand Down
1 change: 0 additions & 1 deletion examples/claude/github-session/agents.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,4 +29,3 @@ agents:
- type: github_repository
url: ${GITHUB_REPOSITORY_URL}
authorization_token: ${GITHUB_TOKEN}
mount_path: /workspace/repository
2 changes: 1 addition & 1 deletion packages/playground/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
19 changes: 8 additions & 11 deletions packages/sdk/src/internal/core/session-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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)),
};
}

Expand DownExpand Up@@ -214,15 +214,12 @@ export async function startSessionRunPolling(
prompt: string,
options: SessionRuntimeRunOptions = {},
): Promise<CreatedSessionRun & CollectedSessionEvents> {
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(
Expand Down
23 changes: 3 additions & 20 deletions packages/sdk/src/internal/providers/session-resource-mapper.ts
Original file line numberDiff line numberDiff line change
@@ -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: {
Expand Down
97 changes: 93 additions & 4 deletions packages/sdk/src/internal/utils/sandbox-mount.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
qoder: "/data",
Expand All@@ -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];
}
Expand All@@ -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}`;
}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

/**
* 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.
Expand All@@ -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}`;
}
22 changes: 22 additions & 0 deletions packages/sdk/tests/unit/core-session-runtime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand Down
8 changes: 4 additions & 4 deletions packages/sdk/tests/unit/map-session.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: [
Expand DownExpand Up@@ -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(),
Expand All@@ -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<string, unknown>;
Expand DownExpand Up@@ -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: [
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion docs/guides/run-sessions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<repo-name>` 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 -- <mount-path> &&` 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/<repo-name>` |
| Claude | `/workspace` | `/workspace/<repo-name>` |
Expand Down
1 change: 0 additions & 1 deletion examples/claude/github-session/agents.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,4 +29,3 @@ agents:
- type: github_repository
url: ${GITHUB_REPOSITORY_URL}
authorization_token: ${GITHUB_TOKEN}
mount_path: /workspace/repository
2 changes: 1 addition & 1 deletion packages/playground/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
19 changes: 8 additions & 11 deletions packages/sdk/src/internal/core/session-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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)),
};
}

Expand DownExpand Up@@ -214,15 +214,12 @@ export async function startSessionRunPolling(
prompt: string,
options: SessionRuntimeRunOptions = {},
): Promise<CreatedSessionRun & CollectedSessionEvents> {
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(
Expand Down
23 changes: 3 additions & 20 deletions packages/sdk/src/internal/providers/session-resource-mapper.ts
Original file line numberDiff line numberDiff line change
@@ -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: {
Expand Down
97 changes: 93 additions & 4 deletions packages/sdk/src/internal/utils/sandbox-mount.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
qoder: "/data",
Expand All@@ -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];
}
Expand All@@ -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}`;
}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

/**
* 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.
Expand All@@ -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}`;
}
22 changes: 22 additions & 0 deletions packages/sdk/tests/unit/core-session-runtime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand Down
8 changes: 4 additions & 4 deletions packages/sdk/tests/unit/map-session.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: [
Expand DownExpand Up@@ -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(),
Expand All@@ -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<string, unknown>;
Expand DownExpand Up@@ -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: [
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion docs/guides/run-sessions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<repo-name>` 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 -- <mount-path> &&` 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/<repo-name>` |
| Claude | `/workspace` | `/workspace/<repo-name>` |
Expand Down
1 change: 0 additions & 1 deletion examples/claude/github-session/agents.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,4 +29,3 @@ agents:
- type: github_repository
url: ${GITHUB_REPOSITORY_URL}
authorization_token: ${GITHUB_TOKEN}
mount_path: /workspace/repository
2 changes: 1 addition & 1 deletion packages/playground/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
19 changes: 8 additions & 11 deletions packages/sdk/src/internal/core/session-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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)),
};
}

Expand DownExpand Up@@ -214,15 +214,12 @@ export async function startSessionRunPolling(
prompt: string,
options: SessionRuntimeRunOptions = {},
): Promise<CreatedSessionRun & CollectedSessionEvents> {
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(
Expand Down
23 changes: 3 additions & 20 deletions packages/sdk/src/internal/providers/session-resource-mapper.ts
Original file line numberDiff line numberDiff line change
@@ -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: {
Expand Down
97 changes: 93 additions & 4 deletions packages/sdk/src/internal/utils/sandbox-mount.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
qoder: "/data",
Expand All@@ -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];
}
Expand All@@ -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}`;
}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

/**
* 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.
Expand All@@ -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}`;
}
22 changes: 22 additions & 0 deletions packages/sdk/tests/unit/core-session-runtime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand Down
8 changes: 4 additions & 4 deletions packages/sdk/tests/unit/map-session.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: [
Expand DownExpand Up@@ -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(),
Expand All@@ -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<string, unknown>;
Expand DownExpand Up@@ -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: [
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion docs/guides/run-sessions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<repo-name>` 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 -- <mount-path> &&` 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/<repo-name>` |
| Claude | `/workspace` | `/workspace/<repo-name>` |
Expand Down
1 change: 0 additions & 1 deletion examples/claude/github-session/agents.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,4 +29,3 @@ agents:
- type: github_repository
url: ${GITHUB_REPOSITORY_URL}
authorization_token: ${GITHUB_TOKEN}
mount_path: /workspace/repository
2 changes: 1 addition & 1 deletion packages/playground/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
19 changes: 8 additions & 11 deletions packages/sdk/src/internal/core/session-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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)),
};
}

Expand DownExpand Up@@ -214,15 +214,12 @@ export async function startSessionRunPolling(
prompt: string,
options: SessionRuntimeRunOptions = {},
): Promise<CreatedSessionRun & CollectedSessionEvents> {
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(
Expand Down
23 changes: 3 additions & 20 deletions packages/sdk/src/internal/providers/session-resource-mapper.ts
Original file line numberDiff line numberDiff line change
@@ -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: {
Expand Down
97 changes: 93 additions & 4 deletions packages/sdk/src/internal/utils/sandbox-mount.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
qoder: "/data",
Expand All@@ -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];
}
Expand All@@ -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}`;
}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

/**
* 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.
Expand All@@ -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}`;
}
22 changes: 22 additions & 0 deletions packages/sdk/tests/unit/core-session-runtime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand Down
8 changes: 4 additions & 4 deletions packages/sdk/tests/unit/map-session.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: [
Expand DownExpand Up@@ -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(),
Expand All@@ -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<string, unknown>;
Expand DownExpand Up@@ -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: [
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion docs/guides/run-sessions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<repo-name>` 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 -- <mount-path> &&` 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/<repo-name>` |
| Claude | `/workspace` | `/workspace/<repo-name>` |
Expand Down
1 change: 0 additions & 1 deletion examples/claude/github-session/agents.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,4 +29,3 @@ agents:
- type: github_repository
url: ${GITHUB_REPOSITORY_URL}
authorization_token: ${GITHUB_TOKEN}
mount_path: /workspace/repository
2 changes: 1 addition & 1 deletion packages/playground/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
19 changes: 8 additions & 11 deletions packages/sdk/src/internal/core/session-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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)),
};
}

Expand DownExpand Up@@ -214,15 +214,12 @@ export async function startSessionRunPolling(
prompt: string,
options: SessionRuntimeRunOptions = {},
): Promise<CreatedSessionRun & CollectedSessionEvents> {
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(
Expand Down
23 changes: 3 additions & 20 deletions packages/sdk/src/internal/providers/session-resource-mapper.ts
Original file line numberDiff line numberDiff line change
@@ -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: {
Expand Down
97 changes: 93 additions & 4 deletions packages/sdk/src/internal/utils/sandbox-mount.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
qoder: "/data",
Expand All@@ -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];
}
Expand All@@ -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}`;
}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

/**
* 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.
Expand All@@ -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}`;
}
22 changes: 22 additions & 0 deletions packages/sdk/tests/unit/core-session-runtime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand Down
8 changes: 4 additions & 4 deletions packages/sdk/tests/unit/map-session.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: [
Expand DownExpand Up@@ -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(),
Expand All@@ -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<string, unknown>;
Expand DownExpand Up@@ -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: [
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion docs/guides/run-sessions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<repo-name>` 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 -- <mount-path> &&` 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/<repo-name>` |
| Claude | `/workspace` | `/workspace/<repo-name>` |
Expand Down
1 change: 0 additions & 1 deletion examples/claude/github-session/agents.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,4 +29,3 @@ agents:
- type: github_repository
url: ${GITHUB_REPOSITORY_URL}
authorization_token: ${GITHUB_TOKEN}
mount_path: /workspace/repository
2 changes: 1 addition & 1 deletion packages/playground/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
19 changes: 8 additions & 11 deletions packages/sdk/src/internal/core/session-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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)),
};
}

Expand DownExpand Up@@ -214,15 +214,12 @@ export async function startSessionRunPolling(
prompt: string,
options: SessionRuntimeRunOptions = {},
): Promise<CreatedSessionRun & CollectedSessionEvents> {
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(
Expand Down
23 changes: 3 additions & 20 deletions packages/sdk/src/internal/providers/session-resource-mapper.ts
Original file line numberDiff line numberDiff line change
@@ -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: {
Expand Down
97 changes: 93 additions & 4 deletions packages/sdk/src/internal/utils/sandbox-mount.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
qoder: "/data",
Expand All@@ -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];
}
Expand All@@ -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}`;
}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

/**
* 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.
Expand All@@ -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}`;
}
22 changes: 22 additions & 0 deletions packages/sdk/tests/unit/core-session-runtime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand Down
8 changes: 4 additions & 4 deletions packages/sdk/tests/unit/map-session.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: [
Expand DownExpand Up@@ -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(),
Expand All@@ -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<string, unknown>;
Expand DownExpand Up@@ -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: [
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion docs/guides/run-sessions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<repo-name>` 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 -- <mount-path> &&` 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/<repo-name>` |
| Claude | `/workspace` | `/workspace/<repo-name>` |
Expand Down
1 change: 0 additions & 1 deletion examples/claude/github-session/agents.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,4 +29,3 @@ agents:
- type: github_repository
url: ${GITHUB_REPOSITORY_URL}
authorization_token: ${GITHUB_TOKEN}
mount_path: /workspace/repository
2 changes: 1 addition & 1 deletion packages/playground/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
19 changes: 8 additions & 11 deletions packages/sdk/src/internal/core/session-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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)),
};
}

Expand DownExpand Up@@ -214,15 +214,12 @@ export async function startSessionRunPolling(
prompt: string,
options: SessionRuntimeRunOptions = {},
): Promise<CreatedSessionRun & CollectedSessionEvents> {
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(
Expand Down
23 changes: 3 additions & 20 deletions packages/sdk/src/internal/providers/session-resource-mapper.ts
Original file line numberDiff line numberDiff line change
@@ -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: {
Expand Down
97 changes: 93 additions & 4 deletions packages/sdk/src/internal/utils/sandbox-mount.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
qoder: "/data",
Expand All@@ -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];
}
Expand All@@ -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}`;
}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

/**
* 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.
Expand All@@ -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}`;
}
22 changes: 22 additions & 0 deletions packages/sdk/tests/unit/core-session-runtime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand Down
8 changes: 4 additions & 4 deletions packages/sdk/tests/unit/map-session.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: [
Expand DownExpand Up@@ -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(),
Expand All@@ -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<string, unknown>;
Expand DownExpand Up@@ -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: [
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion docs/guides/run-sessions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<repo-name>` 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 -- <mount-path> &&` 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/<repo-name>` |
| Claude | `/workspace` | `/workspace/<repo-name>` |
Expand Down
1 change: 0 additions & 1 deletion examples/claude/github-session/agents.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,4 +29,3 @@ agents:
- type: github_repository
url: ${GITHUB_REPOSITORY_URL}
authorization_token: ${GITHUB_TOKEN}
mount_path: /workspace/repository
2 changes: 1 addition & 1 deletion packages/playground/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
19 changes: 8 additions & 11 deletions packages/sdk/src/internal/core/session-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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)),
};
}

Expand DownExpand Up@@ -214,15 +214,12 @@ export async function startSessionRunPolling(
prompt: string,
options: SessionRuntimeRunOptions = {},
): Promise<CreatedSessionRun & CollectedSessionEvents> {
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(
Expand Down
23 changes: 3 additions & 20 deletions packages/sdk/src/internal/providers/session-resource-mapper.ts
Original file line numberDiff line numberDiff line change
@@ -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: {
Expand Down
97 changes: 93 additions & 4 deletions packages/sdk/src/internal/utils/sandbox-mount.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
qoder: "/data",
Expand All@@ -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];
}
Expand All@@ -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}`;
}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

/**
* 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.
Expand All@@ -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}`;
}
22 changes: 22 additions & 0 deletions packages/sdk/tests/unit/core-session-runtime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand Down
8 changes: 4 additions & 4 deletions packages/sdk/tests/unit/map-session.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: [
Expand DownExpand Up@@ -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(),
Expand All@@ -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<string, unknown>;
Expand DownExpand Up@@ -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: [
Expand Down
Loading