@peakflo/agent-harness 0.2.16-rc.1 → 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.
@@ -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 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.
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;
@@ -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 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.
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(bare, worktree, branch) {
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 (!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');
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
- catch (error) {
120
- throw new Error(`Existing repository workspace cannot be reused safely: ${worktree}: ${error instanceof Error ? error.message : String(error)}`);
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(bare, worktree, branch);
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
- 'worktree',
380
- 'add',
381
- '-b',
382
- branch,
383
- worktree,
384
- `origin/${repository.defaultBranch}`
385
- ], { cwd: bare });
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: bare
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.16-rc.1",
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",