@bridge4dev/runner 0.11.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/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/dist/adapters/claude.d.ts +19 -0
  4. package/dist/adapters/claude.js +631 -0
  5. package/dist/adapters/codex-home.d.ts +61 -0
  6. package/dist/adapters/codex-home.js +234 -0
  7. package/dist/adapters/codex-protocol.d.ts +59 -0
  8. package/dist/adapters/codex-protocol.js +204 -0
  9. package/dist/adapters/codex.d.ts +61 -0
  10. package/dist/adapters/codex.js +1406 -0
  11. package/dist/adapters/types.d.ts +183 -0
  12. package/dist/adapters/types.js +5 -0
  13. package/dist/async-queue.d.ts +11 -0
  14. package/dist/async-queue.js +50 -0
  15. package/dist/attachments.d.ts +72 -0
  16. package/dist/attachments.js +149 -0
  17. package/dist/auth-relay.d.ts +57 -0
  18. package/dist/auth-relay.js +289 -0
  19. package/dist/config.d.ts +96 -0
  20. package/dist/config.js +73 -0
  21. package/dist/fsview.d.ts +20 -0
  22. package/dist/fsview.js +122 -0
  23. package/dist/git.d.ts +54 -0
  24. package/dist/git.js +168 -0
  25. package/dist/gitops.d.ts +136 -0
  26. package/dist/gitops.js +596 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +352 -0
  29. package/dist/journal.d.ts +118 -0
  30. package/dist/journal.js +300 -0
  31. package/dist/log.d.ts +7 -0
  32. package/dist/log.js +19 -0
  33. package/dist/paths.d.ts +7 -0
  34. package/dist/paths.js +33 -0
  35. package/dist/policy.d.ts +17 -0
  36. package/dist/policy.js +272 -0
  37. package/dist/protocol.d.ts +754 -0
  38. package/dist/protocol.js +154 -0
  39. package/dist/self-update.d.ts +75 -0
  40. package/dist/self-update.js +221 -0
  41. package/dist/status-file.d.ts +14 -0
  42. package/dist/status-file.js +29 -0
  43. package/dist/supervisor.d.ts +216 -0
  44. package/dist/supervisor.js +1648 -0
  45. package/dist/version.d.ts +2 -0
  46. package/dist/version.js +3 -0
  47. package/dist/ws-client.d.ts +30 -0
  48. package/dist/ws-client.js +171 -0
  49. package/package.json +52 -0
