@peakflo/agent-harness 0.2.17-rc.1 → 0.2.17-rc.3

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.
@@ -40,6 +40,10 @@ export declare class GitHubWorkspaceProvider implements WorkspaceProvider {
40
40
  private sameRepository;
41
41
  /** Never fetch a different repository into a shared durable bare clone. */
42
42
  private bareRepositoryPath;
43
+ private isUnusableCheckout;
44
+ private checkoutPath;
45
+ /** Locate only this checkout's registration; never prune sibling records. */
46
+ private worktreeRegistration;
43
47
  /**
44
48
  * A session owns its checkout, including branch changes and detached HEAD.
45
49
  * Check its root and repository identity, never its branch or commit. Keep
@@ -25,8 +25,8 @@ function githubRepositoryIdentity(remote) {
25
25
  try {
26
26
  const url = new URL(ssh ? `https://github.com/${ssh[1]}` : remote);
27
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.
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
30
  if (!['github.com', '127.0.0.1', 'localhost', '[::1]'].includes(host)) {
31
31
  return null;
32
32
  }
@@ -87,6 +87,8 @@ class GitHubWorkspaceProvider {
87
87
  }
88
88
  }
89
89
  this.gitEnvironment.GIT_TERMINAL_PROMPT = '0';
90
+ // Recovery classifies Git's stable diagnostics, never localized text.
91
+ this.gitEnvironment.LC_ALL = 'C';
90
92
  this.rememberDirectUrls(options.directUrls ?? []);
91
93
  }
92
94
  rememberDirectUrls(urls) {
@@ -114,81 +116,179 @@ class GitHubWorkspaceProvider {
114
116
  }
115
117
  /** Never fetch a different repository into a shared durable bare clone. */
116
118
  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
119
  const identity = githubRepositoryIdentity(cloneUrl) ?? cloneUrl;
135
120
  const digest = (0, node_crypto_1.createHash)('sha256')
136
121
  .update(identity)
137
122
  .digest('hex')
138
123
  .slice(0, 16);
139
- return this.bareRepositoryPath(`${path}.recovered-${digest}`, cloneUrl);
124
+ // Leave old clones and linked worktrees intact. Bound collision handling
125
+ // without appending the same suffix recursively to a file name.
126
+ for (let attempt = 0; attempt < 16; attempt += 1) {
127
+ const candidate = attempt === 0 ? path : `${path}.recovered-${digest}-${attempt}`;
128
+ try {
129
+ const entry = await (0, promises_1.lstat)(candidate);
130
+ if (!entry.isDirectory() || entry.isSymbolicLink())
131
+ continue;
132
+ }
133
+ catch (error) {
134
+ if (error.code === 'ENOENT')
135
+ return candidate;
136
+ throw error;
137
+ }
138
+ const origin = await this.runGit(['config', '--get', 'remote.origin.url'], {
139
+ cwd: candidate
140
+ }).catch((error) => {
141
+ if (this.isUnusableCheckout(error) || error.code === 1)
142
+ return null;
143
+ throw new Error('Could not inspect repository cache. Existing files were kept.');
144
+ });
145
+ if (origin &&
146
+ (await this.sameRepository(cloneUrl, origin.stdout.trim(), candidate))) {
147
+ return candidate;
148
+ }
149
+ }
150
+ throw new Error('Could not prepare a repository cache. Existing files were kept.');
151
+ }
152
+ isUnusableCheckout(error) {
153
+ const gitError = error;
154
+ return (gitError.code === 128 &&
155
+ /not a git repository|invalid gitfile format|bad config line/i.test(gitError.stderr ?? ''));
156
+ }
157
+ async checkoutPath(path) {
158
+ // Resolve the parent, not the checkout entry: it may now be a symlink or
159
+ // be missing. Match the registered location without following its target.
160
+ return (0, node_path_1.join)(await (0, promises_1.realpath)((0, node_path_1.dirname)(path)), (0, node_path_1.basename)(path));
161
+ }
162
+ /** Locate only this checkout's registration; never prune sibling records. */
163
+ async worktreeRegistration(bare, worktree) {
164
+ const canonical = await this.checkoutPath(worktree);
165
+ const root = (0, node_path_1.join)(bare, 'worktrees');
166
+ const entries = await (0, promises_1.readdir)(root, { withFileTypes: true }).catch((error) => {
167
+ if (error.code === 'ENOENT')
168
+ return [];
169
+ throw error;
170
+ });
171
+ for (const entry of entries) {
172
+ if (!entry.isDirectory() || entry.isSymbolicLink())
173
+ continue;
174
+ const admin = (0, node_path_1.join)(root, entry.name);
175
+ const target = await (0, promises_1.readFile)((0, node_path_1.join)(admin, 'gitdir'), 'utf8').catch((error) => {
176
+ if (error.code === 'ENOENT')
177
+ return '';
178
+ throw error;
179
+ });
180
+ if ((0, node_path_1.basename)(target.trim()) !== '.git')
181
+ continue;
182
+ const parent = await this.checkoutPath((0, node_path_1.dirname)(target.trim())).catch((error) => {
183
+ if (error.code === 'ENOENT')
184
+ return null;
185
+ throw error;
186
+ });
187
+ if (parent === canonical)
188
+ return admin;
189
+ }
190
+ return null;
140
191
  }
