Skip to content

Commit 02878f6

Browse files
committed
Guide sessions into mounted Git working trees
Change-Id: I5af1ca42d79c4d94cdf8f4e3a7d799e0270fce4e
1 parent a7ea745 commit 02878f6

9 files changed

Lines changed: 180 additions & 38 deletions

File tree

docs/guides/run-sessions.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,11 @@ agents:
6363
6464
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.
6565

66+
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.
67+
6668
Provider mount roots are fixed and OpenAgentPack applies the same policy to wire requests and prompt file hints:
6769

68-
| Provider | Required mount root | Default GitHub repository path |
70+
| Provider | Required mount root | Default Git repository path |
6971
| --- | --- | --- |
7072
| Qoder | `/data` | `/data/workspace/<repo-name>` |
7173
| Claude | `/workspace` | `/workspace/<repo-name>` |

examples/claude/github-session/agents.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,3 @@ agents:
2929
- type: github_repository
3030
url: ${GITHUB_REPOSITORY_URL}
3131
authorization_token: ${GITHUB_TOKEN}
32-
mount_path: /workspace/repository

packages/sdk/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,13 @@ export {
163163
uploadFile,
164164
} from "./internal/core/session-runtime.ts";
165165
export { resolveSessionProvider } from "./internal/session/session-manager.ts";
166-
export { prependFileHint, preparePromptForProvider, rewriteFileMentions } from "./internal/utils/sandbox-mount.ts";
166+
export {
167+
prependFileHint,
168+
prepareInitialSessionPrompt,
169+
preparePromptForProvider,
170+
resolveRepositoryMountPath,
171+
rewriteFileMentions,
172+
} from "./internal/utils/sandbox-mount.ts";
167173
export { resolveProviderConfigFromEnv } from "./internal/providers/registry.ts";
168174
export {
169175
applyProviderConfigToEnv,

packages/sdk/src/internal/core/session-runtime.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
ProviderSessionEventList,
1212
} from "../types/session-event.ts";
1313
import type { ProviderSkillInfo } from "../types/skill-info.ts";
14-
import { preparePromptForProvider } from "../utils/sandbox-mount.ts";
14+
import { prepareInitialSessionPrompt } from "../utils/sandbox-mount.ts";
1515
import { resolveAgentMaterialization } from "./agent-materialization.ts";
1616
import type { ProjectRuntimeContext } from "./project-runtime.ts";
1717
import { getRuntimeProvider } from "./project-runtime.ts";
@@ -183,7 +183,7 @@ export async function startSessionRun(
183183
agentName,
184184
provider,
185185
session,
186-
events: streamMessageEvents(adapter, session.id, preparePromptForProvider(prompt, bindings.files, provider)),
186+
events: streamMessageEvents(adapter, session.id, prepareInitialSessionPrompt(prompt, bindings, provider)),
187187
};
188188
}
189189

@@ -213,15 +213,12 @@ export async function startSessionRunPolling(
213213
prompt: string,
214214
options: SessionRuntimeRunOptions = {},
215215
): Promise<CreatedSessionRun & CollectedSessionEvents> {
216-
const run = await createSessionForAgent(ctx, options);
217-
const adapter = getRuntimeProvider(ctx, run.provider);
218-
const hintedPrompt = preparePromptForProvider(
219-
prompt,
220-
options.files?.map((f) => ({ mount_path: f.mountPath })),
221-
run.provider,
222-
);
223-
const collected = await sendSessionMessageAndCollectEvents(adapter, run.session.id, hintedPrompt, options);
224-
return { ...run, ...collected };
216+
const { agentName, provider, adapter } = resolveSessionRuntime(ctx, options);
217+
const bindings = buildSessionBindings(agentName, ctx.config, provider, ctx.state, options);
218+
const session = await adapter.createSession(bindings);
219+
const initialPrompt = prepareInitialSessionPrompt(prompt, bindings, provider);
220+
const collected = await sendSessionMessageAndCollectEvents(adapter, session.id, initialPrompt, options);
221+
return { agentName, provider, session, ...collected };
225222
}
226223

