@bridge4dev/runner 0.13.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.
Files changed (49) hide show
  1. package/dist/adapters/claude.d.ts +15 -7
  2. package/dist/adapters/claude.js +1024 -70
  3. package/dist/adapters/codex.d.ts +18 -3
  4. package/dist/adapters/codex.js +224 -65
  5. package/dist/adapters/questions.d.ts +42 -0
  6. package/dist/adapters/questions.js +86 -0
  7. package/dist/adapters/types.d.ts +200 -4
  8. package/dist/attachments.d.ts +8 -1
  9. package/dist/attachments.js +22 -4
  10. package/dist/auth-relay.d.ts +33 -3
  11. package/dist/auth-relay.js +199 -16
  12. package/dist/auto-resume.d.ts +18 -0
  13. package/dist/auto-resume.js +104 -0
  14. package/dist/commit-message.d.ts +51 -0
  15. package/dist/commit-message.js +224 -0
  16. package/dist/config.d.ts +29 -6
  17. package/dist/config.js +15 -0
  18. package/dist/crash-note.d.ts +54 -0
  19. package/dist/crash-note.js +105 -0
  20. package/dist/environment.d.ts +171 -0
  21. package/dist/environment.js +409 -0
  22. package/dist/git.d.ts +81 -0
  23. package/dist/git.js +301 -15
  24. package/dist/gitops.d.ts +489 -12
  25. package/dist/gitops.js +1717 -96
  26. package/dist/index.js +715 -8
  27. package/dist/paths.d.ts +35 -0
  28. package/dist/paths.js +45 -0
  29. package/dist/policy.d.ts +63 -0
  30. package/dist/policy.js +412 -10
  31. package/dist/protocol.d.ts +382 -60
  32. package/dist/protocol.js +104 -1
  33. package/dist/recipe-schema.d.ts +310 -0
  34. package/dist/recipe-schema.js +103 -0
  35. package/dist/recipe.d.ts +94 -0
  36. package/dist/recipe.js +238 -0
  37. package/dist/self-update.d.ts +21 -0
  38. package/dist/self-update.js +73 -1
  39. package/dist/service-unit.d.ts +61 -2
  40. package/dist/service-unit.js +150 -14
  41. package/dist/supervisor.d.ts +108 -1
  42. package/dist/supervisor.js +1045 -57
  43. package/dist/verify-queue.d.ts +17 -0
  44. package/dist/verify-queue.js +100 -0
  45. package/dist/verify.d.ts +203 -0
  46. package/dist/verify.js +788 -0
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +1 -1
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Everything that depends on WHICH USER the runner runs as.
3
+ *
4
+ * The install instructions offer a choice — root or a dedicated user — and both
5
+ * are legitimate. What is not legitimate is the way the second choice used to
6
+ * fail: silently, later, and in the agent's voice. A dedicated user typically
7
+ * owns none of the project directories, has an empty `$HOME` where the agent
8
+ * CLIs keep their login and settings, and reaches systemd only with
9
+ * `XDG_RUNTIME_DIR` set. Each of those produces a symptom that reads like a
10
+ * broken runner ("agent can't do anything", "not signed in", "service is fine"
11
+ * with exit code 0) while the real cause is a permission nobody was told about.
12
+ *
13
+ * This module turns each of them into a fact with a command next to it.
14
+ */
15
+ export interface RunnerIdentity {
16
+ user: string;
17
+ uid: number;
18
+ gid: number;
19
+ home: string;
20
+ isRoot: boolean;
21
+ }
22
+ export declare function runnerIdentity(): RunnerIdentity;
23
+ export interface PathAccess {
24
+ path: string;
25
+ exists: boolean;
26
+ isDirectory: boolean;
27
+ /** uid of the owner, or -1 when we could not stat it. */
28
+ ownerUid: number;
29
+ ownedByUs: boolean;
30
+ readable: boolean;
31
+ writable: boolean;
32
+ /**
33
+ * True when we could not even look — a directory ABOVE this one denies us
34
+ * traversal. Distinct from `!exists` on purpose: those are opposite answers
35
+ * to the person reading them («create it» vs «grant access»), and `statSync`
36
+ * reports both by throwing.
37
+ */
38
+ unreachable: boolean;
39
+ }
40
+ export declare function inspectPath(target: string): PathAccess;
41
+ /**
42
+ * The first directory on this path the runner cannot enter.
43
+ *
44
+ * «Permission denied» on `/srv/apps/shop` is usually not about `shop` at all —
45
+ * it is about `/srv/apps`, and naming the wrong one sends the person to chmod
46
+ * a directory that was never the problem.
47
+ */
48
+ export declare function firstUnreachableAncestor(target: string): string | null;
49
+ /**
50
+ * Git refuses to work in a repository owned by somebody else — since 2022, and
51
+ * with no exception for root. So the "obvious" fix for a dedicated user (chown
52
+ * the project to it) breaks git for the person who was committing there before,
53
+ * and the exception has to be added on BOTH sides.
54
+ */
55
+ export declare function safeDirectoryCommand(repoPath: string): string;
56
+ export declare function looksLikeDubiousOwnership(message: string): boolean;
57
+ /** Is this path already excused in the current user's git config? */
58
+ export declare function hasSafeDirectory(repoPath: string): Promise<boolean>;
59
+ export declare function addSafeDirectory(repoPath: string): Promise<void>;
60
+ /**
61
+ * What the agent will find in this user's home besides a login.
62
+ *
63
+ * Copying only `.credentials.json` to a fresh user is the usual half-measure:
64
+ * the agent starts, and then behaves like a stranger — no permission allowlist,
65
+ * no slash commands, no plugins, default model. From the outside that reads as
66
+ * «the runner can't do anything», which is why this is worth reporting BEFORE
67
+ * the first session rather than diagnosing after it.
68
+ */
69
+ export interface AgentConfigContour {
70
+ home: string;
71
+ claudeDir: boolean;
72
+ settings: boolean;
73
+ localSettings: boolean;
74
+ /** Number of entries in `permissions.allow`, or null when unreadable. */
75
+ allowRules: number | null;
76
+ commands: number;
77
+ plugins: boolean;
78
+ codexDir: boolean;
79
+ codexConfig: boolean;
80
+ }
81
+ export declare function agentConfigContour(home?: string): AgentConfigContour;
82
+ /**
83
+ * Another user's home that already has an agent set up.
84
+ *
85
+ * Only reported, never copied: a copied OAuth credential means two accounts
86
+ * share one refresh token, and a rotation in either one silently invalidates
87
+ * the other. Signing in as the runner's own user is the clean answer; the copy
88
+ * is the fast one, and the person choosing between them deserves to be told
89
+ * which is which.
90
+ */
91
+ export declare function otherHomeWithAgents(me?: RunnerIdentity): string | null;
92
+ /**
93
+ * `systemctl --user` talks over a per-user D-Bus socket, and finds it through
94
+ * `XDG_RUNTIME_DIR`. Under `sudo -iu <user>` that variable is not set, and the
95
+ * failure is worse than an error: it prints «Failed to connect to bus» and
96
+ * exits **0**, so a health check reads it as success.
97
+ */
98
+ export declare function systemdUserEnv(): NodeJS.ProcessEnv;
99
+ /** True when the user bus is actually reachable — `systemctl --user` lies with exit 0. */
100
+ export declare function systemdUserBusReachable(): Promise<boolean>;
101
+ /** The command form that works under `sudo -iu <user>` — printed in hints. */
102
+ export declare function systemctlHint(args: string): string;
103
+ /**
104
+ * Remembered from binding and from session starts, so `doctor` can check the
105
+ * permissions of real projects instead of asking the person to name them.
106
+ * Best-effort on purpose: a runner that cannot write its own state directory
107
+ * has bigger problems than a diagnostic list, and none of them should turn a
108
+ * session start into an error.
109
+ */
110
+ export declare function rememberWorkspacePath(workspacePath: string): void;
111
+ export declare function knownWorkspacePaths(): string[];
112
+ export interface ToolCheck {
113
+ /** `null` when the tool is not installed at all. */
114
+ path: string | null;
115
+ version?: string;
116
+ /** Set when the tool is there but this user cannot use it. */
117
+ problem?: string;
118
+ }
119
+ /**
120
+ * Node, as THIS user sees it.
121
+ *
122
+ * A dedicated user does not automatically inherit a Node installed for
123
+ * somebody else — fnm, nvm and a root-only prefix are all per-user by design.
124
+ * The runner itself is running, so node clearly exists somewhere; the question
125
+ * this answers is whether it is on the daemon user's own PATH, because that is
126
+ * what agent tooling and `npm install -g` will look at.
127
+ */
128
+ export declare function nodeCheck(): Promise<ToolCheck>;
129
+ /**
130
+ * Docker, as THIS user sees it.
131
+ *
132
+ * Reported rather than judged: plenty of projects never touch it. But when the
133
+ * project's own workflow is `docker compose`, a dedicated user without access
134
+ * to the socket produces a session that fails on its first command, and the
135
+ * error will be about a socket rather than about a group nobody was added to.
136
+ *
137
+ * Worth stating where it is stated: being in the `docker` group is equivalent
138
+ * to root on this machine. That is a fact for the owner to accept knowingly.
139
+ */
140
+ export declare function dockerCheck(): Promise<ToolCheck>;
141
+ /**
142
+ * Does the service survive a logout?
143
+ *
144
+ * `loginctl enable-linger` is what keeps a user's systemd services running with
145
+ * nobody logged in. `install-service` turns it on, but a unit installed by hand
146
+ * — or a user created afterwards — can miss it, and the failure looks like
147
+ * «the server goes offline whenever I close the terminal».
148
+ */
149
+ export declare function lingerEnabled(): Promise<boolean | null>;
150
+ /**
151
+ * Make the agent CLIs findable, without taking anything away.
152
+ *
153
+ * A systemd user service starts with the manager's PATH, which is the
154
+ * distribution default — `/usr/bin` and friends. Both agent CLIs are commonly
155
+ * installed somewhere else: `~/.local/bin` for a per-user install, and a
156
+ * node managed by fnm/nvm/volta lives under its own version directory. When
157
+ * `codex` sits there, the runner scans PATH, does not find it, and reports to
158
+ * the dashboard that this machine has no Codex at all — the agent the person
159
+ * installed simply never appears.
160
+ *
161
+ * Deliberately APPEND-ONLY, and deliberately in the process rather than in the
162
+ * unit file. Writing `Environment=PATH=…` into the unit would REPLACE whatever
163
+ * systemd gives the service today (`/snap/bin`, anything set through
164
+ * `environment.d`), trading something that works for something that is
165
+ * missing. Here nothing can be lost: entries are only added when they are
166
+ * absent and the directory actually exists.
167
+ *
168
+ * Returns what it added, so the caller can say so once at startup.
169
+ */
170
+ export declare function ensureAgentPath(): string[];
171
+ //# sourceMappingURL=environment.d.ts.map
@@ -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;
@@ -22,6 +32,26 @@ export declare function repoKeyFor(pathInsideRepo: string): Promise<string>;
22
32
  export interface SessionWorktree {
23
33
  branch: string;
24
34
  worktreePath: string;
35
+ /** The branch this one forked from, when we created it just now. */
36
+ baseBranch?: string;
37
+ baseSha?: string;
38
+ }
39
+ /**
40
+ * What the API decided about this session's branch (session 13).
41
+ *
42
+ * Before this, the runner guessed: a branch that already existed was silently
43
+ * reused — so a second session on the same tickets inherited a stranger's
44
+ * commits — and a branch that did not was created off whatever the project
45
+ * folder was on. Both guesses are now errors, because both of them lose work
46
+ * without saying anything.
47
+ */
48
+ export interface BranchPlan {
49
+ branch: string;
50
+ /** `NEW` — create it, and fail if it is already there. `CONTINUE` — the opposite. */
51
+ source: 'NEW' | 'CONTINUE';
52
+ /** Fork point for a NEW branch. `baseSha` wins when both are given. */
53
+ baseBranch?: string;
54
+ baseSha?: string;
25
55
  }
