talon-agent 1.53.0 → 2.0.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/README.md +1 -1
- package/package.json +3 -2
- package/src/app.ts +11 -0
- package/src/cli/index.ts +0 -15
- package/src/core/doctor.ts +20 -0
- package/src/core/engine/gateway-actions/index.ts +0 -3
- package/src/core/engine/gateway-actions/native.ts +108 -100
- package/src/core/engine/gateway.ts +0 -28
- package/src/core/tools/index.ts +0 -2
- package/src/core/tools/native.ts +16 -8
- package/src/core/tools/types.ts +1 -2
- package/src/core/vfs/fusefs.ts +269 -0
- package/src/core/vfs/index.ts +21 -2
- package/src/core/vfs/nsdir.ts +85 -0
- package/src/core/vfs/rewrite.ts +170 -0
- package/src/core/vfs/vfs.ts +34 -19
- package/src/native/fusefs.ts +97 -0
- package/src/util/config.ts +8 -0
- package/src/util/log.ts +1 -0
- package/src/util/paths.ts +7 -0
- package/src/cli/fs.ts +0 -138
- package/src/core/engine/gateway-actions/vfs.ts +0 -69
- package/src/core/tools/vfs.ts +0 -61
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FUSE layer lifecycle — mounts the talon:// namespace at ~/.talon/ns
|
|
3
|
+
* while the daemon runs.
|
|
4
|
+
*
|
|
5
|
+
* The mount serves symlinks for file-backed mounts (kernel-followed, so
|
|
6
|
+
* heavy I/O never crosses FUSE) and answers synthetic subtrees (proc/,
|
|
7
|
+
* plugins/) live from the JS Vfs over the addon's callback bridge. When
|
|
8
|
+
* anything is missing — config off, addon absent, no /dev/fuse, mount
|
|
9
|
+
* or sanity check fails — the layer degrades to the plain symlink farm
|
|
10
|
+
* and records why: fuseless hosts get the identical experience minus
|
|
11
|
+
* live views, never an error at boot.
|
|
12
|
+
*
|
|
13
|
+
* Deadlock rule: the daemon must never do SYNCHRONOUS fs I/O under
|
|
14
|
+
* ~/.talon/ns — a sync call blocks the one JS thread that answers the
|
|
15
|
+
* bridge, wedging both sides. Async fs is safe (libuv worker blocks,
|
|
16
|
+
* the event loop answers), and child/external processes are always
|
|
17
|
+
* safe. The bridge handler itself only touches in-memory synthetic
|
|
18
|
+
* mounts, keeping replies non-blocking by construction.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { readdir, stat } from "node:fs/promises";
|
|
24
|
+
import { log, logWarn } from "../../util/log.js";
|
|
25
|
+
import { dirs } from "../../util/paths.js";
|
|
26
|
+
import type { NativeFuseFs } from "../../native/fusefs.js";
|
|
27
|
+
import { nativeFuseFs } from "../../native/fusefs.js";
|
|
28
|
+
import { syncNamespaceDir } from "./nsdir.js";
|
|
29
|
+
import type { VfsErrorCode } from "./types.js";
|
|
30
|
+
import type { Vfs } from "./vfs.js";
|
|
31
|
+
|
|
32
|
+
export type FuseStatus = {
|
|
33
|
+
readonly mounted: boolean;
|
|
34
|
+
/** Why the layer is off — set exactly when `mounted` is false. */
|
|
35
|
+
readonly reason?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** How long a post-mount sanity probe may take before we bail out. */
|
|
39
|
+
const SANITY_TIMEOUT_MS = 3_000;
|
|
40
|
+
|
|
41
|
+
let status: FuseStatus = { mounted: false, reason: "not started" };
|
|
42
|
+
let activeAddon: NativeFuseFs | null = null;
|
|
43
|
+
|
|
44
|
+
/** Is the FUSE layer live? Consulted by the address/command resolvers. */
|
|
45
|
+
export function isNamespaceFsMounted(): boolean {
|
|
46
|
+
return status.mounted;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Current layer status, for doctor/status surfaces. */
|
|
50
|
+
export function namespaceFsStatus(): FuseStatus {
|
|
51
|
+
return status;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Test seam — force a status without a real mount. */
|
|
55
|
+
export function _setNamespaceFsStatusForTesting(next: FuseStatus): void {
|
|
56
|
+
status = next;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Bridge: FUSE thread → JS Vfs ─────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
const ERRNO: Record<VfsErrorCode, string> = {
|
|
62
|
+
"not-found": "ENOENT",
|
|
63
|
+
"invalid-path": "ENOENT",
|
|
64
|
+
"is-a-directory": "EISDIR",
|
|
65
|
+
"not-a-directory": "ENOTDIR",
|
|
66
|
+
"not-writable": "EROFS",
|
|
67
|
+
"too-large": "EFBIG",
|
|
68
|
+
"binary-file": "EIO",
|
|
69
|
+
"io-error": "EIO",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Answer one bridge request. Pure Vfs → JSON: `path` is
|
|
74
|
+
* namespace-relative ("proc/tasks/7"); ops are stat / list / read.
|
|
75
|
+
* Never throws — every failure becomes an errno reply, because an
|
|
76
|
+
* unanswered request strands a kernel caller until the addon times it
|
|
77
|
+
* out.
|
|
78
|
+
*/
|
|
79
|
+
export function serveNamespaceRequest(
|
|
80
|
+
vfs: Vfs,
|
|
81
|
+
op: string,
|
|
82
|
+
path: string,
|
|
83
|
+
): string {
|
|
84
|
+
try {
|
|
85
|
+
const address = `talon://${path}`;
|
|
86
|
+
if (op === "stat") {
|
|
87
|
+
const result = vfs.stat(address);
|
|
88
|
+
if (!result.ok) return errno(result.error);
|
|
89
|
+
return JSON.stringify({
|
|
90
|
+
ok: true,
|
|
91
|
+
kind: result.value.kind,
|
|
92
|
+
size: result.value.size ?? 0,
|
|
93
|
+
mtimeMs: result.value.modifiedAt ?? 0,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (op === "list") {
|
|
97
|
+
const result = vfs.list(address);
|
|
98
|
+
if (!result.ok) return errno(result.error);
|
|
99
|
+
return JSON.stringify({
|
|
100
|
+
ok: true,
|
|
101
|
+
entries: result.value.map((entry) => ({
|
|
102
|
+
name: entry.name,
|
|
103
|
+
kind: entry.kind,
|
|
104
|
+
})),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (op === "read") {
|
|
108
|
+
const result = vfs.read(address);
|
|
109
|
+
if (!result.ok) return errno(result.error);
|
|
110
|
+
return JSON.stringify({
|
|
111
|
+
ok: true,
|
|
112
|
+
data: Buffer.from(result.value, "utf-8").toString("base64"),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return errno("io-error");
|
|
116
|
+
} catch {
|
|
117
|
+
return JSON.stringify({ ok: false, errno: "EIO" });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function errno(code: VfsErrorCode): string {
|
|
122
|
+
return JSON.stringify({ ok: false, errno: ERRNO[code] });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Mount lifecycle ──────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export interface MountOptions {
|
|
128
|
+
/** `config.fuse` — "auto" mounts when possible, "off" never mounts. */
|
|
129
|
+
mode: "auto" | "off";
|
|
130
|
+
vfs: Vfs;
|
|
131
|
+
/** Test seams; production callers omit them. */
|
|
132
|
+
nsRoot?: string;
|
|
133
|
+
addon?: NativeFuseFs | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Bring the FUSE layer up if this host can. Never throws: every failure
|
|
138
|
+
* path lands in `{ mounted: false, reason }` with the symlink farm
|
|
139
|
+
* already synced, so the fuseless experience is intact regardless.
|
|
140
|
+
*/
|
|
141
|
+
export async function mountNamespaceFs(
|
|
142
|
+
options: MountOptions,
|
|
143
|
+
): Promise<FuseStatus> {
|
|
144
|
+
const nsRoot = options.nsRoot ?? dirs.ns;
|
|
145
|
+
await recoverStaleMount(nsRoot);
|
|
146
|
+
try {
|
|
147
|
+
syncNamespaceDir(options.vfs, nsRoot);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return down(`namespace dir sync failed: ${message(err)}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (options.mode === "off") return down('disabled (config fuse: "off")');
|
|
153
|
+
if (process.platform !== "linux") {
|
|
154
|
+
return down("FUSE layer is Linux-only for now");
|
|
155
|
+
}
|
|
156
|
+
if (!existsSync("/dev/fuse")) return down("/dev/fuse not present");
|
|
157
|
+
|
|
158
|
+
const addon = options.addon !== undefined ? options.addon : nativeFuseFs();
|
|
159
|
+
if (!addon) return down("talon-fusefs addon not available");
|
|
160
|
+
|
|
161
|
+
const synthetic = options.vfs
|
|
162
|
+
.describeMounts()
|
|
163
|
+
.filter((mount) => mount.osRoot === undefined)
|
|
164
|
+
.map((mount) => mount.name);
|
|
165
|
+
const symlinks = options.vfs
|
|
166
|
+
.describeMounts()
|
|
167
|
+
.filter((mount) => mount.osRoot !== undefined)
|
|
168
|
+
.map((mount) => ({ name: mount.name, target: mount.osRoot! }));
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
addon.mount(nsRoot, symlinks, synthetic, (id, op, path) => {
|
|
172
|
+
addon.reply(id, serveNamespaceRequest(options.vfs, op, path));
|
|
173
|
+
});
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return down(`mount failed: ${message(err)}`);
|
|
176
|
+
}
|
|
177
|
+
activeAddon = addon;
|
|
178
|
+
|
|
179
|
+
// Sanity: the mount must actually answer before we advertise it.
|
|
180
|
+
// Async fs only — see the deadlock rule in the module doc.
|
|
181
|
+
const probe = synthetic[0];
|
|
182
|
+
const healthy = await withTimeout(
|
|
183
|
+
(async () => {
|
|
184
|
+
const entries = await readdir(nsRoot);
|
|
185
|
+
if (probe !== undefined && !entries.includes(probe)) return false;
|
|
186
|
+
if (probe !== undefined) {
|
|
187
|
+
if (!(await stat(`${nsRoot}/${probe}`)).isDirectory()) return false;
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
})(),
|
|
191
|
+
SANITY_TIMEOUT_MS,
|
|
192
|
+
);
|
|
193
|
+
if (healthy !== true) {
|
|
194
|
+
await unmountNamespaceFs();
|
|
195
|
+
// The failed mount may have shadowed the symlink farm — restore it.
|
|
196
|
+
try {
|
|
197
|
+
syncNamespaceDir(options.vfs, nsRoot);
|
|
198
|
+
} catch {
|
|
199
|
+
// best effort; the boot log already carries the real failure
|
|
200
|
+
}
|
|
201
|
+
return down(
|
|
202
|
+
healthy === false
|
|
203
|
+
? "mount sanity check failed (synthetic mounts not visible)"
|
|
204
|
+
: `mount sanity check timed out after ${SANITY_TIMEOUT_MS}ms`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
status = { mounted: true };
|
|
209
|
+
log("fusefs", `talon:// namespace mounted at ${nsRoot}`);
|
|
210
|
+
return status;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Tear the layer down (daemon shutdown). Never throws, idempotent. */
|
|
214
|
+
export async function unmountNamespaceFs(): Promise<void> {
|
|
215
|
+
const addon = activeAddon;
|
|
216
|
+
activeAddon = null;
|
|
217
|
+
if (status.mounted) status = { mounted: false, reason: "unmounted" };
|
|
218
|
+
if (!addon) return;
|
|
219
|
+
try {
|
|
220
|
+
addon.unmount();
|
|
221
|
+
} catch (err) {
|
|
222
|
+
logWarn("fusefs", `unmount failed: ${message(err)}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* A daemon that died without unmounting leaves the mountpoint wedged —
|
|
228
|
+
* every syscall answers ENOTCONN ("transport endpoint is not
|
|
229
|
+
* connected") until someone detaches it. A predecessor that is alive
|
|
230
|
+
* but not answering is worse: its mount HANGS syscalls instead, so the
|
|
231
|
+
* probe itself carries a timeout and a hang counts as stale. Detect
|
|
232
|
+
* and lazy-unmount before touching the directory.
|
|
233
|
+
*/
|
|
234
|
+
async function recoverStaleMount(nsRoot: string): Promise<void> {
|
|
235
|
+
const probe = await withTimeout(
|
|
236
|
+
stat(nsRoot).then(
|
|
237
|
+
() => "ok" as const,
|
|
238
|
+
(err) => (err as NodeJS.ErrnoException).code ?? "error",
|
|
239
|
+
),
|
|
240
|
+
SANITY_TIMEOUT_MS,
|
|
241
|
+
);
|
|
242
|
+
if (probe !== "ENOTCONN" && probe !== "timeout") return;
|
|
243
|
+
logWarn("fusefs", `stale mount at ${nsRoot} — detaching`);
|
|
244
|
+
for (const bin of ["fusermount3", "fusermount"]) {
|
|
245
|
+
const result = spawnSync(bin, ["-uz", nsRoot], { stdio: "ignore" });
|
|
246
|
+
if (result.status === 0) return;
|
|
247
|
+
}
|
|
248
|
+
spawnSync("umount", ["-l", nsRoot], { stdio: "ignore" });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function down(reason: string): FuseStatus {
|
|
252
|
+
status = { mounted: false, reason };
|
|
253
|
+
log("fusefs", `FUSE layer off — ${reason}`);
|
|
254
|
+
return status;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function message(err: unknown): string {
|
|
258
|
+
return err instanceof Error ? err.message : String(err);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function withTimeout<T>(work: Promise<T>, ms: number): Promise<T | "timeout"> {
|
|
262
|
+
return Promise.race([
|
|
263
|
+
work,
|
|
264
|
+
new Promise<"timeout">((resolve) => {
|
|
265
|
+
const timer = setTimeout(() => resolve("timeout"), ms);
|
|
266
|
+
timer.unref();
|
|
267
|
+
}),
|
|
268
|
+
]);
|
|
269
|
+
}
|
package/src/core/vfs/index.ts
CHANGED
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
* proc/ task table + event bus ring (synthetic, read-only)
|
|
16
16
|
* plugins/ plugin registry view (synthetic, read-only)
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* The namespace IS the filesystem: it lives at ~/.talon/ns (symlink
|
|
19
|
+
* farm via nsdir.ts, live synthetic mounts via fusefs.ts when FUSE is
|
|
20
|
+
* available), and every consumer — native tools, shell commands, the
|
|
21
|
+
* user's own terminal — reaches it through real paths. Address
|
|
22
|
+
* translation for tools lives in rewrite.ts.
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
25
|
import { dirs } from "../../util/paths.js";
|
|
@@ -32,6 +35,22 @@ export { Vfs } from "./vfs.js";
|
|
|
32
35
|
export { createFileMount } from "./mounts/files.js";
|
|
33
36
|
export { createProcMount, type ProcMountDeps } from "./mounts/proc.js";
|
|
34
37
|
export { createPluginsMount, type PluginView } from "./mounts/plugins.js";
|
|
38
|
+
export { syncNamespaceDir, type NsDirSync } from "./nsdir.js";
|
|
39
|
+
export {
|
|
40
|
+
resolveNamespacePath,
|
|
41
|
+
rewriteNamespaceRefs,
|
|
42
|
+
type CommandRewrite,
|
|
43
|
+
type PathResolution,
|
|
44
|
+
} from "./rewrite.js";
|
|
45
|
+
export {
|
|
46
|
+
isNamespaceFsMounted,
|
|
47
|
+
mountNamespaceFs,
|
|
48
|
+
namespaceFsStatus,
|
|
49
|
+
serveNamespaceRequest,
|
|
50
|
+
unmountNamespaceFs,
|
|
51
|
+
type FuseStatus,
|
|
52
|
+
type MountOptions,
|
|
53
|
+
} from "./fusefs.js";
|
|
35
54
|
export type {
|
|
36
55
|
VfsErrorCode,
|
|
37
56
|
VfsMount,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The namespace on disk — ~/.talon/ns/, the OS spelling of talon://.
|
|
3
|
+
*
|
|
4
|
+
* One symlink per file-backed mount (home → workspace/, skills/, …), so
|
|
5
|
+
* `talon://` ↔ `~/.talon/ns/` is a pure prefix substitution: any shell
|
|
6
|
+
* command, editor, or external tool reaches namespace nodes through
|
|
7
|
+
* ordinary paths. Synthetic mounts (proc/, plugins/) are NOT represented
|
|
8
|
+
* here — they exist only while the FUSE layer (core/vfs/fusefs.ts) is
|
|
9
|
+
* mounted over this directory, exactly like /proc appearing at boot.
|
|
10
|
+
*
|
|
11
|
+
* The sync is idempotent and owns only symlinks: stale or retargeted
|
|
12
|
+
* links it created are replaced, anything that isn't a symlink is left
|
|
13
|
+
* untouched and reported rather than deleted.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
lstatSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
readlinkSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
symlinkSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { dirs } from "../../util/paths.js";
|
|
25
|
+
import type { Vfs } from "./vfs.js";
|
|
26
|
+
|
|
27
|
+
export interface NsDirSync {
|
|
28
|
+
/** Symlinks created or retargeted this pass (mount names). */
|
|
29
|
+
readonly linked: string[];
|
|
30
|
+
/** Stale symlinks removed (no longer a mount, or wrong target). */
|
|
31
|
+
readonly pruned: string[];
|
|
32
|
+
/** Entries skipped because they aren't symlinks we own. */
|
|
33
|
+
readonly foreign: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Bring ~/.talon/ns/ (or `nsRoot`) in line with the mount table. Safe to
|
|
38
|
+
* call repeatedly; called at daemon boot before the FUSE layer mounts
|
|
39
|
+
* over the directory. Mount targets are created if missing so the links
|
|
40
|
+
* are never dangling — workspace directories appear lazily elsewhere too.
|
|
41
|
+
*/
|
|
42
|
+
export function syncNamespaceDir(
|
|
43
|
+
vfs: Vfs,
|
|
44
|
+
nsRoot: string = dirs.ns,
|
|
45
|
+
): NsDirSync {
|
|
46
|
+
mkdirSync(nsRoot, { recursive: true });
|
|
47
|
+
const desired = new Map(
|
|
48
|
+
vfs
|
|
49
|
+
.describeMounts()
|
|
50
|
+
.filter((mount) => mount.osRoot !== undefined)
|
|
51
|
+
.map((mount) => [mount.name, mount.osRoot!]),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const linked: string[] = [];
|
|
55
|
+
const pruned: string[] = [];
|
|
56
|
+
const foreign: string[] = [];
|
|
57
|
+
|
|
58
|
+
for (const entry of readdirSync(nsRoot)) {
|
|
59
|
+
const path = `${nsRoot}/${entry}`;
|
|
60
|
+
if (!lstatSync(path).isSymbolicLink()) {
|
|
61
|
+
foreign.push(entry);
|
|
62
|
+
// A foreign entry shadowing a mount name wins — we never delete
|
|
63
|
+
// what we didn't create, so don't try to link over it either.
|
|
64
|
+
desired.delete(entry);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const target = desired.get(entry);
|
|
68
|
+
if (target !== undefined && readlinkSync(path) === target) {
|
|
69
|
+
desired.delete(entry); // already correct
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
rmSync(path);
|
|
73
|
+
if (target === undefined) pruned.push(entry);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const [name, target] of desired) {
|
|
77
|
+
mkdirSync(target, { recursive: true });
|
|
78
|
+
// "dir" matters only on Windows (junction-style resolution); ignored
|
|
79
|
+
// elsewhere.
|
|
80
|
+
symlinkSync(target, `${nsRoot}/${name}`, "dir");
|
|
81
|
+
linked.push(name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { linked, pruned, foreign };
|
|
85
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* talon:// → real path translation — the seam that makes the namespace
|
|
3
|
+
* seamless for OS-level tools.
|
|
4
|
+
*
|
|
5
|
+
* Because the namespace exists on disk (~/.talon/ns/, see nsdir.ts and
|
|
6
|
+
* fusefs.ts), translating an address is pure prefix substitution — no
|
|
7
|
+
* tokenizing, no quoting rules, correct anywhere in a command string:
|
|
8
|
+
*
|
|
9
|
+
* FUSE mounted talon:// → ~/.talon/ns/ (total)
|
|
10
|
+
* fuseless talon://home/x → <workspace>/x (per mount)
|
|
11
|
+
* talon:// → ~/.talon/ns (symlink farm)
|
|
12
|
+
* talon://proc/x → refused — live views exist only
|
|
13
|
+
* while the FUSE layer is mounted
|
|
14
|
+
*
|
|
15
|
+
* Two consumers: `resolveNamespacePath` for a single address parameter
|
|
16
|
+
* (read/write/edit/glob/search), `rewriteNamespaceRefs` for a whole
|
|
17
|
+
* shell command (bash). Both return real host paths that ordinary fs
|
|
18
|
+
* code and child processes use with no further special-casing.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { dirs } from "../../util/paths.js";
|
|
22
|
+
import type { Vfs } from "./vfs.js";
|
|
23
|
+
|
|
24
|
+
const SCHEME = "talon://";
|
|
25
|
+
|
|
26
|
+
/** Chars that can continue a mount name — used as a replace boundary. */
|
|
27
|
+
const MOUNT_NAME = /^[a-z0-9-]+/;
|
|
28
|
+
|
|
29
|
+
export type PathResolution =
|
|
30
|
+
{ ok: true; path: string } | { ok: false; error: string };
|
|
31
|
+
|
|
32
|
+
export type CommandRewrite =
|
|
33
|
+
| { ok: true; command: string; mappings: string[] }
|
|
34
|
+
| { ok: false; error: string };
|
|
35
|
+
|
|
36
|
+
function lockedError(name: string): string {
|
|
37
|
+
return (
|
|
38
|
+
`talon://${name} is a live view that only exists while the FUSE layer is ` +
|
|
39
|
+
`mounted, and it isn't mounted on this host (config \`fuse: "off"\`, ` +
|
|
40
|
+
`missing talon-fusefs addon, or no FUSE support). File-backed mounts ` +
|
|
41
|
+
`under ${dirs.ns} keep working.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function unknownMountError(name: string, vfs: Vfs): string {
|
|
46
|
+
const mounts = vfs
|
|
47
|
+
.describeMounts()
|
|
48
|
+
.map((mount) => mount.name)
|
|
49
|
+
.join(", ");
|
|
50
|
+
return `No mount "${name}" in the talon:// namespace — mounts: ${mounts}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isSynthetic(name: string, vfs: Vfs): boolean {
|
|
54
|
+
return vfs
|
|
55
|
+
.describeMounts()
|
|
56
|
+
.some((mount) => mount.name === name && mount.osRoot === undefined);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve one talon:// address to the real host path it names. The
|
|
61
|
+
* caller has already checked the scheme prefix. With FUSE mounted every
|
|
62
|
+
* address maps into the mountpoint; fuseless, file-backed mounts map to
|
|
63
|
+
* their disk roots and live views are refused (they don't exist).
|
|
64
|
+
*/
|
|
65
|
+
export function resolveNamespacePath(
|
|
66
|
+
address: string,
|
|
67
|
+
vfs: Vfs,
|
|
68
|
+
fuseMounted: boolean,
|
|
69
|
+
nsRoot: string = dirs.ns,
|
|
70
|
+
): PathResolution {
|
|
71
|
+
const rest = address.slice(SCHEME.length).replace(/^\/+/, "");
|
|
72
|
+
if (rest === "") return { ok: true, path: nsRoot };
|
|
73
|
+
if (fuseMounted) return { ok: true, path: `${nsRoot}/${rest}` };
|
|
74
|
+
|
|
75
|
+
const name = MOUNT_NAME.exec(rest)?.[0] ?? "";
|
|
76
|
+
if (isSynthetic(name, vfs)) return { ok: false, error: lockedError(name) };
|
|
77
|
+
|
|
78
|
+
const located = vfs.locate(address);
|
|
79
|
+
if (!located.ok) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: `Cannot resolve ${address}: ${located.error}${located.detail ? ` — ${located.detail}` : ""}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (located.value === undefined) {
|
|
86
|
+
// A mount the table knows but has no disk root and isn't flagged
|
|
87
|
+
// synthetic can't exist; treat as locked for the same honest reason.
|
|
88
|
+
return { ok: false, error: lockedError(name) };
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, path: located.value };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Translate every talon:// reference in a shell command to its real
|
|
95
|
+
* path, so `ls talon://home | head` runs untouched by any namespace
|
|
96
|
+
* plumbing. Returns the applied mappings for the tool result, so the
|
|
97
|
+
* translation is visible rather than silent.
|
|
98
|
+
*/
|
|
99
|
+
export function rewriteNamespaceRefs(
|
|
100
|
+
command: string,
|
|
101
|
+
vfs: Vfs,
|
|
102
|
+
fuseMounted: boolean,
|
|
103
|
+
nsRoot: string = dirs.ns,
|
|
104
|
+
): CommandRewrite {
|
|
105
|
+
if (!command.includes("talon:")) {
|
|
106
|
+
return { ok: true, command, mappings: [] };
|
|
107
|
+
}
|
|
108
|
+
// Near-miss schemes that are unambiguously typos get corrected rather
|
|
109
|
+
// than letting the shell chase a path that never existed: the
|
|
110
|
+
// single-slash form (talon:/x), and talon:<name> when <name> is a real
|
|
111
|
+
// mount. Anything else containing "talon:" (a grep pattern, a log tag)
|
|
112
|
+
// passes through untouched.
|
|
113
|
+
const nearMiss =
|
|
114
|
+
/talon:\/(?!\/)([a-z0-9-][^\s'"`]*)/.exec(command) ??
|
|
115
|
+
/talon:(?!\/)([a-z0-9-]+)/.exec(command);
|
|
116
|
+
if (nearMiss) {
|
|
117
|
+
const rest = nearMiss[1]!;
|
|
118
|
+
const name = MOUNT_NAME.exec(rest)?.[0] ?? "";
|
|
119
|
+
const known = vfs.describeMounts().some((mount) => mount.name === name);
|
|
120
|
+
if (nearMiss[0].startsWith("talon:/") || known) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
error: `"${nearMiss[0]}" is not an address — spell it ${SCHEME}${rest}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!command.includes(SCHEME)) {
|
|
128
|
+
return { ok: true, command, mappings: [] };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (fuseMounted) {
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
command: command.split(SCHEME).join(`${nsRoot}/`),
|
|
135
|
+
mappings: [`talon:// → ${nsRoot}/`],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let rewritten = command;
|
|
140
|
+
const mappings: string[] = [];
|
|
141
|
+
const mounts = vfs
|
|
142
|
+
.describeMounts()
|
|
143
|
+
.filter((mount) => mount.osRoot !== undefined)
|
|
144
|
+
.sort((a, b) => b.name.length - a.name.length);
|
|
145
|
+
for (const mount of mounts) {
|
|
146
|
+
const ref = new RegExp(`talon://${mount.name}(?![a-z0-9-])`, "g");
|
|
147
|
+
if (ref.test(rewritten)) {
|
|
148
|
+
rewritten = rewritten.replace(ref, mount.osRoot!);
|
|
149
|
+
mappings.push(`talon://${mount.name} → ${mount.osRoot}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const residual = /talon:\/\/([a-z0-9-]+)/.exec(rewritten);
|
|
154
|
+
if (residual) {
|
|
155
|
+
const name = residual[1]!;
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
error: isSynthetic(name, vfs)
|
|
159
|
+
? lockedError(name)
|
|
160
|
+
: unknownMountError(name, vfs),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Bare talon:// (the namespace root) — the symlink farm is always there.
|
|
165
|
+
if (rewritten.includes(SCHEME)) {
|
|
166
|
+
rewritten = rewritten.split(SCHEME).join(`${nsRoot}/`);
|
|
167
|
+
mappings.push(`talon:// → ${nsRoot}/`);
|
|
168
|
+
}
|
|
169
|
+
return { ok: true, command: rewritten, mappings };
|
|
170
|
+
}
|
package/src/core/vfs/vfs.ts
CHANGED
|
@@ -6,18 +6,18 @@
|
|
|
6
6
|
* re-prefixing the mount-relative stats that come back. Mounts therefore
|
|
7
7
|
* only ever see clean relative paths.
|
|
8
8
|
*
|
|
9
|
-
* An address is one of
|
|
9
|
+
* An address is one of exactly two spellings of the same node:
|
|
10
10
|
*
|
|
11
11
|
* talon://home/notes.md scheme form — always namespace-interpreted
|
|
12
|
-
* home/notes.md mount-relative form
|
|
13
12
|
* <workspace>/notes.md OS-absolute form — routed through the mount
|
|
14
13
|
* table by containment, exactly like a kernel
|
|
15
14
|
* resolving a path through its mounts
|
|
16
15
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* Nothing else is an address. Bare relative paths and `~` spellings are
|
|
17
|
+
* refused with the correction rather than guessed at — the old
|
|
18
|
+
* mount-relative form (`home/notes.md`) was ambiguous with a relative OS
|
|
19
|
+
* path and is gone. The empty string (and bare separators) still name the
|
|
20
|
+
* namespace root for internal callers.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
@@ -121,31 +121,46 @@ export class Vfs {
|
|
|
121
121
|
);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/**
|
|
125
|
-
|
|
124
|
+
/**
|
|
125
|
+
* The mount table as data: name, description, writability, and — for
|
|
126
|
+
* file-backed mounts — the disk root. Consumed by the namespace dir
|
|
127
|
+
* builder (symlink farm), the command rewriter, and docs/tool output.
|
|
128
|
+
*/
|
|
129
|
+
describeMounts(): {
|
|
130
|
+
name: string;
|
|
131
|
+
description: string;
|
|
132
|
+
writable: boolean;
|
|
133
|
+
osRoot?: string;
|
|
134
|
+
}[] {
|
|
126
135
|
return [...this.#mounts.entries()].map(([name, mount]) => ({
|
|
127
136
|
name,
|
|
128
137
|
description: mount.description,
|
|
129
138
|
writable: mount.writable,
|
|
139
|
+
...(mount.osRoot !== undefined ? { osRoot: mount.osRoot } : {}),
|
|
130
140
|
}));
|
|
131
141
|
}
|
|
132
142
|
|
|
133
143
|
/** null = the namespace root. */
|
|
134
144
|
#parse(raw: string): VfsResult<Resolved | null> {
|
|
135
145
|
let path = raw.trim();
|
|
136
|
-
|
|
137
|
-
if (schemed) {
|
|
146
|
+
if (path.startsWith(SCHEME)) {
|
|
138
147
|
path = path.slice(SCHEME.length);
|
|
148
|
+
} else if (path === "" || /^[\\/]+$/.test(path)) {
|
|
149
|
+
// Internal callers address the namespace root as "" or bare separators.
|
|
150
|
+
return vfsOk(null);
|
|
151
|
+
} else if (OS_ABSOLUTE.test(path)) {
|
|
152
|
+
return this.#parseOsPath(path);
|
|
153
|
+
} else if (path.startsWith("talon:")) {
|
|
154
|
+
// A near-miss scheme (talon:/x, talon:x) is always a typo, never a path.
|
|
155
|
+
return vfsError(
|
|
156
|
+
"invalid-path",
|
|
157
|
+
`Did you mean "${SCHEME}${path.replace(/^talon:\/{0,2}/, "")}"?`,
|
|
158
|
+
);
|
|
139
159
|
} else {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return vfsError(
|
|
145
|
-
"invalid-path",
|
|
146
|
-
'"~" is not expanded — use an absolute path or a talon:// address',
|
|
147
|
-
);
|
|
148
|
-
}
|
|
160
|
+
return vfsError(
|
|
161
|
+
"invalid-path",
|
|
162
|
+
`Not an address — use ${SCHEME}<mount>/… or an absolute OS path`,
|
|
163
|
+
);
|
|
149
164
|
}
|
|
150
165
|
if (path.includes("\\")) {
|
|
151
166
|
return vfsError(
|