@bridge4dev/runner 0.63.0 → 0.64.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/checkpoints.js +5 -13
- package/dist/environment.d.ts +15 -0
- package/dist/environment.js +23 -0
- package/dist/git.d.ts +36 -0
- package/dist/git.js +326 -4
- package/dist/index.js +5 -0
- package/dist/supervisor.js +32 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/checkpoints.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
+
import { cleanGitEnv } from './environment.js';
|
|
6
7
|
import { checkpointsDir } from './paths.js';
|
|
7
8
|
import { EMPTY_TREE_SHA } from './gitops.js';
|
|
8
9
|
import { isSecretPath } from './policy.js';
|
|
@@ -90,7 +91,7 @@ async function gitIn(cwd, ...args) {
|
|
|
90
91
|
// The project's own environment must not leak in: a GIT_INDEX_FILE or
|
|
91
92
|
// GIT_DIR inherited from a parent process would silently retarget every
|
|
92
93
|
// command below at the wrong repository.
|
|
93
|
-
env:
|
|
94
|
+
env: cleanGitEnv(),
|
|
94
95
|
});
|
|
95
96
|
return stdout.replace(/\n$/, '');
|
|
96
97
|
}
|
|
@@ -107,7 +108,7 @@ async function gitStore(store, worktreePath, indexFile, ...args) {
|
|
|
107
108
|
timeout: GIT_TIMEOUT_MS,
|
|
108
109
|
maxBuffer: 32 * 1024 * 1024,
|
|
109
110
|
env: {
|
|
110
|
-
...
|
|
111
|
+
...cleanGitEnv(),
|
|
111
112
|
GIT_DIR: store,
|
|
112
113
|
GIT_WORK_TREE: worktreePath,
|
|
113
114
|
GIT_INDEX_FILE: indexFile,
|
|
@@ -119,22 +120,13 @@ async function gitStore(store, worktreePath, indexFile, ...args) {
|
|
|
119
120
|
});
|
|
120
121
|
return stdout.replace(/\n$/, '');
|
|
121
122
|
}
|
|
122
|
-
function cleanEnv() {
|
|
123
|
-
const env = { ...process.env };
|
|
124
|
-
delete env['GIT_DIR'];
|
|
125
|
-
delete env['GIT_WORK_TREE'];
|
|
126
|
-
delete env['GIT_INDEX_FILE'];
|
|
127
|
-
delete env['GIT_OBJECT_DIRECTORY'];
|
|
128
|
-
delete env['GIT_ALTERNATE_OBJECT_DIRECTORIES'];
|
|
129
|
-
return env;
|
|
130
|
-
}
|
|
131
123
|
async function ensureStore(worktreePath) {
|
|
132
124
|
const { store, objectDir } = await storeFor(worktreePath);
|
|
133
125
|
if (!fs.existsSync(store)) {
|
|
134
126
|
fs.mkdirSync(path.dirname(store), { recursive: true, mode: 0o700 });
|
|
135
127
|
await execFileAsync('git', ['init', '--quiet', '--bare', store], {
|
|
136
128
|
timeout: GIT_TIMEOUT_MS,
|
|
137
|
-
env:
|
|
129
|
+
env: cleanGitEnv(),
|
|
138
130
|
});
|
|
139
131
|
fs.chmodSync(store, 0o700);
|
|
140
132
|
}
|
|
@@ -326,7 +318,7 @@ async function gitRefs(store, ...args) {
|
|
|
326
318
|
const { stdout } = await execFileAsync('git', [...GIT_GLOBAL_ARGS, '--git-dir', store, ...args], {
|
|
327
319
|
timeout: GIT_TIMEOUT_MS,
|
|
328
320
|
maxBuffer: 32 * 1024 * 1024,
|
|
329
|
-
env:
|
|
321
|
+
env: cleanGitEnv(),
|
|
330
322
|
});
|
|
331
323
|
return stdout.replace(/\n$/, '');
|
|
332
324
|
}
|
package/dist/environment.d.ts
CHANGED
|
@@ -20,6 +20,21 @@ export interface RunnerIdentity {
|
|
|
20
20
|
isRoot: boolean;
|
|
21
21
|
}
|
|
22
22
|
export declare function runnerIdentity(): RunnerIdentity;
|
|
23
|
+
/**
|
|
24
|
+
* The environment git must be run in, with the caller's own repository scrubbed
|
|
25
|
+
* out of it.
|
|
26
|
+
*
|
|
27
|
+
* A `GIT_DIR` or `GIT_INDEX_FILE` inherited from a parent process silently
|
|
28
|
+
* retargets every git command at a DIFFERENT repository — the runner is started
|
|
29
|
+
* by systemd, but a session's agent is not, and neither is a test. The restore
|
|
30
|
+
* points have run this way since #126; #417 needs the same guarantee for a much
|
|
31
|
+
* blunter reason: `git init` under an inherited `GIT_DIR` initialises somewhere
|
|
32
|
+
* else entirely and reports success.
|
|
33
|
+
*
|
|
34
|
+
* Here rather than in `checkpoints.ts`, where it was written, because it is now
|
|
35
|
+
* the answer to «how does this package run git» and has two callers.
|
|
36
|
+
*/
|
|
37
|
+
export declare function cleanGitEnv(): NodeJS.ProcessEnv;
|
|
23
38
|
export interface PathAccess {
|
|
24
39
|
path: string;
|
|
25
40
|
exists: boolean;
|
package/dist/environment.js
CHANGED
|
@@ -19,6 +19,29 @@ export function runnerIdentity() {
|
|
|
19
19
|
}
|
|
20
20
|
return { user, uid, gid, home: os.homedir(), isRoot: uid === 0 };
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* The environment git must be run in, with the caller's own repository scrubbed
|
|
24
|
+
* out of it.
|
|
25
|
+
*
|
|
26
|
+
* A `GIT_DIR` or `GIT_INDEX_FILE` inherited from a parent process silently
|
|
27
|
+
* retargets every git command at a DIFFERENT repository — the runner is started
|
|
28
|
+
* by systemd, but a session's agent is not, and neither is a test. The restore
|
|
29
|
+
* points have run this way since #126; #417 needs the same guarantee for a much
|
|
30
|
+
* blunter reason: `git init` under an inherited `GIT_DIR` initialises somewhere
|
|
31
|
+
* else entirely and reports success.
|
|
32
|
+
*
|
|
33
|
+
* Here rather than in `checkpoints.ts`, where it was written, because it is now
|
|
34
|
+
* the answer to «how does this package run git» and has two callers.
|
|
35
|
+
*/
|
|
36
|
+
export function cleanGitEnv() {
|
|
37
|
+
const env = { ...process.env };
|
|
38
|
+
delete env['GIT_DIR'];
|
|
39
|
+
delete env['GIT_WORK_TREE'];
|
|
40
|
+
delete env['GIT_INDEX_FILE'];
|
|
41
|
+
delete env['GIT_OBJECT_DIRECTORY'];
|
|
42
|
+
delete env['GIT_ALTERNATE_OBJECT_DIRECTORIES'];
|
|
43
|
+
return env;
|
|
44
|
+
}
|
|
22
45
|
/** As root every access check passes, which is true and worth saying out loud. */
|
|
23
46
|
function canAccess(target, mode) {
|
|
24
47
|
try {
|
package/dist/git.d.ts
CHANGED
|
@@ -106,6 +106,42 @@ export interface PathValidation {
|
|
|
106
106
|
* '/opt/ids'». That sentence is true and unactionable.
|
|
107
107
|
*/
|
|
108
108
|
export declare function validateWorkspacePath(workspacePath: string): Promise<PathValidation>;
|
|
109
|
+
/**
|
|
110
|
+
* What became of «create this folder and put git in it» (#417).
|
|
111
|
+
*
|
|
112
|
+
* `created` and `exists` are separate answers on purpose: a folder that was
|
|
113
|
+
* already there is not a failure — the wizard simply goes on and binds it —
|
|
114
|
+
* while `created: false, exists: false` is never a success.
|
|
115
|
+
*/
|
|
116
|
+
export interface ProjectDirInit {
|
|
117
|
+
ok: boolean;
|
|
118
|
+
/** This call made the directory. */
|
|
119
|
+
created: boolean;
|
|
120
|
+
/** There was already a directory at this path when we looked. */
|
|
121
|
+
exists: boolean;
|
|
122
|
+
/** The branch the new repository is on, read back rather than assumed. */
|
|
123
|
+
branch?: string;
|
|
124
|
+
error?: string;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Create the project folder and initialise git in it, from the binding window
|
|
128
|
+
* (#417).
|
|
129
|
+
*
|
|
130
|
+
* Two things and no more: ONE directory — the last segment of the path, never a
|
|
131
|
+
* chain of parents — and `git init` with `main` as the branch. No first commit:
|
|
132
|
+
* an empty repository is a legitimate state (#137, and the runner has known how
|
|
133
|
+
* to work in one since 0.63.0), while an «Initial commit» nobody asked for is
|
|
134
|
+
* the thing that makes `git pull` from an existing remote refuse with
|
|
135
|
+
* «unrelated histories» later on.
|
|
136
|
+
*
|
|
137
|
+
* Its own refusal list, because `validateWorkspacePath` has none to share: that
|
|
138
|
+
* function asks «can the runner work here», which is a question about
|
|
139
|
+
* permissions, and every answer it gives is about reaching, reading and writing.
|
|
140
|
+
* «Should anything be created here at all» is a different question and this is
|
|
141
|
+
* the only place that asks it. The API cannot ask it either — it sees the
|
|
142
|
+
* runner's verdict and nothing of the machine — so the list lives here.
|
|
143
|
+
*/
|
|
144
|
+
export declare function initProjectDir(target: string): Promise<ProjectDirInit>;
|
|
109
145
|
/**
|
|
110
146
|
* The repository's main branch, read locally (ADR 0004).
|
|
111
147
|
*
|
package/dist/git.js
CHANGED
|
@@ -2,8 +2,10 @@ 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 { previewsDir, worktreesDir } from './paths.js';
|
|
6
|
-
import { firstUnreachableAncestor, inspectPath, looksLikeDubiousOwnership, runnerIdentity, safeDirectoryCommand, } from './environment.js';
|
|
5
|
+
import { previewsDir, stateDir, worktreesDir } from './paths.js';
|
|
6
|
+
import { cleanGitEnv, firstUnreachableAncestor, inspectPath, looksLikeDubiousOwnership, runnerIdentity, safeDirectoryCommand, } from './environment.js';
|
|
7
|
+
import { isGitInternalPath, isInsideWorktree, isSecretPath } from './policy.js';
|
|
8
|
+
import { log } from './log.js';
|
|
7
9
|
const execFileAsync = promisify(execFile);
|
|
8
10
|
const GIT_TIMEOUT_MS = 30_000;
|
|
9
11
|
/**
|
|
@@ -14,12 +16,27 @@ const GIT_TIMEOUT_MS = 30_000;
|
|
|
14
16
|
* Ninety seconds is the owner's number (plan §9, 06.09.2026).
|
|
15
17
|
*/
|
|
16
18
|
const WORKTREE_ADD_TIMEOUT_MS = 90_000;
|
|
19
|
+
/**
|
|
20
|
+
* Every git call in this file names its repository with `cwd` and nothing else,
|
|
21
|
+
* so an inherited `GIT_DIR` (or index, or object store) can only ever point
|
|
22
|
+
* somewhere we did not mean — see `cleanGitEnv`. Free everywhere; load-bearing
|
|
23
|
+
* in `initProjectDir`, where `git init` under an inherited `GIT_DIR` would
|
|
24
|
+
* initialise a different directory and report success (#417).
|
|
25
|
+
*/
|
|
17
26
|
async function git(cwd, ...args) {
|
|
18
|
-
const { stdout } = await execFileAsync('git', args, {
|
|
27
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
28
|
+
cwd,
|
|
29
|
+
timeout: GIT_TIMEOUT_MS,
|
|
30
|
+
env: cleanGitEnv(),
|
|
31
|
+
});
|
|
19
32
|
return stdout.trim();
|
|
20
33
|
}
|
|
21
34
|
async function gitSlow(cwd, timeoutMs, ...args) {
|
|
22
|
-
const { stdout } = await execFileAsync('git', args, {
|
|
35
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
36
|
+
cwd,
|
|
37
|
+
timeout: timeoutMs,
|
|
38
|
+
env: cleanGitEnv(),
|
|
39
|
+
});
|
|
23
40
|
return stdout.trim();
|
|
24
41
|
}
|
|
25
42
|
/**
|
|
@@ -252,6 +269,311 @@ export async function validateWorkspacePath(workspacePath) {
|
|
|
252
269
|
...(defaultBranch ? { defaultBranch } : {}),
|
|
253
270
|
};
|
|
254
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* The branch a repository created from the binding window starts on (#417).
|
|
274
|
+
*
|
|
275
|
+
* One name, here, because two calls ask for it — `git init --initial-branch`
|
|
276
|
+
* and the `symbolic-ref` that stands in for it on a git older than 2.28 — and a
|
|
277
|
+
* folder that came out on `master` because the two disagreed would be a bug
|
|
278
|
+
* nobody sees until a push.
|
|
279
|
+
*/
|
|
280
|
+
const INITIAL_BRANCH = 'main';
|
|
281
|
+
/**
|
|
282
|
+
* Directories a project folder is never created inside (#417).
|
|
283
|
+
*
|
|
284
|
+
* The root itself and the trees a Linux system is assembled from. Deliberately
|
|
285
|
+
* a short, literal list rather than a clever rule: everything here is a place
|
|
286
|
+
* where `mkdir` succeeding is worse than it failing, and a person who typed one
|
|
287
|
+
* of them meant something else. `/opt`, `/srv`, `/home`, `/var/www` are NOT on
|
|
288
|
+
* it — those are exactly where projects live.
|
|
289
|
+
*
|
|
290
|
+
* `/usr/local` and friends are covered by their parents: the test is «is this
|
|
291
|
+
* path at or below one of these».
|
|
292
|
+
*/
|
|
293
|
+
const SYSTEM_DIRECTORIES = [
|
|
294
|
+
'/etc',
|
|
295
|
+
'/usr',
|
|
296
|
+
'/bin',
|
|
297
|
+
'/sbin',
|
|
298
|
+
'/lib',
|
|
299
|
+
'/lib32',
|
|
300
|
+
'/lib64',
|
|
301
|
+
'/libx32',
|
|
302
|
+
'/boot',
|
|
303
|
+
'/proc',
|
|
304
|
+
'/sys',
|
|
305
|
+
'/dev',
|
|
306
|
+
'/run',
|
|
307
|
+
];
|
|
308
|
+
function refuseInit(error) {
|
|
309
|
+
return { ok: false, created: false, exists: false, error };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Why this path may not be created, or `null` — asked of a STRING (#417).
|
|
313
|
+
*
|
|
314
|
+
* Pulled out of `initProjectDir` because it has to be asked twice: once about
|
|
315
|
+
* what the person typed, and again about where that path really lands once the
|
|
316
|
+
* filesystem has had its say. See `realLandingPlace`.
|
|
317
|
+
*/
|
|
318
|
+
function pathClassRefusal(candidate) {
|
|
319
|
+
if (isGitInternalPath(candidate)) {
|
|
320
|
+
return `${candidate} is inside a .git directory, which belongs to git itself — pick a folder for the project instead`;
|
|
321
|
+
}
|
|
322
|
+
if (candidate === path.sep || SYSTEM_DIRECTORIES.some((dir) => isAtOrBelow(candidate, dir))) {
|
|
323
|
+
return `${candidate} is a system directory on this server — pick a folder for the project instead`;
|
|
324
|
+
}
|
|
325
|
+
// Said separately from the line above, because it would not be true there:
|
|
326
|
+
// `~/.ssh` is not a system directory, and a refusal that misnames what it is
|
|
327
|
+
// refusing teaches the person the wrong thing about their own machine.
|
|
328
|
+
if (isSecretPath(candidate)) {
|
|
329
|
+
return `${candidate} is on this server's protected list — pick a folder for the project instead`;
|
|
330
|
+
}
|
|
331
|
+
// `isSecretPath` cannot answer this one: the runner tree is on its list, but
|
|
332
|
+
// `worktrees/` and `previews/` are excused from it so an agent can read its
|
|
333
|
+
// own workspace. Everything under the state directory is the runner's own
|
|
334
|
+
// bookkeeping either way, and a project living inside it would be rewritten by
|
|
335
|
+
// the next session cleanup. Both sides go through the real filesystem, because
|
|
336
|
+
// `stateDir()` is built from `os.homedir()` and a symlinked home would
|
|
337
|
+
// otherwise make the two paths look unrelated.
|
|
338
|
+
const state = realPathOf(stateDir());
|
|
339
|
+
if (isAtOrBelow(candidate, state)) {
|
|
340
|
+
return `${candidate} is inside the runner's own state directory — pick a folder for the project instead`;
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
/** `realpathSync` where it works, the path itself where it does not. */
|
|
345
|
+
function realPathOf(target) {
|
|
346
|
+
try {
|
|
347
|
+
return fs.realpathSync(target);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return path.resolve(target);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Where `mkdir` would actually land, symlinks and all (#417).
|
|
355
|
+
*
|
|
356
|
+
* Every refusal above is string work — `path.resolve` does not follow symlinks,
|
|
357
|
+
* `isSecretPath` normalises nothing, and containment is `path.relative`. The
|
|
358
|
+
* kernel disagrees: on a stock Debian `/var/run` IS `/run`, so `/var/run/shop`
|
|
359
|
+
* passes a list that exists to refuse exactly that, and the repository ends up
|
|
360
|
+
* on tmpfs and is gone at the next reboot.
|
|
361
|
+
*
|
|
362
|
+
* `policy.ts` solves the same problem in `normalize()` by resolving the parent
|
|
363
|
+
* and putting the last segment back on. That is not enough here, because this
|
|
364
|
+
* is asked BEFORE anything about the parent is known: with `/tmp/link -> /etc`
|
|
365
|
+
* and the path `/tmp/link/new/project`, the parent does not exist, its
|
|
366
|
+
* `realpath` fails, and the fallback is the unresolved string — which would then
|
|
367
|
+
* earn the advice «create /tmp/link/new first», i.e. make a folder in `/etc`. So
|
|
368
|
+
* the walk goes up to the deepest ancestor that DOES exist, resolves that, and
|
|
369
|
+
* puts every missing segment back on.
|
|
370
|
+
*/
|
|
371
|
+
function realLandingPlace(resolved) {
|
|
372
|
+
const missing = [];
|
|
373
|
+
let existing = resolved;
|
|
374
|
+
for (;;) {
|
|
375
|
+
try {
|
|
376
|
+
const real = fs.realpathSync(existing);
|
|
377
|
+
return path.join(real, ...missing.reverse());
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
const up = path.dirname(existing);
|
|
381
|
+
if (up === existing)
|
|
382
|
+
return resolved;
|
|
383
|
+
missing.push(path.basename(existing));
|
|
384
|
+
existing = up;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Is `target` at or below `root`?
|
|
390
|
+
*
|
|
391
|
+
* `isInsideWorktree` is the containment helper this package already has, and it
|
|
392
|
+
* answers exactly this question — the name says worktree because that was its
|
|
393
|
+
* first caller, not because it knows anything about worktrees.
|
|
394
|
+
*/
|
|
395
|
+
function isAtOrBelow(target, root) {
|
|
396
|
+
return isInsideWorktree(target, root);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Create the project folder and initialise git in it, from the binding window
|
|
400
|
+
* (#417).
|
|
401
|
+
*
|
|
402
|
+
* Two things and no more: ONE directory — the last segment of the path, never a
|
|
403
|
+
* chain of parents — and `git init` with `main` as the branch. No first commit:
|
|
404
|
+
* an empty repository is a legitimate state (#137, and the runner has known how
|
|
405
|
+
* to work in one since 0.63.0), while an «Initial commit» nobody asked for is
|
|
406
|
+
* the thing that makes `git pull` from an existing remote refuse with
|
|
407
|
+
* «unrelated histories» later on.
|
|
408
|
+
*
|
|
409
|
+
* Its own refusal list, because `validateWorkspacePath` has none to share: that
|
|
410
|
+
* function asks «can the runner work here», which is a question about
|
|
411
|
+
* permissions, and every answer it gives is about reaching, reading and writing.
|
|
412
|
+
* «Should anything be created here at all» is a different question and this is
|
|
413
|
+
* the only place that asks it. The API cannot ask it either — it sees the
|
|
414
|
+
* runner's verdict and nothing of the machine — so the list lives here.
|
|
415
|
+
*/
|
|
416
|
+
export async function initProjectDir(target) {
|
|
417
|
+
const me = runnerIdentity();
|
|
418
|
+
// The same rule the API's `WorkspacePathSchema` applies, re-derived here: the
|
|
419
|
+
// runner is the process that actually calls `mkdir`, and it must not trust the
|
|
420
|
+
// wire (QA-99 MAJOR-4 is the same argument for `validate_path`).
|
|
421
|
+
if (!target.startsWith('/') || target.split('/').includes('..')) {
|
|
422
|
+
return refuseInit('The project folder must be an absolute path with no ".." segments');
|
|
423
|
+
}
|
|
424
|
+
const resolved = path.resolve(target);
|
|
425
|
+
// The cheap pass, on what the person typed: nothing touches the disk for a
|
|
426
|
+
// path that is obviously not a project folder.
|
|
427
|
+
const typedRefusal = pathClassRefusal(resolved);
|
|
428
|
+
if (typedRefusal)
|
|
429
|
+
return refuseInit(typedRefusal);
|
|
430
|
+
// The second pass, and the one that decides: where would `mkdir` LAND? A
|
|
431
|
+
// symlinked ancestor otherwise walks straight through every refusal above,
|
|
432
|
+
// and `/var/run/shop` on a stock Debian is not a hypothetical: that directory
|
|
433
|
+
// IS `/run`, and the repository would sit on tmpfs until the next reboot.
|
|
434
|
+
//
|
|
435
|
+
// Asked BEFORE any permission check, and that order is the point (the 0.64.0
|
|
436
|
+
// build had it the other way round and was never published): for a runner that is
|
|
437
|
+
// not root, `/tmp/link -> /etc` failed «can the runner write to the parent»
|
|
438
|
+
// first — and the refusal then advised `chown <runner> /tmp/link`, which is
|
|
439
|
+
// `chown` on `/etc`. «This is a system directory» must win over «grant
|
|
440
|
+
// yourself access to it».
|
|
441
|
+
const landing = realLandingPlace(resolved);
|
|
442
|
+
if (landing !== resolved) {
|
|
443
|
+
const realRefusal = pathClassRefusal(landing);
|
|
444
|
+
// Both paths named, in that order: the person recognises what they typed,
|
|
445
|
+
// and then learns where it actually goes — which is the fact they have to
|
|
446
|
+
// act on.
|
|
447
|
+
if (realRefusal)
|
|
448
|
+
return refuseInit(`${resolved} leads to ${realRefusal}`);
|
|
449
|
+
}
|
|
450
|
+
const here = inspectPath(resolved);
|
|
451
|
+
if (here.unreachable) {
|
|
452
|
+
const blocked = firstUnreachableAncestor(resolved) ?? resolved;
|
|
453
|
+
return refuseInit(`The runner runs as ${me.user} and is not allowed into ${blocked}, so it cannot create ${resolved}. ` +
|
|
454
|
+
`Grant that user access to the directory (for example \`chmod o+x ${blocked}\`, ` +
|
|
455
|
+
`or \`setfacl -m u:${me.user}:x ${blocked}\`), then try again.`);
|
|
456
|
+
}
|
|
457
|
+
if (here.exists) {
|
|
458
|
+
// Not a refusal, and deliberately not a `mkdir` either: a directory that is
|
|
459
|
+
// already there is the ordinary case of pressing the button twice, and the
|
|
460
|
+
// window's next move — bind it — is the right one.
|
|
461
|
+
if (here.isDirectory)
|
|
462
|
+
return { ok: true, created: false, exists: true };
|
|
463
|
+
return refuseInit(`${resolved} already exists and is not a directory`);
|
|
464
|
+
}
|
|
465
|
+
const parent = path.dirname(resolved);
|
|
466
|
+
const above = inspectPath(parent);
|
|
467
|
+
if (above.unreachable) {
|
|
468
|
+
const blocked = firstUnreachableAncestor(parent) ?? parent;
|
|
469
|
+
return refuseInit(`The runner runs as ${me.user} and is not allowed into ${blocked}, so it cannot create ${resolved}. ` +
|
|
470
|
+
`Grant that user access to the directory (for example \`chmod o+x ${blocked}\`, ` +
|
|
471
|
+
`or \`setfacl -m u:${me.user}:x ${blocked}\`), then try again.`);
|
|
472
|
+
}
|
|
473
|
+
if (!above.exists || !above.isDirectory) {
|
|
474
|
+
return refuseInit(`${parent} does not exist on this server, and the runner creates only the last folder of the path. ` +
|
|
475
|
+
`Create ${parent} there first (\`mkdir -p ${parent}\` as a user who may write to it), then try again.`);
|
|
476
|
+
}
|
|
477
|
+
if (!above.writable) {
|
|
478
|
+
return refuseInit(`The runner runs as ${me.user} and cannot write to ${parent}, so it cannot create ${resolved} there. ` +
|
|
479
|
+
`Give that user write access (for example \`setfacl -m u:${me.user}:rwx ${parent}\`, ` +
|
|
480
|
+
`or \`chown ${me.user} ${parent}\` if the directory is meant to be theirs), then try again.`);
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
// Deliberately NOT `recursive: true`: the chain of parents is somebody
|
|
484
|
+
// else's decision about the layout of their machine, and a button that
|
|
485
|
+
// quietly makes `/opt/a/b/c` out of a typo is worse than one that says the
|
|
486
|
+
// folder above is missing.
|
|
487
|
+
fs.mkdirSync(resolved);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
const code = error.code;
|
|
491
|
+
if (code === 'EEXIST') {
|
|
492
|
+
// Somebody else got there between `inspectPath` and here — or the name was
|
|
493
|
+
// never free at all: `mkdir(2)` answers EEXIST for ANY existing name, and
|
|
494
|
+
// a dangling symlink is invisible to the `statSync` above, which follows
|
|
495
|
+
// it and reports «not there». Look again rather than assume: «a directory
|
|
496
|
+
// is there» is what the window binds on next.
|
|
497
|
+
const now = inspectPath(resolved);
|
|
498
|
+
if (now.exists && now.isDirectory)
|
|
499
|
+
return { ok: true, created: false, exists: true };
|
|
500
|
+
return refuseInit(`${resolved} already exists and is not a directory`);
|
|
501
|
+
}
|
|
502
|
+
return refuseInit(`The runner runs as ${me.user} and could not create ${resolved} (${code ?? 'unknown error'}). ` +
|
|
503
|
+
`Check that ${parent} is writable by that user, then try again.`);
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
await initRepositoryOnMain(resolved);
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
// All-or-nothing, like laying out a worktree (#360): a folder that exists
|
|
510
|
+
// but is not a repository binds and then fails on «Not a git work tree»,
|
|
511
|
+
// which is a worse place to learn about it. `rmdirSync` refuses a directory
|
|
512
|
+
// with anything in it, which is exactly the safety wanted here — we are only
|
|
513
|
+
// ever removing the empty one made three lines up.
|
|
514
|
+
let takenBack = true;
|
|
515
|
+
try {
|
|
516
|
+
fs.rmdirSync(resolved);
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
// It is not empty, so it is not only ours to remove. Leave it — and say
|
|
520
|
+
// so, because the two outcomes want opposite things from the person.
|
|
521
|
+
takenBack = false;
|
|
522
|
+
}
|
|
523
|
+
// Git's own words go to the runner's log, where the person who can act on
|
|
524
|
+
// them is; the sentence that travels names the folder and what to do next.
|
|
525
|
+
// `describeGitFailure` would have carried `fatal: …` into a browser, and
|
|
526
|
+
// the API's net for that (`actionableRunnerRefusal`) rewrites the WHOLE
|
|
527
|
+
// text — so forwarding it would have cost this sentence as well.
|
|
528
|
+
log.warn('init_project_dir: git init failed', {
|
|
529
|
+
path: resolved,
|
|
530
|
+
takenBack,
|
|
531
|
+
error: describeGitFailure(error),
|
|
532
|
+
});
|
|
533
|
+
const said = takenBack
|
|
534
|
+
? `Git would not start in ${resolved}, so the folder was removed again and nothing was left behind. ` +
|
|
535
|
+
`The runner's log on this server has git's own words — check that git is installed and that the disk is writable, then try again.`
|
|
536
|
+
: `${resolved} was created, but git would not start in it, and the folder is still there. ` +
|
|
537
|
+
`The runner's log on this server has git's own words. Run \`git init -b main\` in that folder yourself, or remove it and try again.`;
|
|
538
|
+
return { ok: false, created: !takenBack, exists: !takenBack, error: said };
|
|
539
|
+
}
|
|
540
|
+
// Read back rather than reported from intent: on a git old enough to need the
|
|
541
|
+
// fallback below, «main» is something we asked for, not something we saw.
|
|
542
|
+
const head = await headState(resolved);
|
|
543
|
+
return {
|
|
544
|
+
ok: true,
|
|
545
|
+
created: true,
|
|
546
|
+
exists: true,
|
|
547
|
+
...(head.branch ? { branch: head.branch } : {}),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* `git init` with `main` as the initial branch, on every git the fleet has.
|
|
552
|
+
*
|
|
553
|
+
* `--initial-branch` arrived in git 2.28 (2020); Debian 10 and CentOS 7 ship
|
|
554
|
+
* older ones and are still out there. The fallback is not a lesser outcome —
|
|
555
|
+
* `symbolic-ref` on a repository with no commits in it is exactly what `-b`
|
|
556
|
+
* does — it just takes two calls.
|
|
557
|
+
*
|
|
558
|
+
* Tried rather than version-parsed on purpose: the question is whether THIS git
|
|
559
|
+
* accepts the flag, and the flag itself answers it in one cheap call.
|
|
560
|
+
*/
|
|
561
|
+
async function initRepositoryOnMain(repoPath) {
|
|
562
|
+
try {
|
|
563
|
+
await git(repoPath, 'init', '--quiet', '--initial-branch', INITIAL_BRANCH);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
// Falls through: either the flag is unknown, or `git init` itself failed —
|
|
568
|
+
// and the plain form below tells the two apart by either working or
|
|
569
|
+
// throwing the real reason to the caller.
|
|
570
|
+
}
|
|
571
|
+
await git(repoPath, 'init', '--quiet');
|
|
572
|
+
// Best effort, and deliberately not fatal: `git init` has already made a
|
|
573
|
+
// working repository, and the caller reads the branch back off it rather than
|
|
574
|
+
// trusting this call. Throwing here would throw away a folder that is fine.
|
|
575
|
+
await git(repoPath, 'symbolic-ref', 'HEAD', `refs/heads/${INITIAL_BRANCH}`).catch(() => undefined);
|
|
576
|
+
}
|
|
255
577
|
/**
|
|
256
578
|
* The repository's main branch, read locally (ADR 0004).
|
|
257
579
|
*
|
package/dist/index.js
CHANGED
|
@@ -566,6 +566,11 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
566
566
|
// это чтение, а не установка, и машину, чей владелец запретил ставить из
|
|
567
567
|
// дашборда, спросить о том, что на ней стоит, по-прежнему можно.
|
|
568
568
|
'agent_versions_refresh',
|
|
569
|
+
// #417: create the project folder and `git init` in it, from the
|
|
570
|
+
// binding window. Announced here and nowhere else — the API refuses the
|
|
571
|
+
// button on a machine that does not name this command, because an older
|
|
572
|
+
// runner drops a command it cannot parse and never answers.
|
|
573
|
+
'init_project_dir',
|
|
569
574
|
// #396. Listed unconditionally, unlike the `restart` flag above: the
|
|
570
575
|
// flag is what the dashboard draws a button from, and this list is what
|
|
571
576
|
// `runCommand` dispatches on. A machine that cannot restart still
|
package/dist/supervisor.js
CHANGED
|
@@ -9,7 +9,7 @@ import { classifyFailure, isRepeatOfSameFailure, MAX_RETRIES_PER_SESSION, retryD
|
|
|
9
9
|
import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
|
|
10
10
|
import { agentPromptSizeLabel, inspectAgentPrompt, quotePath, readAgentPrompt, } from './agent-prompt.js';
|
|
11
11
|
import { JournalStore } from './journal.js';
|
|
12
|
-
import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, WorktreePrepareError, } from './git.js';
|
|
12
|
+
import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, initProjectDir, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, WorktreePrepareError, } from './git.js';
|
|
13
13
|
import { readRecipeProposal } from './recipe.js';
|
|
14
14
|
import { RECIPE_STEP_NAMES, parseProjectRecipe } from './recipe-schema.js';
|
|
15
15
|
import { proposeCommitMessage } from './commit-message.js';
|
|
@@ -6140,6 +6140,37 @@ export class Supervisor {
|
|
|
6140
6140
|
...(validation.ok ? {} : { error: validation.error }),
|
|
6141
6141
|
});
|
|
6142
6142
|
}
|
|
6143
|
+
/**
|
|
6144
|
+
* «Create this folder and put git in it», from the binding window
|
|
6145
|
+
* (#417).
|
|
6146
|
+
*
|
|
6147
|
+
* Next to `validate_path` because it is the same conversation: that
|
|
6148
|
+
* command answers «the folder is not there», and this one is what the
|
|
6149
|
+
* person presses next. Everything it is allowed and not allowed to do
|
|
6150
|
+
* is decided in `initProjectDir` — the API sees a verdict and never the
|
|
6151
|
+
* machine.
|
|
6152
|
+
*
|
|
6153
|
+
* Under the repository lock like every other operation on a folder,
|
|
6154
|
+
* even though there is no repository yet: `repoKeyFor` falls back to the
|
|
6155
|
+
* path itself when git cannot answer, so two presses of the button
|
|
6156
|
+
* serialise against each other rather than racing into one `mkdir`.
|
|
6157
|
+
*/
|
|
6158
|
+
case 'init_project_dir': {
|
|
6159
|
+
const path = typeof frame.args?.['path'] === 'string' ? frame.args['path'] : null;
|
|
6160
|
+
if (!path)
|
|
6161
|
+
return void reply({ ok: false, error: 'path argument is required' });
|
|
6162
|
+
const init = await this.withRepoLockFor(path, () => initProjectDir(path));
|
|
6163
|
+
// Remembered for the same reason `validate_path` remembers it: this
|
|
6164
|
+
// is a real project directory now, and `devbridge-runner doctor`
|
|
6165
|
+
// checks the permissions of the ones it knows about.
|
|
6166
|
+
if (init.ok)
|
|
6167
|
+
rememberWorkspacePath(path);
|
|
6168
|
+
return void reply({
|
|
6169
|
+
ok: init.ok,
|
|
6170
|
+
result: init,
|
|
6171
|
+
...(init.ok ? {} : { error: init.error }),
|
|
6172
|
+
});
|
|
6173
|
+
}
|
|
6143
6174
|
case 'clean': {
|
|
6144
6175
|
const sessionId = frame.sessionId;
|
|
6145
6176
|
if (!sessionId)
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const RUNNER_VERSION = "0.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.64.1";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED