@peakflo/agent-harness 0.2.16 → 0.2.17-rc.2
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/dist/github-workspace.d.ts +11 -4
- package/dist/github-workspace.js +165 -40
- package/dist/ports.d.ts +2 -0
- package/dist/runtime/session-runner.js +14 -0
- package/package.json +1 -1
|
@@ -2,6 +2,8 @@ import { type RequestListener, type Server } from 'node:http';
|
|
|
2
2
|
import type { WorkspaceProvider, WorkspaceRequest } from './ports';
|
|
3
3
|
import type { PeerUidResolver } from './runtime/loopback-peer';
|
|
4
4
|
import type { LeasedGitHubCredential } from './types';
|
|
5
|
+
/** Compare repository identity without retaining URL credentials or a branch. */
|
|
6
|
+
export declare function githubRepositoryIdentity(remote: string): string | null;
|
|
5
7
|
export interface GitHubWorkspaceOptions {
|
|
6
8
|
root: string;
|
|
7
9
|
port?: number;
|
|
@@ -29,16 +31,20 @@ export declare class GitHubWorkspaceProvider implements WorkspaceProvider {
|
|
|
29
31
|
private caCertificatePath;
|
|
30
32
|
private readonly restartNotedSessions;
|
|
31
33
|
private readonly restoredWorktreeNotes;
|
|
34
|
+
private readonly preparationNotes;
|
|
32
35
|
private readonly preparedWorktrees;
|
|
33
36
|
private readonly directHosts;
|
|
34
37
|
constructor(options: GitHubWorkspaceOptions);
|
|
35
38
|
private rememberDirectUrls;
|
|
36
39
|
private runGit;
|
|
40
|
+
private sameRepository;
|
|
41
|
+
/** Never fetch a different repository into a shared durable bare clone. */
|
|
42
|
+
private bareRepositoryPath;
|
|
43
|
+
private isUnusableCheckout;
|
|
37
44
|
/**
|
|
38
|
-
* A
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* and same per-session branch. Anything else fails closed.
|
|
45
|
+
* A session owns its checkout, including branch changes and detached HEAD.
|
|
46
|
+
* Check its root and repository identity, never its branch or commit. Keep
|
|
47
|
+
* an unrelated or unusable checkout aside before preparing a replacement.
|
|
42
48
|
*/
|
|
43
49
|
private canReuseWorktree;
|
|
44
50
|
private dirtyFileCount;
|
|
@@ -56,6 +62,7 @@ export declare class GitHubWorkspaceProvider implements WorkspaceProvider {
|
|
|
56
62
|
prepare(request: WorkspaceRequest): Promise<string>;
|
|
57
63
|
getEnvironment(sessionId: string, directUrls?: string[]): Record<string, string>;
|
|
58
64
|
takeTurnNote(sessionId: string): string | null;
|
|
65
|
+
takePreparationNotes(sessionId: string): string[];
|
|
59
66
|
preserveAtTurnEnd(sessionId: string): Promise<boolean>;
|
|
60
67
|
release(sessionId: string, options?: {
|
|
61
68
|
force?: boolean;
|
package/dist/github-workspace.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.GitHubWorkspaceProvider = void 0;
|
|
4
|
+
exports.githubRepositoryIdentity = githubRepositoryIdentity;
|
|
4
5
|
exports.directHostnames = directHostnames;
|
|
5
6
|
const node_child_process_1 = require("node:child_process");
|
|
6
7
|
const node_crypto_1 = require("node:crypto");
|
|
@@ -18,6 +19,28 @@ const DEFAULT_PROXY_PORT = 7780;
|
|
|
18
19
|
const LINK_SECRET_FILE = '.github-proxy-link-secret';
|
|
19
20
|
const WIP_PUSH_FAILED_FILE = '.worktree-wip-push-failed';
|
|
20
21
|
const WIP_COMMIT_MESSAGE = 'chore(agent): wip — uncommitted at turn end';
|
|
22
|
+
/** Compare repository identity without retaining URL credentials or a branch. */
|
|
23
|
+
function githubRepositoryIdentity(remote) {
|
|
24
|
+
const ssh = /^git@github\.com:(.+)$/i.exec(remote);
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(ssh ? `https://github.com/${ssh[1]}` : remote);
|
|
27
|
+
const host = url.hostname.toLowerCase();
|
|
28
|
+
// Within a harness-owned checkout/cache, loopback owner/repo URLs denote
|
|
29
|
+
// the GitHub proxy. This is not proof of identity for arbitrary local URLs.
|
|
30
|
+
if (!['github.com', '127.0.0.1', 'localhost', '[::1]'].includes(host)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (!['https:', 'http:', 'ssh:'].includes(url.protocol))
|
|
34
|
+
return null;
|
|
35
|
+
const path = url.pathname.replace(/\/$/, '').replace(/\.git$/i, '');
|
|
36
|
+
if (!/^\/[a-z0-9_.-]+\/[a-z0-9_.-]+$/i.test(path))
|
|
37
|
+
return null;
|
|
38
|
+
return `github.com${path.toLowerCase()}`;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
21
44
|
/**
|
|
22
45
|
* Hostnames of URLs the agent reaches WITHOUT a proxy (control plane, model
|
|
23
46
|
* gateway, MCP). Shared with the credential proxy's NO_PROXY so both proxies
|
|
@@ -49,6 +72,7 @@ class GitHubWorkspaceProvider {
|
|
|
49
72
|
caCertificatePath = null;
|
|
50
73
|
restartNotedSessions = new Set();
|
|
51
74
|
restoredWorktreeNotes = new Map();
|
|
75
|
+
preparationNotes = new Map();
|
|
52
76
|
preparedWorktrees = new Map();
|
|
53
77
|
directHosts = new Set();
|
|
54
78
|
constructor(options) {
|
|
@@ -76,13 +100,64 @@ class GitHubWorkspaceProvider {
|
|
|
76
100
|
env: { ...this.gitEnvironment, ...environment }
|
|
77
101
|
});
|
|
78
102
|
}
|
|
103
|
+
async sameRepository(expectedUrl, actualUrl, cwd) {
|
|
104
|
+
const expected = githubRepositoryIdentity(expectedUrl);
|
|
105
|
+
if (expected)
|
|
106
|
+
return githubRepositoryIdentity(actualUrl) === expected;
|
|
107
|
+
try {
|
|
108
|
+
return ((await (0, promises_1.realpath)((0, node_path_1.resolve)(cwd, actualUrl))) ===
|
|
109
|
+
(await (0, promises_1.realpath)(expectedUrl)));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Never fetch a different repository into a shared durable bare clone. */
|
|
116
|
+
async bareRepositoryPath(path, cloneUrl) {
|
|
117
|
+
const identity = githubRepositoryIdentity(cloneUrl) ?? cloneUrl;
|
|
118
|
+
const digest = (0, node_crypto_1.createHash)('sha256')
|
|
119
|
+
.update(identity)
|
|
120
|
+
.digest('hex')
|
|
121
|
+
.slice(0, 16);
|
|
122
|
+
// Leave old clones and linked worktrees intact. Bound collision handling
|
|
123
|
+
// without appending the same suffix recursively to a file name.
|
|
124
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
125
|
+
const candidate = attempt === 0 ? path : `${path}.recovered-${digest}-${attempt}`;
|
|
126
|
+
try {
|
|
127
|
+
const entry = await (0, promises_1.lstat)(candidate);
|
|
128
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error.code === 'ENOENT')
|
|
133
|
+
return candidate;
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
const origin = await this.runGit(['config', '--get', 'remote.origin.url'], {
|
|
137
|
+
cwd: candidate
|
|
138
|
+
}).catch((error) => {
|
|
139
|
+
if (this.isUnusableCheckout(error) || error.code === 1)
|
|
140
|
+
return null;
|
|
141
|
+
throw new Error('Could not inspect repository cache. Existing files were kept.');
|
|
142
|
+
});
|
|
143
|
+
if (origin &&
|
|
144
|
+
(await this.sameRepository(cloneUrl, origin.stdout.trim(), candidate))) {
|
|
145
|
+
return candidate;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
throw new Error('Could not prepare a repository cache. Existing files were kept.');
|
|
149
|
+
}
|
|
150
|
+
isUnusableCheckout(error) {
|
|
151
|
+
const gitError = error;
|
|
152
|
+
return (gitError.code === 128 &&
|
|
153
|
+
/not a git repository|invalid gitfile format|bad config line/i.test(gitError.stderr ?? ''));
|
|
154
|
+
}
|
|
79
155
|
/**
|
|
80
|
-
* A
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* and same per-session branch. Anything else fails closed.
|
|
156
|
+
* A session owns its checkout, including branch changes and detached HEAD.
|
|
157
|
+
* Check its root and repository identity, never its branch or commit. Keep
|
|
158
|
+
* an unrelated or unusable checkout aside before preparing a replacement.
|
|
84
159
|
*/
|
|
85
|
-
async canReuseWorktree(bare, worktree,
|
|
160
|
+
async canReuseWorktree(bare, worktree, cloneUrl, sessionId) {
|
|
86
161
|
let entry;
|
|
87
162
|
try {
|
|
88
163
|
entry = await (0, promises_1.lstat)(worktree);
|
|
@@ -92,33 +167,66 @@ class GitHubWorkspaceProvider {
|
|
|
92
167
|
return false;
|
|
93
168
|
throw error;
|
|
94
169
|
}
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
170
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
171
|
+
try {
|
|
172
|
+
const [topLevel, origin, commonDir] = await Promise.all([
|
|
173
|
+
this.runGit(['rev-parse', '--show-toplevel'], { cwd: worktree }),
|
|
174
|
+
this.runGit(['config', '--get', 'remote.origin.url'], {
|
|
175
|
+
cwd: worktree
|
|
176
|
+
}).catch((error) => {
|
|
177
|
+
if (error.code === 1 && !error.stderr)
|
|
178
|
+
return { stdout: '' };
|
|
179
|
+
throw error;
|
|
180
|
+
}),
|
|
181
|
+
this.runGit(['rev-parse', '--git-common-dir'], { cwd: worktree })
|
|
182
|
+
]);
|
|
183
|
+
const [expectedWorktree, actualWorktree, repositoriesRoot, actualCommon] = await Promise.all([
|
|
184
|
+
(0, promises_1.realpath)(worktree),
|
|
185
|
+
(0, promises_1.realpath)((0, node_path_1.resolve)(worktree, topLevel.stdout.trim())),
|
|
186
|
+
(0, promises_1.realpath)((0, node_path_1.join)((0, node_path_1.resolve)(this.options.root), '.repositories')),
|
|
187
|
+
(0, promises_1.realpath)((0, node_path_1.resolve)(worktree, commonDir.stdout.trim()))
|
|
188
|
+
]);
|
|
189
|
+
const sameRepository = await this.sameRepository(cloneUrl, origin.stdout.trim(), worktree);
|
|
190
|
+
const managedCommon = actualCommon.startsWith(`${repositoriesRoot}/`) ||
|
|
191
|
+
actualCommon === (0, node_path_1.join)(expectedWorktree, '.git');
|
|
192
|
+
if (actualWorktree === expectedWorktree &&
|
|
193
|
+
sameRepository &&
|
|
194
|
+
managedCommon)
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
// Only a proven broken Git checkout is moved. Resource exhaustion,
|
|
199
|
+
// permission errors and timeouts must leave a healthy checkout in place.
|
|
200
|
+
// Do not echo Git errors: they can contain remote credentials.
|
|
201
|
+
if (!this.isUnusableCheckout(error)) {
|
|
202
|
+
throw new Error('Could not inspect the existing repository workspace. Retry the message. Existing files were not changed.');
|
|
203
|
+
}
|
|
116
204
|
}
|
|
117
|
-
return true;
|
|
118
205
|
}
|
|
119
|
-
|
|
120
|
-
|
|
206
|
+
const backupRoot = await (0, promises_1.mkdtemp)((0, node_path_1.join)(this.sessionPath(sessionId), `.preserved-${Date.now()}-`));
|
|
207
|
+
const backup = (0, node_path_1.join)(backupRoot, 'repository');
|
|
208
|
+
// Git updates a linked worktree's administrative paths when possible.
|
|
209
|
+
// A plain directory, symlink or broken checkout still keeps all bytes.
|
|
210
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
211
|
+
await this.runGit(['worktree', 'move', worktree, backup], {
|
|
212
|
+
cwd: worktree
|
|
213
|
+
}).catch(async () => {
|
|
214
|
+
await (0, promises_1.rename)(worktree, backup);
|
|
215
|
+
await this.runGit(['worktree', 'repair', backup], { cwd: bare }).catch(() => undefined);
|
|
216
|
+
});
|
|
121
217
|
}
|
|
218
|
+
else {
|
|
219
|
+
await (0, promises_1.rename)(worktree, backup);
|
|
220
|
+
}
|
|
221
|
+
// A broken .git file cannot be repaired. Remove only obsolete Git
|
|
222
|
+
// registrations now, before reusing the vacated path; keep all saved files.
|
|
223
|
+
await this.runGit(['worktree', 'prune', '--expire=now'], { cwd: bare });
|
|
224
|
+
const notes = this.preparationNotes.get(sessionId) ?? [];
|
|
225
|
+
notes.push(`The previous repository folder could not be reused. All its files ` +
|
|
226
|
+
`were kept at ${backup}. A fresh checkout was prepared. ` +
|
|
227
|
+
'Review the saved folder before you remove it.');
|
|
228
|
+
this.preparationNotes.set(sessionId, notes);
|
|
229
|
+
return false;
|
|
122
230
|
}
|
|
123
231
|
async dirtyFileCount(worktree) {
|
|
124
232
|
const status = await this.runGit(['status', '--porcelain=v1', '-z'], {
|
|
@@ -331,7 +439,7 @@ class GitHubWorkspaceProvider {
|
|
|
331
439
|
let reusedWorktree = false;
|
|
332
440
|
for (const repository of request.repositories) {
|
|
333
441
|
const [owner, name] = repository.fullName.split('/');
|
|
334
|
-
const bare = (0, node_path_1.join)(repositoriesRoot, `${owner}--${name}.git`);
|
|
442
|
+
const bare = await this.bareRepositoryPath((0, node_path_1.join)(repositoriesRoot, `${owner}--${name}.git`), repository.cloneUrl);
|
|
335
443
|
const worktree = (0, node_path_1.join)(sessionRoot, name);
|
|
336
444
|
// The empty value resets every inherited/global helper. Without it, Git
|
|
337
445
|
// can pass the installation token to a desktop keychain helper on the
|
|
@@ -359,6 +467,11 @@ class GitHubWorkspaceProvider {
|
|
|
359
467
|
'+refs/heads/*:refs/remotes/origin/*'
|
|
360
468
|
], { cwd: bare });
|
|
361
469
|
}
|
|
470
|
+
// The identity check above permits old proxy/sentinel URL forms. Fetch
|
|
471
|
+
// through the current lease URL so a stale loopback port cannot fail first.
|
|
472
|
+
await this.runGit(['config', 'remote.origin.url', repository.cloneUrl], {
|
|
473
|
+
cwd: bare
|
|
474
|
+
});
|
|
362
475
|
await this.runGit([...gitArgs, 'fetch', 'origin', '+refs/heads/*:refs/remotes/origin/*'], {
|
|
363
476
|
cwd: bare,
|
|
364
477
|
timeout: 10 * 60_000,
|
|
@@ -367,7 +480,7 @@ class GitHubWorkspaceProvider {
|
|
|
367
480
|
});
|
|
368
481
|
await this.runGit(['worktree', 'prune'], { cwd: bare });
|
|
369
482
|
const branch = `agent/${request.sessionId}`;
|
|
370
|
-
const reuse = await this.canReuseWorktree(bare, worktree,
|
|
483
|
+
const reuse = await this.canReuseWorktree(bare, worktree, repository.cloneUrl, request.sessionId);
|
|
371
484
|
if (!reuse) {
|
|
372
485
|
try {
|
|
373
486
|
await this.runGit(['worktree', 'add', worktree, branch], {
|
|
@@ -375,14 +488,17 @@ class GitHubWorkspaceProvider {
|
|
|
375
488
|
});
|
|
376
489
|
}
|
|
377
490
|
catch {
|
|
378
|
-
await this.runGit([
|
|
379
|
-
|
|
380
|
-
'add',
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
491
|
+
const existingBranch = await this.runGit(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { cwd: bare }).then(() => true, () => false);
|
|
492
|
+
await this.runGit(existingBranch
|
|
493
|
+
? ['worktree', 'add', '--detach', worktree, branch]
|
|
494
|
+
: [
|
|
495
|
+
'worktree',
|
|
496
|
+
'add',
|
|
497
|
+
'-b',
|
|
498
|
+
branch,
|
|
499
|
+
worktree,
|
|
500
|
+
`origin/${repository.defaultBranch}`
|
|
501
|
+
], { cwd: bare });
|
|
386
502
|
}
|
|
387
503
|
}
|
|
388
504
|
else {
|
|
@@ -400,12 +516,15 @@ class GitHubWorkspaceProvider {
|
|
|
400
516
|
});
|
|
401
517
|
// Hooks must be per worktree; a shared bare-repo hook would overwrite
|
|
402
518
|
// another session's allowed destination when sessions overlap.
|
|
519
|
+
// A reused checkout may belong to a recovered cache or a standalone clone.
|
|
520
|
+
// Configure its actual common directory, rather than the default cache.
|
|
403
521
|
await this.runGit(['config', 'extensions.worktreeConfig', 'true'], {
|
|
404
|
-
cwd:
|
|
522
|
+
cwd: worktree
|
|
405
523
|
});
|
|
406
524
|
await this.runGit(['config', '--worktree', 'core.bare', 'false'], {
|
|
407
525
|
cwd: worktree
|
|
408
526
|
});
|
|
527
|
+
await this.runGit(['config', '--worktree', 'remote.origin.url', repository.cloneUrl], { cwd: worktree });
|
|
409
528
|
const hooks = (0, node_path_1.join)(sessionRoot, '.github-hooks', name);
|
|
410
529
|
await (0, github_command_guards_1.installPushGuard)(hooks, request.sessionId, repository.defaultBranch, repository.cloneUrl);
|
|
411
530
|
await this.runGit(['config', '--worktree', 'core.hooksPath', hooks], {
|
|
@@ -504,6 +623,11 @@ class GitHubWorkspaceProvider {
|
|
|
504
623
|
.filter(Boolean)
|
|
505
624
|
.join('\n');
|
|
506
625
|
}
|
|
626
|
+
takePreparationNotes(sessionId) {
|
|
627
|
+
const notes = this.preparationNotes.get(sessionId) ?? [];
|
|
628
|
+
this.preparationNotes.delete(sessionId);
|
|
629
|
+
return notes;
|
|
630
|
+
}
|
|
507
631
|
async preserveAtTurnEnd(sessionId) {
|
|
508
632
|
const worktrees = await this.worktreesForSession(sessionId);
|
|
509
633
|
const failures = await this.readFailedPushes(sessionId);
|
|
@@ -563,6 +687,7 @@ class GitHubWorkspaceProvider {
|
|
|
563
687
|
await (0, promises_1.rm)(this.sessionPath(sessionId), { recursive: true, force: true });
|
|
564
688
|
this.preparedWorktrees.delete(sessionId);
|
|
565
689
|
this.restoredWorktreeNotes.delete(sessionId);
|
|
690
|
+
this.preparationNotes.delete(sessionId);
|
|
566
691
|
this.restartNotedSessions.delete(sessionId);
|
|
567
692
|
this.clearCredential(sessionId);
|
|
568
693
|
return true;
|
package/dist/ports.d.ts
CHANGED
|
@@ -389,6 +389,8 @@ export interface WorkspaceProvider {
|
|
|
389
389
|
getEnvironment?(sessionId: string, directUrls?: string[]): Record<string, string>;
|
|
390
390
|
/** One-use host note to prepend to the next model turn, when present. */
|
|
391
391
|
takeTurnNote?(sessionId: string): string | null;
|
|
392
|
+
/** Recovery notices to show in the transcript and pass to the model. */
|
|
393
|
+
takePreparationNotes?(sessionId: string): string[];
|
|
392
394
|
/**
|
|
393
395
|
* Save dirty repository work before a turn gives up its credential.
|
|
394
396
|
* A false result means a remote backup failed and cleanup must keep the tree.
|
|
@@ -859,6 +859,20 @@ class SessionRunner {
|
|
|
859
859
|
.join('\n')
|
|
860
860
|
: `Repository workspace was not prepared: ${reason}`);
|
|
861
861
|
}
|
|
862
|
+
const workspaceNotes = this.deps.workspace.takePreparationNotes?.(session.sessionId) ?? [];
|
|
863
|
+
if (workspaceNotes.length > 0) {
|
|
864
|
+
const content = workspaceNotes.join('\n');
|
|
865
|
+
turnContent = `${content}\n\n${turnContent}`;
|
|
866
|
+
partsProduced += this.record(session.sessionId, [
|
|
867
|
+
{
|
|
868
|
+
id: `workspace-recovery-${session.turnId}`,
|
|
869
|
+
role: 'system',
|
|
870
|
+
partType: 'text',
|
|
871
|
+
content
|
|
872
|
+
}
|
|
873
|
+
]);
|
|
874
|
+
await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
|
|
875
|
+
}
|
|
862
876
|
/*
|
|
863
877
|
* THE USER'S ATTACHMENTS, INTO THIS TURN'S WORKSPACE — every turn, since
|
|
864
878
|
* each lease carries its own turn's files and the session may have moved
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peakflo/agent-harness",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.17-rc.2",
|
|
4
4
|
"description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|