-
Notifications
You must be signed in to change notification settings - Fork 38
Expand file tree
/
Copy pathclone-repository.ts
More file actions
256 lines (248 loc) · 8.86 KB
/
Copy pathclone-repository.ts
File metadata and controls
256 lines (248 loc) · 8.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
import {
definePluginTool,
PluginToolInputError,
pluginToolOutputSchema,
type PluginLogger,
type PluginSandbox,
type PluginToolOutput,
type PluginWorkspaceToolContext,
} from "@sentry/junior-plugin-api";
import { z } from "zod";
import { isReservedSandboxDirectory } from "../sandbox-paths.js";
const inputSchema = z
.object({
repo: z.string().describe('Repository in "owner/name" format.'),
directory: z
.string()
.regex(/^(?:[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)$/)
.refine(
(value) =>
!value.split("/").some((part) => part === "." || part === ".."),
{
message:
"Directory must be a relative path without . or .. segments.",
},
)
.describe(
"Optional destination directory under the sandbox root. Defaults to repos/{name}.",
)
.optional(),
allowAdHoc: z
.boolean()
.optional()
.describe(
"Set true to keep an intentional ad-hoc checkout when matching Workspaces exist. Prefer switchWorkspace; the checkout is already present after a successful switch.",
),
blobless: z
.boolean()
.optional()
.describe(
"Set true to perform a blobless shallow clone (--filter=blob:none) to reduce bandwidth and clone times on large repositories.",
),
timeoutSeconds: z
.number()
.int()
.positive()
.max(900)
.optional()
.describe(
"Optional timeout in seconds for the clone operation. Defaults to 300 seconds (5 minutes).",
),
})
.strict();
const cloneSchema = z.object({
path: z.string(),
repo: z.string(),
});
type Clone = z.output<typeof cloneSchema>;
interface Result extends PluginToolOutput, Clone {
target: "cloneRepository";
}
const outputSchema = pluginToolOutputSchema.merge(
cloneSchema.extend({
target: z.literal("cloneRepository"),
}),
);
function parseRepo(value: string): { name: string; owner: string } {
const parts = value.split("/").map((part) => part.trim());
if (parts.length !== 2 || !parts[0] || !parts[1]) {
throw new PluginToolInputError('repo must use "owner/name" format');
}
return { owner: parts[0], name: parts[1] };
}
/** Agent-facing input rejection when a matching Workspace should be used instead. */
function workspaceRequiredError(
repoId: string,
workspaces: string[],
): PluginToolInputError {
const names = workspaces.map((name) => `"${name}"`).join(", ");
if (workspaces.length === 1) {
return new PluginToolInputError(
`Repository ${repoId} is part of Workspace ${names}. Call switchWorkspace with that name; the checkout is already present after the switch. Pass allowAdHoc=true only for an intentional ad-hoc checkout.`,
);
}
return new PluginToolInputError(
`Repository ${repoId} is part of Workspaces ${names}. Call switchWorkspace with one of those names; the checkout is already present after the switch. Pass allowAdHoc=true only for an intentional ad-hoc checkout.`,
);
}
function defaultDirectory(repoName: string): string {
// Keep ad-hoc clones under repos/ so they match Workspace layout and stay
// clear of reserved sandbox roots (skills, data, .junior).
return `repos/${repoName}`;
}
function commandSignal(
signal: AbortSignal | undefined,
timeoutMs: number,
): AbortSignal {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
async function removePartialClone(
ctx: {
log: PluginLogger;
sandbox: PluginSandbox;
},
path: string,
): Promise<void> {
try {
const result = await ctx.sandbox.run({
cmd: "rm",
args: ["-rf", "--", path],
cwd: ctx.sandbox.root,
signal: AbortSignal.timeout(30_000),
});
if (result.exitCode !== 0) {
ctx.log.warn("github.clone.cleanup.failed", {
path,
stderr: result.stderr,
});
}
} catch (error) {
ctx.log.warn("github.clone.cleanup.failed", {
path,
error: error instanceof Error ? error.message : String(error),
});
}
}
/** Clone one GitHub repository into the sandbox as an ad-hoc checkout. */
export function createGitHubCloneRepositoryTool(ctx: {
log: PluginLogger;
sandbox: PluginSandbox;
workspaces: PluginWorkspaceToolContext;
}) {
return definePluginTool({
annotations: {
// Remote GitHub effect is contents-read only. Sandbox checkout creation is
// the same class of ephemeral local materialization as webFetch artifacts,
// so this stays a read-only inspection tool rather than a mutating write.
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
readOnlyHint: true,
},
description:
"Clone a GitHub repository into the sandbox as an ad-hoc checkout. The destination must not already exist. When matching Workspaces exist this is a tool input error: call switchWorkspace instead, or pass allowAdHoc=true for an intentional ad-hoc checkout.",
describeProposal(input) {
const directory =
typeof input.directory === "string" && input.directory.length > 0
? input.directory
: undefined;
const adHoc =
input.allowAdHoc === true ? " as an intentional ad-hoc checkout" : "";
return directory
? `Shallow-clone ${input.repo} into the local sandbox at ${directory}${adHoc} for inspection (no GitHub mutation).`
: `Shallow-clone ${input.repo} into the local sandbox${adHoc} for inspection (no GitHub mutation).`;
},
executionMode: "sequential",
inputSchema,
outputSchema,
async execute(input, options): Promise<Result> {
const repo = parseRepo(input.repo);
const repoId = `${repo.owner}/${repo.name}`;
// Look up matching recipes before any sandbox write so a later
// switchWorkspace does not discard a just-created ad-hoc clone.
let workspaces: string[] = [];
try {
workspaces = await ctx.workspaces.findByRepository({
provider: "github",
repo: repoId,
});
} catch (error) {
// Fail open: a lookup outage must not block inspection clones.
ctx.log.error("github.clone.workspaces_lookup.failed", {
repo: repoId,
error: error instanceof Error ? error.message : String(error),
});
}
if (workspaces.length > 0 && input.allowAdHoc !== true) {
throw workspaceRequiredError(repoId, workspaces);
}
const directory = input.directory ?? defaultDirectory(repo.name);
const rootSegment = directory.split("/")[0] ?? directory;
if (isReservedSandboxDirectory(rootSegment)) {
throw new PluginToolInputError(
`Directory conflicts with a reserved sandbox path: ${directory}`,
);
}
const path = `${ctx.sandbox.root}/${directory}`;
const parentDirectory = directory.includes("/")
? directory.slice(0, directory.lastIndexOf("/"))
: undefined;
if (parentDirectory) {
const mkdir = await ctx.sandbox.run({
cmd: "mkdir",
args: ["-p", "--", `${ctx.sandbox.root}/${parentDirectory}`],
cwd: ctx.sandbox.root,
signal: commandSignal(options.signal, 30_000),
});
if (mkdir.exitCode !== 0) {
throw new PluginToolInputError(
`Failed to create clone parent directory: ${parentDirectory}`,
);
}
}
const exists = await ctx.sandbox.run({
cmd: "bash",
args: ["-c", `test -e "$1"`, "bash", path],
cwd: ctx.sandbox.root,
signal: commandSignal(options.signal, 30_000),
});
if (exists.exitCode === 0) {
throw new PluginToolInputError(`destination already exists: ${path}`);
}
// Git smart HTTP uses git-upload-pack for clone, fetch, pull, deepen, and
// ls-remote, so egress policy cannot distinguish clone without also
// blocking normal repository workflows. This tool is the bounded,
// preferred clone path rather than an enforceable network boundary.
const timeoutMs = (input.timeoutSeconds ?? 300) * 1000;
const cloneArgs = ["clone", "--quiet", "--depth=1"];
if (input.blobless === true) {
cloneArgs.push("--filter=blob:none");
}
cloneArgs.push(
"--",
`https://github.com/${repo.owner}/${repo.name}.git`,
directory,
);
let clone;
try {
clone = await ctx.sandbox.run({
cmd: "git",
args: cloneArgs,
cwd: ctx.sandbox.root,
signal: commandSignal(options.signal, timeoutMs),
});
} catch (error) {
await removePartialClone(ctx, path);
throw error;
}
if (clone.exitCode !== 0) {
await removePartialClone(ctx, path);
throw new PluginToolInputError(
`GitHub repository clone failed: ${clone.stderr.trim() || `exit ${clone.exitCode}`}`,
);
}
return { target: "cloneRepository", repo: repoId, path };
},
});
}