talon-agent 1.49.0 → 1.50.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 +2 -0
- package/package.json +1 -1
- package/src/backend/codex/factory.ts +2 -0
- package/src/backend/codex/handler/message.ts +10 -0
- package/src/backend/kilo/factory.ts +2 -0
- package/src/backend/kilo/handler/message.ts +10 -0
- package/src/backend/openai-agents/factory.ts +2 -0
- package/src/backend/openai-agents/handler/message.ts +10 -0
- package/src/backend/opencode/factory.ts +2 -0
- package/src/backend/opencode/handler/message.ts +10 -0
- package/src/backend/shared/index.ts +4 -0
- package/src/backend/shared/turn-interrupt.ts +61 -0
- package/src/bootstrap.ts +5 -0
- package/src/cli/events.ts +47 -12
- package/src/cli/fs.ts +138 -0
- package/src/cli/index.ts +31 -7
- package/src/cli/tasks.ts +85 -20
- package/src/core/engine/gateway-actions/index.ts +3 -0
- package/src/core/engine/gateway-actions/native.ts +151 -24
- package/src/core/engine/gateway-actions/vfs.ts +69 -0
- package/src/core/engine/gateway.ts +28 -0
- package/src/core/tasks/table.ts +1 -1
- package/src/core/tasks/types.ts +6 -2
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/native.ts +19 -6
- package/src/core/tools/types.ts +2 -1
- package/src/core/tools/vfs.ts +61 -0
- package/src/core/vfs/index.ts +113 -0
- package/src/core/vfs/mounts/files.ts +154 -0
- package/src/core/vfs/mounts/plugins.ts +72 -0
- package/src/core/vfs/mounts/proc.ts +125 -0
- package/src/core/vfs/types.ts +103 -0
- package/src/core/vfs/vfs.ts +237 -0
- package/src/core/weaver/weaver.ts +40 -3
- package/src/frontend/native/discovery.ts +3 -0
- package/src/frontend/native/index.ts +10 -1
- package/src/frontend/native/server.ts +65 -6
- package/src/frontend/native/tls.ts +313 -0
- package/src/storage/journal.ts +91 -0
- package/src/storage/repositories/journal-repo.ts +38 -0
- package/src/storage/sql/journal.sql +16 -0
- package/src/storage/sql/schema.sql +15 -0
- package/src/storage/sql/statements.generated.ts +24 -1
- package/src/util/config.ts +9 -0
- package/src/util/log.ts +1 -0
- package/src/util/paths.ts +2 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VFS vocabulary — the unified `talon://` namespace.
|
|
3
|
+
*
|
|
4
|
+
* One rooted, mountable namespace over everything the daemon owns, in the
|
|
5
|
+
* Plan 9 tradition: real-file stores (workspace, skills, scripts, logs)
|
|
6
|
+
* and synthetic /proc-style views of live state (task table, event bus,
|
|
7
|
+
* plugin registry) answer the same four operations. The namespace is for
|
|
8
|
+
* unification and introspection — mounts decide what they can do (a
|
|
9
|
+
* synthetic view has nothing meaningful to write), but the VFS is never a
|
|
10
|
+
* permission layer.
|
|
11
|
+
*
|
|
12
|
+
* An address reaches the namespace in one of three spellings, all resolved
|
|
13
|
+
* by the same grammar (see `Vfs.#parse`):
|
|
14
|
+
*
|
|
15
|
+
* - scheme: `talon://skills/review-pr/SKILL.md`
|
|
16
|
+
* - mount-relative: `skills/review-pr/SKILL.md` (`/`-separated everywhere)
|
|
17
|
+
* - OS-absolute: the disk spelling of a node inside a file-backed
|
|
18
|
+
* mount's root — the same node, other coordinate system
|
|
19
|
+
*
|
|
20
|
+
* Mounts receive mount-relative paths ("" = the mount root) and report
|
|
21
|
+
* entry paths mount-relative too — the resolver owns prefixing.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** What a path points at. */
|
|
25
|
+
export type VfsNodeKind = "dir" | "file";
|
|
26
|
+
|
|
27
|
+
/** Errno-style failure vocabulary — every operation fails with one of these. */
|
|
28
|
+
export type VfsErrorCode =
|
|
29
|
+
| "not-found"
|
|
30
|
+
| "invalid-path"
|
|
31
|
+
| "is-a-directory"
|
|
32
|
+
| "not-a-directory"
|
|
33
|
+
| "not-writable"
|
|
34
|
+
| "too-large"
|
|
35
|
+
| "binary-file"
|
|
36
|
+
| "io-error";
|
|
37
|
+
|
|
38
|
+
export type VfsResult<T> =
|
|
39
|
+
| { readonly ok: true; readonly value: T }
|
|
40
|
+
| {
|
|
41
|
+
readonly ok: false;
|
|
42
|
+
readonly error: VfsErrorCode;
|
|
43
|
+
readonly detail?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function vfsOk<T>(value: T): VfsResult<T> {
|
|
47
|
+
return { ok: true, value };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function vfsError<T>(
|
|
51
|
+
error: VfsErrorCode,
|
|
52
|
+
detail?: string,
|
|
53
|
+
): VfsResult<T> {
|
|
54
|
+
return { ok: false, error, ...(detail !== undefined ? { detail } : {}) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One node's metadata — also the shape of directory listing entries. */
|
|
58
|
+
export interface VfsStat {
|
|
59
|
+
/** Root-relative path (`skills/review-pr/SKILL.md`); "" is the root. */
|
|
60
|
+
readonly path: string;
|
|
61
|
+
/** Last path segment; the mount name for a mount root. */
|
|
62
|
+
readonly name: string;
|
|
63
|
+
readonly kind: VfsNodeKind;
|
|
64
|
+
/** Content size in bytes, when the mount can know it cheaply. */
|
|
65
|
+
readonly size?: number;
|
|
66
|
+
/** Last modification, epoch ms, when known. */
|
|
67
|
+
readonly modifiedAt?: number;
|
|
68
|
+
/** Whether write() can target this node (or nodes under this dir). */
|
|
69
|
+
readonly writable: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Where this node lives on disk, for file-backed mounts. Absent on
|
|
72
|
+
* synthetic nodes — they have no disk representation. This is what lets
|
|
73
|
+
* OS-level tools (shell, editors) reach a node the namespace named.
|
|
74
|
+
*/
|
|
75
|
+
readonly osPath?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One mounted store. Implementations receive mount-relative paths that the
|
|
80
|
+
* resolver has already normalized (no "..", no backslashes, no empty
|
|
81
|
+
* segments) and return stats with mount-relative `path`s.
|
|
82
|
+
*/
|
|
83
|
+
export interface VfsMount {
|
|
84
|
+
/** One line shown when listing the namespace root. */
|
|
85
|
+
readonly description: string;
|
|
86
|
+
/** Can write() ever succeed here? Synthetic views say false. */
|
|
87
|
+
readonly writable: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Absolute disk root, for file-backed mounts. The single fact that makes
|
|
90
|
+
* addresses bidirectional: the resolver routes OS-absolute addresses into
|
|
91
|
+
* the mount by containment, and `Vfs.locate` maps namespace addresses
|
|
92
|
+
* back to disk. Synthetic mounts omit it.
|
|
93
|
+
*/
|
|
94
|
+
readonly osRoot?: string;
|
|
95
|
+
stat(rel: string): VfsResult<VfsStat>;
|
|
96
|
+
list(rel: string): VfsResult<VfsStat[]>;
|
|
97
|
+
read(rel: string): VfsResult<string>;
|
|
98
|
+
/** Optional — absent means the whole mount is read-only. */
|
|
99
|
+
write?(rel: string, content: string): VfsResult<VfsStat>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Read cap — a namespace read is a context payload, not a file transfer. */
|
|
103
|
+
export const VFS_MAX_READ_BYTES = 256 * 1024;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VFS resolver — the mount table and the address grammar.
|
|
3
|
+
*
|
|
4
|
+
* The resolver owns everything mounts shouldn't have to repeat: address
|
|
5
|
+
* parsing, normalization, traversal rejection, routing to the mount, and
|
|
6
|
+
* re-prefixing the mount-relative stats that come back. Mounts therefore
|
|
7
|
+
* only ever see clean relative paths.
|
|
8
|
+
*
|
|
9
|
+
* An address is one of three spellings of the same node:
|
|
10
|
+
*
|
|
11
|
+
* talon://home/notes.md scheme form — always namespace-interpreted
|
|
12
|
+
* home/notes.md mount-relative form
|
|
13
|
+
* <workspace>/notes.md OS-absolute form — routed through the mount
|
|
14
|
+
* table by containment, exactly like a kernel
|
|
15
|
+
* resolving a path through its mounts
|
|
16
|
+
*
|
|
17
|
+
* The grammar is total: an unschemed address starting with a separator or
|
|
18
|
+
* drive prefix is always an OS path, never mount-relative — so an OS path
|
|
19
|
+
* can't silently misroute into a like-named mount, and a namespace path
|
|
20
|
+
* can't be mistaken for disk.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
24
|
+
import type { VfsMount, VfsResult, VfsStat } from "./types.js";
|
|
25
|
+
import { vfsError, vfsOk } from "./types.js";
|
|
26
|
+
|
|
27
|
+
const SCHEME = "talon://";
|
|
28
|
+
|
|
29
|
+
/** OS-absolute spellings: `/x`, `\\server\x`, `C:\x`, `C:/x`. */
|
|
30
|
+
const OS_ABSOLUTE = /^(?:[\\/]|[A-Za-z]:[\\/])/;
|
|
31
|
+
|
|
32
|
+
/** A parsed path: which mount, and the path inside it. */
|
|
33
|
+
type Resolved = { mount: VfsMount; prefix: string; rel: string };
|
|
34
|
+
|
|
35
|
+
export class Vfs {
|
|
36
|
+
readonly #mounts = new Map<string, VfsMount>();
|
|
37
|
+
|
|
38
|
+
/** Register a mount under a single-segment name ("skills", "proc"). */
|
|
39
|
+
mount(name: string, mount: VfsMount): void {
|
|
40
|
+
if (!/^[a-z0-9-]+$/.test(name)) {
|
|
41
|
+
throw new Error(`Invalid mount name: "${name}"`);
|
|
42
|
+
}
|
|
43
|
+
if (this.#mounts.has(name)) {
|
|
44
|
+
throw new Error(`Mount "${name}" already registered`);
|
|
45
|
+
}
|
|
46
|
+
this.#mounts.set(name, mount);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
stat(path: string): VfsResult<VfsStat> {
|
|
50
|
+
const parsed = this.#parse(path);
|
|
51
|
+
if (!parsed.ok) return parsed;
|
|
52
|
+
if (parsed.value === null) {
|
|
53
|
+
return vfsOk({
|
|
54
|
+
path: "",
|
|
55
|
+
name: SCHEME,
|
|
56
|
+
kind: "dir" as const,
|
|
57
|
+
writable: false,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const { mount, prefix, rel } = parsed.value;
|
|
61
|
+
const result = mount.stat(rel);
|
|
62
|
+
return result.ok ? vfsOk(withPrefix(result.value, prefix)) : result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
list(path: string): VfsResult<VfsStat[]> {
|
|
66
|
+
const parsed = this.#parse(path);
|
|
67
|
+
if (!parsed.ok) return parsed;
|
|
68
|
+
if (parsed.value === null) {
|
|
69
|
+
return vfsOk(
|
|
70
|
+
[...this.#mounts.entries()].map(([name, mount]) => ({
|
|
71
|
+
path: name,
|
|
72
|
+
name,
|
|
73
|
+
kind: "dir" as const,
|
|
74
|
+
writable: mount.writable,
|
|
75
|
+
...(mount.osRoot !== undefined ? { osPath: mount.osRoot } : {}),
|
|
76
|
+
})),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const { mount, prefix, rel } = parsed.value;
|
|
80
|
+
const result = mount.list(rel);
|
|
81
|
+
return result.ok
|
|
82
|
+
? vfsOk(result.value.map((entry) => withPrefix(entry, prefix)))
|
|
83
|
+
: result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
read(path: string): VfsResult<string> {
|
|
87
|
+
const parsed = this.#parse(path);
|
|
88
|
+
if (!parsed.ok) return parsed;
|
|
89
|
+
if (parsed.value === null) return vfsError("is-a-directory");
|
|
90
|
+
return parsed.value.mount.read(parsed.value.rel);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
write(path: string, content: string): VfsResult<VfsStat> {
|
|
94
|
+
const parsed = this.#parse(path);
|
|
95
|
+
if (!parsed.ok) return parsed;
|
|
96
|
+
if (parsed.value === null) return vfsError("is-a-directory");
|
|
97
|
+
const { mount, prefix, rel } = parsed.value;
|
|
98
|
+
if (!mount.write) {
|
|
99
|
+
return vfsError("not-writable", `talon://${prefix} is read-only`);
|
|
100
|
+
}
|
|
101
|
+
const result = mount.write(rel, content);
|
|
102
|
+
return result.ok ? vfsOk(withPrefix(result.value, prefix)) : result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Where an address lives on disk: the absolute path for nodes of
|
|
107
|
+
* file-backed mounts (whether or not the node exists yet), `undefined`
|
|
108
|
+
* for addresses with no disk representation (the root, synthetic
|
|
109
|
+
* mounts). This is the namespace→OS direction of the address grammar —
|
|
110
|
+
* OS-level tools call it to run real file operations on a talon://
|
|
111
|
+
* address.
|
|
112
|
+
*/
|
|
113
|
+
locate(path: string): VfsResult<string | undefined> {
|
|
114
|
+
const parsed = this.#parse(path);
|
|
115
|
+
if (!parsed.ok) return parsed;
|
|
116
|
+
if (parsed.value === null) return vfsOk(undefined);
|
|
117
|
+
const { mount, rel } = parsed.value;
|
|
118
|
+
if (mount.osRoot === undefined) return vfsOk(undefined);
|
|
119
|
+
return vfsOk(
|
|
120
|
+
rel === "" ? mount.osRoot : resolve(mount.osRoot, ...rel.split("/")),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Mount names + descriptions, for docs/tool output. */
|
|
125
|
+
describeMounts(): { name: string; description: string; writable: boolean }[] {
|
|
126
|
+
return [...this.#mounts.entries()].map(([name, mount]) => ({
|
|
127
|
+
name,
|
|
128
|
+
description: mount.description,
|
|
129
|
+
writable: mount.writable,
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** null = the namespace root. */
|
|
134
|
+
#parse(raw: string): VfsResult<Resolved | null> {
|
|
135
|
+
let path = raw.trim();
|
|
136
|
+
const schemed = path.startsWith(SCHEME);
|
|
137
|
+
if (schemed) {
|
|
138
|
+
path = path.slice(SCHEME.length);
|
|
139
|
+
} else {
|
|
140
|
+
// Bare separators address the namespace root ("talon ls /").
|
|
141
|
+
if (/^[\\/]+$/.test(path)) return vfsOk(null);
|
|
142
|
+
if (OS_ABSOLUTE.test(path)) return this.#parseOsPath(path);
|
|
143
|
+
if (path.startsWith("~")) {
|
|
144
|
+
return vfsError(
|
|
145
|
+
"invalid-path",
|
|
146
|
+
'"~" is not expanded — use an absolute path or a talon:// address',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (path.includes("\\")) {
|
|
151
|
+
return vfsError(
|
|
152
|
+
"invalid-path",
|
|
153
|
+
"Namespace paths use / on every platform",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const segments = path.split("/").filter((s) => s.length > 0);
|
|
157
|
+
if (segments.some((s) => s === "." || s === "..")) {
|
|
158
|
+
return vfsError("invalid-path", "Relative segments are not allowed");
|
|
159
|
+
}
|
|
160
|
+
if (segments.length === 0) return vfsOk(null);
|
|
161
|
+
|
|
162
|
+
const [head, ...rest] = segments;
|
|
163
|
+
const mount = this.#mounts.get(head!);
|
|
164
|
+
if (!mount) {
|
|
165
|
+
return vfsError(
|
|
166
|
+
"not-found",
|
|
167
|
+
`No mount "${head}" — the root lists what exists`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return vfsOk({ mount, prefix: head!, rel: rest.join("/") });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Route an OS-absolute address through the mount table by containment —
|
|
175
|
+
* the most specific (longest) disk root wins, so a mount nested inside
|
|
176
|
+
* another's root (skills/ inside the workspace) claims its own subtree.
|
|
177
|
+
* Outside every mount there is nothing to address: refuse with the mount
|
|
178
|
+
* table rather than guess.
|
|
179
|
+
*/
|
|
180
|
+
#parseOsPath(osPath: string): VfsResult<Resolved> {
|
|
181
|
+
// isAbsolute guards the host boundary: a foreign spelling (a drive path
|
|
182
|
+
// on POSIX) can't be resolved against this host's roots.
|
|
183
|
+
if (isAbsolute(osPath)) {
|
|
184
|
+
const abs = resolve(osPath);
|
|
185
|
+
let best: (Resolved & { rootLength: number }) | undefined;
|
|
186
|
+
for (const [name, mount] of this.#mounts) {
|
|
187
|
+
if (mount.osRoot === undefined) continue;
|
|
188
|
+
const offset = relative(mount.osRoot, abs);
|
|
189
|
+
if (
|
|
190
|
+
offset === ".." ||
|
|
191
|
+
offset.startsWith(`..${sep}`) ||
|
|
192
|
+
isAbsolute(offset)
|
|
193
|
+
) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (best !== undefined && mount.osRoot.length <= best.rootLength) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
best = {
|
|
200
|
+
mount,
|
|
201
|
+
prefix: name,
|
|
202
|
+
rel: offset
|
|
203
|
+
.split(sep)
|
|
204
|
+
.filter((s) => s.length > 0)
|
|
205
|
+
.join("/"),
|
|
206
|
+
rootLength: mount.osRoot.length,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (best !== undefined) {
|
|
210
|
+
return vfsOk({ mount: best.mount, prefix: best.prefix, rel: best.rel });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const respelled = osPath
|
|
214
|
+
.split(/[\\/]+/)
|
|
215
|
+
.filter((s) => s.length > 0 && !/^[A-Za-z]:$/.test(s));
|
|
216
|
+
if (respelled[0] !== undefined && this.#mounts.has(respelled[0])) {
|
|
217
|
+
return vfsError(
|
|
218
|
+
"not-found",
|
|
219
|
+
`OS paths resolve only inside a mounted directory — did you mean "talon://${respelled.join("/")}"?`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const roots = [...this.#mounts.entries()]
|
|
223
|
+
.filter(([, mount]) => mount.osRoot !== undefined)
|
|
224
|
+
.map(([name, mount]) => `${name} → ${mount.osRoot}`);
|
|
225
|
+
return vfsError(
|
|
226
|
+
"not-found",
|
|
227
|
+
roots.length > 0
|
|
228
|
+
? `Not inside any mounted directory. Mounts on disk: ${roots.join(", ")}`
|
|
229
|
+
: "Not inside any mounted directory",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function withPrefix(stat: VfsStat, prefix: string): VfsStat {
|
|
235
|
+
const path = stat.path === "" ? prefix : `${prefix}/${stat.path}`;
|
|
236
|
+
return { ...stat, path, name: stat.path === "" ? prefix : stat.name };
|
|
237
|
+
}
|
|
@@ -73,14 +73,32 @@ export class Weaver {
|
|
|
73
73
|
|
|
74
74
|
runTurn(params: ExecuteParams): Promise<ExecuteResult> {
|
|
75
75
|
const thread = this.loom.thread(params.chatId);
|
|
76
|
+
// A turn is killable when its backend can interrupt an in-flight
|
|
77
|
+
// chat turn. The abort hook tracks lifecycle through this closure:
|
|
78
|
+
// before the turn starts, a kill just marks it (run() then refuses to
|
|
79
|
+
// start); once running, the kill signals the backend — which stops
|
|
80
|
+
// the turn cleanly per the interruptChatTurn contract. The started
|
|
81
|
+
// guard matters in a queue: killing a queued turn must never
|
|
82
|
+
// interrupt the same chat's currently running one.
|
|
83
|
+
const chat = this.deps.getBackend(params.chatId).chat;
|
|
84
|
+
const interrupt = chat?.interruptChatTurn?.bind(chat);
|
|
85
|
+
const lifecycle = { started: false, killed: false };
|
|
76
86
|
// Registered before enqueueing so a turn waiting in its chat's FIFO is
|
|
77
87
|
// visible as `queued` in the task table, not invisible until it runs.
|
|
78
88
|
const task = taskTable.enqueue({
|
|
79
89
|
kind: "turn",
|
|
80
90
|
label: params.source,
|
|
81
91
|
chatId: params.chatId,
|
|
92
|
+
...(interrupt
|
|
93
|
+
? {
|
|
94
|
+
abort: () => {
|
|
95
|
+
lifecycle.killed = true;
|
|
96
|
+
if (lifecycle.started) void interrupt(params.chatId);
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
: {}),
|
|
82
100
|
});
|
|
83
|
-
return thread.enqueue(() => this.run(thread, params, task));
|
|
101
|
+
return thread.enqueue(() => this.run(thread, params, task, lifecycle));
|
|
84
102
|
}
|
|
85
103
|
|
|
86
104
|
/** Number of turns currently running (not queued) across all chats. */
|
|
@@ -101,17 +119,36 @@ export class Weaver {
|
|
|
101
119
|
thread: Thread,
|
|
102
120
|
params: ExecuteParams,
|
|
103
121
|
task: TaskHandle,
|
|
122
|
+
lifecycle: { started: boolean; killed: boolean },
|
|
104
123
|
): Promise<ExecuteResult> {
|
|
124
|
+
if (lifecycle.killed) {
|
|
125
|
+
// Killed while queued — the turn never reaches the backend. The
|
|
126
|
+
// caller still gets a resolved (empty) result; nothing is delivered
|
|
127
|
+
// to the chat, which is the point of the kill.
|
|
128
|
+
task.fail(new Error("killed while queued"));
|
|
129
|
+
return this.emptyResult("Turn killed before it started.", params);
|
|
130
|
+
}
|
|
131
|
+
lifecycle.started = true;
|
|
105
132
|
this.activeCount++;
|
|
106
133
|
task.start();
|
|
107
134
|
try {
|
|
108
135
|
const result = await this.executeInner(thread, params, task);
|
|
109
|
-
|
|
136
|
+
const usage = {
|
|
110
137
|
inputTokens: result.inputTokens,
|
|
111
138
|
outputTokens: result.outputTokens,
|
|
112
139
|
cacheRead: result.cacheRead,
|
|
113
140
|
cacheWrite: result.cacheWrite,
|
|
114
|
-
}
|
|
141
|
+
};
|
|
142
|
+
if (lifecycle.killed) {
|
|
143
|
+
// The interrupt landed and the backend closed the turn early but
|
|
144
|
+
// cleanly — that IS the interrupt contract, so the stream ends as
|
|
145
|
+
// a completion, not an error. The task still settles as killed
|
|
146
|
+
// (the truthful outcome of `talon kill`), usage included; the
|
|
147
|
+
// partial result flows back to the caller for normal handling.
|
|
148
|
+
task.fail(new Error("interrupted by kill"), usage);
|
|
149
|
+
} else {
|
|
150
|
+
task.succeed(usage);
|
|
151
|
+
}
|
|
115
152
|
return result;
|
|
116
153
|
} catch (err) {
|
|
117
154
|
task.fail(err);
|
|
@@ -9,6 +9,8 @@ export type BridgeDiscoveryInfo = {
|
|
|
9
9
|
port: number;
|
|
10
10
|
token?: string;
|
|
11
11
|
scheme?: string;
|
|
12
|
+
/** Certificate SHA-256 (hex) when the bridge serves HTTPS. */
|
|
13
|
+
fingerprint?: string;
|
|
12
14
|
startedAt?: number;
|
|
13
15
|
};
|
|
14
16
|
|
|
@@ -24,6 +26,7 @@ export async function writeBridgeDiscovery(
|
|
|
24
26
|
port: info.port,
|
|
25
27
|
token: info.token ?? null,
|
|
26
28
|
scheme: info.scheme ?? "http",
|
|
29
|
+
fingerprint: info.fingerprint ?? null,
|
|
27
30
|
pid: process.pid,
|
|
28
31
|
protocol: BRIDGE_PROTOCOL_VERSION,
|
|
29
32
|
startedAt: info.startedAt ?? now,
|
|
@@ -68,6 +68,7 @@ import { BridgeServer, type BridgeServerHandlers } from "./server.js";
|
|
|
68
68
|
import { createNativeActionHandler } from "./actions.js";
|
|
69
69
|
import { getMeshService } from "../../core/mesh/index.js";
|
|
70
70
|
import { removeBridgeDiscovery, writeBridgeDiscovery } from "./discovery.js";
|
|
71
|
+
import { isLoopbackHost, loadOrCreateBridgeTlsIdentity } from "./tls.js";
|
|
71
72
|
import { readLogEntries } from "./logs.js";
|
|
72
73
|
import {
|
|
73
74
|
BRIDGE_PROTOCOL_VERSION,
|
|
@@ -1183,12 +1184,17 @@ export function createNativeFrontend(
|
|
|
1183
1184
|
};
|
|
1184
1185
|
|
|
1185
1186
|
const nativeCfg = config.native ?? { port: 19880, host: "127.0.0.1" };
|
|
1187
|
+
const bridgeHost = nativeCfg.host ?? "127.0.0.1";
|
|
1188
|
+
// Encrypted by default the moment the bridge leaves the machine; loopback
|
|
1189
|
+
// stays plain HTTP unless explicitly opted in (`native.tls`).
|
|
1190
|
+
const bridgeTls = nativeCfg.tls ?? !isLoopbackHost(bridgeHost);
|
|
1186
1191
|
const server = new BridgeServer(
|
|
1187
1192
|
{
|
|
1188
|
-
host:
|
|
1193
|
+
host: bridgeHost,
|
|
1189
1194
|
port: nativeCfg.port ?? 19880,
|
|
1190
1195
|
token: nativeCfg.token,
|
|
1191
1196
|
startedAt,
|
|
1197
|
+
...(bridgeTls ? { tls: () => loadOrCreateBridgeTlsIdentity() } : {}),
|
|
1192
1198
|
},
|
|
1193
1199
|
handlers,
|
|
1194
1200
|
);
|
|
@@ -1253,9 +1259,12 @@ export function createNativeFrontend(
|
|
|
1253
1259
|
log("native", `Gateway on :${gatewayPort}`);
|
|
1254
1260
|
chats.restore();
|
|
1255
1261
|
await server.start();
|
|
1262
|
+
const fingerprint = server.getFingerprint();
|
|
1256
1263
|
await writeBridgeDiscovery({
|
|
1257
1264
|
port: server.getPort(),
|
|
1258
1265
|
token: nativeCfg.token,
|
|
1266
|
+
scheme: server.getScheme(),
|
|
1267
|
+
...(fingerprint ? { fingerprint } : {}),
|
|
1259
1268
|
startedAt: Date.parse(startedAt),
|
|
1260
1269
|
});
|
|
1261
1270
|
},
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* curl one-liner identically.
|
|
10
10
|
*
|
|
11
11
|
* Binds `host` (loopback by default) with the gateway's EADDRINUSE +1..+5
|
|
12
|
-
* fallback so two daemons on one machine don't collide.
|
|
12
|
+
* fallback so two daemons on one machine don't collide. With a TLS identity
|
|
13
|
+
* injected (see tls.ts) the same server speaks HTTPS instead — clients pin
|
|
14
|
+
* the certificate fingerprint surfaced on `/health`.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import {
|
|
@@ -18,10 +20,13 @@ import {
|
|
|
18
20
|
type Server,
|
|
19
21
|
type ServerResponse,
|
|
20
22
|
} from "node:http";
|
|
23
|
+
import { createServer as createTlsServer } from "node:https";
|
|
24
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
21
25
|
import { createReadStream } from "node:fs";
|
|
22
26
|
import { stat } from "node:fs/promises";
|
|
23
27
|
import { extname } from "node:path";
|
|
24
28
|
import { log, logError, logDebug } from "../../util/log.js";
|
|
29
|
+
import { formatFingerprint, type BridgeTlsIdentity } from "./tls.js";
|
|
25
30
|
import {
|
|
26
31
|
BRIDGE_PROTOCOL_VERSION,
|
|
27
32
|
isLogLevel,
|
|
@@ -138,6 +143,7 @@ export class BridgeServer {
|
|
|
138
143
|
private clients = new Set<ServerResponse>();
|
|
139
144
|
private pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
140
145
|
private port = 0;
|
|
146
|
+
private tlsIdentity: BridgeTlsIdentity | null = null;
|
|
141
147
|
|
|
142
148
|
constructor(
|
|
143
149
|
private readonly opts: {
|
|
@@ -145,6 +151,12 @@ export class BridgeServer {
|
|
|
145
151
|
port: number;
|
|
146
152
|
token?: string;
|
|
147
153
|
startedAt: string;
|
|
154
|
+
/**
|
|
155
|
+
* When present, the bridge serves HTTPS with this identity. A provider
|
|
156
|
+
* (not the identity itself) so the transport stays free of key-file
|
|
157
|
+
* I/O — it resolves once, inside `start()`.
|
|
158
|
+
*/
|
|
159
|
+
tls?: () => Promise<BridgeTlsIdentity>;
|
|
148
160
|
},
|
|
149
161
|
private readonly handlers: BridgeServerHandlers,
|
|
150
162
|
) {}
|
|
@@ -153,6 +165,16 @@ export class BridgeServer {
|
|
|
153
165
|
return this.port;
|
|
154
166
|
}
|
|
155
167
|
|
|
168
|
+
/** "https" once started with a TLS identity, else "http". */
|
|
169
|
+
getScheme(): "http" | "https" {
|
|
170
|
+
return this.tlsIdentity ? "https" : "http";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The served certificate's SHA-256 fingerprint (hex), or null over HTTP. */
|
|
174
|
+
getFingerprint(): string | null {
|
|
175
|
+
return this.tlsIdentity?.fingerprint ?? null;
|
|
176
|
+
}
|
|
177
|
+
|
|
156
178
|
/** Push an event to every connected SSE client. */
|
|
157
179
|
broadcast(event: BridgeEvent): void {
|
|
158
180
|
if (this.clients.size === 0) return;
|
|
@@ -168,7 +190,8 @@ export class BridgeServer {
|
|
|
168
190
|
|
|
169
191
|
async start(): Promise<number> {
|
|
170
192
|
if (this.server) return this.port;
|
|
171
|
-
|
|
193
|
+
this.tlsIdentity = this.opts.tls ? await this.opts.tls() : null;
|
|
194
|
+
const onRequest = (req: IncomingMessage, res: ServerResponse): void => {
|
|
172
195
|
this.handle(req, res).catch((err) => {
|
|
173
196
|
logError("native", "Bridge request handler threw", err);
|
|
174
197
|
if (!res.headersSent) {
|
|
@@ -176,7 +199,15 @@ export class BridgeServer {
|
|
|
176
199
|
res.end(JSON.stringify({ ok: false, error: "Internal error" }));
|
|
177
200
|
}
|
|
178
201
|
});
|
|
179
|
-
}
|
|
202
|
+
};
|
|
203
|
+
// https.Server extends http.Server's request/lifecycle surface — one
|
|
204
|
+
// `Server`-typed field serves both transports.
|
|
205
|
+
const server: Server = this.tlsIdentity
|
|
206
|
+
? createTlsServer(
|
|
207
|
+
{ key: this.tlsIdentity.keyPem, cert: this.tlsIdentity.certPem },
|
|
208
|
+
onRequest,
|
|
209
|
+
)
|
|
210
|
+
: createServer(onRequest);
|
|
180
211
|
|
|
181
212
|
this.pingTimer = setInterval(() => {
|
|
182
213
|
for (const res of this.clients) {
|
|
@@ -215,9 +246,17 @@ export class BridgeServer {
|
|
|
215
246
|
);
|
|
216
247
|
log(
|
|
217
248
|
"native",
|
|
218
|
-
`Bridge listening on ${this.opts.host}:${this.port}` +
|
|
249
|
+
`Bridge listening on ${this.getScheme()}://${this.opts.host}:${this.port}` +
|
|
219
250
|
(this.opts.token ? " (token required)" : ""),
|
|
220
251
|
);
|
|
252
|
+
if (this.tlsIdentity) {
|
|
253
|
+
// The pairing datum: clients confirm this fingerprint on first
|
|
254
|
+
// connect, so it belongs in the log where the operator looks.
|
|
255
|
+
log(
|
|
256
|
+
"native",
|
|
257
|
+
`Bridge certificate fingerprint ${formatFingerprint(this.tlsIdentity.fingerprint)}`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
221
260
|
resolve(this.port);
|
|
222
261
|
});
|
|
223
262
|
};
|
|
@@ -271,6 +310,10 @@ export class BridgeServer {
|
|
|
271
310
|
protocol: BRIDGE_PROTOCOL_VERSION,
|
|
272
311
|
host: this.opts.host,
|
|
273
312
|
port: this.port,
|
|
313
|
+
scheme: this.getScheme(),
|
|
314
|
+
// The certificate's own hash — public by definition (any TLS client
|
|
315
|
+
// sees the certificate), surfaced so pairing UIs can display it.
|
|
316
|
+
fingerprint: this.getFingerprint(),
|
|
274
317
|
authRequired: Boolean(this.opts.token),
|
|
275
318
|
startedAt: this.opts.startedAt,
|
|
276
319
|
botName: s.botName,
|
|
@@ -600,10 +643,26 @@ export class BridgeServer {
|
|
|
600
643
|
private authOk(req: IncomingMessage, url: URL): boolean {
|
|
601
644
|
if (!this.opts.token) return true;
|
|
602
645
|
const header = req.headers["authorization"];
|
|
603
|
-
if (
|
|
646
|
+
if (
|
|
647
|
+
typeof header === "string" &&
|
|
648
|
+
header.startsWith("Bearer ") &&
|
|
649
|
+
this.tokenMatches(header.slice("Bearer ".length))
|
|
650
|
+
)
|
|
604
651
|
return true;
|
|
605
652
|
// EventSource can't set headers, so SSE clients pass ?token=… instead.
|
|
606
|
-
return url.searchParams.get("token")
|
|
653
|
+
return this.tokenMatches(url.searchParams.get("token"));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Constant-time token comparison. Hashing both sides first equalizes
|
|
658
|
+
* lengths (timingSafeEqual demands it) without leaking the real length.
|
|
659
|
+
*/
|
|
660
|
+
private tokenMatches(candidate: string | null): boolean {
|
|
661
|
+
if (candidate === null || !this.opts.token) return false;
|
|
662
|
+
return timingSafeEqual(
|
|
663
|
+
createHash("sha256").update(candidate).digest(),
|
|
664
|
+
createHash("sha256").update(this.opts.token).digest(),
|
|
665
|
+
);
|
|
607
666
|
}
|
|
608
667
|
|
|
609
668
|
private corsHeaders(): Record<string, string> {
|