@bridge4dev/runner 0.61.0 → 0.63.0
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/README.md +12 -6
- package/dist/adapters/claude.js +11 -6
- package/dist/adapters/codex.js +80 -15
- package/dist/adapters/types.d.ts +50 -0
- package/dist/adapters/types.js +59 -0
- package/dist/checkpoints.d.ts +11 -0
- package/dist/checkpoints.js +64 -5
- package/dist/git.d.ts +56 -0
- package/dist/git.js +78 -7
- package/dist/gitops.d.ts +29 -0
- package/dist/gitops.js +74 -19
- package/dist/index.js +25 -0
- package/dist/policy.d.ts +27 -0
- package/dist/policy.js +38 -5
- package/dist/protocol.d.ts +57 -2
- package/dist/protocol.js +15 -0
- package/dist/supervisor.js +14 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/git.js
CHANGED
|
@@ -94,6 +94,44 @@ export class WorktreePrepareError extends Error {
|
|
|
94
94
|
this.baseSha = base.baseSha;
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* A repository with no commits cannot hand out a copy of itself (#137).
|
|
99
|
+
*
|
|
100
|
+
* The mirror of `NO_COMMITS_YET_MESSAGE` in `@devbridge/shared`, which this
|
|
101
|
+
* package cannot import — it is published to npm on its own, and the import
|
|
102
|
+
* would make the tarball unresolvable (`recipe-schema.ts` explains it in full).
|
|
103
|
+
* Same arrangement as the level-event constants and the session limits: the
|
|
104
|
+
* text lives twice and a test pins the two copies together, because the API
|
|
105
|
+
* refuses this case before a session exists and the runner refuses it again if
|
|
106
|
+
* one ever gets that far — and the person must read one sentence, not two.
|
|
107
|
+
*
|
|
108
|
+
* `git.test.ts` compares this against the shared copy byte for byte.
|
|
109
|
+
*/
|
|
110
|
+
// #region no-commits-yet-mirror
|
|
111
|
+
export const NO_COMMITS_YET_MESSAGE = 'This repository has no commits yet — start a session in the project folder itself; a branch of its own becomes possible after the first commit';
|
|
112
|
+
export async function headState(workspacePath) {
|
|
113
|
+
const [symbolic, commit] = await Promise.all([
|
|
114
|
+
git(workspacePath, 'symbolic-ref', '--short', 'HEAD').catch(() => null),
|
|
115
|
+
// `--verify --quiet` is the whole point: an unborn HEAD is exit 1 and
|
|
116
|
+
// SILENCE here, where the plain form is exit 128 and three lines of git.
|
|
117
|
+
//
|
|
118
|
+
// And the exit code is read rather than caught away, the same way the
|
|
119
|
+
// branch probe in `looksHalfCreated` reads it and for the same reason:
|
|
120
|
+
// exit 1 is «there is no commit», while 128, a signal or a
|
|
121
|
+
// dubious-ownership fatal is git failing to LOOK. `unborn` authorises
|
|
122
|
+
// real things — it is what offers the project folder for work and what
|
|
123
|
+
// makes a restore point start from the empty tree — so «could not look»
|
|
124
|
+
// must never arrive here dressed as «no commits yet».
|
|
125
|
+
git(workspacePath, 'rev-parse', '--verify', '--quiet', 'HEAD').then((sha) => ({ sha, absent: false }), (error) => ({
|
|
126
|
+
sha: null,
|
|
127
|
+
absent: error.code === 1,
|
|
128
|
+
})),
|
|
129
|
+
]);
|
|
130
|
+
const branch = symbolic ? symbolic.trim() : '';
|
|
131
|
+
if (!branch)
|
|
132
|
+
return { branch: null, unborn: false, detached: Boolean(commit.sha) };
|
|
133
|
+
return { branch, unborn: commit.absent, detached: false };
|
|
134
|
+
}
|
|
97
135
|
/**
|
|
98
136
|
* Can this runner actually work in this directory — as the user it runs as?
|
|
99
137
|
*
|
|
@@ -190,17 +228,27 @@ export async function validateWorkspacePath(workspacePath) {
|
|
|
190
228
|
`Give that user write access to the repository (for example \`chown -R ${me.user} ${workspacePath}\`), then try again.`,
|
|
191
229
|
};
|
|
192
230
|
}
|
|
193
|
-
|
|
231
|
+
// #137: read through `headState`, not `rev-parse --abbrev-ref HEAD`. The old
|
|
232
|
+
// call was the LAST thing this function did and the only one of six branch
|
|
233
|
+
// reads in this file left uncaught — so a directory with `git init` and no
|
|
234
|
+
// commit in it failed binding with git's manual page forwarded to a browser,
|
|
235
|
+
// after every real check above had already passed.
|
|
236
|
+
const head = await headState(workspacePath);
|
|
194
237
|
// Binding is the one moment we are guaranteed to be looking at this repository
|
|
195
238
|
// with somebody waiting for the answer, so it is where the main branch is
|
|
196
239
|
// learned (ADR 0004). `branch` above is «what the folder is on right now» — a
|
|
197
240
|
// drifting value, and the reason #361 exists; these two must not be confused.
|
|
241
|
+
//
|
|
242
|
+
// On a repository with no commits there is no branch to learn: nothing has
|
|
243
|
+
// been pushed anywhere and no conventional branch exists yet, so the binding's
|
|
244
|
+
// `main_branch` stays «not known yet» and learns itself later (ADR 0004).
|
|
198
245
|
const defaultBranch = await remoteDefaultBranch(workspacePath);
|
|
199
246
|
return {
|
|
200
247
|
ok: true,
|
|
201
248
|
exists: true,
|
|
202
249
|
isGitRepo: true,
|
|
203
|
-
branch,
|
|
250
|
+
...(head.branch ? { branch: head.branch } : {}),
|
|
251
|
+
...(head.unborn ? { unborn: true } : {}),
|
|
204
252
|
...(defaultBranch ? { defaultBranch } : {}),
|
|
205
253
|
};
|
|
206
254
|
}
|
|
@@ -308,6 +356,18 @@ export function sanitizeBranch(hint) {
|
|
|
308
356
|
* in the workspace repo, so the work survives worktree cleanup.
|
|
309
357
|
*/
|
|
310
358
|
export async function ensureSessionWorktree(workspacePath, sessionId, branchHint, options = {}) {
|
|
359
|
+
// #137: a repository with no commits cannot have a copy of itself, and this
|
|
360
|
+
// is the only honest place to say so. Git refuses both halves of the
|
|
361
|
+
// arrangement — `worktree add -b x main|HEAD` is «invalid reference», and
|
|
362
|
+
// `merge --squash` into an unborn HEAD is «Squash commit into empty head not
|
|
363
|
+
// supported yet» — so a session started this way would be created, would
|
|
364
|
+
// fail here in git's own words, and, had it survived, could never be applied.
|
|
365
|
+
// The plan's decision (S3a) is to refuse early rather than to make the copy
|
|
366
|
+
// work: nobody makes the person's first commit for them.
|
|
367
|
+
const head = await headState(workspacePath);
|
|
368
|
+
if (head.unborn) {
|
|
369
|
+
throw new Error(NO_COMMITS_YET_MESSAGE);
|
|
370
|
+
}
|
|
311
371
|
const short = sessionShortId(sessionId);
|
|
312
372
|
// A ticket group gets a branch named after its tickets so the work reads as
|
|
313
373
|
// one thing in git history. The worktree DIRECTORY stays id-derived — it is
|
|
@@ -743,16 +803,27 @@ export async function prepareDirectWorkspace(workspacePath) {
|
|
|
743
803
|
if (inside !== 'true') {
|
|
744
804
|
throw new Error(`${workspacePath} is not a git work tree`);
|
|
745
805
|
}
|
|
746
|
-
|
|
747
|
-
|
|
806
|
+
// #137: `rev-parse --abbrev-ref HEAD` answered the literal word `HEAD` for a
|
|
807
|
+
// detached checkout AND threw for a repository with no commits, so caught it
|
|
808
|
+
// collapsed both into the detached refusal below — and a folder sitting
|
|
809
|
+
// perfectly well on `main`, one `git init` old, was told to «check a branch
|
|
810
|
+
// out there», which it already had. `headState` tells the two apart.
|
|
811
|
+
const head = await headState(workspacePath);
|
|
812
|
+
if (!head.branch) {
|
|
748
813
|
throw new Error('The project folder is not on a branch (detached HEAD) — check a branch out there before starting a session');
|
|
749
814
|
}
|
|
750
815
|
const top = await git(workspacePath, 'rev-parse', '--show-toplevel').catch(() => null);
|
|
751
|
-
|
|
816
|
+
// No commits yet means no fork point, and that is not a failure: DIRECT mode
|
|
817
|
+
// forks from nothing anyway — the session's workplace IS this folder and this
|
|
818
|
+
// branch. The field simply stays absent, which is what it already did for
|
|
819
|
+
// every other reason a base sha could be missing.
|
|
820
|
+
const baseSha = head.unborn
|
|
821
|
+
? undefined
|
|
822
|
+
: await git(workspacePath, 'rev-parse', 'HEAD').catch(() => undefined);
|
|
752
823
|
return {
|
|
753
|
-
branch,
|
|
824
|
+
branch: head.branch,
|
|
754
825
|
worktreePath: top || workspacePath,
|
|
755
|
-
baseBranch: branch,
|
|
826
|
+
baseBranch: head.branch,
|
|
756
827
|
...(baseSha ? { baseSha } : {}),
|
|
757
828
|
};
|
|
758
829
|
}
|
package/dist/gitops.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git's own name for «nothing», and the only thing a repository without a
|
|
3
|
+
* single commit can be diffed against. Constant in every git repository ever
|
|
4
|
+
* made — it is the SHA-1 of the empty tree object.
|
|
5
|
+
*/
|
|
6
|
+
export declare const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
1
7
|
export interface GitFileEntry {
|
|
2
8
|
path: string;
|
|
3
9
|
/** A | M | D | R (git name-status letter). */
|
|
@@ -324,6 +330,22 @@ export interface GitBranchesResult {
|
|
|
324
330
|
branches: GitBranchEntry[];
|
|
325
331
|
/** What the project folder is on right now. */
|
|
326
332
|
currentBranch: string | null;
|
|
333
|
+
/**
|
|
334
|
+
* #137: `currentBranch` above is a branch with no commit on it yet, so it is
|
|
335
|
+
* in no list here — `for-each-ref refs/heads/` has nothing to list until the
|
|
336
|
+
* first commit exists. Without this field the API sees a branch name that is
|
|
337
|
+
* absent from the branches and refuses the session as «Branch main does not
|
|
338
|
+
* exist in this project»; with it, the folder is offered and a copy of its
|
|
339
|
+
* own is refused early and in words.
|
|
340
|
+
*/
|
|
341
|
+
unborn: boolean;
|
|
342
|
+
/**
|
|
343
|
+
* #137: HEAD is a commit rather than a branch. `currentBranch` is null for
|
|
344
|
+
* this AND for «the runner could not read the folder», and the wizard has to
|
|
345
|
+
* tell them apart: one is fixed by checking a branch out, the other is not
|
|
346
|
+
* the person's to fix at all.
|
|
347
|
+
*/
|
|
348
|
+
detached: boolean;
|
|
327
349
|
currentSha: string | null;
|
|
328
350
|
/**
|
|
329
351
|
* The repository's own main branch (ADR 0004) — not the same question as
|
|
@@ -537,6 +559,13 @@ export declare function gitRefs(workspacePath: string): Promise<GitRefsResult>;
|
|
|
537
559
|
export interface WorkspaceStateResult {
|
|
538
560
|
/** The branch the project folder is on; null when HEAD is detached. */
|
|
539
561
|
branch: string | null;
|
|
562
|
+
/**
|
|
563
|
+
* #137: that branch has no commit on it yet. `branch` is still its name and
|
|
564
|
+
* the folder is still perfectly workable — this is what stops every reader
|
|
565
|
+
* downstream from turning «no sha» into «detached HEAD», which is what the
|
|
566
|
+
* run's precondition and the session feed were both doing.
|
|
567
|
+
*/
|
|
568
|
+
unborn: boolean;
|
|
540
569
|
sha: string | null;
|
|
541
570
|
subject: string;
|
|
542
571
|
/** Committer date of that commit, ISO-8601. */
|
package/dist/gitops.js
CHANGED
|
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
|
-
import { firstConventionalBranch, remoteDefaultBranch, sanitizeBranch } from './git.js';
|
|
5
|
+
import { firstConventionalBranch, headState, remoteDefaultBranch, sanitizeBranch } from './git.js';
|
|
6
6
|
import { isSecretPath, maskString } from './policy.js';
|
|
7
7
|
const execFileAsync = promisify(execFile);
|
|
8
8
|
const GIT_TIMEOUT_MS = 30_000;
|
|
@@ -13,7 +13,7 @@ const DIFF_CAP_BYTES = 200_000;
|
|
|
13
13
|
* single commit can be diffed against. Constant in every git repository ever
|
|
14
14
|
* made — it is the SHA-1 of the empty tree object.
|
|
15
15
|
*/
|
|
16
|
-
const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
16
|
+
export const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
17
17
|
/**
|
|
18
18
|
* Global git switches every call in this module needs.
|
|
19
19
|
*
|
|
@@ -246,8 +246,21 @@ async function resolveBase(workspacePath, sessionBranch, pinnedBase) {
|
|
|
246
246
|
return { ...(await guessBase(workspacePath, sessionBranch)), pinned: false, baseMissing: false };
|
|
247
247
|
}
|
|
248
248
|
async function guessBase(workspacePath, sessionBranch) {
|
|
249
|
-
|
|
250
|
-
|
|
249
|
+
// #137: this read had no `.catch` at all, so on a repository with no commits
|
|
250
|
+
// git's own manual page («ambiguous argument 'HEAD'») travelled out of the
|
|
251
|
+
// runner, through the API and into a 409 toast. Every refusal that can be
|
|
252
|
+
// reached from a screen leaves here as a sentence somebody can act on.
|
|
253
|
+
const head = await headState(workspacePath);
|
|
254
|
+
// #137: NOT the branch-picker's sentence. That one says «start a session in
|
|
255
|
+
// the project folder itself», which is advice for somebody choosing where a
|
|
256
|
+
// session will work — and whoever reads THIS is already inside one, looking
|
|
257
|
+
// at a file list or a history tab. True but unactionable is the exact shape
|
|
258
|
+
// this ticket is about, so each place says the thing its own reader can do.
|
|
259
|
+
if (head.unborn) {
|
|
260
|
+
throw new Error('This repository has no commits yet, so there is nothing to compare this session against. Make the first commit and it will start working.');
|
|
261
|
+
}
|
|
262
|
+
const current = head.branch;
|
|
263
|
+
if (current && current !== sessionBranch) {
|
|
251
264
|
return { baseBranch: current, baseRef: current };
|
|
252
265
|
}
|
|
253
266
|
// One list, one order, one place — the same helper the binding uses to learn
|
|
@@ -255,7 +268,9 @@ async function guessBase(workspacePath, sessionBranch) {
|
|
|
255
268
|
const conventional = await firstConventionalBranch(workspacePath);
|
|
256
269
|
if (conventional)
|
|
257
270
|
return { baseBranch: conventional, baseRef: conventional };
|
|
258
|
-
throw new Error(
|
|
271
|
+
throw new Error(current
|
|
272
|
+
? `Cannot determine the base branch (the project folder is on ${current}, the session's own branch)`
|
|
273
|
+
: 'Cannot determine the base branch: the project folder is not on a branch (a detached HEAD) — check one out there');
|
|
259
274
|
}
|
|
260
275
|
/** How far apart two refs are, in one call: `[behind, ahead]`. */
|
|
261
276
|
async function aheadBehind(cwd, baseRef, branchRef) {
|
|
@@ -1136,7 +1151,27 @@ async function applySessionImpl(input) {
|
|
|
1136
1151
|
error: WORKSPACE_DIRTY_MESSAGE,
|
|
1137
1152
|
};
|
|
1138
1153
|
}
|
|
1139
|
-
|
|
1154
|
+
// #137: read through `headState`. The bare call threw git's manual page into
|
|
1155
|
+
// the «Apply» toast on a repository with no commits, and answered the literal
|
|
1156
|
+
// word `HEAD` on a detached one — a value that then went on to be compared
|
|
1157
|
+
// with a branch name and merged into.
|
|
1158
|
+
const workspaceHead = await headState(workspacePath);
|
|
1159
|
+
if (workspaceHead.unborn) {
|
|
1160
|
+
// Its own sentence, for the same reason as `guessBase` above: whoever
|
|
1161
|
+
// pressed «Apply» is not choosing where to work, and telling them to start
|
|
1162
|
+
// a session in the project folder answers a question they did not ask.
|
|
1163
|
+
return {
|
|
1164
|
+
applied: false,
|
|
1165
|
+
error: 'The project folder has no commits yet, and git cannot fold work into a branch that has none. Make the first commit there, then apply.',
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
const workspaceBranch = workspaceHead.branch;
|
|
1169
|
+
if (!workspaceBranch) {
|
|
1170
|
+
return {
|
|
1171
|
+
applied: false,
|
|
1172
|
+
error: 'The project folder is not on a branch (a detached HEAD), so there is nowhere to apply this to — check a branch out there first',
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1140
1175
|
if (workspaceBranch === sessionBranch) {
|
|
1141
1176
|
return { applied: false, error: 'The workspace is checked out on the session branch itself' };
|
|
1142
1177
|
}
|
|
@@ -1156,6 +1191,7 @@ async function applySessionImpl(input) {
|
|
|
1156
1191
|
await git(worktreePath, 'commit', '-m', 'chore(devbridge): session changes before apply');
|
|
1157
1192
|
}
|
|
1158
1193
|
const branchSha = await git(workspacePath, 'rev-parse', sessionBranch);
|
|
1194
|
+
// Safe unconditionally now: an unborn HEAD left through the refusal above.
|
|
1159
1195
|
const baseShaBefore = await git(workspacePath, 'rev-parse', 'HEAD');
|
|
1160
1196
|
// Captured BEFORE anything is written, so a failed merge can tell its own
|
|
1161
1197
|
// leftovers from somebody's work that appeared meanwhile.
|
|
@@ -1339,10 +1375,15 @@ const MAX_BRANCHES = 200;
|
|
|
1339
1375
|
* not after the worktree command fails.
|
|
1340
1376
|
*/
|
|
1341
1377
|
export async function gitBranches(workspacePath) {
|
|
1342
|
-
const [rawBranches, rawWorktrees,
|
|
1378
|
+
const [rawBranches, rawWorktrees, head, remotesRaw] = await Promise.all([
|
|
1343
1379
|
git(workspacePath, 'for-each-ref', `--count=${MAX_BRANCHES + 1}`, '--sort=-committerdate', '--format=%(refname:short)%00%(objectname)%00%(committerdate:iso-strict)%00%(subject)', 'refs/heads/'),
|
|
1344
1380
|
git(workspacePath, 'worktree', 'list', '--porcelain').catch(() => ''),
|
|
1345
|
-
|
|
1381
|
+
// #137: through `headState`, not `rev-parse --abbrev-ref HEAD`. Caught, that
|
|
1382
|
+
// call answered `null` for a folder one `git init` old and the literal word
|
|
1383
|
+
// `HEAD` for a detached one — so the wizard could not tell a repository
|
|
1384
|
+
// waiting for its first commit from a checkout nobody should commit into,
|
|
1385
|
+
// and greyed out «In the project folder» for both.
|
|
1386
|
+
headState(workspacePath),
|
|
1346
1387
|
git(workspacePath, 'remote').catch(() => ''),
|
|
1347
1388
|
]);
|
|
1348
1389
|
// `worktree list --porcelain` emits stanzas: `worktree <path>` … `branch <ref>`.
|
|
@@ -1380,8 +1421,12 @@ export async function gitBranches(workspacePath) {
|
|
|
1380
1421
|
branch.merged = mergedSet.has(branch.name);
|
|
1381
1422
|
return {
|
|
1382
1423
|
branches,
|
|
1383
|
-
currentBranch:
|
|
1384
|
-
|
|
1424
|
+
currentBranch: head.branch,
|
|
1425
|
+
unborn: head.unborn,
|
|
1426
|
+
detached: head.detached,
|
|
1427
|
+
currentSha: head.unborn
|
|
1428
|
+
? null
|
|
1429
|
+
: await git(workspacePath, 'rev-parse', 'HEAD').catch(() => null),
|
|
1385
1430
|
defaultBranch: await remoteDefaultBranch(workspacePath).catch(() => null),
|
|
1386
1431
|
remotes: remotesRaw.split('\n').filter(Boolean).slice(0, 20),
|
|
1387
1432
|
truncated,
|
|
@@ -1886,11 +1931,16 @@ const MAX_REFS = 500;
|
|
|
1886
1931
|
* short name.
|
|
1887
1932
|
*/
|
|
1888
1933
|
export async function gitRefs(workspacePath) {
|
|
1889
|
-
const [raw, rawWorktrees,
|
|
1934
|
+
const [raw, rawWorktrees, head, currentSha, remotesRaw] = await Promise.all([
|
|
1890
1935
|
git(workspacePath, 'for-each-ref', `--count=${MAX_REFS + 1}`, '--sort=-committerdate', '--format=%(refname)%00%(objectname)%00%(committerdate:iso-strict)%00%(upstream:short)%00%(subject)', 'refs/heads', 'refs/remotes', 'refs/tags'),
|
|
1891
1936
|
git(workspacePath, 'worktree', 'list', '--porcelain').catch(() => ''),
|
|
1892
|
-
|
|
1893
|
-
|
|
1937
|
+
// #137: the fifth reader of the same question, and the one left behind by
|
|
1938
|
+
// the first pass. Caught, so no raw git escapes — but it answered «no
|
|
1939
|
+
// branch» for a folder the wizard, the run's precondition and the session
|
|
1940
|
+
// feed all report as standing on `main`, and one screen disagreeing with
|
|
1941
|
+
// three about the same folder is how this class of bug starts again.
|
|
1942
|
+
headState(workspacePath),
|
|
1943
|
+
git(workspacePath, 'rev-parse', '--verify', '--quiet', 'HEAD').catch(() => null),
|
|
1894
1944
|
git(workspacePath, 'remote').catch(() => ''),
|
|
1895
1945
|
]);
|
|
1896
1946
|
const checkedOut = new Map();
|
|
@@ -1925,7 +1975,7 @@ export async function gitRefs(workspacePath) {
|
|
|
1925
1975
|
refs.push({
|
|
1926
1976
|
name: name.slice(0, 200),
|
|
1927
1977
|
type,
|
|
1928
|
-
isHead: type === 'head' && name ===
|
|
1978
|
+
isHead: type === 'head' && name === head.branch,
|
|
1929
1979
|
sha,
|
|
1930
1980
|
subject: maskString(subjectParts.join('\0')).slice(0, 300),
|
|
1931
1981
|
date: (date ?? '').slice(0, 40),
|
|
@@ -1935,7 +1985,7 @@ export async function gitRefs(workspacePath) {
|
|
|
1935
1985
|
}
|
|
1936
1986
|
return {
|
|
1937
1987
|
refs,
|
|
1938
|
-
currentBranch:
|
|
1988
|
+
currentBranch: head.branch,
|
|
1939
1989
|
currentSha,
|
|
1940
1990
|
remotes: remotesRaw.split('\n').filter(Boolean).slice(0, 20),
|
|
1941
1991
|
truncated,
|
|
@@ -1949,21 +1999,26 @@ export async function gitRefs(workspacePath) {
|
|
|
1949
1999
|
* question behind «how do I know docker was not rebuilt». Read-only.
|
|
1950
2000
|
*/
|
|
1951
2001
|
export async function workspaceState(workspacePath) {
|
|
1952
|
-
const [
|
|
1953
|
-
|
|
1954
|
-
|
|
2002
|
+
const [head, sha, porcelain, upstreamRaw, remotesRaw, meta] = await Promise.all([
|
|
2003
|
+
// #137: `rev-parse --abbrev-ref HEAD` could not tell «no commits yet» from
|
|
2004
|
+
// «detached HEAD» — both arrived here as `null` — and this value is what the
|
|
2005
|
+
// run's precondition reads, so a fresh `git init` was refused with a
|
|
2006
|
+
// sentence about checking a branch out that it could not act on.
|
|
2007
|
+
headState(workspacePath),
|
|
2008
|
+
git(workspacePath, 'rev-parse', '--verify', '--quiet', 'HEAD').catch(() => null),
|
|
1955
2009
|
git(workspacePath, 'status', '--porcelain').catch(() => ''),
|
|
1956
2010
|
git(workspacePath, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}').catch(() => null),
|
|
1957
2011
|
git(workspacePath, 'remote').catch(() => ''),
|
|
1958
2012
|
git(workspacePath, 'log', '-1', '--format=%s%x00%cI', 'HEAD', '--').catch(() => ''),
|
|
1959
2013
|
]);
|
|
1960
|
-
const branch =
|
|
2014
|
+
const branch = head.branch;
|
|
1961
2015
|
const upstream = upstreamRaw ? sanitizeBranch(upstreamRaw) : null;
|
|
1962
2016
|
const distance = upstream ? await aheadBehind(workspacePath, upstream, 'HEAD') : null;
|
|
1963
2017
|
const [subject, date] = meta.split('\0');
|
|
1964
2018
|
const dirtyLines = porcelain.split('\n').filter(Boolean);
|
|
1965
2019
|
return {
|
|
1966
2020
|
branch,
|
|
2021
|
+
unborn: head.unborn,
|
|
1967
2022
|
sha,
|
|
1968
2023
|
subject: maskString(subject ?? '').slice(0, SUBJECT_CAP),
|
|
1969
2024
|
date: (date ?? '').slice(0, 40),
|
package/dist/index.js
CHANGED
|
@@ -381,6 +381,31 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
381
381
|
* been handed a worse problem.
|
|
382
382
|
*/
|
|
383
383
|
agentGitPolicy: true,
|
|
384
|
+
/**
|
|
385
|
+
* #418: «the agent may work outside the project folder» is a setting of the
|
|
386
|
+
* project, and this runner honours it.
|
|
387
|
+
*
|
|
388
|
+
* Its own flag rather than a second meaning for `agentGitPolicy`: the two
|
|
389
|
+
* ship a release apart, and a server on 0.61.x announces the git policy
|
|
390
|
+
* perfectly honestly while ignoring this field completely. Announced so the
|
|
391
|
+
* settings card can say so — warn, not refuse, for the same reason as the
|
|
392
|
+
* git policy: a manager must be able to record the decision before the last
|
|
393
|
+
* machine in the fleet is up to date.
|
|
394
|
+
*/
|
|
395
|
+
agentOutsideFolder: true,
|
|
396
|
+
/**
|
|
397
|
+
* #137: this runner can read a repository that has no commits yet.
|
|
398
|
+
*
|
|
399
|
+
* Announced for one reason, and it is a message rather than a refusal: when
|
|
400
|
+
* an OLDER runner meets that repository it answers with git's own manual
|
|
401
|
+
* page, and the API turns anything it cannot parse into «run `git status`
|
|
402
|
+
* on that server and see». For this particular case that advice is a dead
|
|
403
|
+
* end — `git status` there prints «On branch main / No commits yet», which
|
|
404
|
+
* looks perfectly healthy, and the person is none the wiser. With this flag
|
|
405
|
+
* the API can say the true thing instead: the runner on that machine is too
|
|
406
|
+
* old for this, update it.
|
|
407
|
+
*/
|
|
408
|
+
unbornHead: true,
|
|
384
409
|
/**
|
|
385
410
|
* Session 16: git is git.
|
|
386
411
|
*
|
package/dist/policy.d.ts
CHANGED
|
@@ -40,6 +40,33 @@ export interface AgentGitPolicy {
|
|
|
40
40
|
agentAllowForcePush?: boolean;
|
|
41
41
|
/** `git reset --hard` and `git clean`. `undefined` and `false` mean no. */
|
|
42
42
|
agentAllowDestructiveGit?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* #418: may the agent read and write OUTSIDE the folder this session works
|
|
45
|
+
* in — other projects on the same machine included?
|
|
46
|
+
*
|
|
47
|
+
* READ AS `=== true`. `undefined` means an API too old to send the field, or
|
|
48
|
+
* a value the protocol schema threw away, and there the answer must be what
|
|
49
|
+
* it has always been: confined to the folder. The same direction as
|
|
50
|
+
* `agentAllowForcePush` two fields up, the opposite of `agentPushBan` at the
|
|
51
|
+
* top — one names a permission, the other names a ban, and silence refuses
|
|
52
|
+
* in both readings (gotcha 193).
|
|
53
|
+
*
|
|
54
|
+
* **Not a git setting, and it lives in a git-named object on purpose.** The
|
|
55
|
+
* whole delivery pipe for the project's per-agent policy already exists —
|
|
56
|
+
* descriptor → `gitPolicyOf` → `SessionSpec` → `policyContextFor` →
|
|
57
|
+
* `PolicyContext`, with `workspace_settings` for a live change — and it moves
|
|
58
|
+
* this object WHOLE at every hop. A field of its own beside it would have
|
|
59
|
+
* been a second branch in the supervisor, a second one in the live frame and
|
|
60
|
+
* a second chance to forget one of them (plan R8). The name is the price;
|
|
61
|
+
* this comment is the receipt.
|
|
62
|
+
*
|
|
63
|
+
* What it does NOT lift: `isSecretPath`, `isGitInternalPath`,
|
|
64
|
+
* `ctx.agentPromptFile`, `DENIED_COMMAND_PATTERNS`, `SECRET_COMMAND_PATTERNS`,
|
|
65
|
+
* the git policy above, the auto-commit refusal and the heap ceiling. It
|
|
66
|
+
* lifts exactly one rule — «this path is not inside the folder» — and leaves
|
|
67
|
+
* every other reason to refuse standing.
|
|
68
|
+
*/
|
|
69
|
+
agentAllowOutsideFolder?: boolean;
|
|
43
70
|
}
|
|
44
71
|
export interface PolicyContext extends AgentGitPolicy {
|
|
45
72
|
trustMode: TrustMode;
|
package/dist/policy.js
CHANGED
|
@@ -11,6 +11,8 @@ function resolveGitPolicy(ctx) {
|
|
|
11
11
|
protectedBranches: ctx.agentProtectedBranches ?? DEFAULT_PROTECTED_BRANCHES,
|
|
12
12
|
allowForcePush: ctx.agentAllowForcePush === true,
|
|
13
13
|
allowDestructiveGit: ctx.agentAllowDestructiveGit === true,
|
|
14
|
+
// `=== true`: silence keeps the agent in its folder (#418).
|
|
15
|
+
allowOutsideFolder: ctx.agentAllowOutsideFolder === true,
|
|
14
16
|
};
|
|
15
17
|
}
|
|
16
18
|
// ─── Secret masking (plan §8.7) ──────────────────────────────────────
|
|
@@ -1309,6 +1311,18 @@ export function evaluateToolUse(toolName, input, ctx) {
|
|
|
1309
1311
|
if (READ_TOOLS.has(toolName) || WRITE_TOOLS.has(toolName)) {
|
|
1310
1312
|
const rawPath = String(input['file_path'] ?? input['path'] ?? input['notebook_path'] ?? '');
|
|
1311
1313
|
const resolved = rawPath ? normalize(rawPath, ctx.worktreePath) : ctx.worktreePath;
|
|
1314
|
+
/**
|
|
1315
|
+
* #418: this project answered «yes» to «may the agent work outside the
|
|
1316
|
+
* project folder», so «not inside the folder» stops being a reason on its
|
|
1317
|
+
* own. Resolved through `resolveGitPolicy` rather than read off `ctx` here,
|
|
1318
|
+
* because that function is the one place in this file where an absent field
|
|
1319
|
+
* is given its safe meaning — and «unknown» here has to mean «stay in».
|
|
1320
|
+
*
|
|
1321
|
+
* It moves exactly one rule out of the way. Everything checked above and
|
|
1322
|
+
* below this line — secret paths, `.git` internals, the session's own
|
|
1323
|
+
* prompt file — is checked in the same order and refuses the same things.
|
|
1324
|
+
*/
|
|
1325
|
+
const outsideAllowed = resolveGitPolicy(ctx).allowOutsideFolder;
|
|
1312
1326
|
if (isSecretPath(resolved)) {
|
|
1313
1327
|
return { decision: 'deny', reason: 'protected secret path' };
|
|
1314
1328
|
}
|
|
@@ -1328,7 +1342,9 @@ export function evaluateToolUse(toolName, input, ctx) {
|
|
|
1328
1342
|
reason: 'writing inside .git is not allowed — a hook or a config entry is code git runs on its own, past every rule here',
|
|
1329
1343
|
};
|
|
1330
1344
|
}
|
|
1331
|
-
if (WRITE_TOOLS.has(toolName) &&
|
|
1345
|
+
if (WRITE_TOOLS.has(toolName) &&
|
|
1346
|
+
!outsideAllowed &&
|
|
1347
|
+
!isInsideWorktree(resolved, ctx.worktreePath)) {
|
|
1332
1348
|
return { decision: 'deny', reason: 'writes outside the session worktree are not allowed' };
|
|
1333
1349
|
}
|
|
1334
1350
|
// The project's own prompt file — the same rule as `.git` above, for the
|
|
@@ -1344,10 +1360,27 @@ export function evaluateToolUse(toolName, input, ctx) {
|
|
|
1344
1360
|
}
|
|
1345
1361
|
if (trust === 'STRICT')
|
|
1346
1362
|
return { decision: 'ask', reason: 'strict mode' };
|
|
1347
|
-
if (
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1363
|
+
if (!isInsideWorktree(resolved, ctx.worktreePath)) {
|
|
1364
|
+
/**
|
|
1365
|
+
* #418. Below STRICT — which asked one line up and goes on asking, which
|
|
1366
|
+
* is the whole of «Strict still asks» — a path outside the folder is now
|
|
1367
|
+
* an ORDINARY path for a project that allowed it: no card, no refusal,
|
|
1368
|
+
* and the same answer for a read and for a write.
|
|
1369
|
+
*
|
|
1370
|
+
* Its own reason string rather than falling through to «inside worktree»
|
|
1371
|
+
* below: that sentence goes to the log, and about a file in another
|
|
1372
|
+
* folder it would simply be false.
|
|
1373
|
+
*/
|
|
1374
|
+
if (outsideAllowed) {
|
|
1375
|
+
return { decision: 'allow', reason: 'outside the project folder, allowed by this project' };
|
|
1376
|
+
}
|
|
1377
|
+
// Reads only: a write outside was refused above unless the project
|
|
1378
|
+
// allowed it, so nothing else reaches this line.
|
|
1379
|
+
if (READ_TOOLS.has(toolName)) {
|
|
1380
|
+
return trust === 'AUTO'
|
|
1381
|
+
? { decision: 'allow', reason: 'auto mode' }
|
|
1382
|
+
: { decision: 'ask', reason: 'read outside the worktree' };
|
|
1383
|
+
}
|
|
1351
1384
|
}
|
|
1352
1385
|
return { decision: 'allow', reason: 'inside worktree' };
|
|
1353
1386
|
}
|