141
192
  /**
142
193
  * A session owns its checkout, including branch changes and detached HEAD.
143
194
  * Check its root and repository identity, never its branch or commit. Keep
144
195
  * an unrelated or unusable checkout aside before preparing a replacement.
145
196
  */
146
- async canReuseWorktree(worktree, cloneUrl, sessionId) {
197
+ async canReuseWorktree(bare, worktree, cloneUrl, sessionId) {
147
198
  let entry;
148
199
  try {
149
200
  entry = await (0, promises_1.lstat)(worktree);
150
201
  }
151
202
  catch (error) {
152
- if (error.code === 'ENOENT')
153
- return false;
154
- throw error;
203
+ if (error.code !== 'ENOENT')
204
+ throw error;
155
205
  }
156
- if (entry.isDirectory() && !entry.isSymbolicLink()) {
206
+ if (entry?.isDirectory() && !entry.isSymbolicLink()) {
157
207
  try {
158
- const [topLevel, origin] = await Promise.all([
208
+ const [topLevel, origin, commonDir] = await Promise.all([
159
209
  this.runGit(['rev-parse', '--show-toplevel'], { cwd: worktree }),
160
210
  this.runGit(['config', '--get', 'remote.origin.url'], {
161
211
  cwd: worktree
162
- })
212
+ }).catch((error) => {
213
+ if (error.code === 1 && !error.stderr)
214
+ return { stdout: '' };
215
+ throw error;
216
+ }),
217
+ this.runGit(['rev-parse', '--git-common-dir'], { cwd: worktree })
163
218
  ]);
164
- const [expectedWorktree, actualWorktree] = await Promise.all([
219
+ const [expectedWorktree, actualWorktree, repositoriesRoot, actualCommon] = await Promise.all([
165
220
  (0, promises_1.realpath)(worktree),
166
- (0, promises_1.realpath)((0, node_path_1.resolve)(worktree, topLevel.stdout.trim()))
221
+ (0, promises_1.realpath)((0, node_path_1.resolve)(worktree, topLevel.stdout.trim())),
222
+ (0, promises_1.realpath)((0, node_path_1.join)((0, node_path_1.resolve)(this.options.root), '.repositories')),
223
+ (0, promises_1.realpath)((0, node_path_1.resolve)(worktree, commonDir.stdout.trim()))
167
224
  ]);
168
225
  const sameRepository = await this.sameRepository(cloneUrl, origin.stdout.trim(), worktree);
169
- if (actualWorktree === expectedWorktree && sameRepository)
226
+ const managedCommon = actualCommon.startsWith(`${repositoriesRoot}/`) ||
227
+ actualCommon === (0, node_path_1.join)(expectedWorktree, '.git');
228
+ if (actualWorktree === expectedWorktree &&
229
+ sameRepository &&
230
+ managedCommon)
170
231
  return true;
171
232
  }
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.
233
+ catch (error) {
234
+ // Only a proven broken Git checkout is moved. Resource exhaustion,
235
+ // permission errors and timeouts must leave a healthy checkout in place.
236
+ // Do not echo Git errors: they can contain remote credentials.
237
+ if (!this.isUnusableCheckout(error)) {
238
+ throw new Error('Could not inspect the existing repository workspace. Retry the message. Existing files were not changed.');
239
+ }
175
240
  }
176
241
  }
242
+ const registration = await this.worktreeRegistration(bare, worktree);
243
+ if (!entry && !registration)
244
+ return false;
177
245
  const backupRoot = await (0, promises_1.mkdtemp)((0, node_path_1.join)(this.sessionPath(sessionId), `.preserved-${Date.now()}-`));
178
246
  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()) {
247
+ const repairRegistration = async () => {
248
+ try {
249
+ if (registration) {
250
+ // Keep the index, HEAD and reflog. Retain the original .git bytes too.
251
+ const gitFile = (0, node_path_1.join)(backup, '.git');
252
+ await (0, promises_1.rename)(gitFile, (0, node_path_1.join)(backupRoot, 'original-git-file')).catch((error) => {
253
+ if (error.code !== 'ENOENT')
254
+ throw error;
255
+ });
256
+ await (0, promises_1.writeFile)(gitFile, `gitdir: ${registration}\n`, {
257
+ mode: 0o600
258
+ });
259
+ await (0, promises_1.writeFile)((0, node_path_1.join)(registration, 'gitdir'), `${gitFile}\n`, {
260
+ mode: 0o600
261
+ });
262
+ }
263
+ await this.runGit(['worktree', 'repair', backup], { cwd: bare }).catch(() => undefined);
264
+ }
265
+ catch {
266
+ throw new Error(`Repository files were kept at ${backup}. Git state repair failed. ` +
267
+ 'Keep the saved folder and retry after the filesystem is available.');
268
+ }
269
+ };
270
+ if (!entry) {
271
+ // A removed checkout can still have staged work in its Git index.
272
+ await (0, promises_1.mkdir)(backup, { mode: 0o700 });
273
+ await repairRegistration();
274
+ }
275
+ else if (entry.isDirectory() && !entry.isSymbolicLink()) {
182
276
  await this.runGit(['worktree', 'move', worktree, backup], {
183
277
  cwd: worktree
184
- }).catch(() => (0, promises_1.rename)(worktree, backup));
278
+ }).catch(async () => {
279
+ await (0, promises_1.rename)(worktree, backup);
280
+ await repairRegistration();
281
+ });
185
282
  }
186
283
  else {
187
- await (0, promises_1.rename)(worktree, backup);
284
+ await (0, promises_1.mkdir)(backup, { mode: 0o700 });
285
+ await (0, promises_1.rename)(worktree, (0, node_path_1.join)(backup, 'original-entry'));
286
+ await repairRegistration();
188
287
  }
189
288
  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. ` +
289
+ notes.push(`The previous repository folder could not be reused. Its files and Git state ` +
290
+ `were kept under ${backupRoot}. Saved checkout: ${backup}. ` +
291
+ 'A fresh checkout was prepared. ' +
192
292
  'Review the saved folder before you remove it.');
193
293
  this.preparationNotes.set(sessionId, notes);
194
294
  return false;
@@ -404,6 +504,12 @@ class GitHubWorkspaceProvider {
404
504
  let reusedWorktree = false;
405
505
  for (const repository of request.repositories) {
406
506
  const [owner, name] = repository.fullName.split('/');
507
+ const githubIdentity = githubRepositoryIdentity(repository.cloneUrl);
508
+ // GitHub's canonical clone endpoint is HTTPS owner/repo.git. Strip URL
509
+ // credentials and old loopback ports; the credential proxy supplies auth.
510
+ const transportUrl = githubIdentity
511
+ ? `https://${githubIdentity}.git`
512
+ : repository.cloneUrl;
407
513
  const bare = await this.bareRepositoryPath((0, node_path_1.join)(repositoriesRoot, `${owner}--${name}.git`), repository.cloneUrl);
408
514
  const worktree = (0, node_path_1.join)(sessionRoot, name);
409
515
  // The empty value resets every inherited/global helper. Without it, Git
@@ -421,7 +527,7 @@ class GitHubWorkspaceProvider {
421
527
  });
422
528
  }
423
529
  catch {
424
- await this.runGit([...gitArgs, 'clone', '--bare', repository.cloneUrl, bare], {
530
+ await this.runGit([...gitArgs, 'clone', '--bare', transportUrl, bare], {
425
531
  timeout: 10 * 60_000,
426
532
  maxBuffer: 10 * 1024 * 1024,
427
533
  environment: gitNetworkEnvironment
@@ -432,15 +538,21 @@ class GitHubWorkspaceProvider {
432
538
  '+refs/heads/*:refs/remotes/origin/*'
433
539
  ], { cwd: bare });
434
540
  }
435
- await this.runGit([...gitArgs, 'fetch', 'origin', '+refs/heads/*:refs/remotes/origin/*'], {
541
+ // The identity check above permits old proxy/sentinel URL forms. Fetch
542
+ // through the current lease URL so a stale loopback port cannot fail first.
543
+ await this.runGit([
544
+ ...gitArgs,
545
+ 'fetch',
546
+ transportUrl,
547
+ '+refs/heads/*:refs/remotes/origin/*'
548
+ ], {
436
549
  cwd: bare,
437
550
  timeout: 10 * 60_000,
438
551
  maxBuffer: 10 * 1024 * 1024,
439
552
  environment: gitNetworkEnvironment
440
553
  });
441
- await this.runGit(['worktree', 'prune'], { cwd: bare });
442
554
  const branch = `agent/${request.sessionId}`;
443
- const reuse = await this.canReuseWorktree(worktree, repository.cloneUrl, request.sessionId);
555
+ const reuse = await this.canReuseWorktree(bare, worktree, repository.cloneUrl, request.sessionId);
444
556
  if (!reuse) {
445
557
  try {
446
558
  await this.runGit(['worktree', 'add', worktree, branch], {
@@ -476,14 +588,17 @@ class GitHubWorkspaceProvider {
476
588
  });
477
589
  // Hooks must be per worktree; a shared bare-repo hook would overwrite
478
590
  // another session's allowed destination when sessions overlap.
591
+ // A reused checkout may belong to a recovered cache or a standalone clone.
592
+ // Configure its actual common directory, rather than the default cache.
479
593
  await this.runGit(['config', 'extensions.worktreeConfig', 'true'], {
480
594
  cwd: worktree
481
595
  });
482
596
  await this.runGit(['config', '--worktree', 'core.bare', 'false'], {
483
597
  cwd: worktree
484
598
  });
599
+ await this.runGit(['config', '--worktree', 'remote.origin.url', transportUrl], { cwd: worktree });
485
600
  const hooks = (0, node_path_1.join)(sessionRoot, '.github-hooks', name);
486
- await (0, github_command_guards_1.installPushGuard)(hooks, request.sessionId, repository.defaultBranch, repository.cloneUrl);
601
+ await (0, github_command_guards_1.installPushGuard)(hooks, request.sessionId, repository.defaultBranch, transportUrl);
487
602
  await this.runGit(['config', '--worktree', 'core.hooksPath', hooks], {
488
603
  cwd: worktree
489
604
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.17-rc.1",
3
+ "version": "0.2.17-rc.3",
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",