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 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`. Disabling keeps the entry (or a
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.23.0",
3
+ "version": "5.24.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "The Falconry",
6
6
  "license": "Apache-2.0",
@@ -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
- * Cloning always uses `--depth=1` (installs never need history), ends the
14
- * options with `--` so a URL can never be read as a git flag, and reports
15
- * the commit it got so the install can be pinned/audited later. It spawns
16
- * `git`/`npm` via cross-spawn, which resolves the `.cmd`/`.exe` shims on
17
- * Windows — never assume a POSIX shell here.
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
- | { kind: "local"; dir: string }
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
- if (
41
- GIT_URL_RE.test(trimmed) ||
42
- trimmed.startsWith("git@") ||
43
- trimmed.endsWith(".git")
44
- ) {
45
- return { kind: "git", url: trimmed };
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 (!trimmed.startsWith("@")) {
48
- const match = GITHUB_SHORTHAND_RE.exec(trimmed);
49
- if (match) {
50
- const [, owner, repo, rest] = match;
51
- return {
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 { kind: "other", raw: trimmed };
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
- // `--` below already stops option parsing; refusing a leading dash too
111
- // means a hostile "URL" never even reaches git.
112
- if (url.startsWith("-")) {
113
- return { ok: false, error: `Refusing a git URL that starts with "-"` };
114
- }
115
- const dir = mkdtempSync(join(tmpdir(), "talon-install-"));
116
- const cleanup = () => rmSync(dir, { recursive: true, force: true });
117
- const outcome = runTool("git", ["clone", "--depth=1", "--", url, dir]);
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
- /** Write where an install came from and exactly which commit it is. */
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: { source: string; subpath?: string; commit?: string },
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`. Windows-safe throughout — tools are spawned via cross-spawn.
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
- cloneShallow,
29
+ cloneSource,
28
30
  writeInstallRecord,
29
31
  resolveSource,
32
+ withCommit,
30
33
  runTool,
31
- type ResolvedSource,
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 = { mcp: boolean; force: boolean; name?: string };
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 = cloneShallow(source.url);
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 resolved = resolveSource(source);
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 { cloneShallow, resolveSource } from "./install-sources.js";
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 force = args.includes("--force");
124
- const source = args.find((arg) => !arg.startsWith("-"));
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 resolved = resolveSource(source);
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 = cloneShallow(resolved.url);
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}`)}`);
@@ -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=` for SSE).
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 (SSE). When unset on a
174
- * non-loopback `host`, the bridge mints and persists one automatically
175
- * (~/.talon/keys/bridge-token) — the network never gets an open bridge.
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
  /**
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  export { DeviceCredentialStore } from "./store.js";
7
- export { isDeviceCredentialToken } from "./token.js";
7
+ export { credentialIdOf, isDeviceCredentialToken } from "./token.js";
8
8
  export {
9
9
  DEFAULT_COMPANION_SCOPES,
10
10
  FORMER_COMPANION_SCOPES,
@@ -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
- * Three layers, all keyed on the remote address (behind a reverse proxy that
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
- /** Hard cap on tracked addresses so the map can't become a memory lever. */
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 = new Map<string, Entry>();
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(remote: string, auth: AuthState): AuthVerdict {
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.liveEntry(remote, now);
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.delete(remote);
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(remote: string, now: number): AuthVerdict {
160
- const count = this.recordFailure(remote, now);
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
- const delay = count === null ? 0 : this.backoffFor(count);
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
- private backoffFor(count: number): number {
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 { isDeviceCredentialToken } from "../../../../core/mesh/credentials/index.js";
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
- ): { state: AuthState; principal: BridgePrincipal | null } {
713
- if (!this.opts.token) return { state: "ok", principal: { kind: "open" } };
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
- // EventSource can't set headers, so SSE clients pass ?token=… instead.
720
- const candidate = fromHeader ?? url.searchParams.get("token");
721
- if (candidate === null) return { state: "anonymous", principal: 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
- : { state: "bad", principal: null };
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,
@@ -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));