@peakflo/agent-harness 0.2.16 → 0.2.17-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/dist/github-workspace.d.ts +10 -4
- package/dist/github-workspace.js +122 -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,19 @@ 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;
|
|
37
43
|
/**
|
|
38
|
-
* A
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* and same per-session branch. Anything else fails closed.
|
|
44
|
+
* A session owns its checkout, including branch changes and detached HEAD.
|
|
45
|
+
* Check its root and repository identity, never its branch or commit. Keep
|
|
46
|
+
* an unrelated or unusable checkout aside before preparing a replacement.
|
|
42
47
|
*/
|
|
43
48
|
private canReuseWorktree;
|
|
44
49
|
private dirtyFileCount;
|
|
@@ -56,6 +61,7 @@ export declare class GitHubWorkspaceProvider implements WorkspaceProvider {
|
|
|
56
61
|
prepare(request: WorkspaceRequest): Promise<string>;
|
|
57
62
|
getEnvironment(sessionId: string, directUrls?: string[]): Record<string, string>;
|
|
58
63
|
takeTurnNote(sessionId: string): string | null;
|
|
64
|
+
takePreparationNotes(sessionId: string): string[];
|
|
59
65
|
preserveAtTurnEnd(sessionId: string): Promise<boolean>;
|
|
60
66
|
release(sessionId: string, options?: {
|
|
61
67
|
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
|
+
// The loopback proxy can also be used as an explicit Git remote. Its
|
|
29
|
+
// credentials and port are transport details, not repository identity.
|
|
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,50 @@ 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
|
+
try {
|
|
118
|
+
await (0, promises_1.lstat)(path);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error.code === 'ENOENT')
|
|
122
|
+
return path;
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
const origin = await this.runGit(['config', '--get', 'remote.origin.url'], {
|
|
126
|
+
cwd: path
|
|
127
|
+
}).catch(() => null);
|
|
128
|
+
if (origin &&
|
|
129
|
+
(await this.sameRepository(cloneUrl, origin.stdout.trim(), path))) {
|
|
130
|
+
return path;
|
|
131
|
+
}
|
|
132
|
+
// Leave the old clone and all its linked worktrees intact. A separate
|
|
133
|
+
// clone also prevents an unrelated agent/<session> ref from being reused.
|
|
134
|
+
const identity = githubRepositoryIdentity(cloneUrl) ?? cloneUrl;
|
|
135
|
+
const digest = (0, node_crypto_1.createHash)('sha256')
|
|
136
|
+
.update(identity)
|
|
137
|
+
.digest('hex')
|
|
138
|
+
.slice(0, 16);
|
|
139
|
+
return this.bareRepositoryPath(`${path}.recovered-${digest}`, cloneUrl);
|
|
140
|
+
}
|
|
79
141
|
/**
|
|
80
|
-
* A
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* and same per-session branch. Anything else fails closed.
|
|
142
|
+
* A session owns its checkout, including branch changes and detached HEAD.
|
|
143
|
+
* Check its root and repository identity, never its branch or commit. Keep
|
|
144
|
+
* an unrelated or unusable checkout aside before preparing a replacement.
|
|
84
145
|
*/
|
|
85
|
-
async canReuseWorktree(
|
|
146
|
+
async canReuseWorktree(worktree, cloneUrl, sessionId) {
|
|
86
147
|
let entry;
|
|
87
148
|
try {
|
|
88
149
|
entry = await (0, promises_1.lstat)(worktree);
|
|
@@ -92,33 +153,45 @@ class GitHubWorkspaceProvider {
|
|
|
92
153
|
return false;
|
|
93
154
|
throw error;
|
|
94
155
|
}
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
currentBranch.stdout.trim() !== branch) {
|
|
115
|
-
throw new Error('repository or branch does not match');
|
|
156
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
157
|
+
try {
|
|
158
|
+
const [topLevel, origin] = await Promise.all([
|
|
159
|
+
this.runGit(['rev-parse', '--show-toplevel'], { cwd: worktree }),
|
|
160
|
+
this.runGit(['config', '--get', 'remote.origin.url'], {
|
|
161
|
+
cwd: worktree
|
|
162
|
+
})
|
|
163
|
+
]);
|
|
164
|
+
const [expectedWorktree, actualWorktree] = await Promise.all([
|
|
165
|
+
(0, promises_1.realpath)(worktree),
|
|
166
|
+
(0, promises_1.realpath)((0, node_path_1.resolve)(worktree, topLevel.stdout.trim()))
|
|
167
|
+
]);
|
|
168
|
+
const sameRepository = await this.sameRepository(cloneUrl, origin.stdout.trim(), worktree);
|
|
169
|
+
if (actualWorktree === expectedWorktree && sameRepository)
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// A broken Git file is recoverable. Do not echo a remote URL or a Git
|
|
174
|
+
// error here: it can contain credentials. Preserve every file below.
|
|
116
175
|
}
|
|
117
|
-
return true;
|
|
118
176
|
}
|
|
119
|
-
|
|
120
|
-
|
|
177
|
+
const backupRoot = await (0, promises_1.mkdtemp)((0, node_path_1.join)(this.sessionPath(sessionId), `.preserved-${Date.now()}-`));
|
|
178
|
+
const backup = (0, node_path_1.join)(backupRoot, 'repository');
|
|
179
|
+
// Git updates a linked worktree's administrative paths when possible.
|
|
180
|
+
// A plain directory, symlink or broken checkout still keeps all bytes.
|
|
181
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
182
|
+
await this.runGit(['worktree', 'move', worktree, backup], {
|
|
183
|
+
cwd: worktree
|
|
184
|
+
}).catch(() => (0, promises_1.rename)(worktree, backup));
|
|
121
185
|
}
|
|
186
|
+
else {
|
|
187
|
+
await (0, promises_1.rename)(worktree, backup);
|
|
188
|
+
}
|
|
189
|
+
const notes = this.preparationNotes.get(sessionId) ?? [];
|
|
190
|
+
notes.push(`The previous repository folder could not be reused. All its files ` +
|
|
191
|
+
`were kept at ${backup}. A fresh checkout was prepared. ` +
|
|
192
|
+
'Review the saved folder before you remove it.');
|
|
193
|
+
this.preparationNotes.set(sessionId, notes);
|
|
194
|
+
return false;
|
|
122
195
|
}
|
|
123
196
|
async dirtyFileCount(worktree) {
|
|
124
197
|
const status = await this.runGit(['status', '--porcelain=v1', '-z'], {
|
|
@@ -331,7 +404,7 @@ class GitHubWorkspaceProvider {
|
|
|
331
404
|
let reusedWorktree = false;
|
|
332
405
|
for (const repository of request.repositories) {
|
|
333
406
|
const [owner, name] = repository.fullName.split('/');
|
|
334
|
-
const bare = (0, node_path_1.join)(repositoriesRoot, `${owner}--${name}.git`);
|
|
407
|
+
const bare = await this.bareRepositoryPath((0, node_path_1.join)(repositoriesRoot, `${owner}--${name}.git`), repository.cloneUrl);
|
|
335
408
|
const worktree = (0, node_path_1.join)(sessionRoot, name);
|
|
336
409
|
// The empty value resets every inherited/global helper. Without it, Git
|
|
337
410
|
// can pass the installation token to a desktop keychain helper on the
|
|
@@ -367,7 +440,7 @@ class GitHubWorkspaceProvider {
|
|
|
367
440
|
});
|
|
368
441
|
await this.runGit(['worktree', 'prune'], { cwd: bare });
|
|
369
442
|
const branch = `agent/${request.sessionId}`;
|
|
370
|
-
const reuse = await this.canReuseWorktree(
|
|
443
|
+
const reuse = await this.canReuseWorktree(worktree, repository.cloneUrl, request.sessionId);
|
|
371
444
|
if (!reuse) {
|
|
372
445
|
try {
|
|
373
446
|
await this.runGit(['worktree', 'add', worktree, branch], {
|
|
@@ -375,14 +448,17 @@ class GitHubWorkspaceProvider {
|
|
|
375
448
|
});
|
|
376
449
|
}
|
|
377
450
|
catch {
|
|
378
|
-
await this.runGit([
|
|
379
|
-
|
|
380
|
-
'add',
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
451
|
+
const existingBranch = await this.runGit(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { cwd: bare }).then(() => true, () => false);
|
|
452
|
+
await this.runGit(existingBranch
|
|
453
|
+
? ['worktree', 'add', '--detach', worktree, branch]
|
|
454
|
+
: [
|
|
455
|
+
'worktree',
|
|
456
|
+
'add',
|
|
457
|
+
'-b',
|
|
458
|
+
branch,
|
|
459
|
+
worktree,
|
|
460
|
+
`origin/${repository.defaultBranch}`
|
|
461
|
+
], { cwd: bare });
|
|
386
462
|
}
|
|
387
463
|
}
|
|
388
464
|
else {
|
|
@@ -401,7 +477,7 @@ class GitHubWorkspaceProvider {
|
|
|
401
477
|
// Hooks must be per worktree; a shared bare-repo hook would overwrite
|
|
402
478
|
// another session's allowed destination when sessions overlap.
|
|
403
479
|
await this.runGit(['config', 'extensions.worktreeConfig', 'true'], {
|
|
404
|
-
cwd:
|
|
480
|
+
cwd: worktree
|
|
405
481
|
});
|
|
406
482
|
await this.runGit(['config', '--worktree', 'core.bare', 'false'], {
|
|
407
483
|
cwd: worktree
|
|
@@ -504,6 +580,11 @@ class GitHubWorkspaceProvider {
|
|
|
504
580
|
.filter(Boolean)
|
|
505
581
|
.join('\n');
|
|
506
582
|
}
|
|
583
|
+
takePreparationNotes(sessionId) {
|
|
584
|
+
const notes = this.preparationNotes.get(sessionId) ?? [];
|
|
585
|
+
this.preparationNotes.delete(sessionId);
|
|
586
|
+
return notes;
|
|
587
|
+
}
|
|
507
588
|
async preserveAtTurnEnd(sessionId) {
|
|
508
589
|
const worktrees = await this.worktreesForSession(sessionId);
|
|
509
590
|
const failures = await this.readFailedPushes(sessionId);
|
|
@@ -563,6 +644,7 @@ class GitHubWorkspaceProvider {
|
|
|
563
644
|
await (0, promises_1.rm)(this.sessionPath(sessionId), { recursive: true, force: true });
|
|
564
645
|
this.preparedWorktrees.delete(sessionId);
|
|
565
646
|
this.restoredWorktreeNotes.delete(sessionId);
|
|
647
|
+
this.preparationNotes.delete(sessionId);
|
|
566
648
|
this.restartNotedSessions.delete(sessionId);
|
|
567
649
|
this.clearCredential(sessionId);
|
|
568
650
|
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.1",
|
|
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",
|