227224
export async function sendSessionMessageAndCollectEvents(

packages/sdk/src/internal/providers/session-resource-mapper.ts

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,11 @@
1-
import { UserError } from "../errors.ts";
21
import type { SessionGithubRepositoryResource } from "../types/session.ts";
3-
import { providerMountPrefix } from "../utils/sandbox-mount.ts";
2+
import { resolveRepositoryMountPath } from "../utils/sandbox-mount.ts";
43

54
export function resolveGithubRepositoryMountPath(provider: string, resource: SessionGithubRepositoryResource): string {
6-
const prefix = providerMountPrefix(provider);
7-
if (!prefix) throw new UserError(`Provider '${provider}' has no declared mount path prefix.`);
8-
if (resource.mount_path) {
9-
if (resource.mount_path !== prefix && !resource.mount_path.startsWith(`${prefix}/`)) {
10-
throw new UserError(`${provider} GitHub Session resource mount_path must start with '${prefix}/'.`);
11-
}
12-
return resource.mount_path;
13-
}
14-
const repositoryName = new URL(resource.url).pathname
15-
.split("/")
16-
.filter(Boolean)
17-
.at(-1)
18-
?.replace(/\.git$/i, "");
19-
if (!repositoryName) {
20-
throw new UserError(`Cannot derive a ${provider} GitHub mount path from repository URL '${resource.url}'.`);
21-
}
22-
return provider === "qoder" ? `${prefix}/workspace/${repositoryName}` : `${prefix}/${repositoryName}`;
5+
return resolveRepositoryMountPath(provider, resource);
236
}
247

25-
/** Map the provider-neutral GitHub resource declaration to the shared CAS wire shape. */
8+
/** Map the provider-neutral Git repository declaration to the provider's legacy wire discriminator. */
269
export function mapGithubRepositorySessionResource(
2710
resource: SessionGithubRepositoryResource,
2811
options: {

packages/sdk/src/internal/utils/sandbox-mount.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// Pure string ops only (no `node:path`): this module ships to the browser via the SDK bundle.
77

88
import { UserError } from "../errors.ts";
9+
import type { SessionBindings, SessionGithubRepositoryResource } from "../types/session.ts";
910

1011
const PROVIDER_MOUNT_PREFIXES: Readonly<Record<string, string>> = {
1112
qoder: "/data",
@@ -20,6 +21,11 @@ function joinAbsolute(prefix: string, sub: string): string {
2021
return `${left}/${right}`;
2122
}
2223

24+
/** Quote an arbitrary path as one POSIX shell word. */
25+
function quoteShellWord(value: string): string {
26+
return `'${value.replaceAll("'", `'"'"'`)}'`;
27+
}
28+
2329
export function providerMountPrefix(provider: string): string | undefined {
2430
return PROVIDER_MOUNT_PREFIXES[provider];
2531
}
@@ -45,6 +51,29 @@ export interface MountedFile {
4551
mount_path: string;
4652
}
4753

54+
/** Resolve the provider path used for a mounted Git repository. */
55+
export function resolveRepositoryMountPath(provider: string, resource: SessionGithubRepositoryResource): string {
56+
const prefix = providerMountPrefix(provider);
57+
if (!prefix) throw new UserError(`Provider '${provider}' has no declared mount path prefix.`);
58+
if (resource.mount_path) {
59+
if (resource.mount_path !== prefix && !resource.mount_path.startsWith(`${prefix}/`)) {
60+
throw new UserError(`${provider} Git repository Session resource mount_path must start with '${prefix}/'.`);
61+
}
62+
return resource.mount_path;
63+
}
64+
// Accept URL remotes as well as scp-like SSH remotes such as git@host:team/repo.git.
65+
const repositoryName = resource.url
66+
.replace(/[?#].*$/, "")
67+
.split(/[/:]/)
68+
.filter(Boolean)
69+
.at(-1)
70+
?.replace(/\.git$/i, "");
71+
if (!repositoryName) {
72+
throw new UserError(`Cannot derive a ${provider} Git repository mount path from URL '${resource.url}'.`);
73+
}
74+
return provider === "qoder" ? `${prefix}/workspace/${repositoryName}` : `${prefix}/${repositoryName}`;
75+
}
76+
4877
/**
4978
* Build an English hint listing the real sandbox paths of the uploaded files, so the model
5079
* knows where to read them. Returns "" when there are no files.
@@ -79,3 +108,29 @@ export function rewriteFileMentions(prompt: string, provider: string): string {
79108
export function preparePromptForProvider(prompt: string, files: MountedFile[] | undefined, provider: string): string {
80109
return prependFileHint(rewriteFileMentions(prompt, provider), files, provider);
81110
}
111+
112+
/** Prepare only the first user message for a newly created Session. */
113+
export function prepareInitialSessionPrompt(prompt: string, bindings: SessionBindings, provider: string): string {
114+
const prepared = preparePromptForProvider(prompt, bindings.files, provider);
115+
const repositories = (bindings.resources ?? []).filter(
116+
(resource): resource is SessionGithubRepositoryResource => resource.type === "github_repository",
117+
);
118+
if (repositories.length === 0) return prepared;
119+
120+
const paths = repositories.map((resource) => resolveRepositoryMountPath(provider, resource));
121+
if (paths.length === 1) {
122+
const path = paths[0]!;
123+
return [
124+
`The Git working tree for this task is mounted at \`${path}\`.`,
125+
"Work only inside this directory unless the user explicitly requests otherwise.",
126+
"Prefix every shell command with:",
127+
`cd -- ${quoteShellWord(path)} &&`,
128+
`Use absolute paths under \`${path}\` for non-shell file tools.`,
129+
"",
130+
prepared,
131+
].join("\n");
132+
}
133+
const lines = ["Git working trees for this task are mounted at:", ...paths.map((path) => `- ${path}`)];
134+
lines.push("Choose the appropriate working tree for the task before inspecting or modifying files.");
135+
return `${lines.join("\n")}\n\n${prepared}`;
136+
}

packages/sdk/tests/unit/core-session-runtime.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,28 @@ describe("core session runtime", () => {
183183
expect(calls).toEqual(["createSession", "send:do work", "stream:evt_user"]);
184184
});
185185

186+
test("startRun tells the first message to work inside the mounted Git repository", async () => {
187+
const calls: string[] = [];
188+
const provider = adapter("qoder", calls, true);
189+
const runtime = ctx(provider);
190+
runtime.config.agents!.assistant!.resources = [
191+
{
192+
type: "github_repository",
193+
url: "git@code.example.com:team/project.git",
194+
authorization_token: "never-send-this",
195+
},
196+
];
197+
const run = await startSessionRun(runtime, "do work", { agent: "assistant" });
198+
for await (const _event of run.events) {
199+
// Consume the lazy stream so the message is sent.
200+
}
201+
202+
const sent = calls.find((call) => call.startsWith("send:"));
203+
expect(sent).toContain("/data/workspace/project");
204+
expect(sent).toContain("cd -- '/data/workspace/project' &&");
205+
expect(sent).not.toContain("never-send-this");
206+
});
207+
186208
test("forwards explicit tunnel overrides when creating a session", async () => {
187209
let tunnelId: string | undefined;
188210
const provider = {

packages/sdk/tests/unit/map-session.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ function minimalBindings(): SessionBindings {
2929
}
3030

3131
describe("Qoder mapSession", () => {
32-
test("maps a GitHub repository resource", () => {
32+
test("maps a Git repository resource", () => {
3333
const body = mapQoderSession({
3434
...minimalBindings(),
3535
resources: [
@@ -69,7 +69,7 @@ describe("Qoder mapSession", () => {
6969
mount_path: "/data/workspace/my-repo",
7070
});
7171
});
72-
test("rejects an explicit GitHub mount path outside /data", () => {
72+
test("rejects an explicit Git repository mount path outside /data", () => {
7373
expect(() =>
7474
mapQoderSession({
7575
...minimalBindings(),
@@ -82,7 +82,7 @@ describe("Qoder mapSession", () => {
8282
},
8383
],
8484
}),
85-
).toThrow("qoder GitHub Session resource mount_path must start with '/data/'.");
85+
).toThrow("qoder Git repository Session resource mount_path must start with '/data/'.");
8686
});
8787
test("full bindings produce correct body with resources array", () => {
8888
const body = mapQoderSession(fullBindings()) as Record<string, unknown>;
@@ -129,7 +129,7 @@ describe("Qoder mapSession", () => {
129129
});
130130

131131
describe("Claude mapSession", () => {
132-
test("maps a GitHub repository resource", () => {
132+
test("maps a Git repository resource", () => {
133133
const body = mapClaudeSession({
134134
...minimalBindings(),
135135
resources: [

packages/sdk/tests/unit/sandbox-mount.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
11
import { describe, expect, test } from "bun:test";
2+
import type { SessionBindings } from "../../src/internal/types/session.ts";
23
import {
34
composeFileMountHint,
5+
prepareInitialSessionPrompt,
46
preparePromptForProvider,
57
prependFileHint,
68
resolveSandboxMountPath,
79
rewriteFileMentions,
810
} from "../../src/internal/utils/sandbox-mount.ts";
911

12+
function bindings(resources: SessionBindings["resources"] = []): SessionBindings {
13+
return {
14+
agent_id: "agent_1",
15+
environment_id: "env_1",
16+
vault_ids: [],
17+
memory_store_ids: [],
18+
resources,
19+
};
20+
}
21+
1022
describe("resolveSandboxMountPath", () => {
1123
test("uses each provider's fixed mount prefix and preserves subdirs", () => {
1224
expect(resolveSandboxMountPath("qoder", "uploads/report.pdf")).toBe("/data/uploads/report.pdf");
@@ -91,3 +103,69 @@ describe("preparePromptForProvider", () => {
91103
expect(out).toContain("The user uploaded files");
92104
});
93105
});
106+
107+
describe("prepareInitialSessionPrompt", () => {
108+
test("uses a single Git repository as the task working directory", () => {
109+
const out = prepareInitialSessionPrompt(
110+
"implement the feature",
111+
bindings([
112+
{
113+
type: "github_repository",
114+
url: "https://gitlab.example.com/team/repo.git",
115+
authorization_token: "do-not-leak",
116+
},
117+
]),
118+
"qoder",
119+
);
120+
expect(out).toContain("The Git working tree for this task is mounted at `/data/workspace/repo`.");
121+
expect(out).toContain("Prefix every shell command with:\ncd -- '/data/workspace/repo' &&");
122+
expect(out).toContain("Use absolute paths under `/data/workspace/repo` for non-shell file tools.");
123+
expect(out).not.toContain("gitlab.example.com");
124+
expect(out).not.toContain("do-not-leak");
125+
expect(out.endsWith("implement the feature")).toBe(true);
126+
});
127+
128+
test("shell-quotes an explicit repository mount path", () => {
129+
const out = prepareInitialSessionPrompt(
130+
"inspect it",
131+
bindings([
132+
{
133+
type: "github_repository",
134+
url: "https://code.example.com/team/repo.git",
135+
authorization_token: "secret",
136+
mount_path: "/data/workspace/team's repo",
137+
},
138+
]),
139+
"qoder",
140+
);
141+
expect(out).toContain(`cd -- '/data/workspace/team'"'"'s repo' &&`);
142+
});
143+
144+
test("supports scp-like Git remotes without naming a single working directory when several are mounted", () => {
145+
const out = prepareInitialSessionPrompt(
146+
"coordinate the change",
147+
bindings([
148+
{
149+
type: "github_repository",
150+
url: "git@host.example.com:team/api.git",
151+
authorization_token: "secret-a",
152+
},
153+
{
154+
type: "github_repository",
155+
url: "https://gitea.example.com/team/web.git",
156+
authorization_token: "secret-b",
157+
mount_path: "/data/projects/web",
158+
},
159+
]),
160+
"qoder",
161+
);
162+
expect(out).toContain("- /data/workspace/api");
163+
expect(out).toContain("- /data/projects/web");
164+
expect(out).toContain("Choose the appropriate working tree");
165+
expect(out).not.toContain(" as the working directory");
166+
});
167+
168+
test("leaves prompts unchanged when the Session has no files or repositories", () => {
169+
expect(prepareInitialSessionPrompt("hello", bindings(), "qoder")).toBe("hello");
170+
});
171+
});

0 commit comments

Comments
 (0)