talon-agent 5.23.0 → 5.24.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 +8 -2
- package/package.json +1 -1
- package/src/cli/install-sources.ts +187 -35
- package/src/cli/plugin.ts +27 -11
- package/src/cli/skill.ts +60 -7
- package/src/core/config/index.ts +6 -4
- package/src/core/mesh/credentials/index.ts +1 -1
- package/src/frontend/native/bridge/auth-guard.ts +117 -53
- package/src/frontend/native/bridge/credentials/principal.ts +13 -1
- package/src/frontend/native/bridge/routes/table.ts +13 -0
- package/src/frontend/native/bridge/server.ts +37 -11
- package/src/storage/skills.ts +7 -1
package/README.md
CHANGED
|
@@ -88,7 +88,9 @@ xattr -d com.apple.quarantine ./talon-darwin-arm64
|
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
Verify a direct download against the release `SHA256SUMS`:
|
|
91
|
-
`sha256sum -c SHA256SUMS --ignore-missing
|
|
91
|
+
`sha256sum -c SHA256SUMS --ignore-missing`, and its build provenance with
|
|
92
|
+
`gh attestation verify talon-linux-x64 --repo thefalconry/talon` (see
|
|
93
|
+
[SECURITY.md](SECURITY.md#verifying-a-release)).
|
|
92
94
|
|
|
93
95
|
**Server only, no Telegram?** Run the daemon with just the client bridge,
|
|
94
96
|
reached by the companion app and talon-node: see
|
|
@@ -272,6 +274,7 @@ daemon (plugins) or apply on the next session (skills):
|
|
|
272
274
|
talon plugin install @scope/my-talon-plugin # npm → module plugin
|
|
273
275
|
talon plugin install some-mcp-server --mcp # npm → standalone MCP server (npx)
|
|
274
276
|
talon plugin install owner/repo # git → module plugin
|
|
277
|
+
talon plugin install owner/repo#<sha> # …at that commit (or --commit <sha>)
|
|
275
278
|
talon plugin list # built-ins + configured entries
|
|
276
279
|
talon plugin disable github # also toggles built-ins
|
|
277
280
|
talon plugin remove my-talon-plugin
|
|
@@ -285,7 +288,10 @@ talon skill remove pdf
|
|
|
285
288
|
```
|
|
286
289
|
|
|
287
290
|
Module plugins install under `~/.talon/plugins/`; standalone MCP servers are
|
|
288
|
-
registered as `npx` entries in `config.json`.
|
|
291
|
+
registered as `npx` entries in `config.json`. A git source can be pinned to a
|
|
292
|
+
commit with `#<sha>` or `--commit <sha>` (7-64 hex digits): Talon checks that
|
|
293
|
+
commit out and verifies HEAD before installing. Either way the install
|
|
294
|
+
folder's `.talon-install.json` records the repo and the exact commit. Disabling keeps the entry (or a
|
|
289
295
|
`.disabled` marker in the skill folder) so enabling restores it unchanged.
|
|
290
296
|
|
|
291
297
|
## Built-in Plugins
|
package/package.json
CHANGED
|
@@ -10,11 +10,17 @@
|
|
|
10
10
|
* 4. anything else → { kind: "other" } — the caller
|
|
11
11
|
* decides (plugins treat it as an npm spec, skills reject it)
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* A git source may name a commit: `<source>#<sha>` (7-64 hex digits), or
|
|
14
|
+
* `--commit <sha>` on the command line (`withCommit`).
|
|
15
|
+
*
|
|
16
|
+
* Without a commit, cloning uses `--depth=1` (installs never need history).
|
|
17
|
+
* With one, it clones with `--filter=blob:none` (history, but only the
|
|
18
|
+
* blobs of the commit it checks out), checks the commit out and verifies
|
|
19
|
+
* HEAD is that commit. Either way the options end with `--` so a URL can
|
|
20
|
+
* never be read as a git flag, a URL starting with "-" is refused before
|
|
21
|
+
* git runs, and the commit it got is reported so the install can record
|
|
22
|
+
* it. It spawns `git`/`npm` via cross-spawn, which resolves the
|
|
23
|
+
* `.cmd`/`.exe` shims on Windows — never assume a POSIX shell here.
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
26
|
import crossSpawn from "cross-spawn";
|
|
@@ -22,40 +28,84 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
22
28
|
import { tmpdir } from "node:os";
|
|
23
29
|
import { join, resolve } from "node:path";
|
|
24
30
|
|
|
31
|
+
export type GitSource = {
|
|
32
|
+
kind: "git";
|
|
33
|
+
url: string;
|
|
34
|
+
subpath?: string;
|
|
35
|
+
/** Requested commit (lowercase hex, 7-64 digits); unset = default branch. */
|
|
36
|
+
commit?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
25
39
|
export type ResolvedSource =
|
|
26
|
-
|
|
27
|
-
| { kind: "git"; url: string; subpath?: string }
|
|
28
|
-
| { kind: "other"; raw: string };
|
|
40
|
+
{ kind: "local"; dir: string } | GitSource | { kind: "other"; raw: string };
|
|
29
41
|
|
|
30
42
|
const GIT_URL_RE = /^(https?|git|ssh):\/\//;
|
|
31
43
|
/** `owner/repo` or `owner/repo/sub/path` — never an npm scope (`@…`). */
|
|
32
44
|
const GITHUB_SHORTHAND_RE =
|
|
33
45
|
/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)((?:\/[^\s/]+)*)$/;
|
|
46
|
+
/** An abbreviated or full commit id (SHA-1 or SHA-256). */
|
|
47
|
+
const COMMIT_RE = /^[0-9a-f]{7,64}$/;
|
|
48
|
+
/** `<source>#<commit>` — only a hex fragment is a pin. */
|
|
49
|
+
const COMMIT_FRAGMENT_RE = /^(.+)#([0-9a-fA-F]{7,64})$/;
|
|
50
|
+
|
|
51
|
+
function gitSource(spec: string): GitSource | undefined {
|
|
52
|
+
if (
|
|
53
|
+
GIT_URL_RE.test(spec) ||
|
|
54
|
+
spec.startsWith("git@") ||
|
|
55
|
+
spec.endsWith(".git")
|
|
56
|
+
) {
|
|
57
|
+
return { kind: "git", url: spec };
|
|
58
|
+
}
|
|
59
|
+
if (spec.startsWith("@")) return undefined;
|
|
60
|
+
const match = GITHUB_SHORTHAND_RE.exec(spec);
|
|
61
|
+
if (!match) return undefined;
|
|
62
|
+
const [, owner, repo, rest] = match;
|
|
63
|
+
return {
|
|
64
|
+
kind: "git",
|
|
65
|
+
url: `https://github.com/${owner}/${repo}.git`,
|
|
66
|
+
...(rest ? { subpath: rest.slice(1) } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
34
69
|
|
|
35
70
|
export function resolveSource(raw: string): ResolvedSource {
|
|
36
71
|
const trimmed = raw.trim();
|
|
37
72
|
if (existsSync(resolve(trimmed))) {
|
|
38
73
|
return { kind: "local", dir: resolve(trimmed) };
|
|
39
74
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
75
|
+
const pinned = COMMIT_FRAGMENT_RE.exec(trimmed);
|
|
76
|
+
if (pinned) {
|
|
77
|
+
const git = gitSource(pinned[1]!);
|
|
78
|
+
if (git) return { ...git, commit: pinned[2]!.toLowerCase() };
|
|
79
|
+
}
|
|
80
|
+
return gitSource(trimmed) ?? { kind: "other", raw: trimmed };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Apply a `--commit <sha>` flag to a resolved source: only git sources
|
|
85
|
+
* take one, and it must agree with a `#<sha>` already in the source.
|
|
86
|
+
*/
|
|
87
|
+
export function withCommit(
|
|
88
|
+
source: ResolvedSource,
|
|
89
|
+
commit: string | undefined,
|
|
90
|
+
): { ok: true; source: ResolvedSource } | { ok: false; error: string } {
|
|
91
|
+
if (commit === undefined) return { ok: true, source };
|
|
92
|
+
const sha = commit.trim().toLowerCase();
|
|
93
|
+
if (!COMMIT_RE.test(sha)) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: `"${commit}" is not a commit id (7-64 hex digits)`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (source.kind !== "git") {
|
|
100
|
+
return { ok: false, error: "--commit only applies to git sources" };
|
|
46
101
|
}
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
kind: "git",
|
|
53
|
-
url: `https://github.com/${owner}/${repo}.git`,
|
|
54
|
-
...(rest ? { subpath: rest.slice(1) } : {}),
|
|
55
|
-
};
|
|
56
|
-
}
|
|
102
|
+
if (source.commit !== undefined && source.commit !== sha) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: `--commit ${sha} conflicts with #${source.commit} in the source`,
|
|
106
|
+
};
|
|
57
107
|
}
|
|
58
|
-
return {
|
|
108
|
+
return { ok: true, source: { ...source, commit: sha } };
|
|
59
109
|
}
|
|
60
110
|
|
|
61
111
|
export type CommandOutcome = { ok: true } | { ok: false; error: string };
|
|
@@ -105,16 +155,56 @@ function headCommit(dir: string): string | undefined {
|
|
|
105
155
|
return sha && /^[0-9a-f]{40,64}$/.test(sha) ? sha : undefined;
|
|
106
156
|
}
|
|
107
157
|
|
|
158
|
+
/** Whether the clone already has `commit` (no network). */
|
|
159
|
+
function hasCommit(dir: string, commit: string): boolean {
|
|
160
|
+
return (
|
|
161
|
+
crossSpawn.sync(
|
|
162
|
+
"git",
|
|
163
|
+
["-C", dir, "cat-file", "-e", `${commit}^{commit}`],
|
|
164
|
+
{
|
|
165
|
+
stdio: "ignore",
|
|
166
|
+
},
|
|
167
|
+
).status === 0
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `--` stops git's option parsing; refusing a leading dash too means a
|
|
173
|
+
* hostile "URL" never even reaches git.
|
|
174
|
+
*/
|
|
175
|
+
function dashRefusal(url: string): CloneOutcome | undefined {
|
|
176
|
+
return url.startsWith("-")
|
|
177
|
+
? { ok: false, error: `Refusing a git URL that starts with "-"` }
|
|
178
|
+
: undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check out bytes exactly as committed. Windows git defaults to
|
|
183
|
+
* core.autocrlf=true, which rewrites LF to CRLF on checkout — a pinned
|
|
184
|
+
* install would then differ from the commit it names, and CRLF frontmatter
|
|
185
|
+
* fails to parse as a skill. `clone -c` writes this into the new repo's
|
|
186
|
+
* config, so the later `checkout` honours it too.
|
|
187
|
+
*/
|
|
188
|
+
const EXACT_BYTES = ["-c", "core.autocrlf=false"];
|
|
189
|
+
|
|
190
|
+
function tempCloneDir(): { dir: string; cleanup: () => void } {
|
|
191
|
+
const dir = mkdtempSync(join(tmpdir(), "talon-install-"));
|
|
192
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
193
|
+
}
|
|
194
|
+
|
|
108
195
|
/** Shallow-clone into a fresh temp directory. Caller must run `cleanup`. */
|
|
109
196
|
export function cloneShallow(url: string): CloneOutcome {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
197
|
+
const refused = dashRefusal(url);
|
|
198
|
+
if (refused) return refused;
|
|
199
|
+
const { dir, cleanup } = tempCloneDir();
|
|
200
|
+
const outcome = runTool("git", [
|
|
201
|
+
"clone",
|
|
202
|
+
...EXACT_BYTES,
|
|
203
|
+
"--depth=1",
|
|
204
|
+
"--",
|
|
205
|
+
url,
|
|
206
|
+
dir,
|
|
207
|
+
]);
|
|
118
208
|
if (!outcome.ok) {
|
|
119
209
|
cleanup();
|
|
120
210
|
return { ok: false, error: `Clone failed: ${outcome.error}` };
|
|
@@ -122,13 +212,75 @@ export function cloneShallow(url: string): CloneOutcome {
|
|
|
122
212
|
return { ok: true, dir, commit: headCommit(dir), cleanup };
|
|
123
213
|
}
|
|
124
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Clone and check out exactly `commit`, verifying HEAD is that commit.
|
|
217
|
+
* Caller must run `cleanup`. The clone keeps history (a commit can be
|
|
218
|
+
* anywhere in it) but fetches file contents only for the checked-out tree.
|
|
219
|
+
*/
|
|
220
|
+
export function cloneAtCommit(url: string, commit: string): CloneOutcome {
|
|
221
|
+
const refused = dashRefusal(url);
|
|
222
|
+
if (refused) return refused;
|
|
223
|
+
const { dir, cleanup } = tempCloneDir();
|
|
224
|
+
const fail = (error: string): CloneOutcome => {
|
|
225
|
+
cleanup();
|
|
226
|
+
return { ok: false, error };
|
|
227
|
+
};
|
|
228
|
+
const cloned = runTool("git", [
|
|
229
|
+
"clone",
|
|
230
|
+
...EXACT_BYTES,
|
|
231
|
+
"--filter=blob:none",
|
|
232
|
+
"--no-checkout",
|
|
233
|
+
"--",
|
|
234
|
+
url,
|
|
235
|
+
dir,
|
|
236
|
+
]);
|
|
237
|
+
if (!cloned.ok) return fail(`Clone failed: ${cloned.error}`);
|
|
238
|
+
if (!hasCommit(dir, commit) && commit.length >= 40) {
|
|
239
|
+
// A commit no branch reaches (a PR head, say): ask for it by id.
|
|
240
|
+
runTool("git", ["-C", dir, "fetch", "-q", "origin", commit]);
|
|
241
|
+
}
|
|
242
|
+
const checkout = runTool("git", [
|
|
243
|
+
"-C",
|
|
244
|
+
dir,
|
|
245
|
+
"checkout",
|
|
246
|
+
"-q",
|
|
247
|
+
"--detach",
|
|
248
|
+
commit,
|
|
249
|
+
"--",
|
|
250
|
+
]);
|
|
251
|
+
if (!checkout.ok) {
|
|
252
|
+
return fail(`Commit ${commit} not found in ${url}: ${checkout.error}`);
|
|
253
|
+
}
|
|
254
|
+
const head = headCommit(dir);
|
|
255
|
+
if (!head?.startsWith(commit)) {
|
|
256
|
+
return fail(`Checked out ${head ?? "nothing"}, not commit ${commit}`);
|
|
257
|
+
}
|
|
258
|
+
return { ok: true, dir, commit: head, cleanup };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Clone a git source: at its requested commit, else the default branch. */
|
|
262
|
+
export function cloneSource(source: GitSource): CloneOutcome {
|
|
263
|
+
return source.commit
|
|
264
|
+
? cloneAtCommit(source.url, source.commit)
|
|
265
|
+
: cloneShallow(source.url);
|
|
266
|
+
}
|
|
267
|
+
|
|
125
268
|
/** The file a git-installed plugin keeps its provenance in. */
|
|
126
269
|
const INSTALL_RECORD = ".talon-install.json";
|
|
127
270
|
|
|
128
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* Write where an install came from and exactly which commit it is.
|
|
273
|
+
* `pinned` marks a commit the user asked for, rather than whatever the
|
|
274
|
+
* default branch pointed at.
|
|
275
|
+
*/
|
|
129
276
|
export function writeInstallRecord(
|
|
130
277
|
dir: string,
|
|
131
|
-
record: {
|
|
278
|
+
record: {
|
|
279
|
+
source: string;
|
|
280
|
+
subpath?: string;
|
|
281
|
+
commit?: string;
|
|
282
|
+
pinned?: boolean;
|
|
283
|
+
},
|
|
132
284
|
): void {
|
|
133
285
|
writeFileSync(
|
|
134
286
|
join(dir, INSTALL_RECORD),
|
package/src/cli/plugin.ts
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* Install sources (see cli/install-sources.ts for the shared grammar):
|
|
13
13
|
* local path and git checkouts become module entries under ~/.talon/plugins;
|
|
14
14
|
* an npm spec installs there too, or registers an `npx` MCP entry with
|
|
15
|
-
* `--mcp`.
|
|
15
|
+
* `--mcp`. A cloned source installs at a given commit with `#<sha>` or
|
|
16
|
+
* `--commit <sha>`. Windows-safe throughout — tools are spawned via
|
|
17
|
+
* cross-spawn.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
import pc from "picocolors";
|
|
@@ -24,11 +26,12 @@ import { findRunningInstance } from "../core/daemon/discovery.js";
|
|
|
24
26
|
import { fetchGateway } from "./daemon-api.js";
|
|
25
27
|
import { loadConfig, saveConfig, type Config } from "./config.js";
|
|
26
28
|
import {
|
|
27
|
-
|
|
29
|
+
cloneSource,
|
|
28
30
|
writeInstallRecord,
|
|
29
31
|
resolveSource,
|
|
32
|
+
withCommit,
|
|
30
33
|
runTool,
|
|
31
|
-
type
|
|
34
|
+
type GitSource,
|
|
32
35
|
} from "./install-sources.js";
|
|
33
36
|
import {
|
|
34
37
|
BUILTIN_PLUGINS,
|
|
@@ -50,7 +53,8 @@ const USAGE = [
|
|
|
50
53
|
" Commands:",
|
|
51
54
|
` ${pc.cyan("list")} Show built-ins and configured plugins`,
|
|
52
55
|
` ${pc.cyan("install <source>")} Add a plugin (local path, git URL,`,
|
|
53
|
-
" owner/repo, or npm spec)",
|
|
56
|
+
" owner/repo, or npm spec); a git source",
|
|
57
|
+
" may end in #<commit>",
|
|
54
58
|
` ${pc.cyan("enable <name>")} Enable a plugin`,
|
|
55
59
|
` ${pc.cyan("disable <name>")} Disable a plugin (kept in config)`,
|
|
56
60
|
` ${pc.cyan("remove <name>")} Remove a plugin entry (and its install)`,
|
|
@@ -60,6 +64,7 @@ const USAGE = [
|
|
|
60
64
|
" MCP server (npx) instead of a module",
|
|
61
65
|
` ${pc.cyan("--name <name>")} Override the derived plugin name`,
|
|
62
66
|
` ${pc.cyan("--force")} Replace an existing install/entry`,
|
|
67
|
+
` ${pc.cyan("--commit <sha>")} Install a git source at this commit`,
|
|
63
68
|
"",
|
|
64
69
|
].join("\n");
|
|
65
70
|
|
|
@@ -184,7 +189,12 @@ function cmdList(): void {
|
|
|
184
189
|
|
|
185
190
|
// ── install ─────────────────────────────────────────────────────────────────
|
|
186
191
|
|
|
187
|
-
type InstallFlags = {
|
|
192
|
+
type InstallFlags = {
|
|
193
|
+
mcp: boolean;
|
|
194
|
+
force: boolean;
|
|
195
|
+
name?: string;
|
|
196
|
+
commit?: string;
|
|
197
|
+
};
|
|
188
198
|
|
|
189
199
|
function parseInstallArgs(
|
|
190
200
|
args: string[],
|
|
@@ -196,10 +206,12 @@ function parseInstallArgs(
|
|
|
196
206
|
if (arg === "--mcp") flags.mcp = true;
|
|
197
207
|
else if (arg === "--force") flags.force = true;
|
|
198
208
|
else if (arg === "--name") flags.name = args[++i];
|
|
209
|
+
else if (arg === "--commit") flags.commit = args[++i];
|
|
199
210
|
else if (!arg.startsWith("-") && source === undefined) source = arg;
|
|
200
211
|
else return null;
|
|
201
212
|
}
|
|
202
213
|
if (!source || (flags.name !== undefined && !flags.name)) return null;
|
|
214
|
+
if (flags.commit !== undefined && !flags.commit) return null;
|
|
203
215
|
return { source, flags };
|
|
204
216
|
}
|
|
205
217
|
|
|
@@ -229,10 +241,7 @@ function installFromLocalDir(dir: string): EntryOutcome {
|
|
|
229
241
|
* once the staged copy is known-good, so a failed install never destroys
|
|
230
242
|
* a working one.
|
|
231
243
|
*/
|
|
232
|
-
function installFromGit(
|
|
233
|
-
source: Extract<ResolvedSource, { kind: "git" }>,
|
|
234
|
-
flags: InstallFlags,
|
|
235
|
-
): EntryOutcome {
|
|
244
|
+
function installFromGit(source: GitSource, flags: InstallFlags): EntryOutcome {
|
|
236
245
|
const derived = source.subpath
|
|
237
246
|
? basename(source.subpath)
|
|
238
247
|
: basename(source.url, ".git");
|
|
@@ -245,7 +254,7 @@ function installFromGit(
|
|
|
245
254
|
};
|
|
246
255
|
}
|
|
247
256
|
|
|
248
|
-
const clone =
|
|
257
|
+
const clone = cloneSource(source);
|
|
249
258
|
if (!clone.ok) return { ok: false, error: clone.error };
|
|
250
259
|
try {
|
|
251
260
|
const stage = source.subpath
|
|
@@ -265,6 +274,7 @@ function installFromGit(
|
|
|
265
274
|
source: source.url,
|
|
266
275
|
...(source.subpath ? { subpath: source.subpath } : {}),
|
|
267
276
|
...(clone.commit ? { commit: clone.commit } : {}),
|
|
277
|
+
...(source.commit ? { pinned: true } : {}),
|
|
268
278
|
});
|
|
269
279
|
if (clone.commit) {
|
|
270
280
|
console.log(` ${pc.dim(`Commit ${clone.commit}`)}`);
|
|
@@ -338,7 +348,13 @@ async function cmdInstall(args: string[]): Promise<void> {
|
|
|
338
348
|
}
|
|
339
349
|
const { source, flags } = parsed;
|
|
340
350
|
|
|
341
|
-
const
|
|
351
|
+
const pinned = withCommit(resolveSource(source), flags.commit);
|
|
352
|
+
if (!pinned.ok) {
|
|
353
|
+
fail(pinned.error);
|
|
354
|
+
process.exitCode = 1;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const resolved = pinned.source;
|
|
342
358
|
let outcome: EntryOutcome;
|
|
343
359
|
switch (resolved.kind) {
|
|
344
360
|
case "local":
|
package/src/cli/skill.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import pc from "picocolors";
|
|
17
17
|
import { existsSync, readdirSync } from "node:fs";
|
|
18
|
-
import { resolve } from "node:path";
|
|
18
|
+
import { relative, resolve, sep } from "node:path";
|
|
19
19
|
import {
|
|
20
20
|
deleteSkill,
|
|
21
21
|
installSkillFromDir,
|
|
@@ -23,7 +23,13 @@ import {
|
|
|
23
23
|
setSkillEnabled,
|
|
24
24
|
type Skill,
|
|
25
25
|
} from "../storage/skills.js";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
cloneSource,
|
|
28
|
+
resolveSource,
|
|
29
|
+
withCommit,
|
|
30
|
+
writeInstallRecord,
|
|
31
|
+
type GitSource,
|
|
32
|
+
} from "./install-sources.js";
|
|
27
33
|
|
|
28
34
|
const USAGE = [
|
|
29
35
|
` Usage: ${pc.cyan("talon skill <command>")}`,
|
|
@@ -32,6 +38,7 @@ const USAGE = [
|
|
|
32
38
|
` ${pc.cyan("list")} Show installed skills`,
|
|
33
39
|
` ${pc.cyan("install <source> [--force]")} Add skills from a local folder,`,
|
|
34
40
|
" git URL, or owner/repo[/subpath]",
|
|
41
|
+
` ${pc.cyan(" [--commit <sha>]")} …at this commit (or <source>#<sha>)`,
|
|
35
42
|
` ${pc.cyan("enable <name>")} Restore a skill to the prompt index`,
|
|
36
43
|
` ${pc.cyan("disable <name>")} Hide a skill from the prompt index`,
|
|
37
44
|
` ${pc.cyan("remove <name>")} Delete a skill folder`,
|
|
@@ -119,16 +126,61 @@ function installAll(dirs: string[], force: boolean): Skill[] {
|
|
|
119
126
|
return installed;
|
|
120
127
|
}
|
|
121
128
|
|
|
129
|
+
type SkillInstallArgs = { source: string; force: boolean; commit?: string };
|
|
130
|
+
|
|
131
|
+
function parseInstallArgs(args: string[]): SkillInstallArgs | null {
|
|
132
|
+
let source: string | undefined;
|
|
133
|
+
let force = false;
|
|
134
|
+
let commit: string | undefined;
|
|
135
|
+
for (let i = 0; i < args.length; i++) {
|
|
136
|
+
const arg = args[i]!;
|
|
137
|
+
if (arg === "--force") force = true;
|
|
138
|
+
else if (arg === "--commit") commit = args[++i];
|
|
139
|
+
else if (!arg.startsWith("-") && source === undefined) source = arg;
|
|
140
|
+
else return null;
|
|
141
|
+
}
|
|
142
|
+
if (!source || (commit !== undefined && !commit)) return null;
|
|
143
|
+
return { source, force, ...(commit !== undefined ? { commit } : {}) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Record, in each skill folder of the clone, which repo and commit it came
|
|
148
|
+
* from — before it is copied into the store.
|
|
149
|
+
*/
|
|
150
|
+
function recordProvenance(
|
|
151
|
+
source: GitSource,
|
|
152
|
+
root: string,
|
|
153
|
+
dirs: string[],
|
|
154
|
+
commit: string | undefined,
|
|
155
|
+
): void {
|
|
156
|
+
for (const dir of dirs) {
|
|
157
|
+
const subpath = relative(root, dir).split(sep).join("/");
|
|
158
|
+
const inRepo = [source.subpath, subpath].filter(Boolean).join("/");
|
|
159
|
+
writeInstallRecord(dir, {
|
|
160
|
+
source: source.url,
|
|
161
|
+
...(inRepo ? { subpath: inRepo } : {}),
|
|
162
|
+
...(commit ? { commit } : {}),
|
|
163
|
+
...(source.commit ? { pinned: true } : {}),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
122
168
|
async function cmdInstall(args: string[]): Promise<void> {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
if (!source) {
|
|
169
|
+
const parsed = parseInstallArgs(args);
|
|
170
|
+
if (!parsed) {
|
|
126
171
|
console.log(USAGE);
|
|
127
172
|
process.exitCode = 1;
|
|
128
173
|
return;
|
|
129
174
|
}
|
|
175
|
+
const { source, force } = parsed;
|
|
130
176
|
|
|
131
|
-
const
|
|
177
|
+
const pinned = withCommit(resolveSource(source), parsed.commit);
|
|
178
|
+
if (!pinned.ok) {
|
|
179
|
+
fail(pinned.error);
|
|
180
|
+
process.exitCode = 1;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const resolved = pinned.source;
|
|
132
184
|
if (resolved.kind === "other") {
|
|
133
185
|
fail(
|
|
134
186
|
`"${source}" is not a folder, git URL, or owner/repo — skills install from SKILL.md folders.`,
|
|
@@ -147,7 +199,7 @@ async function cmdInstall(args: string[]): Promise<void> {
|
|
|
147
199
|
}
|
|
148
200
|
installed = installAll(dirs, force);
|
|
149
201
|
} else {
|
|
150
|
-
const clone =
|
|
202
|
+
const clone = cloneSource(resolved);
|
|
151
203
|
if (!clone.ok) {
|
|
152
204
|
fail(clone.error);
|
|
153
205
|
process.exitCode = 1;
|
|
@@ -170,6 +222,7 @@ async function cmdInstall(args: string[]): Promise<void> {
|
|
|
170
222
|
process.exitCode = 1;
|
|
171
223
|
return;
|
|
172
224
|
}
|
|
225
|
+
recordProvenance(resolved, root, dirs, clone.commit);
|
|
173
226
|
installed = installAll(dirs, force);
|
|
174
227
|
if (clone.commit) {
|
|
175
228
|
console.log(` ${pc.dim(`From ${resolved.url} @ ${clone.commit}`)}`);
|
package/src/core/config/index.ts
CHANGED
|
@@ -154,7 +154,8 @@ const frontendEnum = z.enum([
|
|
|
154
154
|
*
|
|
155
155
|
* Defaults are loopback-only and unauthenticated (single-machine use). To
|
|
156
156
|
* reach Talon remotely, set `host: "0.0.0.0"` and a `token` — the bridge
|
|
157
|
-
* then requires `Authorization: Bearer <token>` (or `?token=`
|
|
157
|
+
* then requires `Authorization: Bearer <token>` (or `?token=` on `GET /events`
|
|
158
|
+
* and `GET /media` only, where clients can't set a header).
|
|
158
159
|
* A non-loopback bind with no token auto-mints a persistent one
|
|
159
160
|
* (~/.talon/keys/bridge-token) rather than serving the LAN open.
|
|
160
161
|
*/
|
|
@@ -170,9 +171,10 @@ const nativeConfigSchema = z
|
|
|
170
171
|
host: z.string().default("127.0.0.1"),
|
|
171
172
|
/**
|
|
172
173
|
* Optional shared secret. When set, every request must present it as a
|
|
173
|
-
* bearer token (header) or `?token=` query param
|
|
174
|
-
* non-loopback `host`, the bridge
|
|
175
|
-
* (~/.talon/keys/bridge-token) —
|
|
174
|
+
* bearer token (header), or a `?token=` query param on `GET /events` and
|
|
175
|
+
* `GET /media` only. When unset on a non-loopback `host`, the bridge
|
|
176
|
+
* mints and persists one automatically (~/.talon/keys/bridge-token) —
|
|
177
|
+
* the network never gets an open bridge.
|
|
176
178
|
*/
|
|
177
179
|
token: z.string().optional(),
|
|
178
180
|
/**
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* module decides how a wrong one is answered so an internet-facing bridge
|
|
6
6
|
* can't be hammered for free, and so the operator hears about it.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* is the proxy):
|
|
8
|
+
* Four layers. The first three key on the remote address (behind a reverse
|
|
9
|
+
* proxy that is the proxy):
|
|
10
10
|
*
|
|
11
11
|
* 1. Progressive backoff: the first few wrong tokens from an address get an
|
|
12
12
|
* immediate 401; after that each 401 waits longer (base doubling to a
|
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
* dodging (1) and (2). The bridge enters a cooldown: wrong tokens get
|
|
19
19
|
* 429 at once, tokenless requests are slowed, the operator is alerted
|
|
20
20
|
* once. Authenticated traffic keeps working throughout.
|
|
21
|
+
* 4. Per-credential backoff: a wrong secret presented under a per-device
|
|
22
|
+
* credential id (`tdc1.<id>.…` names its id) also counts against that
|
|
23
|
+
* id, whatever the address, so guessing one device's credential from
|
|
24
|
+
* many addresses backs off as if from one. Backoff only, never a
|
|
25
|
+
* lockout: credential ids are safe to log and so knowable, and a
|
|
26
|
+
* lockout would let anyone lock a device out. The real credential is
|
|
27
|
+
* never delayed, and its successes don't reset the count (they come
|
|
28
|
+
* from the device, not from whoever is guessing); the window does.
|
|
21
29
|
*
|
|
22
30
|
* Only presented-and-wrong tokens count as failures. Tokenless probes are
|
|
23
31
|
* scanners finding a locked door. Waits are timers, never a blocked event
|
|
@@ -34,8 +42,12 @@ import type { AuthState } from "./routes/table.js";
|
|
|
34
42
|
export type AuthGuardPolicy = {
|
|
35
43
|
/** Wrong tokens from one address inside the window before 429s. */
|
|
36
44
|
lockoutMaxFailures: number;
|
|
45
|
+
/** How long an address's (or a credential id's) failures are remembered. */
|
|
37
46
|
lockoutWindowMs: number;
|
|
38
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Hard cap on tracked addresses, and separately on tracked credential ids,
|
|
49
|
+
* so neither map can become a memory lever.
|
|
50
|
+
*/
|
|
39
51
|
maxTracked: number;
|
|
40
52
|
/** Wrong tokens answered without delay (typos happen). */
|
|
41
53
|
freeFailures: number;
|
|
@@ -77,17 +89,76 @@ export type AuthVerdict =
|
|
|
77
89
|
|
|
78
90
|
type Entry = { count: number; resetAt: number };
|
|
79
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Failure counts per key (an address or a credential id) inside a window,
|
|
94
|
+
* capped at `maxTracked` keys.
|
|
95
|
+
*/
|
|
96
|
+
class FailureCounter {
|
|
97
|
+
private readonly entries = new Map<string, Entry>();
|
|
98
|
+
private saturatedLogged = false;
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
private readonly what: "address" | "credential",
|
|
102
|
+
private readonly windowMs: number,
|
|
103
|
+
private readonly maxTracked: number,
|
|
104
|
+
) {}
|
|
105
|
+
|
|
106
|
+
get size(): number {
|
|
107
|
+
return this.entries.size;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
live(key: string, now: number): Entry | undefined {
|
|
111
|
+
const entry = this.entries.get(key);
|
|
112
|
+
if (entry && now >= entry.resetAt) {
|
|
113
|
+
this.entries.delete(key);
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
return entry;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
clear(key: string): void {
|
|
120
|
+
this.entries.delete(key);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Bump the key's count; null when the key couldn't be tracked. */
|
|
124
|
+
record(key: string, now: number): number | null {
|
|
125
|
+
const entry = this.live(key, now);
|
|
126
|
+
if (entry) return ++entry.count;
|
|
127
|
+
if (this.entries.size >= this.maxTracked) {
|
|
128
|
+
for (const [k, e] of this.entries) {
|
|
129
|
+
if (now >= e.resetAt) this.entries.delete(k);
|
|
130
|
+
}
|
|
131
|
+
// Still saturated after pruning live entries — under that much churn
|
|
132
|
+
// dropping the newest key beats unbounded growth. The global budget
|
|
133
|
+
// still counts it.
|
|
134
|
+
if (this.entries.size >= this.maxTracked) {
|
|
135
|
+
if (!this.saturatedLogged) {
|
|
136
|
+
this.saturatedLogged = true;
|
|
137
|
+
logWarn(
|
|
138
|
+
"native",
|
|
139
|
+
`bridge.auth event=tracking_saturated reason=${this.what}_cap tracked=${this.entries.size}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
this.saturatedLogged = false;
|
|
146
|
+
this.entries.set(key, { count: 1, resetAt: now + this.windowMs });
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
80
151
|
export class AuthGuard {
|
|
81
152
|
private readonly policy: AuthGuardPolicy;
|
|
82
153
|
private readonly now: () => number;
|
|
83
154
|
private readonly onAlert: ((message: string) => void) | undefined;
|
|
84
|
-
private readonly failures
|
|
155
|
+
private readonly failures: FailureCounter;
|
|
156
|
+
private readonly credentialFailures: FailureCounter;
|
|
85
157
|
private globalCount = 0;
|
|
86
158
|
private globalWindowStart = 0;
|
|
87
159
|
private cooldownUntil = 0;
|
|
88
160
|
private cooling = false;
|
|
89
161
|
private suppressed = 0;
|
|
90
|
-
private saturatedLogged = false;
|
|
91
162
|
private pending = 0;
|
|
92
163
|
|
|
93
164
|
constructor(
|
|
@@ -97,6 +168,13 @@ export class AuthGuard {
|
|
|
97
168
|
this.policy = { ...DEFAULT_AUTH_GUARD_POLICY, ...policy };
|
|
98
169
|
this.now = deps.now ?? Date.now;
|
|
99
170
|
this.onAlert = deps.onAlert;
|
|
171
|
+
const { lockoutWindowMs, maxTracked } = this.policy;
|
|
172
|
+
this.failures = new FailureCounter("address", lockoutWindowMs, maxTracked);
|
|
173
|
+
this.credentialFailures = new FailureCounter(
|
|
174
|
+
"credential",
|
|
175
|
+
lockoutWindowMs,
|
|
176
|
+
maxTracked,
|
|
177
|
+
);
|
|
100
178
|
}
|
|
101
179
|
|
|
102
180
|
/** Addresses currently tracked (tests and diagnostics). */
|
|
@@ -104,6 +182,11 @@ export class AuthGuard {
|
|
|
104
182
|
return this.failures.size;
|
|
105
183
|
}
|
|
106
184
|
|
|
185
|
+
/** Credential ids currently tracked (tests and diagnostics). */
|
|
186
|
+
trackedCredentialCount(): number {
|
|
187
|
+
return this.credentialFailures.size;
|
|
188
|
+
}
|
|
189
|
+
|
|
107
190
|
/** True while the global failure budget is exhausted. */
|
|
108
191
|
inCooldown(): boolean {
|
|
109
192
|
this.refreshCooldown(this.now());
|
|
@@ -112,12 +195,18 @@ export class AuthGuard {
|
|
|
112
195
|
|
|
113
196
|
/**
|
|
114
197
|
* Decide how to answer a request whose credential has been evaluated.
|
|
115
|
-
* Called once per request, before routing.
|
|
198
|
+
* Called once per request, before routing. `credentialId` is the id a
|
|
199
|
+
* refused per-device credential named (null for the shared token or
|
|
200
|
+
* anything malformed) — an identifier, never secret material.
|
|
116
201
|
*/
|
|
117
|
-
check(
|
|
202
|
+
check(
|
|
203
|
+
remote: string,
|
|
204
|
+
auth: AuthState,
|
|
205
|
+
credentialId: string | null = null,
|
|
206
|
+
): AuthVerdict {
|
|
118
207
|
const now = this.now();
|
|
119
208
|
this.refreshCooldown(now);
|
|
120
|
-
const entry = this.
|
|
209
|
+
const entry = this.failures.live(remote, now);
|
|
121
210
|
if (entry && entry.count >= this.policy.lockoutMaxFailures) {
|
|
122
211
|
return {
|
|
123
212
|
kind: "reject",
|
|
@@ -126,7 +215,7 @@ export class AuthGuard {
|
|
|
126
215
|
};
|
|
127
216
|
}
|
|
128
217
|
if (auth === "ok") {
|
|
129
|
-
this.failures.
|
|
218
|
+
this.failures.clear(remote);
|
|
130
219
|
return { kind: "allow" };
|
|
131
220
|
}
|
|
132
221
|
if (auth === "anonymous") {
|
|
@@ -134,7 +223,7 @@ export class AuthGuard {
|
|
|
134
223
|
? { kind: "delay", ms: this.policy.cooldownAnonDelayMs }
|
|
135
224
|
: { kind: "allow" };
|
|
136
225
|
}
|
|
137
|
-
return this.fail(remote, now);
|
|
226
|
+
return this.fail(remote, credentialId, now);
|
|
138
227
|
}
|
|
139
228
|
|
|
140
229
|
/**
|
|
@@ -156,8 +245,16 @@ export class AuthGuard {
|
|
|
156
245
|
|
|
157
246
|
// ── internals ────────────────────────────────────────────────────────────
|
|
158
247
|
|
|
159
|
-
private fail(
|
|
160
|
-
|
|
248
|
+
private fail(
|
|
249
|
+
remote: string,
|
|
250
|
+
credentialId: string | null,
|
|
251
|
+
now: number,
|
|
252
|
+
): AuthVerdict {
|
|
253
|
+
const count = this.failures.record(remote, now);
|
|
254
|
+
const credCount =
|
|
255
|
+
credentialId === null
|
|
256
|
+
? null
|
|
257
|
+
: this.credentialFailures.record(credentialId, now);
|
|
161
258
|
this.recordGlobalFailure(now);
|
|
162
259
|
if (this.cooling) {
|
|
163
260
|
this.suppressed++;
|
|
@@ -170,11 +267,15 @@ export class AuthGuard {
|
|
|
170
267
|
),
|
|
171
268
|
};
|
|
172
269
|
}
|
|
173
|
-
|
|
270
|
+
// Whichever key has seen more failures sets the wait.
|
|
271
|
+
const delay = Math.max(this.backoffFor(count), this.backoffFor(credCount));
|
|
174
272
|
logWarn(
|
|
175
273
|
"native",
|
|
176
274
|
`bridge.auth event=failure addr=${remote} reason=bad_token` +
|
|
177
275
|
(count === null ? " tracked=no" : ` failures=${count}`) +
|
|
276
|
+
(credentialId === null
|
|
277
|
+
? ""
|
|
278
|
+
: ` credential=${credentialId} credentialFailures=${credCount ?? "untracked"}`) +
|
|
178
279
|
` delayMs=${delay}`,
|
|
179
280
|
);
|
|
180
281
|
if (count === this.policy.lockoutMaxFailures) {
|
|
@@ -186,7 +287,9 @@ export class AuthGuard {
|
|
|
186
287
|
return delay > 0 ? { kind: "delay", ms: delay } : { kind: "allow" };
|
|
187
288
|
}
|
|
188
289
|
|
|
189
|
-
|
|
290
|
+
/** The wait after the `count`th failure; an untracked key never waits. */
|
|
291
|
+
private backoffFor(count: number | null): number {
|
|
292
|
+
if (count === null) return 0;
|
|
190
293
|
const n = count - this.policy.freeFailures;
|
|
191
294
|
if (n <= 0) return 0;
|
|
192
295
|
// 2^(n-1) overflows nothing useful past ~30 doublings; clamp first.
|
|
@@ -197,45 +300,6 @@ export class AuthGuard {
|
|
|
197
300
|
);
|
|
198
301
|
}
|
|
199
302
|
|
|
200
|
-
private liveEntry(remote: string, now: number): Entry | undefined {
|
|
201
|
-
const entry = this.failures.get(remote);
|
|
202
|
-
if (entry && now >= entry.resetAt) {
|
|
203
|
-
this.failures.delete(remote);
|
|
204
|
-
return undefined;
|
|
205
|
-
}
|
|
206
|
-
return entry;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** Bump the address's count; null when the address couldn't be tracked. */
|
|
210
|
-
private recordFailure(remote: string, now: number): number | null {
|
|
211
|
-
const entry = this.liveEntry(remote, now);
|
|
212
|
-
if (entry) return ++entry.count;
|
|
213
|
-
if (this.failures.size >= this.policy.maxTracked) {
|
|
214
|
-
for (const [ip, e] of this.failures) {
|
|
215
|
-
if (now >= e.resetAt) this.failures.delete(ip);
|
|
216
|
-
}
|
|
217
|
-
// Still saturated after pruning live entries — under that much churn
|
|
218
|
-
// dropping the newest address beats unbounded growth. The global
|
|
219
|
-
// budget still counts it.
|
|
220
|
-
if (this.failures.size >= this.policy.maxTracked) {
|
|
221
|
-
if (!this.saturatedLogged) {
|
|
222
|
-
this.saturatedLogged = true;
|
|
223
|
-
logWarn(
|
|
224
|
-
"native",
|
|
225
|
-
`bridge.auth event=tracking_saturated reason=address_cap tracked=${this.failures.size}`,
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
return null;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
this.saturatedLogged = false;
|
|
232
|
-
this.failures.set(remote, {
|
|
233
|
-
count: 1,
|
|
234
|
-
resetAt: now + this.policy.lockoutWindowMs,
|
|
235
|
-
});
|
|
236
|
-
return 1;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
303
|
private recordGlobalFailure(now: number): void {
|
|
240
304
|
if (now - this.globalWindowStart >= this.policy.globalWindowMs) {
|
|
241
305
|
this.globalWindowStart = now;
|
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import type { IncomingMessage } from "node:http";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
credentialIdOf,
|
|
22
|
+
isDeviceCredentialToken,
|
|
23
|
+
} from "../../../../core/mesh/credentials/index.js";
|
|
21
24
|
import { logWarn } from "../../../../util/log.js";
|
|
22
25
|
|
|
23
26
|
/**
|
|
@@ -137,6 +140,15 @@ function isLocalRequest(req: IncomingMessage): boolean {
|
|
|
137
140
|
);
|
|
138
141
|
}
|
|
139
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The per-device credential id a presented bearer names (`tdc1.<id>.…`), so
|
|
145
|
+
* a refusal can count against that id as well as the address. Null for the
|
|
146
|
+
* shared token or anything malformed. Ids are identifiers, safe to log.
|
|
147
|
+
*/
|
|
148
|
+
export function presentedCredentialId(candidate: string): string | null {
|
|
149
|
+
return credentialIdOf(candidate);
|
|
150
|
+
}
|
|
151
|
+
|
|
140
152
|
/** Addresses already warned about a refused legacy token (bounded). */
|
|
141
153
|
const refusedLegacy = new Set<string>();
|
|
142
154
|
|
|
@@ -105,6 +105,19 @@ export const BRIDGE_ROUTE_AUTH = {
|
|
|
105
105
|
|
|
106
106
|
export type BridgeRouteKey = keyof typeof BRIDGE_ROUTE_AUTH;
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The only routes that take the credential as a `?token=` query parameter.
|
|
110
|
+
* Everywhere else it must come as `Authorization: Bearer`. A token in a URL
|
|
111
|
+
* ends up in proxy logs, browser history and screenshots, so it is accepted
|
|
112
|
+
* only where a client can't set a header: an EventSource stream, and media
|
|
113
|
+
* handed to an image widget or an external viewer. Those are the only two
|
|
114
|
+
* places shipped companions put it; talon-node never does.
|
|
115
|
+
*/
|
|
116
|
+
export const QUERY_TOKEN_ROUTES: ReadonlySet<BridgeRouteKey> = new Set([
|
|
117
|
+
"GET /events",
|
|
118
|
+
"GET /media",
|
|
119
|
+
]);
|
|
120
|
+
|
|
108
121
|
/** What a route handler receives; `auth` is already evaluated. */
|
|
109
122
|
export type RouteContext = {
|
|
110
123
|
req: IncomingMessage;
|
|
@@ -45,6 +45,7 @@ import { buildRoutes } from "./routes/index.js";
|
|
|
45
45
|
import type { BridgeServerHandlers, RouteHost } from "./routes/host.js";
|
|
46
46
|
import {
|
|
47
47
|
BRIDGE_ROUTE_AUTH,
|
|
48
|
+
QUERY_TOKEN_ROUTES,
|
|
48
49
|
type AuthState,
|
|
49
50
|
type BridgeRouteKey,
|
|
50
51
|
type RouteContext,
|
|
@@ -53,6 +54,7 @@ import {
|
|
|
53
54
|
import {
|
|
54
55
|
describeTier,
|
|
55
56
|
hasScope,
|
|
57
|
+
presentedCredentialId,
|
|
56
58
|
resolvePrincipal,
|
|
57
59
|
routeAllows,
|
|
58
60
|
type BridgeCredentials,
|
|
@@ -519,10 +521,14 @@ export class BridgeServer {
|
|
|
519
521
|
}
|
|
520
522
|
|
|
521
523
|
const remote = req.socket.remoteAddress ?? "unknown";
|
|
522
|
-
const { state: auth, principal } = this.authState(req, url);
|
|
523
|
-
if (!(await this.admit(res, remote, auth))) return;
|
|
524
|
-
|
|
525
524
|
const key = `${method} ${path}` as BridgeRouteKey;
|
|
525
|
+
const {
|
|
526
|
+
state: auth,
|
|
527
|
+
principal,
|
|
528
|
+
credentialId,
|
|
529
|
+
} = this.authState(req, url, QUERY_TOKEN_ROUTES.has(key));
|
|
530
|
+
if (!(await this.admit(res, remote, auth, credentialId))) return;
|
|
531
|
+
|
|
526
532
|
const route = this.routes.get(key);
|
|
527
533
|
const ctx: RouteContext = { req, res, url, auth, principal };
|
|
528
534
|
const tier = route ? BRIDGE_ROUTE_AUTH[key] : undefined;
|
|
@@ -706,19 +712,34 @@ export class BridgeServer {
|
|
|
706
712
|
|
|
707
713
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
708
714
|
|
|
715
|
+
/**
|
|
716
|
+
* Evaluate the presented credential. `queryToken` says whether this route
|
|
717
|
+
* also takes it as `?token=` (QUERY_TOKEN_ROUTES); elsewhere a query
|
|
718
|
+
* token is ignored, so the request reads as tokenless. A refused
|
|
719
|
+
* per-device credential reports the id it named, for the auth guard.
|
|
720
|
+
*/
|
|
709
721
|
private authState(
|
|
710
722
|
req: IncomingMessage,
|
|
711
723
|
url: URL,
|
|
712
|
-
|
|
713
|
-
|
|
724
|
+
queryToken: boolean,
|
|
725
|
+
): {
|
|
726
|
+
state: AuthState;
|
|
727
|
+
principal: BridgePrincipal | null;
|
|
728
|
+
credentialId: string | null;
|
|
729
|
+
} {
|
|
730
|
+
if (!this.opts.token) {
|
|
731
|
+
return { state: "ok", principal: { kind: "open" }, credentialId: null };
|
|
732
|
+
}
|
|
714
733
|
const header = req.headers["authorization"];
|
|
715
734
|
const fromHeader =
|
|
716
735
|
typeof header === "string" && header.startsWith("Bearer ")
|
|
717
736
|
? header.slice("Bearer ".length)
|
|
718
737
|
: null;
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
if (candidate === null)
|
|
738
|
+
const candidate =
|
|
739
|
+
fromHeader ?? (queryToken ? url.searchParams.get("token") : null);
|
|
740
|
+
if (candidate === null) {
|
|
741
|
+
return { state: "anonymous", principal: null, credentialId: null };
|
|
742
|
+
}
|
|
722
743
|
// The shared token or a per-device credential (credentials/principal.ts).
|
|
723
744
|
const principal = resolvePrincipal(
|
|
724
745
|
candidate,
|
|
@@ -727,8 +748,12 @@ export class BridgeServer {
|
|
|
727
748
|
this.opts.credentials,
|
|
728
749
|
);
|
|
729
750
|
return principal
|
|
730
|
-
? { state: "ok", principal }
|
|
731
|
-
: {
|
|
751
|
+
? { state: "ok", principal, credentialId: null }
|
|
752
|
+
: {
|
|
753
|
+
state: "bad",
|
|
754
|
+
principal: null,
|
|
755
|
+
credentialId: presentedCredentialId(candidate),
|
|
756
|
+
};
|
|
732
757
|
}
|
|
733
758
|
|
|
734
759
|
/**
|
|
@@ -740,8 +765,9 @@ export class BridgeServer {
|
|
|
740
765
|
res: ServerResponse,
|
|
741
766
|
remote: string,
|
|
742
767
|
auth: AuthState,
|
|
768
|
+
credentialId: string | null,
|
|
743
769
|
): Promise<boolean> {
|
|
744
|
-
const verdict = this.authGuard.check(remote, auth);
|
|
770
|
+
const verdict = this.authGuard.check(remote, auth, credentialId);
|
|
745
771
|
if (verdict.kind === "reject") {
|
|
746
772
|
this.refuse(
|
|
747
773
|
res,
|
package/src/storage/skills.ts
CHANGED
|
@@ -62,6 +62,11 @@ const SKILL_FILE = "SKILL.md";
|
|
|
62
62
|
* sibling file survives every update, and is visible in the filesystem.
|
|
63
63
|
*/
|
|
64
64
|
const DISABLED_FILE = ".disabled";
|
|
65
|
+
/**
|
|
66
|
+
* Provenance `talon skill install` writes for a cloned source (repo URL,
|
|
67
|
+
* commit) — metadata, not a resource the skill offers.
|
|
68
|
+
*/
|
|
69
|
+
const INSTALL_RECORD_FILE = ".talon-install.json";
|
|
65
70
|
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
66
71
|
const DESCRIPTION_MAX_CHARS = 300;
|
|
67
72
|
const BODY_MAX_BYTES = 128 * 1024;
|
|
@@ -212,7 +217,8 @@ function listResources(dir: string): string[] {
|
|
|
212
217
|
(entry) =>
|
|
213
218
|
entry.isFile() &&
|
|
214
219
|
entry.name !== SKILL_FILE &&
|
|
215
|
-
entry.name !== DISABLED_FILE
|
|
220
|
+
entry.name !== DISABLED_FILE &&
|
|
221
|
+
entry.name !== INSTALL_RECORD_FILE,
|
|
216
222
|
)
|
|
217
223
|
.map((entry) => entry.name)
|
|
218
224
|
.sort((a, b) => a.localeCompare(b));
|