@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- package/package.json +53 -4
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GitHubWorkspaceProvider = void 0;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const promises_1 = require("node:fs/promises");
|
|
8
|
+
const node_http_1 = require("node:http");
|
|
9
|
+
const node_path_1 = require("node:path");
|
|
10
|
+
const node_util_1 = require("node:util");
|
|
11
|
+
const github_command_guards_1 = require("./github-command-guards");
|
|
12
|
+
const github_proxy_1 = require("./github-proxy");
|
|
13
|
+
const github_proxy_certificates_1 = require("./github-proxy-certificates");
|
|
14
|
+
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
15
|
+
const DEFAULT_PROXY_PORT = 7780;
|
|
16
|
+
const LINK_SECRET_FILE = '.github-proxy-link-secret';
|
|
17
|
+
const WIP_PUSH_FAILED_FILE = '.worktree-wip-push-failed';
|
|
18
|
+
const WIP_COMMIT_MESSAGE = 'chore(agent): wip — uncommitted at turn end';
|
|
19
|
+
/** Bare clone plus one worktree per session, adapted from 20x. */
|
|
20
|
+
class GitHubWorkspaceProvider {
|
|
21
|
+
options;
|
|
22
|
+
sessions = new Map();
|
|
23
|
+
gitEnvironment;
|
|
24
|
+
server = null;
|
|
25
|
+
port = null;
|
|
26
|
+
serverStart = null;
|
|
27
|
+
linkSecret = null;
|
|
28
|
+
caCertificatePath = null;
|
|
29
|
+
restartNotedSessions = new Set();
|
|
30
|
+
restoredWorktreeNotes = new Map();
|
|
31
|
+
preparedWorktrees = new Map();
|
|
32
|
+
directHosts = new Set();
|
|
33
|
+
constructor(options) {
|
|
34
|
+
this.options = options;
|
|
35
|
+
// Capture PATH before harness.start prepends the agent-facing guard bin.
|
|
36
|
+
// These control-plane-owned clone/worktree commands use the original Git;
|
|
37
|
+
// only backend shells should traverse the defense-in-depth launcher.
|
|
38
|
+
this.gitEnvironment = { ...process.env };
|
|
39
|
+
for (const key of Object.keys(this.gitEnvironment)) {
|
|
40
|
+
if (key.startsWith('GIT_') || key.startsWith('GH_')) {
|
|
41
|
+
delete this.gitEnvironment[key];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
this.gitEnvironment.GIT_TERMINAL_PROMPT = '0';
|
|
45
|
+
this.rememberDirectUrls(options.directUrls ?? []);
|
|
46
|
+
}
|
|
47
|
+
rememberDirectUrls(urls) {
|
|
48
|
+
for (const value of urls) {
|
|
49
|
+
if (!value)
|
|
50
|
+
continue;
|
|
51
|
+
try {
|
|
52
|
+
this.directHosts.add(new URL(value).hostname.toLowerCase());
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* The component that owns an invalid URL reports it. */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
runGit(args, options = {}) {
|
|
60
|
+
const { environment, ...execOptions } = options;
|
|
61
|
+
return execFileAsync('git', args, {
|
|
62
|
+
...execOptions,
|
|
63
|
+
env: { ...this.gitEnvironment, ...environment }
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A harness crash must not erase or recreate a session checkout. Accept an
|
|
68
|
+
* existing path only when Git proves that it is the exact worktree this
|
|
69
|
+
* provider would have created: same canonical root, same bare repository,
|
|
70
|
+
* and same per-session branch. Anything else fails closed.
|
|
71
|
+
*/
|
|
72
|
+
async canReuseWorktree(bare, worktree, branch) {
|
|
73
|
+
let entry;
|
|
74
|
+
try {
|
|
75
|
+
entry = await (0, promises_1.lstat)(worktree);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error.code === 'ENOENT')
|
|
79
|
+
return false;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
83
|
+
throw new Error(`Existing repository workspace is not a directory: ${worktree}`);
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const [topLevel, commonDir, currentBranch] = await Promise.all([
|
|
87
|
+
this.runGit(['rev-parse', '--show-toplevel'], { cwd: worktree }),
|
|
88
|
+
this.runGit(['rev-parse', '--git-common-dir'], { cwd: worktree }),
|
|
89
|
+
this.runGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], {
|
|
90
|
+
cwd: worktree
|
|
91
|
+
})
|
|
92
|
+
]);
|
|
93
|
+
const [expectedWorktree, actualWorktree, expectedBare, actualBare] = await Promise.all([
|
|
94
|
+
(0, promises_1.realpath)(worktree),
|
|
95
|
+
(0, promises_1.realpath)((0, node_path_1.resolve)(worktree, topLevel.stdout.trim())),
|
|
96
|
+
(0, promises_1.realpath)(bare),
|
|
97
|
+
(0, promises_1.realpath)((0, node_path_1.resolve)(worktree, commonDir.stdout.trim()))
|
|
98
|
+
]);
|
|
99
|
+
if (actualWorktree !== expectedWorktree ||
|
|
100
|
+
actualBare !== expectedBare ||
|
|
101
|
+
currentBranch.stdout.trim() !== branch) {
|
|
102
|
+
throw new Error('repository or branch does not match');
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw new Error(`Existing repository workspace cannot be reused safely: ${worktree}: ${error instanceof Error ? error.message : String(error)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async dirtyFileCount(worktree) {
|
|
111
|
+
const status = await this.runGit(['status', '--porcelain=v1', '-z'], {
|
|
112
|
+
cwd: worktree,
|
|
113
|
+
maxBuffer: 10 * 1024 * 1024
|
|
114
|
+
});
|
|
115
|
+
return status.stdout.split('\0').filter(Boolean).length;
|
|
116
|
+
}
|
|
117
|
+
async worktreesForSession(sessionId) {
|
|
118
|
+
const prepared = this.preparedWorktrees.get(sessionId);
|
|
119
|
+
if (prepared)
|
|
120
|
+
return prepared;
|
|
121
|
+
const root = this.sessionPath(sessionId);
|
|
122
|
+
let entries;
|
|
123
|
+
try {
|
|
124
|
+
entries = await (0, promises_1.readdir)(root, { withFileTypes: true });
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error.code === 'ENOENT')
|
|
128
|
+
return [];
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
const worktrees = [];
|
|
132
|
+
try {
|
|
133
|
+
await this.runGit(['rev-parse', '--is-inside-work-tree'], { cwd: root });
|
|
134
|
+
worktrees.push(root);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* Repository-mode session roots contain child worktrees instead. */
|
|
138
|
+
}
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
141
|
+
continue;
|
|
142
|
+
const candidate = (0, node_path_1.join)(root, entry.name);
|
|
143
|
+
try {
|
|
144
|
+
await this.runGit(['rev-parse', '--is-inside-work-tree'], {
|
|
145
|
+
cwd: candidate
|
|
146
|
+
});
|
|
147
|
+
worktrees.push(candidate);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* An ordinary session child directory is not a Git worktree. */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return worktrees;
|
|
154
|
+
}
|
|
155
|
+
failedPushMarker(sessionId) {
|
|
156
|
+
return (0, node_path_1.join)(this.sessionPath(sessionId), WIP_PUSH_FAILED_FILE);
|
|
157
|
+
}
|
|
158
|
+
async readFailedPushes(sessionId) {
|
|
159
|
+
try {
|
|
160
|
+
const value = JSON.parse(await (0, promises_1.readFile)(this.failedPushMarker(sessionId), 'utf8'));
|
|
161
|
+
return new Set(Array.isArray(value)
|
|
162
|
+
? value.filter((entry) => typeof entry === 'string')
|
|
163
|
+
: []);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (error.code === 'ENOENT')
|
|
167
|
+
return new Set();
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async writeFailedPushes(sessionId, failures) {
|
|
172
|
+
const marker = this.failedPushMarker(sessionId);
|
|
173
|
+
if (failures.size === 0) {
|
|
174
|
+
await (0, promises_1.unlink)(marker).catch((error) => {
|
|
175
|
+
if (error.code !== 'ENOENT')
|
|
176
|
+
throw error;
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
await (0, promises_1.writeFile)(marker, `${JSON.stringify([...failures].sort())}\n`, {
|
|
181
|
+
mode: 0o600
|
|
182
|
+
});
|
|
183
|
+
await (0, promises_1.chmod)(marker, 0o600);
|
|
184
|
+
}
|
|
185
|
+
setCredential(sessionId, credential) {
|
|
186
|
+
const existing = this.sessions.get(sessionId);
|
|
187
|
+
this.sessions.set(sessionId, {
|
|
188
|
+
sessionId,
|
|
189
|
+
sentinel: existing?.sentinel ?? `pfgh_${this.sentinelFor(sessionId)}`,
|
|
190
|
+
credential
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
loadOrCreateLinkSecret() {
|
|
194
|
+
(0, node_fs_1.mkdirSync)(this.options.root, { recursive: true, mode: 0o700 });
|
|
195
|
+
const path = (0, node_path_1.join)((0, node_path_1.resolve)(this.options.root), LINK_SECRET_FILE);
|
|
196
|
+
let descriptor = null;
|
|
197
|
+
try {
|
|
198
|
+
descriptor = (0, node_fs_1.openSync)(path, 'wx', 0o600);
|
|
199
|
+
(0, node_fs_1.writeFileSync)(descriptor, (0, node_crypto_1.randomBytes)(32));
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (error.code !== 'EEXIST')
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
if (descriptor !== null)
|
|
207
|
+
(0, node_fs_1.closeSync)(descriptor);
|
|
208
|
+
}
|
|
209
|
+
(0, node_fs_1.chmodSync)(path, 0o600);
|
|
210
|
+
const secret = (0, node_fs_1.readFileSync)(path);
|
|
211
|
+
if (secret.length !== 32) {
|
|
212
|
+
throw new Error(`Invalid GitHub proxy link secret at ${path}.`);
|
|
213
|
+
}
|
|
214
|
+
return secret;
|
|
215
|
+
}
|
|
216
|
+
sentinelFor(sessionId) {
|
|
217
|
+
this.linkSecret ??= this.loadOrCreateLinkSecret();
|
|
218
|
+
return (0, node_crypto_1.createHmac)('sha256', this.linkSecret)
|
|
219
|
+
.update(sessionId)
|
|
220
|
+
.digest('hex');
|
|
221
|
+
}
|
|
222
|
+
clearCredential(sessionId) {
|
|
223
|
+
this.sessions.delete(sessionId);
|
|
224
|
+
}
|
|
225
|
+
sessionPath(sessionId) {
|
|
226
|
+
if (!/^[A-Za-z0-9._-]+$/.test(sessionId) ||
|
|
227
|
+
['.', '..'].includes(sessionId)) {
|
|
228
|
+
throw new Error(`Unusable session id for a workspace path: ${JSON.stringify(sessionId)}`);
|
|
229
|
+
}
|
|
230
|
+
const root = (0, node_path_1.resolve)(this.options.root);
|
|
231
|
+
const path = (0, node_path_1.resolve)((0, node_path_1.join)(root, sessionId));
|
|
232
|
+
if (!path.startsWith(root + '/'))
|
|
233
|
+
throw new Error('Workspace path escaped the root.');
|
|
234
|
+
return path;
|
|
235
|
+
}
|
|
236
|
+
async ensureServer() {
|
|
237
|
+
if (this.port !== null)
|
|
238
|
+
return this.port;
|
|
239
|
+
if (this.serverStart)
|
|
240
|
+
return this.serverStart;
|
|
241
|
+
this.serverStart = this.startServer();
|
|
242
|
+
try {
|
|
243
|
+
return await this.serverStart;
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
this.serverStart = null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async startServer() {
|
|
250
|
+
const server = (this.options.createServer ?? node_http_1.createServer)((_request, response) => {
|
|
251
|
+
response.writeHead(400, { 'content-type': 'text/plain' });
|
|
252
|
+
response.end('This endpoint is an HTTPS CONNECT proxy.\n');
|
|
253
|
+
});
|
|
254
|
+
const certificates = await (0, github_proxy_certificates_1.createGitHubProxyCertificates)(this.options.root);
|
|
255
|
+
this.caCertificatePath = certificates.caCertificatePath;
|
|
256
|
+
new github_proxy_1.GitHubTlsProxy(server, {
|
|
257
|
+
certificates,
|
|
258
|
+
logger: this.options.logger,
|
|
259
|
+
isDirectHostAllowed: (hostname) => this.directHosts.has(hostname),
|
|
260
|
+
findSession: (sentinel) => [...this.sessions.values()].find((entry) => entry.sentinel === sentinel)
|
|
261
|
+
});
|
|
262
|
+
const listen = (port) => new Promise((resolvePromise, reject) => {
|
|
263
|
+
const onError = (error) => reject(error);
|
|
264
|
+
server.once('error', onError);
|
|
265
|
+
server.listen(port, '127.0.0.1', () => {
|
|
266
|
+
server.off('error', onError);
|
|
267
|
+
resolvePromise();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
const preferredPort = this.options.port ?? DEFAULT_PROXY_PORT;
|
|
271
|
+
try {
|
|
272
|
+
await listen(preferredPort);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (preferredPort === 0 ||
|
|
276
|
+
error.code !== 'EADDRINUSE') {
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
(this.options.logger ?? console).warn(`[GitHubWorkspace] GitHub proxy port ${preferredPort} is in use; ` +
|
|
280
|
+
`using a kernel-assigned loopback port instead: ${error instanceof Error ? error.message : String(error)}`);
|
|
281
|
+
await listen(0);
|
|
282
|
+
}
|
|
283
|
+
const address = server.address();
|
|
284
|
+
if (!address || typeof address === 'string') {
|
|
285
|
+
server.close();
|
|
286
|
+
throw new Error('GitHub proxy did not bind.');
|
|
287
|
+
}
|
|
288
|
+
this.server = server;
|
|
289
|
+
this.port = address.port;
|
|
290
|
+
return this.port;
|
|
291
|
+
}
|
|
292
|
+
async prepare(request) {
|
|
293
|
+
const sessionRoot = this.sessionPath(request.sessionId);
|
|
294
|
+
await (0, promises_1.mkdir)(sessionRoot, { recursive: true, mode: 0o700 });
|
|
295
|
+
await (0, promises_1.chmod)(sessionRoot, 0o700);
|
|
296
|
+
if (request.mode === 'scratch')
|
|
297
|
+
return sessionRoot;
|
|
298
|
+
if (request.refusalReason)
|
|
299
|
+
throw new Error(request.refusalReason);
|
|
300
|
+
const session = this.sessions.get(request.sessionId);
|
|
301
|
+
if (!session || !request.repositories?.length) {
|
|
302
|
+
throw new Error('GitHub repository workspace has no in-memory credential.');
|
|
303
|
+
}
|
|
304
|
+
await this.ensureServer();
|
|
305
|
+
const helper = '!gh auth git-credential';
|
|
306
|
+
const ghConfig = (0, node_path_1.join)(sessionRoot, '.gh');
|
|
307
|
+
await (0, promises_1.mkdir)(ghConfig, { recursive: true, mode: 0o700 });
|
|
308
|
+
await (0, promises_1.chmod)(ghConfig, 0o700);
|
|
309
|
+
const gitNetworkEnvironment = this.getEnvironment(request.sessionId);
|
|
310
|
+
const repositoriesRoot = (0, node_path_1.join)((0, node_path_1.resolve)(this.options.root), '.repositories');
|
|
311
|
+
await (0, promises_1.mkdir)(repositoriesRoot, { recursive: true, mode: 0o700 });
|
|
312
|
+
const worktrees = [];
|
|
313
|
+
let reusedWorktree = false;
|
|
314
|
+
for (const repository of request.repositories) {
|
|
315
|
+
const [owner, name] = repository.fullName.split('/');
|
|
316
|
+
const bare = (0, node_path_1.join)(repositoriesRoot, `${owner}--${name}.git`);
|
|
317
|
+
const worktree = (0, node_path_1.join)(sessionRoot, name);
|
|
318
|
+
// The empty value resets every inherited/global helper. Without it, Git
|
|
319
|
+
// can pass the installation token to a desktop keychain helper on the
|
|
320
|
+
// approval phase even though this helper itself never writes a token.
|
|
321
|
+
const gitArgs = [
|
|
322
|
+
'-c',
|
|
323
|
+
'credential.helper=',
|
|
324
|
+
'-c',
|
|
325
|
+
`credential.helper=${helper}`
|
|
326
|
+
];
|
|
327
|
+
try {
|
|
328
|
+
await this.runGit(['rev-parse', '--is-bare-repository'], {
|
|
329
|
+
cwd: bare
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
await this.runGit([...gitArgs, 'clone', '--bare', repository.cloneUrl, bare], {
|
|
334
|
+
timeout: 10 * 60_000,
|
|
335
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
336
|
+
environment: gitNetworkEnvironment
|
|
337
|
+
});
|
|
338
|
+
await this.runGit([
|
|
339
|
+
'config',
|
|
340
|
+
'remote.origin.fetch',
|
|
341
|
+
'+refs/heads/*:refs/remotes/origin/*'
|
|
342
|
+
], { cwd: bare });
|
|
343
|
+
}
|
|
344
|
+
await this.runGit([...gitArgs, 'fetch', 'origin', '+refs/heads/*:refs/remotes/origin/*'], {
|
|
345
|
+
cwd: bare,
|
|
346
|
+
timeout: 10 * 60_000,
|
|
347
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
348
|
+
environment: gitNetworkEnvironment
|
|
349
|
+
});
|
|
350
|
+
await this.runGit(['worktree', 'prune'], { cwd: bare });
|
|
351
|
+
const branch = `agent/${request.sessionId}`;
|
|
352
|
+
const reuse = await this.canReuseWorktree(bare, worktree, branch);
|
|
353
|
+
if (!reuse) {
|
|
354
|
+
try {
|
|
355
|
+
await this.runGit(['worktree', 'add', worktree, branch], {
|
|
356
|
+
cwd: bare
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
await this.runGit([
|
|
361
|
+
'worktree',
|
|
362
|
+
'add',
|
|
363
|
+
'-b',
|
|
364
|
+
branch,
|
|
365
|
+
worktree,
|
|
366
|
+
`origin/${repository.defaultBranch}`
|
|
367
|
+
], { cwd: bare });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
reusedWorktree = true;
|
|
372
|
+
}
|
|
373
|
+
worktrees.push(worktree);
|
|
374
|
+
await this.runGit(['config', '--unset-all', 'credential.helper'], {
|
|
375
|
+
cwd: worktree
|
|
376
|
+
}).catch(() => undefined);
|
|
377
|
+
await this.runGit(['config', '--add', 'credential.helper', ''], {
|
|
378
|
+
cwd: worktree
|
|
379
|
+
});
|
|
380
|
+
await this.runGit(['config', '--add', 'credential.helper', helper], {
|
|
381
|
+
cwd: worktree
|
|
382
|
+
});
|
|
383
|
+
// Hooks must be per worktree; a shared bare-repo hook would overwrite
|
|
384
|
+
// another session's allowed destination when sessions overlap.
|
|
385
|
+
await this.runGit(['config', 'extensions.worktreeConfig', 'true'], {
|
|
386
|
+
cwd: bare
|
|
387
|
+
});
|
|
388
|
+
await this.runGit(['config', '--worktree', 'core.bare', 'false'], {
|
|
389
|
+
cwd: worktree
|
|
390
|
+
});
|
|
391
|
+
const hooks = (0, node_path_1.join)(sessionRoot, '.github-hooks', name);
|
|
392
|
+
await (0, github_command_guards_1.installPushGuard)(hooks, request.sessionId, repository.defaultBranch, repository.cloneUrl);
|
|
393
|
+
await this.runGit(['config', '--worktree', 'core.hooksPath', hooks], {
|
|
394
|
+
cwd: worktree
|
|
395
|
+
});
|
|
396
|
+
request.onRepositoryStatus?.({
|
|
397
|
+
fullName: repository.fullName,
|
|
398
|
+
status: 'checkedOut'
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
this.preparedWorktrees.set(request.sessionId, worktrees);
|
|
402
|
+
if (reusedWorktree) {
|
|
403
|
+
let dirtyFiles = 0;
|
|
404
|
+
for (const worktree of worktrees) {
|
|
405
|
+
dirtyFiles += await this.dirtyFileCount(worktree);
|
|
406
|
+
}
|
|
407
|
+
this.restoredWorktreeNotes.set(request.sessionId, dirtyFiles === 0
|
|
408
|
+
? 'Workspace restored; the repository worktree is clean.'
|
|
409
|
+
: `Workspace restored; ${dirtyFiles} ${dirtyFiles === 1 ? 'file' : 'files'} with uncommitted changes ${dirtyFiles === 1 ? 'is' : 'are'} still here.`);
|
|
410
|
+
}
|
|
411
|
+
const instructions = [
|
|
412
|
+
'# GitHub repository access',
|
|
413
|
+
'',
|
|
414
|
+
'Each repository is a child directory. Commit and push only the agent/<session-id> branch.',
|
|
415
|
+
'GitHub is reachable through a local proxy; use gh and git as usual. The token in GH_TOKEN is a session sentinel that works only inside this sandbox. Merges and pushes outside agent/<session-id> are refused.',
|
|
416
|
+
''
|
|
417
|
+
].join('\n');
|
|
418
|
+
await (0, promises_1.writeFile)((0, node_path_1.join)(sessionRoot, 'AGENTS.md'), instructions, {
|
|
419
|
+
mode: 0o600
|
|
420
|
+
});
|
|
421
|
+
return sessionRoot;
|
|
422
|
+
}
|
|
423
|
+
getEnvironment(sessionId, directUrls = []) {
|
|
424
|
+
const session = this.sessions.get(sessionId);
|
|
425
|
+
if (!session || this.port === null || !this.caCertificatePath)
|
|
426
|
+
return {};
|
|
427
|
+
this.rememberDirectUrls(directUrls);
|
|
428
|
+
const noProxy = new Set(['127.0.0.1', 'localhost', ...this.directHosts]);
|
|
429
|
+
const proxy = `http://127.0.0.1:${this.port}`;
|
|
430
|
+
const bypass = [...noProxy].join(',');
|
|
431
|
+
return {
|
|
432
|
+
// Git's libcurl on the Debian sandbox reads the lowercase form. With
|
|
433
|
+
// uppercase only, public clones appeared to work anonymously while a
|
|
434
|
+
// private clone sent the sentinel directly to GitHub and failed with
|
|
435
|
+
// "Invalid username or token". Keep both forms: Go-based gh uses the
|
|
436
|
+
// uppercase convention and native Git uses the lowercase convention.
|
|
437
|
+
HTTPS_PROXY: proxy,
|
|
438
|
+
https_proxy: proxy,
|
|
439
|
+
NO_PROXY: bypass,
|
|
440
|
+
no_proxy: bypass,
|
|
441
|
+
SSL_CERT_FILE: this.caCertificatePath,
|
|
442
|
+
GIT_SSL_CAINFO: this.caCertificatePath,
|
|
443
|
+
CURL_CA_BUNDLE: this.caCertificatePath,
|
|
444
|
+
NODE_EXTRA_CA_CERTS: this.caCertificatePath,
|
|
445
|
+
GH_TOKEN: session.sentinel,
|
|
446
|
+
GH_CONFIG_DIR: (0, node_path_1.join)(this.sessionPath(sessionId), '.gh')
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
takeTurnNote(sessionId) {
|
|
450
|
+
if (this.restartNotedSessions.has(sessionId))
|
|
451
|
+
return null;
|
|
452
|
+
if (!this.sessions.has(sessionId) || this.port === null)
|
|
453
|
+
return null;
|
|
454
|
+
this.restartNotedSessions.add(sessionId);
|
|
455
|
+
const restored = this.restoredWorktreeNotes.get(sessionId);
|
|
456
|
+
return [
|
|
457
|
+
'Harness restarted; GitHub access is through the local proxy — use gh/git as usual.',
|
|
458
|
+
restored
|
|
459
|
+
]
|
|
460
|
+
.filter(Boolean)
|
|
461
|
+
.join('\n');
|
|
462
|
+
}
|
|
463
|
+
async preserveAtTurnEnd(sessionId) {
|
|
464
|
+
const worktrees = await this.worktreesForSession(sessionId);
|
|
465
|
+
const failures = await this.readFailedPushes(sessionId);
|
|
466
|
+
const branch = `agent/${sessionId}`;
|
|
467
|
+
for (const worktree of worktrees) {
|
|
468
|
+
const name = worktree.slice(worktree.lastIndexOf('/') + 1);
|
|
469
|
+
try {
|
|
470
|
+
const dirtyFiles = await this.dirtyFileCount(worktree);
|
|
471
|
+
if (dirtyFiles > 0) {
|
|
472
|
+
await this.runGit(['add', '--all'], { cwd: worktree });
|
|
473
|
+
await this.runGit([
|
|
474
|
+
'-c',
|
|
475
|
+
'user.name=Peakflo Agent',
|
|
476
|
+
'-c',
|
|
477
|
+
'user.email=agent@peakflo.bot',
|
|
478
|
+
'commit',
|
|
479
|
+
'-m',
|
|
480
|
+
WIP_COMMIT_MESSAGE
|
|
481
|
+
], { cwd: worktree, maxBuffer: 10 * 1024 * 1024 });
|
|
482
|
+
}
|
|
483
|
+
if (dirtyFiles > 0 || failures.has(name)) {
|
|
484
|
+
await this.runGit(['push', 'origin', `HEAD:refs/heads/${branch}`], {
|
|
485
|
+
cwd: worktree,
|
|
486
|
+
timeout: 10 * 60_000,
|
|
487
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
488
|
+
environment: this.getEnvironment(sessionId)
|
|
489
|
+
});
|
|
490
|
+
failures.delete(name);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
failures.add(name);
|
|
495
|
+
(this.options.logger ?? console).warn(`[GitHubWorkspace] worktree-wip-push-failed sessionId=${sessionId} ` +
|
|
496
|
+
`repository=${name} error=${error instanceof Error ? error.message : String(error)}`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
await this.writeFailedPushes(sessionId, failures);
|
|
500
|
+
return failures.size === 0;
|
|
501
|
+
}
|
|
502
|
+
async release(sessionId, options = {}) {
|
|
503
|
+
const failedPushes = await this.readFailedPushes(sessionId);
|
|
504
|
+
if (failedPushes.size > 0) {
|
|
505
|
+
(this.options.logger ?? console).warn(`[GitHubWorkspace] worktree-release-refused-unpushed-wip ` +
|
|
506
|
+
`sessionId=${sessionId} repositories=${[...failedPushes].join(',')}`);
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
const worktrees = await this.worktreesForSession(sessionId);
|
|
510
|
+
let dirtyFiles = 0;
|
|
511
|
+
for (const worktree of worktrees) {
|
|
512
|
+
dirtyFiles += await this.dirtyFileCount(worktree);
|
|
513
|
+
}
|
|
514
|
+
if (dirtyFiles > 0 && !options.force) {
|
|
515
|
+
(this.options.logger ?? console).warn(`[GitHubWorkspace] worktree-release-refused-dirty ` +
|
|
516
|
+
`sessionId=${sessionId} dirtyFiles=${dirtyFiles}`);
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
await (0, promises_1.rm)(this.sessionPath(sessionId), { recursive: true, force: true });
|
|
520
|
+
this.preparedWorktrees.delete(sessionId);
|
|
521
|
+
this.restoredWorktreeNotes.delete(sessionId);
|
|
522
|
+
this.restartNotedSessions.delete(sessionId);
|
|
523
|
+
this.clearCredential(sessionId);
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
async stop() {
|
|
527
|
+
if (!this.server)
|
|
528
|
+
return;
|
|
529
|
+
await new Promise((resolvePromise) => this.server?.close(() => resolvePromise()));
|
|
530
|
+
this.server = null;
|
|
531
|
+
this.port = null;
|
|
532
|
+
this.serverStart = null;
|
|
533
|
+
this.sessions.clear();
|
|
534
|
+
this.caCertificatePath = null;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
exports.GitHubWorkspaceProvider = GitHubWorkspaceProvider;
|