@@ -0,0 +1,289 @@
1
+ import { execFile, spawn } 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 { log } from './log.js';
7
+ import { maskString } from './policy.js';
8
+ import { adoptLoginResult, discardStagingHome, prepareStagingHome, repairCodexAuth, stagingCodexHomePath, } from './adapters/codex-home.js';
9
+ const execFileAsync = promisify(execFile);
10
+ /* eslint-disable no-control-regex -- this module parses raw pty output, so
11
+ matching ANSI/OSC escape bytes (\x1b, \x07) is exactly the point. */
12
+ const URL_START_TIMEOUT_MS = 25_000;
13
+ const CODE_EXCHANGE_TIMEOUT_MS = 60_000;
14
+ // Longer than codex's own device-auth window (15 min) so we never kill a login
15
+ // the provider still considers live.
16
+ const RELAY_MAX_LIFETIME_MS = 16 * 60_000;
17
+ const URL_PATTERNS = {
18
+ claude: /https:\/\/(?:claude\.com|claude\.ai)\/[^\s\x07\x1b"']+/,
19
+ codex: /https:\/\/[^\s\x07\x1b"']+/,
20
+ };
21
+ /** Strip ANSI/OSC control sequences so text matching sees plain output. */
22
+ export function stripControl(raw) {
23
+ return raw
24
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, ' ') // OSC (hyperlinks etc.)
25
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
26
+ .replace(/\x1b[()][A-Z0-9]/g, '')
27
+ .replace(/\r/g, '\n');
28
+ }
29
+ export function extractLoginUrl(agent, raw) {
30
+ // OSC-8 hyperlinks carry the pristine URL in the escape target — prefer it.
31
+ const osc = raw.match(/\x1b\]8;[^;]*;(https:\/\/[^\x07\x1b]+)(?:\x07|\x1b\\)/);
32
+ const candidate = osc?.[1] ?? stripControl(raw).match(URL_PATTERNS[agent])?.[0] ?? null;
33
+ if (!candidate)
34
+ return null;
35
+ return candidate.replace(/[.,)\]}>'"]+$/, '');
36
+ }
37
+ export function extractDeviceCode(raw) {
38
+ // Device-auth user codes look like XXXX-XXXX (letters/digits).
39
+ return stripControl(raw).match(/\b[A-Z0-9]{4,8}-[A-Z0-9]{4,8}\b/)?.[0] ?? null;
40
+ }
41
+ const LOGIN_COMMANDS = {
42
+ claude: 'claude setup-token',
43
+ codex: 'codex login --device-auth',
44
+ };
45
+ export class AuthRelay {
46
+ commands;
47
+ active = null;
48
+ constructor(commands = LOGIN_COMMANDS) {
49
+ this.commands = commands;
50
+ }
51
+ /** Start (or restart) a login flow and wait until the sign-in URL appears. */
52
+ async start(agent) {
53
+ this.cancel();
54
+ // Codex logs into a THROWAWAY home and is promoted only on success. The
55
+ // old flow deleted the shared credential link up front, so abandoning the
56
+ // sign-in (or letting it time out) left the server permanently signed out
57
+ // with no way back except restarting the daemon — and writing through the
58
+ // link would have overwritten the host user's own account (QA-100 MINOR-5).
59
+ const stagingHome = agent === 'codex' ? prepareStagingHome() : null;
60
+ const proc = spawn('script', ['-qec', this.commands[agent], '/dev/null'], {
61
+ stdio: ['pipe', 'pipe', 'pipe'],
62
+ // Codex must log in to a home WE control, never the host user's ~/.codex.
63
+ ...(stagingHome ? { env: { ...process.env, CODEX_HOME: stagingHome } } : {}),
64
+ });
65
+ const relay = {
66
+ agent,
67
+ proc,
68
+ buffer: '',
69
+ exited: false,
70
+ exitCode: null,
71
+ killTimer: setTimeout(() => this.cancel(), RELAY_MAX_LIFETIME_MS),
72
+ };
73
+ relay.killTimer.unref();
74
+ this.active = relay;
75
+ const append = (chunk) => {
76
+ relay.buffer = (relay.buffer + chunk.toString()).slice(-64_000);
77
+ };
78
+ proc.stdout?.on('data', append);
79
+ proc.stderr?.on('data', append);
80
+ proc.on('exit', (code) => {
81
+ relay.exited = true;
82
+ relay.exitCode = code;
83
+ // The Codex device flow has no callback into the dashboard: the CLI just
84
+ // exits once the browser side is confirmed. That exit IS the completion
85
+ // signal — promote the staging credential, or throw it away.
86
+ //
87
+ // Only when this relay is still the current one: the staging home is a
88
+ // single fixed path, so a cancelled login's exit handler firing late
89
+ // would otherwise delete the home a NEW login is already writing into.
90
+ if (relay.agent !== 'codex' || this.active !== relay)
91
+ return;
92
+ try {
93
+ if (code === 0 && adoptLoginResult(stagingCodexHomePath())) {
94
+ log.info('codex: device login completed — credential adopted');
95
+ }
96
+ else {
97
+ discardStagingHome();
98
+ }
99
+ }
100
+ catch (error) {
101
+ log.warn('codex: could not finish the device login', { error: String(error) });
102
+ discardStagingHome();
103
+ }
104
+ });
105
+ proc.on('error', (error) => {
106
+ relay.buffer += `\nspawn error: ${String(error)}`;
107
+ relay.exited = true;
108
+ relay.exitCode = -1;
109
+ });
110
+ const deadline = Date.now() + URL_START_TIMEOUT_MS;
111
+ for (;;) {
112
+ const url = extractLoginUrl(agent, relay.buffer);
113
+ if (url) {
114
+ const code = agent === 'codex' ? extractDeviceCode(relay.buffer) : null;
115
+ return { url, ...(code ? { code } : {}), expectsCode: agent === 'claude' };
116
+ }
117
+ if (relay.exited) {
118
+ const tail = maskString(stripControl(relay.buffer)).slice(-400);
119
+ this.cancel();
120
+ throw new Error(`${agent} login exited before printing a sign-in URL: ${tail}`);
121
+ }
122
+ if (Date.now() > deadline) {
123
+ this.cancel();
124
+ throw new Error(`${agent} login did not print a sign-in URL in time`);
125
+ }
126
+ await sleep(200);
127
+ }
128
+ }
129
+ /** Paste the confirmation code back into the waiting CLI (Claude flow). */
130
+ async submitCode(agent, code) {
131
+ const relay = this.active;
132
+ if (!relay || relay.agent !== agent) {
133
+ return { ok: false, detail: 'No login in progress — start again' };
134
+ }
135
+ if (relay.exited) {
136
+ this.cancel();
137
+ return { ok: false, detail: 'The login process has already exited — start again' };
138
+ }
139
+ relay.proc.stdin?.write(`${code.trim()}\n`);
140
+ const deadline = Date.now() + CODE_EXCHANGE_TIMEOUT_MS;
141
+ const bufferMark = relay.buffer.length;
142
+ while (Date.now() < deadline) {
143
+ const fresh = stripControl(relay.buffer.slice(bufferMark));
144
+ if (relay.exited) {
145
+ this.cancel();
146
+ if (relay.exitCode === 0)
147
+ return { ok: true };
148
+ return { ok: false, detail: maskString(fresh).slice(-400) || 'Login failed' };
149
+ }
150
+ if (/invalid|error|failed|expired/i.test(fresh)) {
151
+ // The CLI usually re-prompts after a bad code; surface it and keep
152
+ // the relay alive so the user can retry with a corrected code.
153
+ return { ok: false, detail: maskString(fresh).trim().slice(-400) };
154
+ }
155
+ await sleep(300);
156
+ }
157
+ this.cancel();
158
+ return { ok: false, detail: 'Timed out waiting for the login to complete' };
159
+ }
160
+ cancel() {
161
+ const relay = this.active;
162
+ if (!relay)
163
+ return;
164
+ this.active = null;
165
+ clearTimeout(relay.killTimer);
166
+ try {
167
+ relay.proc.kill('SIGKILL');
168
+ }
169
+ catch {
170
+ // already gone
171
+ }
172
+ }
173
+ }
174
+ function sleep(ms) {
175
+ return new Promise((resolve) => setTimeout(resolve, ms));
176
+ }
177
+ // ─── Auth health probe ───────────────────────────────────────────────
178
+ /**
179
+ * Claude: the subscription token's expiry is recorded in the CLI's own
180
+ * credentials file. The RUNNER reads it (its own host user's file) — the
181
+ * agent itself is still denied this path by layer-1 policy.
182
+ */
183
+ export async function claudeAuthStatus(homedir = os.homedir()) {
184
+ if (process.env['CLAUDE_CODE_OAUTH_TOKEN']) {
185
+ return { status: 'ok', detail: 'CLAUDE_CODE_OAUTH_TOKEN is configured' };
186
+ }
187
+ const file = path.join(homedir, '.claude', '.credentials.json');
188
+ try {
189
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
190
+ const oauth = parsed.claudeAiOauth;
191
+ if (!oauth)
192
+ return { status: 'missing', detail: 'No subscription login found' };
193
+ const expiresAt = oauth.expiresAt ? new Date(oauth.expiresAt).toISOString() : undefined;
194
+ if (oauth.expiresAt && oauth.expiresAt < Date.now()) {
195
+ return { status: 'expired', ...(expiresAt ? { expiresAt } : {}) };
196
+ }
197
+ return {
198
+ status: 'ok',
199
+ ...(expiresAt ? { expiresAt } : {}),
200
+ ...(oauth.subscriptionType ? { detail: `subscription ${oauth.subscriptionType}` } : {}),
201
+ };
202
+ }
203
+ catch {
204
+ return { status: 'missing', detail: 'No Claude login on this server' };
205
+ }
206
+ }
207
+ /**
208
+ * Codex reports its own login state via an exit code (0 signed in / 1 not).
209
+ * Probed against the RUNNER's home: the host user can be signed in while our
210
+ * isolated home is not, and it is ours that sessions use.
211
+ */
212
+ export async function codexAuthStatus() {
213
+ // Re-assert the credential before judging it. The link into the shared store
214
+ // can disappear under a live daemon (codex has its own auth.json removal
215
+ // path), and reporting "login expired" about a credential that is merely
216
+ // unlinked — while the user's own file is valid for another week — is the
217
+ // exact complaint this fixes.
218
+ const home = repairCodexAuth();
219
+ const local = readCodexCredential(home.path);
220
+ if (home.auth === 'missing' && !local) {
221
+ return { status: 'missing', detail: 'not signed in on this server' };
222
+ }
223
+ try {
224
+ await execFileAsync('codex', ['login', 'status'], {
225
+ timeout: 10_000,
226
+ env: { ...process.env, CODEX_HOME: home.path },
227
+ });
228
+ return {
229
+ status: 'ok',
230
+ detail: home.auth === 'linked' ? 'signed in with ChatGPT (shared login)' : 'signed in with ChatGPT',
231
+ ...(local?.expiresAt ? { expiresAt: local.expiresAt } : {}),
232
+ };
233
+ }
234
+ catch (error) {
235
+ const err = error;
236
+ if (err.code === 'ENOENT')
237
+ return { status: 'missing', detail: 'codex CLI is not installed' };
238
+ if (typeof err.code === 'number') {
239
+ // Exit 1 means "no usable credential here" — which is "never signed in",
240
+ // not "your login expired". Only call it expired when we can see a
241
+ // credential that is genuinely past its date.
242
+ if (local?.expired) {
243
+ return {
244
+ status: 'expired',
245
+ detail: 'the stored login has expired',
246
+ expiresAt: local.expiresAt,
247
+ };
248
+ }
249
+ return { status: 'missing', detail: 'not signed in on this server' };
250
+ }
251
+ // Timeout, signal, spawn failure — we learned nothing. Saying "expired"
252
+ // here sent users to re-login over a slow probe.
253
+ log.warn('auth-relay: codex login status probe failed', { error: String(error) });
254
+ return { status: 'unknown', detail: 'could not check the login on this server' };
255
+ }
256
+ }
257
+ /**
258
+ * Read what the runner's own auth.json says, without ever logging a token.
259
+ * Used only to tell "no credential" apart from "credential past its date" —
260
+ * the CLI probe is local and cannot detect a server-side revocation.
261
+ */
262
+ function readCodexCredential(homePath) {
263
+ try {
264
+ const raw = JSON.parse(fs.readFileSync(path.join(homePath, 'auth.json'), 'utf8'));
265
+ const access = raw.tokens?.access_token;
266
+ if (!access)
267
+ return null;
268
+ // A refresh token means the access token's own expiry is not the whole
269
+ // story — codex renews it on its own.
270
+ if (raw.tokens?.refresh_token)
271
+ return { expired: false };
272
+ const payload = access.split('.')[1];
273
+ if (!payload)
274
+ return { expired: false };
275
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
276
+ if (typeof claims.exp !== 'number')
277
+ return { expired: false };
278
+ const expiresAt = new Date(claims.exp * 1000).toISOString();
279
+ return { expiresAt, expired: claims.exp * 1000 < Date.now() };
280
+ }
281
+ catch {
282
+ return null;
283
+ }
284
+ }
285
+ export async function agentAuthStatuses() {
286
+ const [claude, codex] = await Promise.all([claudeAuthStatus(), codexAuthStatus()]);
287
+ return { claude, codex };
288
+ }
289
+ //# sourceMappingURL=auth-relay.js.map
@@ -0,0 +1,96 @@
1
+ import { z } from 'zod';
2
+ declare const ConfigSchema: z.ZodObject<{
3
+ api: z.ZodObject<{
4
+ url: z.ZodString;
5
+ ws_url: z.ZodString;
6
+ }, "strip", z.ZodTypeAny, {
7
+ url: string;
8
+ ws_url: string;
9
+ }, {
10
+ url: string;
11
+ ws_url: string;
12
+ }>;
13
+ server: z.ZodObject<{
14
+ id: z.ZodString;
15
+ name: z.ZodString;
16
+ token: z.ZodString;
17
+ }, "strip", z.ZodTypeAny, {
18
+ id: string;
19
+ name: string;
20
+ token: string;
21
+ }, {
22
+ id: string;
23
+ name: string;
24
+ token: string;
25
+ }>;
26
+ mcp: z.ZodOptional<z.ZodObject<{
27
+ url: z.ZodString;
28
+ token: z.ZodString;
29
+ }, "strip", z.ZodTypeAny, {
30
+ url: string;
31
+ token: string;
32
+ }, {
33
+ url: string;
34
+ token: string;
35
+ }>>;
36
+ codex: z.ZodOptional<z.ZodObject<{
37
+ auth: z.ZodDefault<z.ZodEnum<["link", "own"]>>;
38
+ }, "strip", z.ZodTypeAny, {
39
+ auth: "link" | "own";
40
+ }, {
41
+ auth?: "link" | "own" | undefined;
42
+ }>>;
43
+ limits: z.ZodOptional<z.ZodObject<{
44
+ max_sessions: z.ZodNumber;
45
+ }, "strip", z.ZodTypeAny, {
46
+ max_sessions: number;
47
+ }, {
48
+ max_sessions: number;
49
+ }>>;
50
+ }, "strip", z.ZodTypeAny, {
51
+ api: {
52
+ url: string;
53
+ ws_url: string;
54
+ };
55
+ server: {
56
+ id: string;
57
+ name: string;
58
+ token: string;
59
+ };
60
+ codex?: {
61
+ auth: "link" | "own";
62
+ } | undefined;
63
+ mcp?: {
64
+ url: string;
65
+ token: string;
66
+ } | undefined;
67
+ limits?: {
68
+ max_sessions: number;
69
+ } | undefined;
70
+ }, {
71
+ api: {
72
+ url: string;
73
+ ws_url: string;
74
+ };
75
+ server: {
76
+ id: string;
77
+ name: string;
78
+ token: string;
79
+ };
80
+ codex?: {
81
+ auth?: "link" | "own" | undefined;
82
+ } | undefined;
83
+ mcp?: {
84
+ url: string;
85
+ token: string;
86
+ } | undefined;
87
+ limits?: {
88
+ max_sessions: number;
89
+ } | undefined;
90
+ }>;
91
+ export type RunnerConfig = z.infer<typeof ConfigSchema>;
92
+ export declare function loadConfig(): RunnerConfig | null;
93
+ export declare function requireConfig(): RunnerConfig;
94
+ export declare function saveConfig(config: RunnerConfig): void;
95
+ export {};
96
+ //# sourceMappingURL=config.d.ts.map
package/dist/config.js ADDED
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse, stringify } from 'smol-toml';
4
+ import { z } from 'zod';
5
+ import { configDir, configFilePath } from './paths.js';
6
+ // config.toml holds the runner token — the file itself is the secret store
7
+ // (0600, plan §8.1: provider/runner tokens never leave the user's server).
8
+ const ConfigSchema = z.object({
9
+ api: z.object({
10
+ url: z.string().url(),
11
+ ws_url: z.string().url(),
12
+ }),
13
+ server: z.object({
14
+ id: z.string().min(1),
15
+ name: z.string().min(1),
16
+ token: z.string().regex(/^dbr_[A-Za-z0-9_-]+$/, 'runner token must start with dbr_'),
17
+ }),
18
+ // Optional: lets sessions reach DevBridge tickets over MCP. The dbk_ key is
19
+ // provisioned by the user (dashboard → API keys) until pairing issues one.
20
+ mcp: z
21
+ .object({
22
+ url: z.string().url(),
23
+ token: z.string().min(1),
24
+ })
25
+ .optional(),
26
+ // How the runner's isolated CODEX_HOME gets its credential.
27
+ // link (default) — share the host user's ~/.codex/auth.json, so a token
28
+ // refresh on either side keeps both working;
29
+ // own — the runner keeps its own device login. Recommended when
30
+ // the owner also uses codex interactively on this machine:
31
+ // OpenAI rotates refresh tokens, and two processes sharing
32
+ // one credential store can invalidate each other.
33
+ codex: z
34
+ .object({
35
+ auth: z.enum(['link', 'own']).default('link'),
36
+ })
37
+ .optional(),
38
+ // Layer-1 resource guard (plan §8.6): a ceiling the dashboard cannot raise.
39
+ // The API also sends a per-server ceiling; the runner honours whichever is
40
+ // stricter, so the machine's owner always has the last word on how many agent
41
+ // processes and worktrees may exist at once.
42
+ limits: z
43
+ .object({
44
+ max_sessions: z.number().int().min(1).max(64),
45
+ })
46
+ .optional(),
47
+ });
48
+ export function loadConfig() {
49
+ const file = configFilePath();
50
+ if (!fs.existsSync(file))
51
+ return null;
52
+ const raw = fs.readFileSync(file, 'utf8');
53
+ return ConfigSchema.parse(parse(raw));
54
+ }
55
+ export function requireConfig() {
56
+ const config = loadConfig();
57
+ if (!config) {
58
+ throw new Error(`Runner is not paired: ${configFilePath()} not found. Run \`devbridge-runner pair <code> --api <url>\` first.`);
59
+ }
60
+ return config;
61
+ }
62
+ export function saveConfig(config) {
63
+ ConfigSchema.parse(config);
64
+ const dir = configDir();
65
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
66
+ const file = configFilePath();
67
+ const tmp = path.join(dir, `.config.toml.${process.pid}.tmp`);
68
+ fs.writeFileSync(tmp, stringify(config) + '\n', { mode: 0o600 });
69
+ fs.renameSync(tmp, file);
70
+ // rename keeps the tmp file's mode, but harden in case the file pre-existed.
71
+ fs.chmodSync(file, 0o600);
72
+ }
73
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,20 @@
1
+ export type FsViewResult = {
2
+ kind: 'dir';
3
+ path: string;
4
+ entries: FsEntry[];
5
+ truncated: boolean;
6
+ } | {
7
+ kind: 'file';
8
+ path: string;
9
+ content?: string;
10
+ size: number;
11
+ truncated: boolean;
12
+ binary: boolean;
13
+ };
14
+ export interface FsEntry {
15
+ name: string;
16
+ type: 'dir' | 'file';
17
+ size: number | null;
18
+ }
19
+ export declare function fsView(root: string, relPath?: string): FsViewResult;
20
+ //# sourceMappingURL=fsview.d.ts.map
package/dist/fsview.js ADDED
@@ -0,0 +1,122 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isSecretPath, maskString } from './policy.js';
4
+ // Read-only file browser for the dashboard's Files tab (plan §5, session 4).
5
+ // Same hard rules as the agent policy: secret paths are hidden from listings
6
+ // and refused on read, symlinks are resolved, escaping the root is forbidden.
7
+ const FILE_CAP_BYTES = 256 * 1024;
8
+ const MAX_ENTRIES = 500;
9
+ const BINARY_SNIFF_BYTES = 8 * 1024;
10
+ /**
11
+ * The Files tab may only ever be pointed at a git work tree. Without this the
12
+ * root is whatever the API sent, and an org admin could retarget a workspace at
13
+ * `/home` and browse the box (QA-99 MAJOR-4 — the server-side half validates
14
+ * the path before storing it).
15
+ */
16
+ function assertRepoRoot(rootReal) {
17
+ if (!fs.existsSync(path.join(rootReal, '.git'))) {
18
+ throw new Error('The workspace path is not a git repository');
19
+ }
20
+ }
21
+ function resolveInsideRoot(root, relPath) {
22
+ let rootReal;
23
+ try {
24
+ rootReal = fs.realpathSync(root);
25
+ }
26
+ catch {
27
+ throw new Error('The workspace path is not available on this server');
28
+ }
29
+ assertRepoRoot(rootReal);
30
+ const target = path.resolve(rootReal, relPath.replace(/^\/+/, ''));
31
+ // Lexical containment FIRST: resolving a non-existent outside path used to
32
+ // surface `ENOENT … '/root/x'`, i.e. an existence oracle plus absolute-path
33
+ // disclosure for anything outside the root (QA-99 MINOR-3).
34
+ const lexicalRel = path.relative(rootReal, target);
35
+ if (lexicalRel.startsWith('..') || path.isAbsolute(lexicalRel)) {
36
+ throw new Error('Path escapes the workspace root');
37
+ }
38
+ // Resolve symlinks so a link cannot smuggle a read outside the root.
39
+ let real;
40
+ try {
41
+ real = fs.realpathSync(target);
42
+ }
43
+ catch {
44
+ throw new Error('Path not found');
45
+ }
46
+ const rel = path.relative(rootReal, real);
47
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
48
+ throw new Error('Path escapes the workspace root');
49
+ }
50
+ // `.git` is hidden from listings AND blocked from direct navigation —
51
+ // .git/config can carry remote-URL credentials (QA-98 F2).
52
+ if (rel.split(path.sep).includes('.git')) {
53
+ throw new Error('This path is protected by runner policy');
54
+ }
55
+ return real;
56
+ }
57
+ function isHiddenFromView(fullPath, name) {
58
+ return name === '.git' || isSecretPath(fullPath) || isSecretPath(name);
59
+ }
60
+ export function fsView(root, relPath = '.') {
61
+ const target = resolveInsideRoot(root, relPath);
62
+ if (isSecretPath(target))
63
+ throw new Error('This path is protected by runner policy');
64
+ let stat;
65
+ try {
66
+ stat = fs.statSync(target);
67
+ }
68
+ catch {
69
+ throw new Error('Path not found');
70
+ }
71
+ const rootReal = fs.realpathSync(root);
72
+ const displayPath = path.relative(rootReal, target) || '.';
73
+ if (stat.isDirectory()) {
74
+ const dirents = fs.readdirSync(target, { withFileTypes: true });
75
+ const visible = dirents.filter((d) => !isHiddenFromView(path.join(target, d.name), d.name));
76
+ visible.sort((a, b) => {
77
+ const dirDiff = Number(b.isDirectory()) - Number(a.isDirectory());
78
+ return dirDiff !== 0 ? dirDiff : a.name.localeCompare(b.name);
79
+ });
80
+ const truncated = visible.length > MAX_ENTRIES;
81
+ const entries = visible.slice(0, MAX_ENTRIES).map((d) => {
82
+ const isDir = d.isDirectory();
83
+ let size = null;
84
+ if (!isDir) {
85
+ try {
86
+ size = fs.statSync(path.join(target, d.name)).size;
87
+ }
88
+ catch {
89
+ size = null;
90
+ }
91
+ }
92
+ return { name: d.name, type: isDir ? 'dir' : 'file', size };
93
+ });
94
+ return { kind: 'dir', path: displayPath, entries, truncated };
95
+ }
96
+ if (!stat.isFile())
97
+ throw new Error('Not a regular file');
98
+ const size = stat.size;
99
+ const fd = fs.openSync(target, 'r');
100
+ try {
101
+ const sniff = Buffer.alloc(Math.min(BINARY_SNIFF_BYTES, size));
102
+ fs.readSync(fd, sniff, 0, sniff.length, 0);
103
+ if (sniff.includes(0)) {
104
+ return { kind: 'file', path: displayPath, size, truncated: false, binary: true };
105
+ }
106
+ const readLength = Math.min(FILE_CAP_BYTES, size);
107
+ const buffer = Buffer.alloc(readLength);
108
+ fs.readSync(fd, buffer, 0, readLength, 0);
109
+ return {
110
+ kind: 'file',
111
+ path: displayPath,
112
+ content: maskString(buffer.toString('utf8')),
113
+ size,
114
+ truncated: size > FILE_CAP_BYTES,
115
+ binary: false,
116
+ };
117
+ }
118
+ finally {
119
+ fs.closeSync(fd);
120
+ }
121
+ }
122
+ //# sourceMappingURL=fsview.js.map
package/dist/git.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ export interface PathValidation {
2
+ ok: boolean;
3
+ exists: boolean;
4
+ isGitRepo: boolean;
5
+ branch?: string;
6
+ error?: string;
7
+ }
8
+ export declare function validateWorkspacePath(workspacePath: string): Promise<PathValidation>;
9
+ export declare function sessionShortId(sessionId: string): string;
10
+ export declare function sessionWorktreePath(sessionId: string): string;
11
+ /**
12
+ * A stable key identifying the *shared* repository behind any path inside it.
13
+ *
14
+ * A linked worktree and its main checkout are the same repo: they share one
15
+ * object store, one index lock namespace and one branch namespace. Locking a
16
+ * commit by worktree path while locking a squash-merge by workspace path gave
17
+ * two different keys and therefore no mutual exclusion at all — a commit inside
18
+ * the worktree could interleave with a merge reading that same branch tip.
19
+ * `git rev-parse --git-common-dir` collapses both to the main `.git` directory.
20
+ */
21
+ export declare function repoKeyFor(pathInsideRepo: string): Promise<string>;
22
+ export interface SessionWorktree {
23
+ branch: string;
24
+ worktreePath: string;
25
+ }
26
+ /**
27
+ * Accept a branch name from the API only if git would accept it too. The value
28
+ * reaches a `git worktree add -b` argument, so anything odd — a leading dash, a
29
+ * `..`, a control character, a trailing `.lock` — is rejected in favour of the
30
+ * id-derived default rather than passed through.
31
+ */
32
+ export declare function sanitizeBranch(hint: string | undefined): string | null;
33
+ /**
34
+ * One branch + worktree per session (plan §7). The worktree lives under the
35
+ * runner's state dir so the user's checkout stays untouched; the branch lives
36
+ * in the workspace repo, so the work survives worktree cleanup.
37
+ */
38
+ export declare function ensureSessionWorktree(workspacePath: string, sessionId: string, branchHint?: string, options?: {
39
+ requireExistingBranch?: boolean;
40
+ }): Promise<SessionWorktree>;
41
+ /**
42
+ * Drop a session branch after its worktree is gone. Only ever called when the
43
+ * API confirmed the work was already applied to the base branch — `-D` because
44
+ * «Применить» is a squash-merge, which git never recognises as a merge, so `-d`
45
+ * would refuse exactly the branches that ARE safe to delete.
46
+ */
47
+ export declare function deleteSessionBranch(workspacePath: string, branch: string): Promise<void>;
48
+ /**
49
+ * Remove the session worktree (the branch — and the work — stays in the
50
+ * repo). The main repo is recovered from the worktree's `.git` pointer file,
51
+ * so the caller only needs the session id.
52
+ */
53
+ export declare function removeSessionWorktree(sessionId: string): Promise<void>;
54
+ //# sourceMappingURL=git.d.ts.map