@bridge4dev/runner 0.22.1 → 0.26.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/dist/auth-relay.d.ts +33 -3
- package/dist/auth-relay.js +199 -16
- package/dist/environment.d.ts +171 -0
- package/dist/environment.js +409 -0
- package/dist/git.d.ts +10 -0
- package/dist/git.js +94 -5
- package/dist/index.js +328 -19
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +11 -0
- package/dist/protocol.d.ts +2 -2
- package/dist/self-update.d.ts +14 -0
- package/dist/self-update.js +45 -0
- package/dist/service-unit.d.ts +13 -1
- package/dist/service-unit.js +41 -10
- package/dist/supervisor.js +35 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { knownWorkspacesPath } from './paths.js';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
export function runnerIdentity() {
|
|
9
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : -1;
|
|
10
|
+
const gid = typeof process.getgid === 'function' ? process.getgid() : -1;
|
|
11
|
+
let user = process.env['USER'] ?? process.env['LOGNAME'] ?? '';
|
|
12
|
+
if (!user) {
|
|
13
|
+
try {
|
|
14
|
+
user = os.userInfo().username;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
user = uid === 0 ? 'root' : String(uid);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return { user, uid, gid, home: os.homedir(), isRoot: uid === 0 };
|
|
21
|
+
}
|
|
22
|
+
/** As root every access check passes, which is true and worth saying out loud. */
|
|
23
|
+
function canAccess(target, mode) {
|
|
24
|
+
try {
|
|
25
|
+
fs.accessSync(target, mode);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function inspectPath(target) {
|
|
33
|
+
const me = runnerIdentity();
|
|
34
|
+
const base = {
|
|
35
|
+
path: target,
|
|
36
|
+
exists: false,
|
|
37
|
+
isDirectory: false,
|
|
38
|
+
ownerUid: -1,
|
|
39
|
+
ownedByUs: false,
|
|
40
|
+
readable: false,
|
|
41
|
+
writable: false,
|
|
42
|
+
unreachable: false,
|
|
43
|
+
};
|
|
44
|
+
let stat;
|
|
45
|
+
try {
|
|
46
|
+
stat = fs.statSync(target);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
const code = error.code;
|
|
50
|
+
return code === 'ENOENT' || code === 'ENOTDIR' ? base : { ...base, unreachable: true };
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
path: target,
|
|
54
|
+
exists: true,
|
|
55
|
+
isDirectory: stat.isDirectory(),
|
|
56
|
+
ownerUid: stat.uid,
|
|
57
|
+
ownedByUs: me.uid < 0 || stat.uid === me.uid,
|
|
58
|
+
readable: canAccess(target, fs.constants.R_OK),
|
|
59
|
+
writable: canAccess(target, fs.constants.W_OK),
|
|
60
|
+
unreachable: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The first directory on this path the runner cannot enter.
|
|
65
|
+
*
|
|
66
|
+
* «Permission denied» on `/srv/apps/shop` is usually not about `shop` at all —
|
|
67
|
+
* it is about `/srv/apps`, and naming the wrong one sends the person to chmod
|
|
68
|
+
* a directory that was never the problem.
|
|
69
|
+
*/
|
|
70
|
+
export function firstUnreachableAncestor(target) {
|
|
71
|
+
const parts = path.resolve(target).split(path.sep).filter(Boolean);
|
|
72
|
+
let current = path.sep;
|
|
73
|
+
for (const part of parts) {
|
|
74
|
+
current = path.join(current, part);
|
|
75
|
+
try {
|
|
76
|
+
fs.statSync(current);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
const code = error.code;
|
|
80
|
+
// ENOENT here means the path simply ends — not a permission problem.
|
|
81
|
+
return code === 'ENOENT' || code === 'ENOTDIR' ? null : current;
|
|
82
|
+
}
|
|
83
|
+
if (!canAccess(current, fs.constants.X_OK))
|
|
84
|
+
return current;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Git refuses to work in a repository owned by somebody else — since 2022, and
|
|
90
|
+
* with no exception for root. So the "obvious" fix for a dedicated user (chown
|
|
91
|
+
* the project to it) breaks git for the person who was committing there before,
|
|
92
|
+
* and the exception has to be added on BOTH sides.
|
|
93
|
+
*/
|
|
94
|
+
export function safeDirectoryCommand(repoPath) {
|
|
95
|
+
return `git config --global --add safe.directory ${repoPath}`;
|
|
96
|
+
}
|
|
97
|
+
export function looksLikeDubiousOwnership(message) {
|
|
98
|
+
return /dubious ownership|safe\.directory/i.test(message);
|
|
99
|
+
}
|
|
100
|
+
/** Is this path already excused in the current user's git config? */
|
|
101
|
+
export async function hasSafeDirectory(repoPath) {
|
|
102
|
+
try {
|
|
103
|
+
const { stdout } = await execFileAsync('git', ['config', '--global', '--get-all', 'safe.directory'], { timeout: 10_000 });
|
|
104
|
+
const entries = stdout.split('\n').map((line) => line.trim());
|
|
105
|
+
return entries.includes(repoPath) || entries.includes('*');
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// No git config at all (exit 1) — nothing is excused.
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export async function addSafeDirectory(repoPath) {
|
|
113
|
+
await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', repoPath], {
|
|
114
|
+
timeout: 10_000,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function countAllowRules(file) {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
120
|
+
const allow = parsed.permissions?.allow;
|
|
121
|
+
return Array.isArray(allow) ? allow.length : 0;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function countCommands(dir) {
|
|
128
|
+
let total = 0;
|
|
129
|
+
const walk = (current, depth) => {
|
|
130
|
+
if (depth > 3)
|
|
131
|
+
return;
|
|
132
|
+
let entries;
|
|
133
|
+
try {
|
|
134
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
if (entry.isDirectory())
|
|
141
|
+
walk(path.join(current, entry.name), depth + 1);
|
|
142
|
+
else if (entry.name.endsWith('.md'))
|
|
143
|
+
total += 1;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
walk(dir, 0);
|
|
147
|
+
return total;
|
|
148
|
+
}
|
|
149
|
+
export function agentConfigContour(home = os.homedir()) {
|
|
150
|
+
const claudeDir = path.join(home, '.claude');
|
|
151
|
+
const settings = path.join(claudeDir, 'settings.json');
|
|
152
|
+
const localSettings = path.join(claudeDir, 'settings.local.json');
|
|
153
|
+
const commandsDir = path.join(claudeDir, 'commands');
|
|
154
|
+
const codexDir = path.join(home, '.codex');
|
|
155
|
+
return {
|
|
156
|
+
home,
|
|
157
|
+
claudeDir: fs.existsSync(claudeDir),
|
|
158
|
+
settings: fs.existsSync(settings),
|
|
159
|
+
localSettings: fs.existsSync(localSettings),
|
|
160
|
+
allowRules: fs.existsSync(localSettings)
|
|
161
|
+
? countAllowRules(localSettings)
|
|
162
|
+
: fs.existsSync(settings)
|
|
163
|
+
? countAllowRules(settings)
|
|
164
|
+
: null,
|
|
165
|
+
commands: fs.existsSync(commandsDir) ? countCommands(commandsDir) : 0,
|
|
166
|
+
plugins: fs.existsSync(path.join(claudeDir, 'plugins')),
|
|
167
|
+
codexDir: fs.existsSync(codexDir),
|
|
168
|
+
codexConfig: fs.existsSync(path.join(codexDir, 'config.toml')),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Another user's home that already has an agent set up.
|
|
173
|
+
*
|
|
174
|
+
* Only reported, never copied: a copied OAuth credential means two accounts
|
|
175
|
+
* share one refresh token, and a rotation in either one silently invalidates
|
|
176
|
+
* the other. Signing in as the runner's own user is the clean answer; the copy
|
|
177
|
+
* is the fast one, and the person choosing between them deserves to be told
|
|
178
|
+
* which is which.
|
|
179
|
+
*/
|
|
180
|
+
export function otherHomeWithAgents(me = runnerIdentity()) {
|
|
181
|
+
const candidates = me.isRoot ? [] : ['/root'];
|
|
182
|
+
for (const home of candidates) {
|
|
183
|
+
if (home === me.home)
|
|
184
|
+
continue;
|
|
185
|
+
if (fs.existsSync(path.join(home, '.claude')) || fs.existsSync(path.join(home, '.codex'))) {
|
|
186
|
+
return home;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* `systemctl --user` talks over a per-user D-Bus socket, and finds it through
|
|
193
|
+
* `XDG_RUNTIME_DIR`. Under `sudo -iu <user>` that variable is not set, and the
|
|
194
|
+
* failure is worse than an error: it prints «Failed to connect to bus» and
|
|
195
|
+
* exits **0**, so a health check reads it as success.
|
|
196
|
+
*/
|
|
197
|
+
export function systemdUserEnv() {
|
|
198
|
+
const env = { ...process.env };
|
|
199
|
+
if (!env['XDG_RUNTIME_DIR']) {
|
|
200
|
+
const uid = runnerIdentity().uid;
|
|
201
|
+
if (uid >= 0)
|
|
202
|
+
env['XDG_RUNTIME_DIR'] = `/run/user/${uid}`;
|
|
203
|
+
}
|
|
204
|
+
if (!env['DBUS_SESSION_BUS_ADDRESS'] && env['XDG_RUNTIME_DIR']) {
|
|
205
|
+
env['DBUS_SESSION_BUS_ADDRESS'] = `unix:path=${env['XDG_RUNTIME_DIR']}/bus`;
|
|
206
|
+
}
|
|
207
|
+
return env;
|
|
208
|
+
}
|
|
209
|
+
/** True when the user bus is actually reachable — `systemctl --user` lies with exit 0. */
|
|
210
|
+
export async function systemdUserBusReachable() {
|
|
211
|
+
const dir = systemdUserEnv()['XDG_RUNTIME_DIR'];
|
|
212
|
+
if (!dir || !fs.existsSync(path.join(dir, 'bus')))
|
|
213
|
+
return false;
|
|
214
|
+
try {
|
|
215
|
+
const { stdout, stderr } = await execFileAsync('systemctl', ['--user', 'is-system-running'], {
|
|
216
|
+
timeout: 10_000,
|
|
217
|
+
env: systemdUserEnv(),
|
|
218
|
+
});
|
|
219
|
+
return !/Failed to connect to bus/i.test(`${stdout}${stderr}`);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
// A non-zero exit is normal here (`degraded`, `starting`); only a bus
|
|
223
|
+
// failure means we could not talk to systemd at all.
|
|
224
|
+
const text = String(error?.stderr ?? error);
|
|
225
|
+
return !/Failed to connect to bus|No medium found/i.test(text);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** The command form that works under `sudo -iu <user>` — printed in hints. */
|
|
229
|
+
export function systemctlHint(args) {
|
|
230
|
+
const me = runnerIdentity();
|
|
231
|
+
if (me.isRoot)
|
|
232
|
+
return `systemctl --user ${args}`;
|
|
233
|
+
return `sudo -iu ${me.user} env XDG_RUNTIME_DIR=/run/user/${me.uid} DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${me.uid}/bus systemctl --user ${args}`;
|
|
234
|
+
}
|
|
235
|
+
// ─── Which project directories this machine actually works in ────────
|
|
236
|
+
/**
|
|
237
|
+
* Remembered from binding and from session starts, so `doctor` can check the
|
|
238
|
+
* permissions of real projects instead of asking the person to name them.
|
|
239
|
+
* Best-effort on purpose: a runner that cannot write its own state directory
|
|
240
|
+
* has bigger problems than a diagnostic list, and none of them should turn a
|
|
241
|
+
* session start into an error.
|
|
242
|
+
*/
|
|
243
|
+
export function rememberWorkspacePath(workspacePath) {
|
|
244
|
+
try {
|
|
245
|
+
const file = knownWorkspacesPath();
|
|
246
|
+
const known = knownWorkspacePaths();
|
|
247
|
+
if (known.includes(workspacePath))
|
|
248
|
+
return;
|
|
249
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
250
|
+
// Newest last, capped: this is a diagnostic aid, not a registry.
|
|
251
|
+
const next = [...known, workspacePath].slice(-32);
|
|
252
|
+
fs.writeFileSync(file, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
/* diagnostics only */
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
export function knownWorkspacePaths() {
|
|
259
|
+
try {
|
|
260
|
+
const parsed = JSON.parse(fs.readFileSync(knownWorkspacesPath(), 'utf8'));
|
|
261
|
+
if (!Array.isArray(parsed))
|
|
262
|
+
return [];
|
|
263
|
+
return parsed.filter((entry) => typeof entry === 'string' && entry.length > 0);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function whichExecutable(name) {
|
|
270
|
+
for (const dir of (process.env['PATH'] ?? '').split(path.delimiter).filter(Boolean)) {
|
|
271
|
+
const candidate = path.join(dir, name);
|
|
272
|
+
try {
|
|
273
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
274
|
+
return candidate;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
/* keep looking */
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Node, as THIS user sees it.
|
|
284
|
+
*
|
|
285
|
+
* A dedicated user does not automatically inherit a Node installed for
|
|
286
|
+
* somebody else — fnm, nvm and a root-only prefix are all per-user by design.
|
|
287
|
+
* The runner itself is running, so node clearly exists somewhere; the question
|
|
288
|
+
* this answers is whether it is on the daemon user's own PATH, because that is
|
|
289
|
+
* what agent tooling and `npm install -g` will look at.
|
|
290
|
+
*/
|
|
291
|
+
export async function nodeCheck() {
|
|
292
|
+
const found = whichExecutable('node');
|
|
293
|
+
if (!found)
|
|
294
|
+
return { path: null, problem: 'node is not on this user’s PATH' };
|
|
295
|
+
try {
|
|
296
|
+
const { stdout } = await execFileAsync(found, ['--version'], { timeout: 10_000 });
|
|
297
|
+
const version = stdout.trim();
|
|
298
|
+
const major = Number(version.replace(/^v/, '').split('.')[0]);
|
|
299
|
+
return {
|
|
300
|
+
path: found,
|
|
301
|
+
version,
|
|
302
|
+
...(Number.isFinite(major) && major < 20 ? { problem: 'Node 20 or newer is required' } : {}),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
return { path: found, problem: String(error instanceof Error ? error.message : error) };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Docker, as THIS user sees it.
|
|
311
|
+
*
|
|
312
|
+
* Reported rather than judged: plenty of projects never touch it. But when the
|
|
313
|
+
* project's own workflow is `docker compose`, a dedicated user without access
|
|
314
|
+
* to the socket produces a session that fails on its first command, and the
|
|
315
|
+
* error will be about a socket rather than about a group nobody was added to.
|
|
316
|
+
*
|
|
317
|
+
* Worth stating where it is stated: being in the `docker` group is equivalent
|
|
318
|
+
* to root on this machine. That is a fact for the owner to accept knowingly.
|
|
319
|
+
*/
|
|
320
|
+
export async function dockerCheck() {
|
|
321
|
+
const found = whichExecutable('docker');
|
|
322
|
+
if (!found)
|
|
323
|
+
return { path: null };
|
|
324
|
+
const socket = '/var/run/docker.sock';
|
|
325
|
+
if (fs.existsSync(socket) && !canAccess(socket, fs.constants.R_OK | fs.constants.W_OK)) {
|
|
326
|
+
const me = runnerIdentity();
|
|
327
|
+
return {
|
|
328
|
+
path: found,
|
|
329
|
+
problem: `${me.user} cannot use the docker socket (membership of the docker group is equivalent to root here)`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
await execFileAsync(found, ['info', '--format', '{{.ServerVersion}}'], { timeout: 15_000 });
|
|
334
|
+
return { path: found };
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
return {
|
|
338
|
+
path: found,
|
|
339
|
+
problem: `docker is installed but did not answer: ${String(error instanceof Error ? error.message : error).slice(0, 160)}`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Does the service survive a logout?
|
|
345
|
+
*
|
|
346
|
+
* `loginctl enable-linger` is what keeps a user's systemd services running with
|
|
347
|
+
* nobody logged in. `install-service` turns it on, but a unit installed by hand
|
|
348
|
+
* — or a user created afterwards — can miss it, and the failure looks like
|
|
349
|
+
* «the server goes offline whenever I close the terminal».
|
|
350
|
+
*/
|
|
351
|
+
export async function lingerEnabled() {
|
|
352
|
+
const me = runnerIdentity();
|
|
353
|
+
try {
|
|
354
|
+
const { stdout } = await execFileAsync('loginctl', ['show-user', me.user, '--property=Linger'], { timeout: 10_000 });
|
|
355
|
+
return stdout.trim().endsWith('=yes');
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// No loginctl, or the user has no session recorded — unknown, not false.
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Make the agent CLIs findable, without taking anything away.
|
|
364
|
+
*
|
|
365
|
+
* A systemd user service starts with the manager's PATH, which is the
|
|
366
|
+
* distribution default — `/usr/bin` and friends. Both agent CLIs are commonly
|
|
367
|
+
* installed somewhere else: `~/.local/bin` for a per-user install, and a
|
|
368
|
+
* node managed by fnm/nvm/volta lives under its own version directory. When
|
|
369
|
+
* `codex` sits there, the runner scans PATH, does not find it, and reports to
|
|
370
|
+
* the dashboard that this machine has no Codex at all — the agent the person
|
|
371
|
+
* installed simply never appears.
|
|
372
|
+
*
|
|
373
|
+
* Deliberately APPEND-ONLY, and deliberately in the process rather than in the
|
|
374
|
+
* unit file. Writing `Environment=PATH=…` into the unit would REPLACE whatever
|
|
375
|
+
* systemd gives the service today (`/snap/bin`, anything set through
|
|
376
|
+
* `environment.d`), trading something that works for something that is
|
|
377
|
+
* missing. Here nothing can be lost: entries are only added when they are
|
|
378
|
+
* absent and the directory actually exists.
|
|
379
|
+
*
|
|
380
|
+
* Returns what it added, so the caller can say so once at startup.
|
|
381
|
+
*/
|
|
382
|
+
export function ensureAgentPath() {
|
|
383
|
+
const entries = (process.env['PATH'] ?? '').split(path.delimiter).filter(Boolean);
|
|
384
|
+
const added = [];
|
|
385
|
+
for (const candidate of [
|
|
386
|
+
path.join(os.homedir(), '.local', 'bin'),
|
|
387
|
+
// The directory of the node running us — under fnm/nvm the agent CLIs and
|
|
388
|
+
// other globally installed tools sit next to it.
|
|
389
|
+
path.dirname(process.execPath),
|
|
390
|
+
]) {
|
|
391
|
+
if (!candidate || entries.includes(candidate))
|
|
392
|
+
continue;
|
|
393
|
+
let usable;
|
|
394
|
+
try {
|
|
395
|
+
usable = fs.statSync(candidate).isDirectory();
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
usable = false;
|
|
399
|
+
}
|
|
400
|
+
if (!usable)
|
|
401
|
+
continue;
|
|
402
|
+
entries.push(candidate);
|
|
403
|
+
added.push(candidate);
|
|
404
|
+
}
|
|
405
|
+
if (added.length > 0)
|
|
406
|
+
process.env['PATH'] = entries.join(path.delimiter);
|
|
407
|
+
return added;
|
|
408
|
+
}
|
|
409
|
+
//# sourceMappingURL=environment.js.map
|
package/dist/git.d.ts
CHANGED
|
@@ -5,6 +5,16 @@ export interface PathValidation {
|
|
|
5
5
|
branch?: string;
|
|
6
6
|
error?: string;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Can this runner actually work in this directory — as the user it runs as?
|
|
10
|
+
*
|
|
11
|
+
* Everything here answers with the fix rather than the symptom. Binding a
|
|
12
|
+
* project is the moment the two legitimate install choices (root / dedicated
|
|
13
|
+
* user) start to differ, and until now the second one failed by forwarding
|
|
14
|
+
* git's own words to a dashboard the person may have no shell behind:
|
|
15
|
+
* «git check failed: fatal: detected dubious ownership in repository at
|
|
16
|
+
* '/opt/ids'». That sentence is true and unactionable.
|
|
17
|
+
*/
|
|
8
18
|
export declare function validateWorkspacePath(workspacePath: string): Promise<PathValidation>;
|
|
9
19
|
export declare function sessionShortId(sessionId: string): string;
|
|
10
20
|
export declare function sessionWorktreePath(sessionId: string): string;
|
package/dist/git.js
CHANGED
|
@@ -3,32 +3,121 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { previewsDir, worktreesDir } from './paths.js';
|
|
6
|
+
import { firstUnreachableAncestor, inspectPath, looksLikeDubiousOwnership, runnerIdentity, safeDirectoryCommand, } from './environment.js';
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
const GIT_TIMEOUT_MS = 30_000;
|
|
8
9
|
async function git(cwd, ...args) {
|
|
9
10
|
const { stdout } = await execFileAsync('git', args, { cwd, timeout: GIT_TIMEOUT_MS });
|
|
10
11
|
return stdout.trim();
|
|
11
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Can this runner actually work in this directory — as the user it runs as?
|
|
15
|
+
*
|
|
16
|
+
* Everything here answers with the fix rather than the symptom. Binding a
|
|
17
|
+
* project is the moment the two legitimate install choices (root / dedicated
|
|
18
|
+
* user) start to differ, and until now the second one failed by forwarding
|
|
19
|
+
* git's own words to a dashboard the person may have no shell behind:
|
|
20
|
+
* «git check failed: fatal: detected dubious ownership in repository at
|
|
21
|
+
* '/opt/ids'». That sentence is true and unactionable.
|
|
22
|
+
*/
|
|
12
23
|
export async function validateWorkspacePath(workspacePath) {
|
|
13
|
-
|
|
14
|
-
|
|
24
|
+
const me = runnerIdentity();
|
|
25
|
+
const dir = inspectPath(workspacePath);
|
|
26
|
+
// «Cannot look» and «is not there» are opposite instructions to whoever reads
|
|
27
|
+
// this, and both arrive as a thrown `statSync`. A dedicated-user install hits
|
|
28
|
+
// the first one constantly — usually on a directory ABOVE the project, which
|
|
29
|
+
// is why the blocking one is named rather than the one that was asked about.
|
|
30
|
+
if (dir.unreachable) {
|
|
31
|
+
const blocked = firstUnreachableAncestor(workspacePath) ?? workspacePath;
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
exists: true,
|
|
35
|
+
isGitRepo: false,
|
|
36
|
+
error: `The runner runs as ${me.user} and is not allowed into ${blocked}, so it cannot reach ${workspacePath}. ` +
|
|
37
|
+
`Grant that user access to the directory (for example \`chmod o+x ${blocked}\`, ` +
|
|
38
|
+
`or \`setfacl -m u:${me.user}:x ${blocked}\`), then try again.`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (!dir.exists || !dir.isDirectory) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
exists: dir.exists,
|
|
45
|
+
isGitRepo: false,
|
|
46
|
+
error: dir.exists
|
|
47
|
+
? `${workspacePath} is not a directory`
|
|
48
|
+
: `${workspacePath} does not exist on this server`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// A directory we cannot even enter: as a non-root runner this is the most
|
|
52
|
+
// common outcome of pointing at somebody else's project, and git's error for
|
|
53
|
+
// it says nothing about permissions.
|
|
54
|
+
if (!dir.readable) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
exists: true,
|
|
58
|
+
isGitRepo: false,
|
|
59
|
+
error: `The runner runs as ${me.user} and cannot read ${workspacePath}. ` +
|
|
60
|
+
`Give that user access (for example \`setfacl -R -m u:${me.user}:rX ${workspacePath}\`, ` +
|
|
61
|
+
`or \`chown -R ${me.user} ${workspacePath}\` if the directory is meant to be theirs), then try again.`,
|
|
62
|
+
};
|
|
15
63
|
}
|
|
16
64
|
try {
|
|
17
65
|
const inside = await git(workspacePath, 'rev-parse', '--is-inside-work-tree');
|
|
18
66
|
if (inside !== 'true') {
|
|
19
67
|
return { ok: false, exists: true, isGitRepo: false, error: 'Not a git work tree' };
|
|
20
68
|
}
|
|
21
|
-
const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
22
|
-
return { ok: true, exists: true, isGitRepo: true, branch };
|
|
23
69
|
}
|
|
24
70
|
catch (error) {
|
|
71
|
+
const message = String(error instanceof Error ? error.message : error);
|
|
72
|
+
if (looksLikeDubiousOwnership(message)) {
|
|
73
|
+
// The single most common failure of a dedicated-user install, and the one
|
|
74
|
+
// whose fix is one line — which is why we print the line.
|
|
75
|
+
const owner = dir.ownerUid >= 0 ? ` (it belongs to uid ${dir.ownerUid})` : '';
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
exists: true,
|
|
79
|
+
isGitRepo: false,
|
|
80
|
+
error: `Git refuses to use ${workspacePath} because the runner runs as ${me.user} and does not own it${owner}. ` +
|
|
81
|
+
`Run this on the server as ${me.user}: ${safeDirectoryCommand(workspacePath)} — ` +
|
|
82
|
+
`or run \`devbridge-runner doctor --fix\`, which does it for every bound project. ` +
|
|
83
|
+
`Note: if you instead hand the directory over with \`chown\`, add the same line for its previous owner, ` +
|
|
84
|
+
`who will otherwise lose git in that repository.`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
25
87
|
return {
|
|
26
88
|
ok: false,
|
|
27
89
|
exists: true,
|
|
28
90
|
isGitRepo: false,
|
|
29
|
-
error: `git check failed: ${
|
|
91
|
+
error: `git check failed: ${message.slice(0, 300)}`,
|
|
30
92
|
};
|
|
31
93
|
}
|
|
94
|
+
// Sessions write here: a worktree registers itself inside `.git/worktrees`,
|
|
95
|
+
// and a DIRECT-mode session commits in the tree itself. Read-only access
|
|
96
|
+
// binds fine and then fails on the first session, which is the worst place
|
|
97
|
+
// to learn about it.
|
|
98
|
+
const gitDir = await resolveGitDir(workspacePath);
|
|
99
|
+
const gitAccess = gitDir ? inspectPath(gitDir) : null;
|
|
100
|
+
if (gitAccess && gitAccess.exists && !gitAccess.writable) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
exists: true,
|
|
104
|
+
isGitRepo: true,
|
|
105
|
+
error: `The runner runs as ${me.user} and cannot write to ${gitDir}, so it could not create a branch or a worktree here. ` +
|
|
106
|
+
`Give that user write access to the repository (for example \`chown -R ${me.user} ${workspacePath}\`), then try again.`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
110
|
+
return { ok: true, exists: true, isGitRepo: true, branch };
|
|
111
|
+
}
|
|
112
|
+
/** The real `.git` directory of a work tree (a linked worktree has a file there). */
|
|
113
|
+
async function resolveGitDir(workspacePath) {
|
|
114
|
+
try {
|
|
115
|
+
const common = await git(workspacePath, 'rev-parse', '--git-common-dir');
|
|
116
|
+
return path.resolve(workspacePath, common);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
32
121
|
}
|
|
33
122
|
export function sessionShortId(sessionId) {
|
|
34
123
|
return sessionId.replace(/-/g, '').slice(0, 8);
|