26
56
  /**
27
57
  * Accept a branch name from the API only if git would accept it too. The value
@@ -37,7 +67,58 @@ export declare function sanitizeBranch(hint: string | undefined): string | null;
37
67
  */
38
68
  export declare function ensureSessionWorktree(workspacePath: string, sessionId: string, branchHint?: string, options?: {
39
69
  requireExistingBranch?: boolean;
70
+ plan?: BranchPlan;
40
71
  }): Promise<SessionWorktree>;
72
+ /**
73
+ * DIRECT mode (session 16): the session's workplace IS the project folder.
74
+ *
75
+ * Nothing is created and nothing is moved. The folder stays on the branch it is
76
+ * on, and that branch is the session's branch — which is the whole point: the
77
+ * work the agent does is already where the person expects to find it, with no
78
+ * «Apply» step in between and nothing to lose if the session is never applied.
79
+ *
80
+ * The two refusals are both about NOT guessing:
81
+ *
82
+ * - not a git work tree — every git surface downstream would fail one call at
83
+ * a time instead of once, here, with a sentence that names the folder;
84
+ * - a detached HEAD — commits would land on no branch at all and be reachable
85
+ * only by sha. A person who checked out a tag to look at something must not
86
+ * discover an agent committed onto it.
87
+ *
88
+ * The path returned is the repository ROOT, not necessarily the folder that was
89
+ * configured. Every path in the Source Control panel is repo-root-relative
90
+ * because that is what `git status` prints, so the root is the only place the
91
+ * paths and the commands agree — and it is also the confinement root layer 1
92
+ * hands the agent.
93
+ */
94
+ export declare function prepareDirectWorkspace(workspacePath: string): Promise<SessionWorktree>;
95
+ /**
96
+ * One preview checkout per repository, and never the project folder itself.
97
+ *
98
+ * The honest constraint behind «show me branch B while branch A is running»:
99
+ * the docker build context IS the project folder, so a rebuild there replaces
100
+ * the single running copy. A second worktree is the only way to have both — and
101
+ * the runner NEVER switches the branch in the project folder, because that
102
+ * silently moves the base, and the target of «Apply», for every session of the
103
+ * project at once.
104
+ *
105
+ * Checked out DETACHED at a sha rather than on the branch: git refuses to have
106
+ * one branch checked out twice, and a preview is a snapshot of a commit, not a
107
+ * place anybody commits.
108
+ */
109
+ export declare function previewWorktreePath(workspaceKey: string): string;
110
+ export interface PreviewCheckout {
111
+ worktreePath: string;
112
+ branch: string;
113
+ sha: string;
114
+ }
115
+ export declare function ensurePreviewWorktree(input: {
116
+ workspacePath: string;
117
+ workspaceKey: string;
118
+ branch: string;
119
+ }): Promise<PreviewCheckout>;
120
+ /** Give the slot back. The branch is untouched — it was never checked out. */
121
+ export declare function removePreviewWorktree(workspaceKey: string): Promise<boolean>;
41
122
  /**
42
123
  * Drop a session branch after its worktree is gone. Only ever called when the
43
124
  * API confirmed the work was already applied to the base branch — `-D` because