@fnclaude/cli 0.7.5 → 0.7.7
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/package.json +1 -1
- package/src/argParser.ts +1 -1
- package/src/args/preserve.ts +6 -0
- package/src/args.ts +2 -2
- package/src/autoname.ts +12 -11
- package/src/hostAliases.ts +33 -22
- package/src/mcp/protocol.ts +139 -2
- package/src/mcp/socketListener.ts +29 -3
- package/src/prompts.ts +18 -0
- package/src/pty.ts +39 -4
- package/src/resolver.ts +9 -1
- package/src/sessionState.ts +4 -4
- package/src/worktree.ts +11 -10
package/package.json
CHANGED
package/src/argParser.ts
CHANGED
|
@@ -177,7 +177,7 @@ export function parseArgs(argv: readonly string[], home: string): ParsedArgs {
|
|
|
177
177
|
const passthrough: string[] = [];
|
|
178
178
|
let noTmux = false;
|
|
179
179
|
let worktreeSet = false;
|
|
180
|
-
let worktreeArg
|
|
180
|
+
let worktreeArg: string | undefined;
|
|
181
181
|
|
|
182
182
|
// Magic slots: filled at most once each, in strict order.
|
|
183
183
|
let magicModel = '';
|
package/src/args/preserve.ts
CHANGED
|
@@ -106,6 +106,12 @@ export function preserveArgs(
|
|
|
106
106
|
while (i < origArgs.length) {
|
|
107
107
|
const tok = origArgs[i] as string;
|
|
108
108
|
|
|
109
|
+
// `--` separates flags from the original session's initial prompt
|
|
110
|
+
// (everything after is the prompt body). Carrying it across a
|
|
111
|
+
// relaunch shadows the transfer's @summary file or re-prompts after
|
|
112
|
+
// --resume on restart — drop the separator and the entire tail.
|
|
113
|
+
if (tok === '--') break;
|
|
114
|
+
|
|
109
115
|
// Equals-form (--flag=value): match by the flag-prefix-before-= part.
|
|
110
116
|
if (deny !== null) {
|
|
111
117
|
const eq = tok.indexOf('=');
|
package/src/args.ts
CHANGED
|
@@ -75,9 +75,9 @@ export interface BaseArgs {
|
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* WorktreeArg is the name/value given with -w / --worktree (or the 2nd
|
|
78
|
-
* positional / +workspace suffix), or
|
|
78
|
+
* positional / +workspace suffix), or undefined if the flag was bare.
|
|
79
79
|
*/
|
|
80
|
-
readonly worktreeArg: string;
|
|
80
|
+
readonly worktreeArg: string | undefined;
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* UsedNoopFallback is true when CWD was filled by the noop fallback (no
|
package/src/autoname.ts
CHANGED
|
@@ -59,15 +59,15 @@ export function shouldAutoName(passthrough: readonly string[]): boolean {
|
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
61
|
* extractPrompt returns the first non-empty token after "--" in passthrough.
|
|
62
|
-
* Returns
|
|
62
|
+
* Returns undefined if not found.
|
|
63
63
|
*/
|
|
64
|
-
export function extractPrompt(passthrough: readonly string[]): string {
|
|
64
|
+
export function extractPrompt(passthrough: readonly string[]): string | undefined {
|
|
65
65
|
const sepIdx = passthrough.indexOf('--');
|
|
66
|
-
if (sepIdx < 0) return
|
|
66
|
+
if (sepIdx < 0) return undefined;
|
|
67
67
|
for (const t of passthrough.slice(sepIdx + 1)) {
|
|
68
68
|
if (t !== '') return t;
|
|
69
69
|
}
|
|
70
|
-
return
|
|
70
|
+
return undefined;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// ── stop-words + heuristic fallback ────────────────────────────────────────
|
|
@@ -123,9 +123,9 @@ const RE_WHITESPACE = /\s+/g;
|
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
125
|
* sanitizeSlug cleans raw LLM output into a valid kebab slug (up to 3 dash-
|
|
126
|
-
* separated segments). Returns
|
|
126
|
+
* separated segments). Returns undefined when nothing survives sanitization.
|
|
127
127
|
*/
|
|
128
|
-
export function sanitizeSlug(raw: string): string {
|
|
128
|
+
export function sanitizeSlug(raw: string): string | undefined {
|
|
129
129
|
let s = raw.trim().toLowerCase();
|
|
130
130
|
s = s.replace(RE_WHITESPACE, '-');
|
|
131
131
|
s = s.replace(RE_NON_SLUG, '');
|
|
@@ -136,7 +136,7 @@ export function sanitizeSlug(raw: string): string {
|
|
|
136
136
|
s = parts.slice(0, 3).join('-');
|
|
137
137
|
// Trim again in case joining re-introduced edge dashes.
|
|
138
138
|
s = s.replace(/^-+|-+$/g, '');
|
|
139
|
-
return s;
|
|
139
|
+
return s !== '' ? s : undefined;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
// ── LLM client abstraction ──────────────────────────────────────────────────
|
|
@@ -235,11 +235,12 @@ export function claudeCliFn(model: string, spawnFn: SpawnFn = defaultSpawnFn): L
|
|
|
235
235
|
* On any error the function falls back to heuristicName silently.
|
|
236
236
|
*/
|
|
237
237
|
export async function generateName(
|
|
238
|
-
prompt: string,
|
|
238
|
+
prompt: string | undefined,
|
|
239
239
|
cfg: NameConfig,
|
|
240
240
|
apiKey: string,
|
|
241
241
|
llmFn?: LlmClientFn,
|
|
242
242
|
): Promise<string> {
|
|
243
|
+
const resolved = prompt ?? '';
|
|
243
244
|
let usingCLI = false;
|
|
244
245
|
if (!llmFn) {
|
|
245
246
|
if (apiKey) {
|
|
@@ -261,11 +262,11 @@ export async function generateName(
|
|
|
261
262
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
262
263
|
|
|
263
264
|
try {
|
|
264
|
-
const raw = await llmFn(cfg.model,
|
|
265
|
+
const raw = await llmFn(cfg.model, resolved, controller.signal);
|
|
265
266
|
const name = sanitizeSlug(raw);
|
|
266
|
-
return name !==
|
|
267
|
+
return name !== undefined ? name : heuristicName(resolved);
|
|
267
268
|
} catch {
|
|
268
|
-
return heuristicName(
|
|
269
|
+
return heuristicName(resolved);
|
|
269
270
|
} finally {
|
|
270
271
|
clearTimeout(timer);
|
|
271
272
|
}
|
package/src/hostAliases.ts
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Load the {host-short} alias map.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Builtin defaults (BUILTIN_HOST_ALIASES) ship with the package — npm install
|
|
4
|
+
// has no hook to drop a JSON file under /usr/share, so the canonical 4 host
|
|
5
|
+
// mappings live in source. Users override per-key via
|
|
6
|
+
// ~/.local/share/fnrhombus/host-aliases.json.
|
|
7
7
|
//
|
|
8
|
-
// Missing
|
|
8
|
+
// Missing user file is silently treated as empty. If a user template uses
|
|
9
9
|
// {host-short} and the merged map has no entry for the current host, the
|
|
10
10
|
// substitution step calls missingHostShortError() to produce a message
|
|
11
|
-
// naming
|
|
11
|
+
// naming the user file path and a copy-pasteable JSON example.
|
|
12
12
|
|
|
13
13
|
import { readFileSync } from 'node:fs';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
* the file
|
|
18
|
+
* Bundled host-alias defaults. These ship with the package and are
|
|
19
|
+
* always present; the user file (if any) overrides per key.
|
|
20
|
+
*
|
|
21
|
+
* Kept in sync with the Go reference's AUR PKGBUILD install fixture.
|
|
20
22
|
*/
|
|
21
|
-
export const
|
|
23
|
+
export const BUILTIN_HOST_ALIASES: Readonly<Record<string, string>> = Object.freeze({
|
|
24
|
+
'github.com': 'gh',
|
|
25
|
+
'gitlab.com': 'gl',
|
|
26
|
+
'bitbucket.org': 'bb',
|
|
27
|
+
'codeberg.org': 'cb',
|
|
28
|
+
});
|
|
22
29
|
|
|
23
30
|
/**
|
|
24
|
-
* Per-user override path. Wins per-key against the
|
|
31
|
+
* Per-user override path. Wins per-key against the builtin defaults.
|
|
25
32
|
*/
|
|
26
33
|
export function hostAliasesUserPath(home: string): string {
|
|
27
34
|
return join(home, '.local', 'share', 'fnrhombus', 'host-aliases.json');
|
|
@@ -39,17 +46,22 @@ export interface LoadHostAliasesResult {
|
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
49
|
+
* Return the merged alias map: builtin defaults overlaid with the user
|
|
50
|
+
* file (if present). User entries win per key. Missing user file is the
|
|
51
|
+
* common path and stays silent.
|
|
44
52
|
*/
|
|
45
53
|
export function loadHostAliases(home: string): LoadHostAliasesResult {
|
|
46
|
-
|
|
54
|
+
const userResult = mergeHostAliases([hostAliasesUserPath(home)]);
|
|
55
|
+
const merged: Record<string, string> = { ...BUILTIN_HOST_ALIASES };
|
|
56
|
+
for (const k of Object.keys(userResult.aliases)) {
|
|
57
|
+
merged[k] = userResult.aliases[k] as string;
|
|
58
|
+
}
|
|
59
|
+
return { aliases: merged, warnings: userResult.warnings };
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
/**
|
|
50
63
|
* Read each path (if it exists) and merge per-key with later entries
|
|
51
|
-
* winning over earlier ones.
|
|
52
|
-
* user wins on conflict.
|
|
64
|
+
* winning over earlier ones.
|
|
53
65
|
*/
|
|
54
66
|
export function mergeHostAliases(paths: string[]): LoadHostAliasesResult {
|
|
55
67
|
const merged: Record<string, string> = {};
|
|
@@ -106,9 +118,9 @@ export function readHostAliasesFile(path: string): ReadHostAliasesFileResult {
|
|
|
106
118
|
|
|
107
119
|
/**
|
|
108
120
|
* Build the error emitted when a template uses {host-short} but no alias
|
|
109
|
-
* is configured for the current host.
|
|
110
|
-
*
|
|
111
|
-
*
|
|
121
|
+
* is configured for the current host. Points only at the user file path
|
|
122
|
+
* since builtins already cover the common forges and any further
|
|
123
|
+
* overrides go in the user file.
|
|
112
124
|
*
|
|
113
125
|
* `home` is injected so callers in tests can pin the user-path output
|
|
114
126
|
* without touching $HOME; production callers should pass `process.env.HOME
|
|
@@ -119,9 +131,8 @@ export function missingHostShortError(host: string, home?: string): Error {
|
|
|
119
131
|
const userPath = hostAliasesUserPath(h);
|
|
120
132
|
return new Error(
|
|
121
133
|
`cannot resolve {host-short} for host ${JSON.stringify(host)}: no alias configured.\n` +
|
|
122
|
-
`Add an entry to
|
|
123
|
-
` ${
|
|
124
|
-
` ${userPath} (user-level, takes precedence on conflict)\n` +
|
|
134
|
+
`Add an entry to:\n` +
|
|
135
|
+
` ${userPath}\n` +
|
|
125
136
|
`Example:\n` +
|
|
126
137
|
` { "github.com": "gh", "gitlab.com": "gl" }`,
|
|
127
138
|
);
|
package/src/mcp/protocol.ts
CHANGED
|
@@ -200,12 +200,149 @@ export function encodeResponse(resp: Response): Buffer {
|
|
|
200
200
|
* The line may or may not include the terminating '\n'; both are accepted
|
|
201
201
|
* (matches Go's bufio.ReadBytes which keeps the delimiter).
|
|
202
202
|
*
|
|
203
|
-
* Throws on malformed JSON
|
|
203
|
+
* Throws on malformed JSON or on payloads that fail boundary validation
|
|
204
|
+
* (see `validateRequest` for the rules). Wire input is untrusted —
|
|
205
|
+
* anything that knows the socket path can submit JSON, and the dispatcher
|
|
206
|
+
* acts on it (re-exec, file writes, clipboard). Validate before handing
|
|
207
|
+
* to the dispatcher rather than scattering checks across handlers.
|
|
204
208
|
*/
|
|
205
209
|
export function decodeRequest(line: string | Buffer): Request {
|
|
206
210
|
const text = typeof line === 'string' ? line : line.toString('utf8');
|
|
207
211
|
const trimmed = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
208
|
-
|
|
212
|
+
const raw: unknown = JSON.parse(trimmed);
|
|
213
|
+
return validateRequest(raw);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Request validation ─────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Maximum byte length for each string field on a Request. Values are sized
|
|
220
|
+
* to the field's job:
|
|
221
|
+
* - overrides (model, effort, permission_mode, agent): short identifiers
|
|
222
|
+
* - allowed_tools: comma-joined tool names, room for ~30 longest
|
|
223
|
+
* - session_id: UUID-shaped, but we don't pre-validate the shape here
|
|
224
|
+
* (that lives in the handler so the error message can mention UUID)
|
|
225
|
+
* - destination / name: filesystem paths / kebab-case names
|
|
226
|
+
* - text (clipboard): bounded for typical clipboard payloads
|
|
227
|
+
* - summary: continuity blob — generous but bounded; a malicious peer
|
|
228
|
+
* submitting hundreds of MB would otherwise wedge the writeFile path
|
|
229
|
+
*/
|
|
230
|
+
const STRING_LIMITS: Record<string, number> = {
|
|
231
|
+
// Shared overrides
|
|
232
|
+
model: 64,
|
|
233
|
+
effort: 64,
|
|
234
|
+
permission_mode: 64,
|
|
235
|
+
allowed_tools: 4096,
|
|
236
|
+
agent: 256,
|
|
237
|
+
// Restart / switch / spawn
|
|
238
|
+
session_id: 128,
|
|
239
|
+
destination: 4096,
|
|
240
|
+
name: 256,
|
|
241
|
+
summary: 1024 * 1024, // 1 MiB
|
|
242
|
+
// Copy
|
|
243
|
+
text: 1024 * 1024, // 1 MiB
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** Discriminated union of fields legal for each Op. */
|
|
247
|
+
const STRING_FIELDS_BY_OP: Record<Op, readonly string[]> = {
|
|
248
|
+
restart: ['session_id', 'model', 'effort', 'permission_mode', 'allowed_tools', 'agent'],
|
|
249
|
+
switch: [
|
|
250
|
+
'destination',
|
|
251
|
+
'name',
|
|
252
|
+
'summary',
|
|
253
|
+
'session_id',
|
|
254
|
+
'model',
|
|
255
|
+
'effort',
|
|
256
|
+
'permission_mode',
|
|
257
|
+
'allowed_tools',
|
|
258
|
+
'agent',
|
|
259
|
+
],
|
|
260
|
+
spawn: [
|
|
261
|
+
'destination',
|
|
262
|
+
'name',
|
|
263
|
+
'summary',
|
|
264
|
+
'model',
|
|
265
|
+
'effort',
|
|
266
|
+
'permission_mode',
|
|
267
|
+
'allowed_tools',
|
|
268
|
+
'agent',
|
|
269
|
+
],
|
|
270
|
+
copy_to_clipboard: ['text'],
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const BOOL_FIELDS: readonly string[] = ['brief', 'chrome', 'ide', 'verbose', 'confirmed'];
|
|
274
|
+
|
|
275
|
+
/** Field names that are filesystem paths and must reject ".." traversal. */
|
|
276
|
+
const PATH_FIELDS: readonly string[] = ['destination'];
|
|
277
|
+
|
|
278
|
+
const KNOWN_OPS: readonly Op[] = ['restart', 'switch', 'spawn', 'copy_to_clipboard'];
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Validate a parsed JSON value as a Request. Returns the validated value
|
|
282
|
+
* (typed as Request) on success; throws Error with a human-readable
|
|
283
|
+
* reason on failure. The caller (`decodeRequest`) is the only intended
|
|
284
|
+
* entry point — handlers consume the typed Request and trust its shape.
|
|
285
|
+
*
|
|
286
|
+
* Checks performed:
|
|
287
|
+
* 1. Value is a plain object (not array, null, or scalar).
|
|
288
|
+
* 2. `op` is one of the four known string discriminants.
|
|
289
|
+
* 3. Every string field on the per-op allowlist is either absent or a
|
|
290
|
+
* string under its byte-length cap, with no null bytes.
|
|
291
|
+
* 4. Path-shaped fields (`destination`) additionally reject `..`
|
|
292
|
+
* segments (delimited by `/` or string ends).
|
|
293
|
+
* 5. Boolean override fields are either absent, true, false, or null
|
|
294
|
+
* (null is treated as "preserve existing" by handlers).
|
|
295
|
+
*
|
|
296
|
+
* Unknown extra keys are tolerated (forward compatibility with newer
|
|
297
|
+
* clients) but their values are not validated.
|
|
298
|
+
*/
|
|
299
|
+
export function validateRequest(value: unknown): Request {
|
|
300
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
301
|
+
throw new Error('request must be a JSON object');
|
|
302
|
+
}
|
|
303
|
+
const obj = value as Record<string, unknown>;
|
|
304
|
+
const op = obj.op;
|
|
305
|
+
if (typeof op !== 'string' || !KNOWN_OPS.includes(op as Op)) {
|
|
306
|
+
throw new Error(`unknown op ${JSON.stringify(op)}`);
|
|
307
|
+
}
|
|
308
|
+
const allowed = STRING_FIELDS_BY_OP[op as Op];
|
|
309
|
+
for (const field of allowed) {
|
|
310
|
+
const v = obj[field];
|
|
311
|
+
if (v === undefined) continue;
|
|
312
|
+
if (typeof v !== 'string') {
|
|
313
|
+
throw new Error(`field ${field} must be a string`);
|
|
314
|
+
}
|
|
315
|
+
if (v.includes('\x00')) {
|
|
316
|
+
throw new Error(`field ${field} contains a null byte`);
|
|
317
|
+
}
|
|
318
|
+
const cap = STRING_LIMITS[field] ?? 1024;
|
|
319
|
+
if (Buffer.byteLength(v, 'utf8') > cap) {
|
|
320
|
+
throw new Error(`field ${field} exceeds max length ${cap}`);
|
|
321
|
+
}
|
|
322
|
+
if (PATH_FIELDS.includes(field) && hasParentSegment(v)) {
|
|
323
|
+
throw new Error(`field ${field} contains a path-traversal segment ("..")`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const field of BOOL_FIELDS) {
|
|
327
|
+
const v = obj[field];
|
|
328
|
+
if (v === undefined || v === null) continue;
|
|
329
|
+
if (typeof v !== 'boolean') {
|
|
330
|
+
throw new Error(`field ${field} must be a boolean`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return obj as unknown as Request;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Returns true when the path contains a literal `..` segment — i.e. `..`
|
|
338
|
+
* delimited by `/` (or by start/end of the string). Catches `../etc`,
|
|
339
|
+
* `/foo/../bar`, `foo/..`, and bare `..`; ignores `foo..bar` and any
|
|
340
|
+
* other substring where `..` is part of a larger name.
|
|
341
|
+
*/
|
|
342
|
+
function hasParentSegment(p: string): boolean {
|
|
343
|
+
// Split on '/' is sufficient — POSIX path semantics, and a Windows
|
|
344
|
+
// backslash path would never be a legitimate destination on the wire.
|
|
345
|
+
return p.split('/').includes('..');
|
|
209
346
|
}
|
|
210
347
|
|
|
211
348
|
/** Decode one newline-terminated JSON line into a Response. */
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { createServer, type Server, type Socket } from 'node:net';
|
|
17
|
-
import { writeFile, unlink } from 'node:fs/promises';
|
|
17
|
+
import { writeFile, unlink, chmod } from 'node:fs/promises';
|
|
18
18
|
import type { Config } from '../config.js';
|
|
19
19
|
import { handoffContentPath, type HandoffSpec } from '../handoff.js';
|
|
20
20
|
import {
|
|
@@ -144,6 +144,15 @@ export class SocketListener {
|
|
|
144
144
|
* Open the AF_UNIX listener at spec.socketPath and start the accept
|
|
145
145
|
* loop. Best-effort removes any stale socket file from a prior crashed
|
|
146
146
|
* invocation at this path (net listen errors with EADDRINUSE otherwise).
|
|
147
|
+
*
|
|
148
|
+
* The socket file is chmod'd to 0600 immediately after bind so other
|
|
149
|
+
* UIDs on the host cannot dial it. Node's createServer() does NOT honor
|
|
150
|
+
* a mode option for AF_UNIX paths — it inherits the process umask, which
|
|
151
|
+
* defaults to 022 (world-readable) or worse depending on caller. We
|
|
152
|
+
* tighten unconditionally rather than rely on umask discipline at every
|
|
153
|
+
* launch site. The race window between bind and chmod is small (single
|
|
154
|
+
* tick) but real; we accept it as the trade vs. a per-process umask
|
|
155
|
+
* dance that would still leak any *other* file created in the same tick.
|
|
147
156
|
*/
|
|
148
157
|
static async start(opts: StartOptions): Promise<SocketListener> {
|
|
149
158
|
try {
|
|
@@ -169,6 +178,23 @@ export class SocketListener {
|
|
|
169
178
|
server.once('listening', onOk);
|
|
170
179
|
server.listen(opts.spec.socketPath);
|
|
171
180
|
});
|
|
181
|
+
// Tighten the socket to owner-only rw — see method-level note above.
|
|
182
|
+
// Windows AF_UNIX implementations don't honor POSIX modes; the chmod
|
|
183
|
+
// call is a no-op there but harmless.
|
|
184
|
+
try {
|
|
185
|
+
await chmod(opts.spec.socketPath, 0o600);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
// Don't leave a world-readable socket up if we can't tighten it.
|
|
188
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
189
|
+
try {
|
|
190
|
+
await unlink(opts.spec.socketPath);
|
|
191
|
+
} catch {
|
|
192
|
+
// already gone
|
|
193
|
+
}
|
|
194
|
+
throw new Error(
|
|
195
|
+
`failed to chmod socket to 0600 at ${opts.spec.socketPath}: ${(err as Error).message}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
172
198
|
return listener;
|
|
173
199
|
}
|
|
174
200
|
|
|
@@ -305,7 +331,7 @@ export class SocketListener {
|
|
|
305
331
|
!flagPresent(withOverrides, '--permission-mode')
|
|
306
332
|
) {
|
|
307
333
|
const live = readLivePermissionMode(this.launchCWD, sid);
|
|
308
|
-
if (live !==
|
|
334
|
+
if (live !== undefined) {
|
|
309
335
|
withOverrides = [...withOverrides, '--permission-mode', live];
|
|
310
336
|
}
|
|
311
337
|
}
|
|
@@ -351,7 +377,7 @@ export class SocketListener {
|
|
|
351
377
|
sid !== ''
|
|
352
378
|
) {
|
|
353
379
|
const live = readLivePermissionMode(this.launchCWD, sid);
|
|
354
|
-
if (live !==
|
|
380
|
+
if (live !== undefined) {
|
|
355
381
|
withOverrides = [...withOverrides, '--permission-mode', live];
|
|
356
382
|
}
|
|
357
383
|
}
|
package/src/prompts.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { statSync } from 'node:fs';
|
|
|
16
16
|
import { readFile, stat } from 'node:fs/promises';
|
|
17
17
|
import { dirname, join } from 'node:path';
|
|
18
18
|
import process from 'node:process';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
19
20
|
import { resolveSelfPath } from './paths.js';
|
|
20
21
|
|
|
21
22
|
export interface PromptSet {
|
|
@@ -55,6 +56,16 @@ export interface LoadPromptsResult {
|
|
|
55
56
|
* contains the shipped prompts/. This is the production path for any
|
|
56
57
|
* `npm i -g @fnclaude/cli` install.
|
|
57
58
|
* 4. `<exe-dir>/../share/fnclaude/prompts/` — FHS/AUR install layout.
|
|
59
|
+
* 5. `<module-dir>/../prompts/` — umbrella-install layout: when invoked
|
|
60
|
+
* via `npm i -g fnclaude` (the umbrella package), `process.argv[1]`
|
|
61
|
+
* points at the umbrella's `bin/fnc.js`, which `await import`s into
|
|
62
|
+
* `@fnclaude/cli/bin/fnc.js`. Candidates 2–4 all anchor at the
|
|
63
|
+
* umbrella's exe-dir and miss the cli package entirely. Anchoring at
|
|
64
|
+
* this module's own location reliably reaches the cli package root,
|
|
65
|
+
* since `prompts.ts` always lives inside `@fnclaude/cli` regardless
|
|
66
|
+
* of which bin invoked it. Works for both the `dist/prompts.js`
|
|
67
|
+
* (installed) and `src/prompts.ts` (dev) layouts — both sit one
|
|
68
|
+
* level under the package root where `prompts/` ships.
|
|
58
69
|
*
|
|
59
70
|
* Symlinks in the exe path are resolved before the search.
|
|
60
71
|
*
|
|
@@ -110,10 +121,17 @@ export function findPromptsDir(): FindPromptsDirResult {
|
|
|
110
121
|
// resolveSelfPath handles the argv[1] / execPath / realpathSync logic.
|
|
111
122
|
const exeDir = dirname(resolveSelfPath());
|
|
112
123
|
|
|
124
|
+
// Anchor at this module's own location to catch the umbrella-install
|
|
125
|
+
// layout where argv[1] points at the fnclaude umbrella's bin/fnc.js
|
|
126
|
+
// (which delegates into @fnclaude/cli via dynamic import). The exe-dir
|
|
127
|
+
// candidates miss the cli package in that shape; this one doesn't.
|
|
128
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
129
|
+
|
|
113
130
|
const candidates = [
|
|
114
131
|
join(exeDir, 'prompts'),
|
|
115
132
|
join(exeDir, '..', 'prompts'),
|
|
116
133
|
join(exeDir, '..', 'share', 'fnclaude', 'prompts'),
|
|
134
|
+
join(moduleDir, '..', 'prompts'),
|
|
117
135
|
];
|
|
118
136
|
for (const c of candidates) {
|
|
119
137
|
try {
|
package/src/pty.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { mkdir, rmdir, stat } from 'node:fs/promises';
|
|
14
14
|
import type { Stats } from 'node:fs';
|
|
15
|
-
import { dirname } from 'node:path';
|
|
15
|
+
import { dirname, isAbsolute, resolve as resolvePath } from 'node:path';
|
|
16
16
|
import type { Config } from './config.js';
|
|
17
17
|
import type { HandoffSpec } from './handoff.js';
|
|
18
18
|
import { isFlag, isMagicWord, preserveArgs, splitLeadingMagic } from './args/preserve.js';
|
|
@@ -119,8 +119,18 @@ export interface CrossCwdMatch {
|
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
121
|
* Scan `tail` for the cross-cwd redirect message. Returns null when no
|
|
122
|
-
* match is found
|
|
123
|
-
* the LAST match
|
|
122
|
+
* match is found OR when the captured `dest` fails safety validation.
|
|
123
|
+
* When multiple matches appear (unlikely but defensive), the LAST match
|
|
124
|
+
* wins.
|
|
125
|
+
*
|
|
126
|
+
* Security note: the `dest` capture flows into `silentRelaunch` and
|
|
127
|
+
* becomes the cwd for the relaunched process. The PTY stream is not a
|
|
128
|
+
* trusted channel — a hostile MCP tool (or any subprocess that prints to
|
|
129
|
+
* claude's terminal) can emit a fake "To resume, run: cd /tmp/evil &&
|
|
130
|
+
* claude --resume <uuid>" line and steer the parent into relaunching in
|
|
131
|
+
* an attacker-controlled directory. We refuse to act on a dest unless
|
|
132
|
+
* it's an absolute path that survives canonicalisation unchanged and
|
|
133
|
+
* contains no null bytes / `..` segments.
|
|
124
134
|
*/
|
|
125
135
|
export function detectCrossCwd(tail: Buffer): CrossCwdMatch | null {
|
|
126
136
|
// Decode as Latin-1 so every byte maps to a code unit; the regex matches
|
|
@@ -137,7 +147,32 @@ export function detectCrossCwd(tail: Buffer): CrossCwdMatch | null {
|
|
|
137
147
|
last = m;
|
|
138
148
|
}
|
|
139
149
|
if (last === null) return null;
|
|
140
|
-
|
|
150
|
+
const dest = last[1]!;
|
|
151
|
+
if (!isSafeDest(dest)) return null;
|
|
152
|
+
return { dest, uuid: last[2]! };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Reject `dest` values that shouldn't be honored as relaunch cwds:
|
|
157
|
+
* - contains a null byte
|
|
158
|
+
* - is not an absolute path (a relative dest would resolve against
|
|
159
|
+
* whatever the current cwd happens to be — non-obvious to a user
|
|
160
|
+
* reading the relaunch and easy to abuse)
|
|
161
|
+
* - contains a `..` segment delimited by `/` (path traversal)
|
|
162
|
+
* - doesn't round-trip through `path.resolve` (catches `/foo/./bar`,
|
|
163
|
+
* trailing slashes, and any other non-canonical form a peer might
|
|
164
|
+
* cook up to slip past parent-segment detection)
|
|
165
|
+
*
|
|
166
|
+
* On Windows we'd also want backslash handling; the cross-cwd-resume
|
|
167
|
+
* flow is POSIX-only by design (the Windows PTY stub disables it) so
|
|
168
|
+
* this validator targets POSIX paths.
|
|
169
|
+
*/
|
|
170
|
+
function isSafeDest(dest: string): boolean {
|
|
171
|
+
if (dest.includes('\x00')) return false;
|
|
172
|
+
if (!isAbsolute(dest)) return false;
|
|
173
|
+
if (dest.split('/').includes('..')) return false;
|
|
174
|
+
if (resolvePath(dest) !== dest) return false;
|
|
175
|
+
return true;
|
|
141
176
|
}
|
|
142
177
|
|
|
143
178
|
// ── reconstructArgv ────────────────────────────────────────────────────────
|
package/src/resolver.ts
CHANGED
|
@@ -75,6 +75,11 @@ export interface ResolveDeps {
|
|
|
75
75
|
ghCmd: (args: readonly string[]) => Promise<GhResult>;
|
|
76
76
|
/** Shell out `gh repo clone <ownerRepo> <dest>`. */
|
|
77
77
|
runClone: (ownerRepo: string, dest: string) => Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* User-visible log line (e.g. "cloning X → Y"). Production writes to
|
|
80
|
+
* stderr; tests stub it to silence test output and assert on the call.
|
|
81
|
+
*/
|
|
82
|
+
log: (message: string) => void;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
// ── Production dependency wiring ───────────────────────────────────────────
|
|
@@ -111,6 +116,9 @@ export function productionDeps(): ResolveDeps {
|
|
|
111
116
|
},
|
|
112
117
|
ghCmd,
|
|
113
118
|
runClone,
|
|
119
|
+
log: (msg: string) => {
|
|
120
|
+
process.stderr.write(msg.endsWith('\n') ? msg : `${msg}\n`);
|
|
121
|
+
},
|
|
114
122
|
};
|
|
115
123
|
}
|
|
116
124
|
|
|
@@ -494,7 +502,7 @@ async function cloneAndReturn(
|
|
|
494
502
|
}
|
|
495
503
|
|
|
496
504
|
// Clone. gh decides SSH vs HTTPS from its config.
|
|
497
|
-
|
|
505
|
+
deps.log(`fnclaude: cloning ${ref.owner}/${ref.name} → ${target}`);
|
|
498
506
|
try {
|
|
499
507
|
await deps.runClone(`${ref.owner}/${ref.name}`, target);
|
|
500
508
|
} catch (e) {
|
package/src/sessionState.ts
CHANGED
|
@@ -58,7 +58,7 @@ export function sessionJSONLPath(launchCWD: string, sessionID: string): string {
|
|
|
58
58
|
* scan with last-wins semantics is correct (and adequate at the file
|
|
59
59
|
* sizes real sessions reach).
|
|
60
60
|
*
|
|
61
|
-
* Returns `
|
|
61
|
+
* Returns `undefined` if the file is missing, unreadable, or contains no
|
|
62
62
|
* permission-mode records. Callers should fall back to startup-arg
|
|
63
63
|
* preservation in that case.
|
|
64
64
|
*
|
|
@@ -70,15 +70,15 @@ export function sessionJSONLPath(launchCWD: string, sessionID: string): string {
|
|
|
70
70
|
export function readLivePermissionMode(
|
|
71
71
|
launchCWD: string,
|
|
72
72
|
sessionID: string,
|
|
73
|
-
): string {
|
|
73
|
+
): string | undefined {
|
|
74
74
|
const path = sessionJSONLPath(launchCWD, sessionID);
|
|
75
75
|
let data: string;
|
|
76
76
|
try {
|
|
77
77
|
data = readFileSync(path, 'utf8');
|
|
78
78
|
} catch {
|
|
79
|
-
return
|
|
79
|
+
return undefined;
|
|
80
80
|
}
|
|
81
|
-
let latest
|
|
81
|
+
let latest: string | undefined;
|
|
82
82
|
for (const line of data.split('\n')) {
|
|
83
83
|
if (line.length === 0) continue;
|
|
84
84
|
let r: { type?: unknown; permissionMode?: unknown };
|
package/src/worktree.ts
CHANGED
|
@@ -63,10 +63,10 @@ export interface WorktreeInfo {
|
|
|
63
63
|
/** Absolute filesystem path of the worktree. */
|
|
64
64
|
path: string;
|
|
65
65
|
/**
|
|
66
|
-
* Bare branch name (e.g. "feat-x" or "worktree-feat-x");
|
|
66
|
+
* Bare branch name (e.g. "feat-x" or "worktree-feat-x"); undefined if the
|
|
67
67
|
* worktree is detached.
|
|
68
68
|
*/
|
|
69
|
-
branch: string;
|
|
69
|
+
branch: string | undefined;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
@@ -86,7 +86,7 @@ export function listWorktrees(dir: string, runner: GitRunner = defaultGitRunner)
|
|
|
86
86
|
const result: WorktreeInfo[] = [];
|
|
87
87
|
for (const block of out.split('\n\n')) {
|
|
88
88
|
let path = '';
|
|
89
|
-
let branch
|
|
89
|
+
let branch: string | undefined;
|
|
90
90
|
for (const line of block.split('\n')) {
|
|
91
91
|
if (line.startsWith('worktree ')) {
|
|
92
92
|
path = line.slice('worktree '.length).trim();
|
|
@@ -114,20 +114,21 @@ export function listWorktrees(dir: string, runner: GitRunner = defaultGitRunner)
|
|
|
114
114
|
* 3. Basename of the path == query (last-resort fallback for worktrees
|
|
115
115
|
* whose branch was renamed or whose creator skipped the convention)
|
|
116
116
|
*
|
|
117
|
-
* Returns null when no entry matches.
|
|
118
|
-
* so that detached worktrees (branch=
|
|
117
|
+
* Returns null when no entry matches. Undefined `query` short-circuits to
|
|
118
|
+
* null so that detached worktrees (branch=undefined) can't be matched by
|
|
119
|
+
* accident.
|
|
119
120
|
*/
|
|
120
121
|
export function findWorktree(
|
|
121
122
|
worktrees: readonly WorktreeInfo[],
|
|
122
|
-
query: string,
|
|
123
|
+
query: string | undefined,
|
|
123
124
|
): WorktreeInfo | null {
|
|
124
|
-
if (query ===
|
|
125
|
+
if (query === undefined) return null;
|
|
125
126
|
|
|
126
127
|
for (const wt of worktrees) {
|
|
127
128
|
if (wt.branch === query) return wt;
|
|
128
129
|
}
|
|
129
130
|
for (const wt of worktrees) {
|
|
130
|
-
if (wt.branch !==
|
|
131
|
+
if (wt.branch !== undefined && stripWorktreePrefix(wt.branch) === query) return wt;
|
|
131
132
|
}
|
|
132
133
|
for (const wt of worktrees) {
|
|
133
134
|
if (basename(wt.path) === query) return wt;
|
|
@@ -153,7 +154,7 @@ function basename(p: string): string {
|
|
|
153
154
|
* No input is mutated. The four cases:
|
|
154
155
|
*
|
|
155
156
|
* 1. worktreeSet=false → carry through with worktreeMatched=false.
|
|
156
|
-
* 2. Bare -w (worktreeArg=
|
|
157
|
+
* 2. Bare -w (worktreeArg=undefined) → append --worktree to passthrough,
|
|
157
158
|
* worktreeMatched=false.
|
|
158
159
|
* 3. Existing worktree matched → swap cwd to the worktree path, set
|
|
159
160
|
* worktreeMatched=true, suppress --worktree.
|
|
@@ -173,7 +174,7 @@ export function applyWorktreeIntercept(
|
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
// Bare -w with no name: push --worktree back through unchanged.
|
|
176
|
-
if (a.worktreeArg ===
|
|
177
|
+
if (a.worktreeArg === undefined) {
|
|
177
178
|
return withIntercepted(a, {
|
|
178
179
|
passthrough: [...a.passthrough, '--worktree'],
|
|
179
180
|
worktreeMatched: false,
|