@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.
@@ -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 harness crash must not erase or recreate a session checkout. Accept an
39
- * existing path only when Git proves that it is the exact worktree this
40
- * provider would have created: same canonical root, same bare repository,
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;
@@ -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 harness crash must not erase or recreate a session checkout. Accept an
81
- * existing path only when Git proves that it is the exact worktree this
82
- * provider would have created: same canonical root, same bare repository,
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, branch) {
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 (!entry.isDirectory() || entry.isSymbolicLink()) {
96
- throw new Error(`Existing repository workspace is not a directory: ${worktree}`);
97
- }
98
- try {
99
- const [topLevel, commonDir, currentBranch] = await Promise.all([
100
- this.runGit(['rev-parse', '--show-toplevel'], { cwd: worktree }),
101
- this.runGit(['rev-parse', '--git-common-dir'], { cwd: worktree }),
102
- this.runGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], {
103
- cwd: worktree
104
- })
105
- ]);
106
- const [expectedWorktree, actualWorktree, expectedBare, actualBare] = await Promise.all([
107
- (0, promises_1.realpath)(worktree),
108
- (0, promises_1.realpath)((0, node_path_1.resolve)(worktree, topLevel.stdout.trim())),
109
- (0, promises_1.realpath)(bare),
110
- (0, promises_1.realpath)((0, node_path_1.resolve)(worktree, commonDir.stdout.trim()))
111
- ]);
112
- if (actualWorktree !== expectedWorktree ||
113
- actualBare !== expectedBare ||
114
- currentBranch.stdout.trim() !== branch) {
115
- throw new Error('repository or branch does not match');
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
- catch (error) {
120
- throw new Error(`Existing repository workspace cannot be reused safely: ${worktree}: ${error instanceof Error ? error.message : String(error)}`);
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, branch);
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
- 'worktree',
380
- 'add',
381
- '-b',
382
- branch,
383
- worktree,
384
- `origin/${repository.defaultBranch}`
385
- ], { cwd: bare });
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: bare
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.16",
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",