talon-agent 3.1.0 → 3.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -71,7 +71,7 @@
71
71
  "test:openai-agents:backend": "vitest run --reporter=verbose --reporter=json --outputFile=openai-agents-backend-results.json src/__tests__/integration/openai-agents-live-discovery.test.ts",
72
72
  "build:sql": "tsx scripts/embed-sql.ts",
73
73
  "build:prompts": "tsx scripts/embed-prompts.ts",
74
- "build:wasm": "node native/blake3-wasm/build.mjs",
74
+ "build:wasm": "node native/blake3-wasm/build.mjs && node native/strsim-wasm/build.mjs && node native/sqlguard-wasm/build.mjs && node native/htmlents-wasm/build.mjs",
75
75
  "tarball:check": "node .github/scripts/tarball-check.mjs",
76
76
  "build:stub-sea": "node src/__tests__/integration/stub-claude/build-sea.mjs",
77
77
  "test:watch": "vitest",
@@ -86,9 +86,7 @@
86
86
  "ci:protect": "node .github/scripts/enforce-ci-gate.mjs",
87
87
  "build:gleam": "cd native/scheduler-core && gleam build --target javascript && node embed.mjs",
88
88
  "build:zig": "node native/textops-wasm/build.mjs",
89
- "build:c": "node native/strsim-c/build.mjs && node native/sqlguard-c/build.mjs",
90
- "build:cpp": "node native/htmlents-cpp/build.mjs",
91
- "build:native": "npm run build:wasm && npm run build:zig && npm run build:c && npm run build:cpp && npm run build:gleam",
89
+ "build:native": "npm run build:wasm && npm run build:zig && npm run build:gleam",
92
90
  "build:driver": "node native/talon-driver/build.mjs",
93
91
  "build:driver:all": "node native/talon-driver/build.mjs --all",
94
92
  "build:warden": "node native/talon-warden/build.mjs",
@@ -105,7 +103,7 @@
105
103
  "@kilocode/sdk": "^7.2.22",
106
104
  "@modelcontextprotocol/sdk": "^1.29.0",
107
105
  "@openai/agents": "^0.13.0",
108
- "@openai/codex-sdk": "^0.144.0",
106
+ "@openai/codex-sdk": "^0.145.0",
109
107
  "@opencode-ai/sdk": "^1.17.4",
110
108
  "@playwright/mcp": "^0.0.78",
111
109
  "@types/cross-spawn": "^6.0.6",
package/src/cli/index.ts CHANGED
@@ -172,7 +172,7 @@ export async function runCli(): Promise<void> {
172
172
  mainMenu();
173
173
  break;
174
174
  default: {
175
- // "did you mean ...?" via the C similarity core (native/strsim-c).
175
+ // "did you mean ...?" via the native similarity core (native/strsim-wasm).
176
176
  const { closestMatch } = await import("../native/strsim.js");
177
177
  const suggestion = closestMatch(command, CLI_COMMANDS);
178
178
  const hint = suggestion
@@ -57,4 +57,12 @@ export const meshHandlers: SharedActionHandlers = {
57
57
  body.apk_path,
58
58
  body.remote_path,
59
59
  ),
60
+ // Remote self-update for a headless talon-node: push a new binary and have
61
+ // the node verify, swap, and restart into it.
62
+ update_node: (body) =>
63
+ getMeshService().updateNodeBinary(
64
+ body.device,
65
+ body.binary_path,
66
+ body.remote_path,
67
+ ),
60
68
  };
@@ -873,6 +873,87 @@ export class MeshService {
873
873
  };
874
874
  }
875
875
 
876
+ /**
877
+ * `update_node`: remote self-update for a headless talon-node. Streams a
878
+ * replacement binary to the node, then sends `update_node` so the node
879
+ * verifies the digest, atomically swaps its own binary, and restarts into
880
+ * it (an in-place execve under systemd/launchd, so the mesh connection
881
+ * returns on its own within seconds — the same UX as the Android path).
882
+ *
883
+ * The binary is hashed here and the digest travels with the command; the
884
+ * node re-hashes the pushed file and refuses to swap on a mismatch, so a
885
+ * truncated transfer can never be installed. Build the replacement for the
886
+ * node's platform/arch (talon-node-<os>-<arch>) before calling.
887
+ */
888
+ async updateNodeBinary(
889
+ query: unknown,
890
+ localBinaryPath: unknown,
891
+ remotePath?: unknown,
892
+ ): Promise<MeshToolResult> {
893
+ const local =
894
+ typeof localBinaryPath === "string" && localBinaryPath.trim()
895
+ ? resolve(dirs.workspace, localBinaryPath.trim())
896
+ : "";
897
+ if (!local) return { ok: false, text: "A local binary path is required." };
898
+ await this.load();
899
+ const resolved = this.resolveDevice(query);
900
+ if ("error" in resolved) return { ok: false, text: resolved.error };
901
+ const target = resolved.target;
902
+ if (target.capabilities && !target.capabilities.includes("update_node")) {
903
+ return {
904
+ ok: false,
905
+ text: `${target.name} can't self-update — it needs the update_node capability (a talon-node headless device).`,
906
+ };
907
+ }
908
+
909
+ let sha256: string;
910
+ let size: number;
911
+ try {
912
+ ({ sha256, size } = await hashFile(local));
913
+ } catch (err) {
914
+ return {
915
+ ok: false,
916
+ text: `Cannot read binary ${local}: ${(err as Error).message}`,
917
+ };
918
+ }
919
+ if (size === 0) return { ok: false, text: `Binary ${local} is empty.` };
920
+
921
+ // Default staging path is /tmp on unix nodes (the node re-stages next to
922
+ // its own executable before the atomic swap, so this is only transient).
923
+ const remote =
924
+ typeof remotePath === "string" && remotePath.trim()
925
+ ? remotePath.trim()
926
+ : "/tmp/talon-node.update";
927
+
928
+ // 1. Stream the new binary to the node.
929
+ const push = await this.pushFileToDevice(target.id, local, remote);
930
+ if (!push.ok) {
931
+ return { ok: false, text: `Update aborted — push failed: ${push.text}` };
932
+ }
933
+
934
+ // 2. Trigger the swap + restart (node verifies the digest first).
935
+ const dispatched = await this.dispatchCommand(
936
+ target.id,
937
+ "update_node",
938
+ { path: remote, sha256 },
939
+ this.commandTimeoutMs,
940
+ );
941
+ if ("error" in dispatched) return { ok: false, text: dispatched.error };
942
+ if (!dispatched.result.ok) {
943
+ return {
944
+ ok: false,
945
+ text: dispatched.result.message ?? `${target.name} refused the update.`,
946
+ };
947
+ }
948
+ return {
949
+ ok: true,
950
+ text:
951
+ `Pushed ${formatBytes(size)} and staged the update on ${target.name}. ` +
952
+ `${dispatched.result.message ?? "Restarting now."} ` +
953
+ `Confirm with get_device_status once it reconnects (appVersion should change).`,
954
+ };
955
+ }
956
+
876
957
  /**
877
958
  * Chunked read of a remote file into a Buffer. Loops `read_file` with
878
959
  * increasing offsets until the device reports EOF.
@@ -185,4 +185,25 @@ export const meshTools: ToolDefinition[] = [
185
185
  execute: (params, bridge) => bridge("update_device", params),
186
186
  tag: "mesh",
187
187
  },
188
+ {
189
+ name: "update_node",
190
+ description:
191
+ "Remotely update a headless talon-node (Linux/macOS/Windows server on the mesh): stream a new node binary from the daemon and have the node verify its hash, atomically swap its own binary, and restart into it. On Linux/macOS this is an in-place execve so the mesh connection returns within seconds; the node re-hashes the pushed file and refuses a truncated or mismatched binary. Build the replacement for the node's platform/arch first (talon-node-<os>-<arch>). Confirm success with get_device_status afterwards (appVersion should change). This is the node counterpart of update_device — use update_device for Android companions.",
192
+ schema: {
193
+ device: deviceParam,
194
+ binary_path: z
195
+ .string()
196
+ .describe(
197
+ "Path to the new talon-node binary, relative to the workspace (or absolute), built for the node's OS/arch.",
198
+ ),
199
+ remote_path: z
200
+ .string()
201
+ .optional()
202
+ .describe(
203
+ "Where to stage the binary on the node (default /tmp/talon-node.update; the node re-stages next to its own executable before the atomic swap).",
204
+ ),
205
+ },
206
+ execute: (params, bridge) => bridge("update_node", params),
207
+ tag: "mesh",
208
+ },
188
209
  ];
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Bridge auth token — the shared secret behind every non-/health route.
3
+ *
4
+ * The operator can set one explicitly (`native.token`); this module covers
5
+ * the case where they didn't but the bridge is about to bind a non-loopback
6
+ * host. Serving the full agent API unauthenticated to the LAN is never an
7
+ * acceptable default, so a token is minted once and persisted under
8
+ * ~/.talon/keys/ — stable across restarts so paired clients keep working.
9
+ *
10
+ * The token value itself never goes to the log (SECURITY.md treats
11
+ * credentials in logs as a vulnerability); same-machine clients pick it up
12
+ * from the 0600 discovery file, remote clients read it from the key file.
13
+ */
14
+
15
+ import { randomBytes } from "node:crypto";
16
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { resolve } from "node:path";
18
+ import { dirs } from "../../util/paths.js";
19
+ import { log } from "../../util/log.js";
20
+
21
+ const TOKEN_FILE = "bridge-token";
22
+ /** 32 random bytes → 43 base64url chars; comfortably beyond brute force. */
23
+ const TOKEN_BYTES = 32;
24
+
25
+ /** Path of the persisted token, for operator-facing messages. */
26
+ export function bridgeTokenPath(dir: string = dirs.keys): string {
27
+ return resolve(dir, TOKEN_FILE);
28
+ }
29
+
30
+ /**
31
+ * Load the persisted auto-generated bridge token, minting it on first use.
32
+ * Lives under ~/.talon/keys/ with owner-only permissions, like the TLS
33
+ * identity next to it.
34
+ */
35
+ export function loadOrCreateBridgeToken(dir: string = dirs.keys): string {
36
+ const path = bridgeTokenPath(dir);
37
+ try {
38
+ const existing = readFileSync(path, "utf-8").trim();
39
+ if (existing) return existing;
40
+ } catch {
41
+ // first boot — nothing persisted yet
42
+ }
43
+ const token = randomBytes(TOKEN_BYTES).toString("base64url");
44
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
45
+ writeFileSync(path, `${token}\n`, { mode: 0o600 });
46
+ chmodSync(path, 0o600); // mode above is ignored when the file exists
47
+ log(
48
+ "native",
49
+ `Minted bridge auth token (non-loopback bind with no native.token) — pair remote clients with the value in ${path}`,
50
+ );
51
+ return token;
52
+ }
@@ -75,6 +75,7 @@ import { createNativeActionHandler } from "./actions.js";
75
75
  import { getMeshService } from "../../core/mesh/index.js";
76
76
  import { removeBridgeDiscovery, writeBridgeDiscovery } from "./discovery.js";
77
77
  import { isLoopbackHost, loadOrCreateBridgeTlsIdentity } from "./tls.js";
78
+ import { loadOrCreateBridgeToken } from "./auth.js";
78
79
  import { readLogEntries } from "./logs.js";
79
80
  import {
80
81
  BRIDGE_PROTOCOL_VERSION,
@@ -1200,11 +1201,16 @@ export function createNativeFrontend(
1200
1201
  // Encrypted by default the moment the bridge leaves the machine; loopback
1201
1202
  // stays plain HTTP unless explicitly opted in (`native.tls`).
1202
1203
  const bridgeTls = nativeCfg.tls ?? !isLoopbackHost(bridgeHost);
1204
+ // Never serve the agent API to the network unauthenticated: a non-loopback
1205
+ // bind with no configured token gets a persistent auto-minted one instead.
1206
+ const bridgeToken =
1207
+ nativeCfg.token ??
1208
+ (isLoopbackHost(bridgeHost) ? undefined : loadOrCreateBridgeToken());
1203
1209
  const server = new BridgeServer(
1204
1210
  {
1205
1211
  host: bridgeHost,
1206
1212
  port: nativeCfg.port ?? 19880,
1207
- token: nativeCfg.token,
1213
+ token: bridgeToken,
1208
1214
  startedAt,
1209
1215
  ...(bridgeTls ? { tls: () => loadOrCreateBridgeTlsIdentity() } : {}),
1210
1216
  },
@@ -1274,7 +1280,7 @@ export function createNativeFrontend(
1274
1280
  const fingerprint = server.getFingerprint();
1275
1281
  await writeBridgeDiscovery({
1276
1282
  port: server.getPort(),
1277
- token: nativeCfg.token,
1283
+ token: bridgeToken,
1278
1284
  scheme: server.getScheme(),
1279
1285
  ...(fingerprint ? { fingerprint } : {}),
1280
1286
  startedAt: Date.parse(startedAt),
@@ -25,7 +25,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
25
25
  import { createReadStream } from "node:fs";
26
26
  import { stat } from "node:fs/promises";
27
27
  import { extname } from "node:path";
28
- import { log, logError, logDebug } from "../../util/log.js";
28
+ import { log, logError, logDebug, logWarn } from "../../util/log.js";
29
29
  import { formatFingerprint, type BridgeTlsIdentity } from "./tls.js";
30
30
  import {
31
31
  BRIDGE_PROTOCOL_VERSION,
@@ -149,12 +149,32 @@ const MAX_BODY_BYTES = 256 * 1024;
149
149
  const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
150
150
  const PORT_FALLBACKS = 5;
151
151
 
152
+ // Failed-auth lockout: after this many wrong tokens from one address inside
153
+ // the window, that address gets 429s until the window lapses. The token's
154
+ // 256 bits make brute force hopeless anyway — this is about not letting an
155
+ // internet-facing bridge be hammered for free (and giving fail2ban-style
156
+ // tooling a clean signal in the log). Only *presented-and-wrong* secrets
157
+ // count: tokenless probes are just scanners finding a locked door.
158
+ const AUTH_LOCKOUT_MAX_FAILURES = 20;
159
+ const AUTH_LOCKOUT_WINDOW_MS = 15 * 60 * 1000;
160
+ /** Hard cap on tracked addresses so the map can't become a memory lever. */
161
+ const AUTH_LOCKOUT_MAX_TRACKED = 10_000;
162
+
163
+ /**
164
+ * ok: request carries the right token (or none is required).
165
+ * anonymous: no credential presented — a pre-pairing probe, not an attack.
166
+ * bad: a credential was presented and it is wrong.
167
+ */
168
+ type AuthState = "ok" | "anonymous" | "bad";
169
+
152
170
  export class BridgeServer {
153
171
  private server: Server | null = null;
154
172
  private clients = new Set<ServerResponse>();
155
173
  private pingTimer: ReturnType<typeof setInterval> | undefined;
156
174
  private port = 0;
157
175
  private tlsIdentity: BridgeTlsIdentity | null = null;
176
+ /** Wrong-token counts per remote address (behind a proxy: per proxy). */
177
+ private authFailures = new Map<string, { count: number; resetAt: number }>();
158
178
 
159
179
  constructor(
160
180
  private readonly opts: {
@@ -311,21 +331,43 @@ export class BridgeServer {
311
331
  return;
312
332
  }
313
333
 
334
+ const remote = req.socket.remoteAddress ?? "unknown";
335
+ if (this.authLockedOut(remote)) {
336
+ res.writeHead(429, {
337
+ ...this.jsonHeaders(),
338
+ "Retry-After": String(Math.ceil(AUTH_LOCKOUT_WINDOW_MS / 1000)),
339
+ });
340
+ res.end(
341
+ JSON.stringify({ ok: false, error: "Too many failed auth attempts" }),
342
+ );
343
+ return;
344
+ }
345
+
346
+ const auth = this.authState(req, url);
347
+ if (auth === "bad") this.recordAuthFailure(remote);
348
+ else if (auth === "ok") this.authFailures.delete(remote);
349
+
314
350
  // /health is unauthenticated so clients can discover/ping the bridge
315
- // before they hold a token. It exposes no chat content.
351
+ // before they hold a token. Pre-auth it serves only what pairing needs
352
+ // (identity, protocol, fingerprint) — operational details like bot name,
353
+ // backend, and chat count are not for internet scanners to enumerate.
316
354
  if (method === "GET" && path === "/health") {
317
- const s = this.handlers.status();
318
- return this.json(res, 200, {
355
+ const base = {
319
356
  app: "talon-bridge",
320
357
  ok: true,
321
358
  protocol: BRIDGE_PROTOCOL_VERSION,
322
- host: this.opts.host,
323
359
  port: this.port,
324
360
  scheme: this.getScheme(),
325
361
  // The certificate's own hash — public by definition (any TLS client
326
362
  // sees the certificate), surfaced so pairing UIs can display it.
327
363
  fingerprint: this.getFingerprint(),
328
364
  authRequired: Boolean(this.opts.token),
365
+ };
366
+ if (auth !== "ok") return this.json(res, 200, base);
367
+ const s = this.handlers.status();
368
+ return this.json(res, 200, {
369
+ ...base,
370
+ host: this.opts.host,
329
371
  startedAt: this.opts.startedAt,
330
372
  botName: s.botName,
331
373
  backend: s.backend,
@@ -335,7 +377,7 @@ export class BridgeServer {
335
377
  });
336
378
  }
337
379
 
338
- if (!this.authOk(req, url)) {
380
+ if (auth !== "ok") {
339
381
  return this.json(res, 401, { ok: false, error: "Unauthorized" });
340
382
  }
341
383
 
@@ -677,17 +719,54 @@ export class BridgeServer {
677
719
 
678
720
  // ── Helpers ──────────────────────────────────────────────────────────────
679
721
 
680
- private authOk(req: IncomingMessage, url: URL): boolean {
681
- if (!this.opts.token) return true;
722
+ private authState(req: IncomingMessage, url: URL): AuthState {
723
+ if (!this.opts.token) return "ok";
682
724
  const header = req.headers["authorization"];
683
- if (
684
- typeof header === "string" &&
685
- header.startsWith("Bearer ") &&
686
- this.tokenMatches(header.slice("Bearer ".length))
687
- )
688
- return true;
725
+ const fromHeader =
726
+ typeof header === "string" && header.startsWith("Bearer ")
727
+ ? header.slice("Bearer ".length)
728
+ : null;
689
729
  // EventSource can't set headers, so SSE clients pass ?token=… instead.
690
- return this.tokenMatches(url.searchParams.get("token"));
730
+ const candidate = fromHeader ?? url.searchParams.get("token");
731
+ if (candidate === null) return "anonymous";
732
+ return this.tokenMatches(candidate) ? "ok" : "bad";
733
+ }
734
+
735
+ private authLockedOut(remote: string): boolean {
736
+ const entry = this.authFailures.get(remote);
737
+ if (!entry) return false;
738
+ if (Date.now() >= entry.resetAt) {
739
+ this.authFailures.delete(remote);
740
+ return false;
741
+ }
742
+ return entry.count >= AUTH_LOCKOUT_MAX_FAILURES;
743
+ }
744
+
745
+ private recordAuthFailure(remote: string): void {
746
+ const now = Date.now();
747
+ const entry = this.authFailures.get(remote);
748
+ if (!entry || now >= entry.resetAt) {
749
+ if (this.authFailures.size >= AUTH_LOCKOUT_MAX_TRACKED) {
750
+ for (const [ip, e] of this.authFailures) {
751
+ if (now >= e.resetAt) this.authFailures.delete(ip);
752
+ }
753
+ // Still saturated after pruning live entries — under that much churn
754
+ // dropping the newest attacker beats unbounded growth.
755
+ if (this.authFailures.size >= AUTH_LOCKOUT_MAX_TRACKED) return;
756
+ }
757
+ this.authFailures.set(remote, {
758
+ count: 1,
759
+ resetAt: now + AUTH_LOCKOUT_WINDOW_MS,
760
+ });
761
+ return;
762
+ }
763
+ entry.count++;
764
+ if (entry.count === AUTH_LOCKOUT_MAX_FAILURES) {
765
+ logWarn(
766
+ "native",
767
+ `Bridge auth lockout for ${remote} (${AUTH_LOCKOUT_MAX_FAILURES} wrong tokens in ${AUTH_LOCKOUT_WINDOW_MS / 60_000}m)`,
768
+ );
769
+ }
691
770
  }
692
771
 
693
772
  /**
@@ -708,6 +787,8 @@ export class BridgeServer {
708
787
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
709
788
  "Access-Control-Allow-Headers": "Authorization, Content-Type",
710
789
  "Access-Control-Max-Age": "86400",
790
+ // Every response states its type; never let a browser guess one.
791
+ "X-Content-Type-Options": "nosniff",
711
792
  };
712
793
  }
713
794
 
@@ -211,7 +211,7 @@ export function registerAdminCommands(
211
211
  }
212
212
 
213
213
  // Unknown /command → "did you mean ...?" via the C similarity core
214
- // (native/strsim-c). Registered after every real command, so grammY
214
+ // (native/strsim-wasm). Registered after every real command, so grammY
215
215
  // only reaches this when nothing above matched. Only bare commands
216
216
  // are intercepted — a close miss gets a suggestion, anything else
217
217
  // keeps flowing to the agent as a normal message.
@@ -18,7 +18,7 @@ export function splitMessage(text: string, max: number): string[] {
18
18
  /**
19
19
  * Escape HTML special characters for Telegram HTML parse mode.
20
20
  * Must be applied to all text that is NOT inside an HTML tag.
21
- * Delegates to the C++ core (native/htmlents-cpp): one pass over the
21
+ * Delegates to the Rust core (native/htmlents-wasm): one pass over the
22
22
  * bytes instead of the five chained regex passes this replaces.
23
23
  */
24
24
  export function escapeHtml(text: string): string {
@@ -1,7 +1,7 @@
1
1
  // Generated by native/shared/build-lib.mjs — do not edit by hand.
2
- // Rebuild with `npm run build:cpp` after changing the source.
3
- // Source: native/htmlents-cpp (1448 bytes of wasm32-freestanding).
2
+ // Rebuild with `npm run build:wasm` after changing the source.
3
+ // Source: native/htmlents-wasm (1195 bytes of wasm32-unknown-unknown).
4
4
 
5
- /** native/htmlents-cpp artifact, base64-encoded. Decoded + instantiated lazily by src/native/htmlents.ts. */
5
+ /** native/htmlents-wasm artifact, base64-encoded. Decoded + instantiated lazily by src/native/htmlents.ts. */
6
6
  export const HTMLENTS_WASM_BASE64 =
7
- "AGFzbQEAAAABEQNgAX8Bf2ACf38AYAJ/fwF/AwQDAAECBAUBcAEBAQUDAQARBgkBfwFBgIDAAAsHKgQGbWVtb3J5AgAFYWxsb2MAAAdkZWFsbG9jAAELZXNjYXBlX2h0bWwAAgruCQOPAQEDfwJAIAANAEEADwtBACEBQQBBACgCyIDAgAAiAkHQgMCAACACG0EHakF4cSICNgLIgMCAAAJAIAIgAGoiACACSQ0AAkAgAD8AQRB0IgNNDQAgACADa0H//wNqQRB2QABBf0YNAQtBACAANgLIgMCAAEEAQQAoAsyAwIAAQQFqNgLMgMCAACACIQELIAELQAACQCAARQ0AIAFFDQBBACgCzIDAgAAiAEUNAEEAIABBf2oiADYCzIDAgAAgAA0AQQBB0IDAgAA2AsiAwIAACwuZCAEJf0EAIQICQAJAIAENAEEMIQNBACEEDAELIAFBAXEhBQJAAkAgAUEBRw0AQQAhBEEAIQMMAQsgAUF+cSEGQQAhBEEAIQMDQEGAgMCAACEHQQEhCEEBIQkCQAJAAkACQAJAAkAgACADaiIKLQAAQV5qDh0CBQUFBAMFBQUFBQUFBQUFBQUFBQUFBQUFBQAFAQULQaiAwIAAIQcMAwtBsIDAgAAhBwwCC0G4gMCAACEHDAELQcCAwIAAIQcLIAcoAgQhCQsgCSAEaiEHQYCAwIAAIQkCQAJAAkACQAJAAkAgCkEBai0AAEFeag4dAQUFBQQABQUFBQUFBQUFBQUFBQUFBQUFBQUDBQIFC0HAgMCAACEJDAMLQbiAwIAAIQkMAgtBsIDAgAAhCQwBC0GogMCAACEJCyAJKAIEIQgLIAggB2ohBCAGIANBAmoiA0cNAAsLAkAgBUUNAEGAgMCAACEJQQEhCAJAAkACQAJAAkACQCAAIANqLQAAQV5qDh0BBQUFBAAFBQUFBQUFBQUFBQUFBQUFBQUFBQMFAgULQcCAwIAAIQkMAwtBuIDAgAAhCQwCC0GwgMCAACEJDAELQaiAwIAAIQkLIAkoAgQhCAsgCCAEaiEECyAEQQxqIgMNAEEADwtBAEEAKALIgMCAACIIQdCAwIAAIAgbQQdqQXhxIgU2AsiAwIAAAkAgBSADaiIIIAVJDQACQCAIPwBBEHQiCU0NACAIIAlrQf//A2pBEHZAAEF/Rg0BC0EAIQJBACAINgLIgMCAAEEAQQAoAsyAwIAAQQFqNgLMgMCAACAFRQ0AIAUgBDoACCAFQQE2AgQgBSADOgAAIAUgBEEYdjoACyAFIARBEHY6AAogBSAEQQh2OgAJIAUgA0EYdjoAAyAFIANBEHY6AAIgBSADQQh2OgABAkAgAUUNACAFQQxqIQNBACECA0BBgIDAgAAhCAJAAkACQAJAAkACQAJAIAAgAmotAAAiCUFeag4dAgUFBQQDBQUFBQUFBQUFBQUFBQUFBQUFBQUABQEFC0GogMCAACEIDAMLQbCAwIAAIQgMAgtBuIDAgAAhCAwBC0HAgMCAACEICyAIKAIEIglFDQEgCUEDcSEEIAgoAgAhCkEAIQgCQCAJQQRJDQAgCUF8cSEGQQAhCANAIAMgCGoiCSAKIAhqIgctAAA6AAAgCUEBaiAHQQFqLQAAOgAAIAlBAmogB0ECai0AADoAACAJQQNqIAdBA2otAAA6AAAgBiAIQQRqIghHDQALIAMgCGohAwsgBEUNASAKIAhqIQgDQCADIAgtAAA6AAAgCEEBaiEIIANBAWohAyAEQX9qIgQNAAwCCwsgAyAJOgAAIANBAWohAwsgAkEBaiICIAFHDQALCyAFIQILIAILC1EBAEGAgMAAC0gZABAABQAAACZxdW90OwAmbHQ7ACZndDsAJmFtcDsAJiMzOTsAAAAADwAQAAQAAAAUABAABAAAAAgAEAAGAAAAHwAQAAUAAAA=";
7
+ "AGFzbQEAAAABKQdgBX9/f39/AGADf39/AGACf38AYAR/f39/AGABfwF/YAJ/fwF/YAAAAwsKAAECAwIEAgUCBgUDAQARBhkDfwFBgIDAAAt/AEGggMAAC38AQaCAwAALB0UGBm1lbW9yeQIABWFsbG9jAAULX19oZWFwX2Jhc2UDAQdkZWFsbG9jAAYLZXNjYXBlX2h0bWwABwpfX2RhdGFfZW5kAwIK3gcKNQACQCACIAFJDQAgAiAESw0AIAAgAiABazYCBCAAIAMgAWo2AgAPCyABIAIgBBCBgICAAAALCQAQiYCAgAAAC40BAQJ/QQAhAgJAAkACQAJAAkACQAJAAkAgAUH/AXEiAUFeag4GBAcHBwEFAAsgAUFEag4DAQYCBgtBgIDAgAAhAgwEC0GFgMCAACECQQQhAwwEC0GJgMCAACECQQQhAwwDC0GNgMCAACECQQYhAwwCC0GTgMCAACECC0EFIQMLIAAgAzYCBCAAIAI2AgALKgACQCABIANHDQACQCABRQ0AIAAgAiAB/AoAAAsPCyABIAMQhICAgAAACwkAEImAgIAAAAuiAQEDfwJAIAANAEEADwtBACEBAkBBACgCmIDAgAAiAg0AQaCAwIAAIQJBAEGggMCAADYCmIDAgAALAkACQCACQQdqQXhxIgIgAGoiACACSQ0AIAA/AEEQdCIDTQ0BQQAhASAAIANrQRB2IABB//8DcUEAR2pAAEF/Rw0BCyABDwtBACAANgKYgMCAAEEAQQAoApyAwIAAQQFqNgKcgMCAACACC0AAAkAgAEUNACABRQ0AQQAoApyAwIAAIgBFDQBBACAAQX9qIgA2ApyAwIAAIAANAEEAQaCAwIAANgKYgMCAAAsL5AMBB38jgICAgABBwABrIgIkgICAgABBACEDQQAhBAJAIAFFDQBBACEEIAAhBSABIQYDQCACQTBqIAUtAAAQgoCAgAAgAigCNEEBIAIoAjAbIARqIQQgBUEBaiEFIAZBf2oiBg0ACwtBDCEFAkAgBEEMaiIHEIWAgIAAIghFDQAgAEEBIAEbIQYgAkEoakEAQQQgCCAHEICAgIAAIAIoAiwhAyACKAIoIQAgAiAHNgI8IAAgAyACQTxqQQQQg4CAgAAgAkEgakEEQQggCCAHEICAgIAAIAIoAiQhAyACKAIgIQAgAkEBNgI8IAAgAyACQTxqQQQQg4CAgAAgAkEYakEIQQwgCCAHEICAgIAAIAIoAhwhAyACKAIYIQAgAiAENgI8IAAgAyACQTxqQQQQg4CAgAADQAJAAkACQCABRQ0AIAJBEGogBi0AACIDEIKAgIAAIAIoAhAiBEUNASACQQhqIAUgAigCFCIDIAVqIgAgCCAHEICAgIAAIAIoAgggAigCDCAEIAMQg4CAgAAgACEFDAILIAghAwwDCwJAIAUgB08NACAIIAVqIAM6AAAgBUEBaiEFDAELIAUgBxCIgICAAAALIAZBAWohBiABQX9qIQEMAAsLIAJBwABqJICAgIAAIAMLCQAQiYCAgAAACwMAAAsLIQEAQYCAwAALGCZhbXA7Jmx0OyZndDsmcXVvdDsmIzM5Ow==";
@@ -1,10 +1,10 @@
1
1
  /**
2
- * HTML entity escaping — TypeScript boundary over the C++ wasm module.
2
+ * HTML entity escaping — TypeScript boundary over the Rust wasm module.
3
3
  *
4
4
  * One single-pass escaper for Telegram HTML parse mode, replacing the
5
5
  * five chained regex passes it supersedes. Every outbound Telegram
6
- * render flows through here (frontend/telegram/formatting.ts). The C++
7
- * source lives in native/htmlents-cpp and exports a three-function
6
+ * render flows through here (frontend/telegram/formatting.ts). The Rust
7
+ * source lives in native/htmlents-wasm and exports a three-function
8
8
  * C ABI (alloc / dealloc / escape_html) — see that module's README for
9
9
  * the contract, and src/native/runtime.ts for the embedding and memory
10
10
  * conventions shared by every native module.
@@ -24,7 +24,7 @@ import {
24
24
  type WasmCoreExports,
25
25
  } from "./runtime.js";
26
26
 
27
- /** The C-ABI surface exported by native/htmlents-cpp. */
27
+ /** The C-ABI surface exported by native/htmlents-wasm. */
28
28
  interface HtmlentsExports extends WasmCoreExports {
29
29
  escape_html(inputPtr: number, len: number): number;
30
30
  }
@@ -75,9 +75,9 @@ export const NATIVE_MODULES: readonly NativeModuleSpec[] = [
75
75
  },
76
76
  {
77
77
  name: "strsim",
78
- language: "C",
79
- target: "wasm32-freestanding",
80
- sourceDir: "native/strsim-c",
78
+ language: "Rust",
79
+ target: "wasm32-unknown-unknown",
80
+ sourceDir: "native/strsim-wasm",
81
81
  sizeBytes: async () =>
82
82
  base64ByteLength(
83
83
  (await import("./strsim-wasm-bytes.js")).STRSIM_WASM_BASE64,
@@ -90,9 +90,9 @@ export const NATIVE_MODULES: readonly NativeModuleSpec[] = [
90
90
  },
91
91
  {
92
92
  name: "sqlguard",
93
- language: "C",
94
- target: "wasm32-freestanding",
95
- sourceDir: "native/sqlguard-c",
93
+ language: "Rust",
94
+ target: "wasm32-unknown-unknown",
95
+ sourceDir: "native/sqlguard-wasm",
96
96
  sizeBytes: async () =>
97
97
  base64ByteLength(
98
98
  (await import("./sqlguard-wasm-bytes.js")).SQLGUARD_WASM_BASE64,
@@ -107,9 +107,9 @@ export const NATIVE_MODULES: readonly NativeModuleSpec[] = [
107
107
  },
108
108
  {
109
109
  name: "htmlents",
110
- language: "C++",
111
- target: "wasm32-freestanding",
112
- sourceDir: "native/htmlents-cpp",
110
+ language: "Rust",
111
+ target: "wasm32-unknown-unknown",
112
+ sourceDir: "native/htmlents-wasm",
113
113
  sizeBytes: async () =>
114
114
  base64ByteLength(
115
115
  (await import("./htmlents-wasm-bytes.js")).HTMLENTS_WASM_BASE64,
@@ -2,8 +2,8 @@
2
2
  * Embedded-wasm runtime — the one place that knows how Talon's native
3
3
  * modules cross the JS boundary.
4
4
  *
5
- * Every wasm module in the native plane (Rust blake3, Zig textops,
6
- * C strsim, C++ htmlents) follows the same contract:
5
+ * Every wasm module in the native plane (Rust blake3, strsim,
6
+ * sqlguard, htmlents; Zig textops) follows the same contract:
7
7
  *
8
8
  * - The artifact ships as base64 inside a generated `*-bytes.ts`
9
9
  * module, so it survives `bun build --compile` single binaries —
@@ -1,7 +1,7 @@
1
1
  // Generated by native/shared/build-lib.mjs — do not edit by hand.
2
- // Rebuild with `npm run build:c` after changing the source.
3
- // Source: native/sqlguard-c (2252 bytes of wasm32-freestanding).
2
+ // Rebuild with `npm run build:wasm` after changing the source.
3
+ // Source: native/sqlguard-wasm (1805 bytes of wasm32-unknown-unknown).
4
4
 
5
- /** native/sqlguard-c artifact, base64-encoded. Decoded + instantiated lazily by src/native/sqlguard.ts. */
5
+ /** native/sqlguard-wasm artifact, base64-encoded. Decoded + instantiated lazily by src/native/sqlguard.ts. */
6
6
  export const SQLGUARD_WASM_BASE64 =
7
- "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";
7
+ "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";
@@ -6,7 +6,7 @@
6
6
  * pattern for a LIKE search (get_user_messages), and `ftsQuote` turns
7
7
  * free-form text into a literal FTS5 MATCH expression (search_history).
8
8
  *
9
- * The C source lives in native/sqlguard-c and exports a four-function
9
+ * The Rust source lives in native/sqlguard-wasm and exports a four-function
10
10
  * C ABI (alloc / dealloc / escape_like / fts_quote) — see that module's
11
11
  * README for the contract, and src/native/runtime.ts for the embedding
12
12
  * and memory conventions shared by every native module.
@@ -27,7 +27,7 @@ import {
27
27
  } from "./runtime.js";
28
28
  import { SQLGUARD_WASM_BASE64 } from "./sqlguard-wasm-bytes.js";
29
29
 
30
- /** The C-ABI surface exported by native/sqlguard-c. */
30
+ /** The C-ABI surface exported by native/sqlguard-wasm. */
31
31
  interface SqlguardExports extends WasmCoreExports {
32
32
  escape_like(inputPtr: number, len: number): number;
33
33
  fts_quote(inputPtr: number, len: number): number;
@@ -1,7 +1,7 @@
1
1
  // Generated by native/shared/build-lib.mjs — do not edit by hand.
2
- // Rebuild with `npm run build:c` after changing the source.
3
- // Source: native/strsim-c (775 bytes of wasm32-freestanding).
2
+ // Rebuild with `npm run build:wasm` after changing the source.
3
+ // Source: native/strsim-wasm (725 bytes of wasm32-unknown-unknown).
4
4
 
5
- /** native/strsim-c artifact, base64-encoded. Decoded + instantiated lazily by src/native/strsim.ts. */
5
+ /** native/strsim-wasm artifact, base64-encoded. Decoded + instantiated lazily by src/native/strsim.ts. */
6
6
  export const STRSIM_WASM_BASE64 =
7
- "AGFzbQEAAAABEwNgAX8Bf2ACf38AYAR/f39/AX8DBAMAAQIEBQFwAQEBBQMBABEGCQF/AUGAgMAACwcqBAZtZW1vcnkCAAVhbGxvYwAAB2RlYWxsb2MAAQtsZXZlbnNodGVpbgACCp4FA48BAQN/AkAgAA0AQQAPC0EAIQFBAEEAKAKAgMCAACICQaCgwIAAIAIbQQdqQXhxIgI2AoCAwIAAAkAgAiAAaiIAIAJJDQACQCAAPwBBEHQiA00NACAAIANrQf//A2pBEHZAAEF/Rg0BC0EAIAA2AoCAwIAAQQBBACgChIDAgABBAWo2AoSAwIAAIAIhAQsgAQtAAAJAIABFDQAgAUUNAEEAKAKEgMCAACIARQ0AQQAgAEF/aiIANgKEgMCAACAADQBBAEGgoMCAADYCgIDAgAALC8kDAQl/QX8hBAJAIAFBgAhLDQAgA0GACEsNAAJAIAENACADDwsCQCADDQAgAQ8LIANBAWoiBUEHcSEGQQAhBAJAIANBB0kNACAFQfgfcSEHQQAhBEGQgMCAACEFA0AgBSAENgIAIAVBHGogBEEHajYCACAFQRhqIARBBmo2AgAgBUEUaiAEQQVqNgIAIAVBEGogBEEEajYCACAFQQxqIARBA2o2AgAgBUEIaiAEQQJqNgIAIAVBBGogBEEBajYCACAFQSBqIQUgBEEIaiIEIAdHDQALCwJAIAZFDQAgBEECdEGQgMCAAGohBQNAIAUgBDYCACAFQQRqIQUgBEEBaiEEIAZBf2oiBg0ACwsgAEF/aiEIQQAoApCAwIAAIQZBASEFA0BBlIDAgAAhBCAIIAUiCWotAABB/wFxIQogAyEHIAIhBSAJIQADQCAEIABBAWoiACAEKAIAIgtBAWoiDCAGIAogBS0AAEdqIgYgDCAGSRsiBiAAIAZJGyIANgIAIARBBGohBCAFQQFqIQUgCyEGIAdBf2oiBw0ACyAJQQFqIQUgCSEGIAkgAUcNAAtBACAJNgKQgMCAACADQQJ0KAKQgMCAACEECyAECw==";
7
+ "AGFzbQEAAAABFgRgAX8Bf2ACf38AYAR/f39/AX9gAAADBgUAAQIBAwUDAQARBhkDfwFBgIDAAAt/AEGQoMAAC38AQYygwAALB0UGBm1lbW9yeQIABWFsbG9jAAALX19oZWFwX2Jhc2UDAQdkZWFsbG9jAAELbGV2ZW5zaHRlaW4AAgpfX2RhdGFfZW5kAwIKwwQFogEBA38CQCAADQBBAA8LQQAhAQJAQQAoAoSgwIAAIgINAEGQoMCAACECQQBBkKDAgAA2AoSgwIAACwJAAkAgAkEHakF4cSICIABqIgAgAkkNACAAPwBBEHQiA00NAUEAIQEgACADa0EQdiAAQf//A3FBAEdqQABBf0cNAQsgAQ8LQQAgADYChKDAgABBAEEAKAKIoMCAAEEBajYCiKDAgAAgAgtAAAJAIABFDQAgAUUNAEEAKAKIoMCAACIARQ0AQQAgAEF/aiIANgKIoMCAACAADQBBAEGQoMCAADYChKDAgAALC80CAQl/QX8hBAJAIAFBgAhLDQAgA0GACEsNAAJAIAENACADDwsCQCADDQAgAQ8LIANBAWohBUEAIQRBgIDAgAAhBgJAA0AgBSAERg0BIAYgBDYCACAGQQRqIQYgBEEBaiEEDAALCyAAIAFqIQdBACEIQQAoAoCAwIAAIQUCQANAIAAgB0YNASAALQAAIQFBACEEQQAgCEEBaiIINgKAgMCAACAAQQFqIQBBhIDAgAAhBiABQf8BcSEJIAghAQNAIAIgBGohCgJAIAMgBEcNACAIIQUMAgsgBEEBaiELAkAgBEGACEcNACALQYEIEIOAgIAAAAsgBiABQQFqIgQgBigCACIMQQFqIgEgBSAJIAotAABHaiIFIAEgBUkbIgUgBCAFSRsiATYCACAGQQRqIQYgCyEEIAwhBQwACwsLIANBAnQoAoCAwIAAIQQLIAQLCQAQhICAgAAACwMAAAs=";
@@ -2,8 +2,8 @@
2
2
  * String similarity — TypeScript boundary over the C wasm module.
3
3
  *
4
4
  * Powers "did you mean ...?" suggestions for unknown Telegram slash
5
- * commands and unknown CLI subcommands. The C source lives in
6
- * native/strsim-c and exports a three-function C ABI (alloc / dealloc /
5
+ * commands and unknown CLI subcommands. The Rust source lives in
6
+ * native/strsim-wasm and exports a three-function C ABI (alloc / dealloc /
7
7
  * levenshtein) — see that module's README for the contract, and
8
8
  * src/native/runtime.ts for the embedding and memory conventions
9
9
  * shared by every native module.
@@ -29,7 +29,7 @@ import {
29
29
  /** Returned by the wasm side when either input exceeds its DP buffer. */
30
30
  const OVERFLOW = 0xffffffff;
31
31
 
32
- /** The C-ABI surface exported by native/strsim-c. */
32
+ /** The C-ABI surface exported by native/strsim-wasm. */
33
33
  interface StrsimExports extends WasmCoreExports {
34
34
  levenshtein(aPtr: number, aLen: number, bPtr: number, bLen: number): number;
35
35
  }
@@ -155,7 +155,7 @@ export function getRecentFormatted(chatId: string, limit = 20): string {
155
155
  * Build an FTS5 MATCH expression from free-form user input. Every
156
156
  * token is double-quoted so FTS operators (AND, NEAR, *, ^) in user
157
157
  * text are treated as literals, not syntax. Delegates to the C core
158
- * (native/sqlguard-c) for byte-identical output.
158
+ * (native/sqlguard-wasm) for byte-identical output.
159
159
  */
160
160
  function ftsQuery(query: string): string {
161
161
  return ftsQuote(query);
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, mkdirSync } from "node:fs";
2
2
  import writeFileAtomic from "write-file-atomic";
3
3
  import { z } from "zod";
4
4
  import { dirs, files as pathFiles } from "./paths.js";
5
+ import { hardenTalonPermissions } from "./harden.js";
5
6
  import { setTimezone } from "./time.js";
6
7
  import { BACKEND_IDS } from "../core/agent-runtime/model-ref.js";
7
8
  import {
@@ -137,6 +138,8 @@ const frontendEnum = z.enum([
137
138
  * Defaults are loopback-only and unauthenticated (single-machine use). To
138
139
  * reach Talon remotely, set `host: "0.0.0.0"` and a `token` — the bridge
139
140
  * then requires `Authorization: Bearer <token>` (or `?token=` for SSE).
141
+ * A non-loopback bind with no token auto-mints a persistent one
142
+ * (~/.talon/keys/bridge-token) rather than serving the LAN open.
140
143
  */
141
144
  const nativeConfigSchema = z
142
145
  .object({
@@ -150,8 +153,9 @@ const nativeConfigSchema = z
150
153
  host: z.string().default("127.0.0.1"),
151
154
  /**
152
155
  * Optional shared secret. When set, every request must present it as a
153
- * bearer token (header) or `?token=` query param (SSE). Required in
154
- * practice whenever `host` is not loopback.
156
+ * bearer token (header) or `?token=` query param (SSE). When unset on a
157
+ * non-loopback `host`, the bridge mints and persists one automatically
158
+ * (~/.talon/keys/bridge-token) — the network never gets an open bridge.
155
159
  */
156
160
  token: z.string().optional(),
157
161
  /**
@@ -596,9 +600,11 @@ function ensureConfigFile(): boolean {
596
600
  if (!existsSync(dirs.root)) mkdirSync(dirs.root, { recursive: true });
597
601
  if (!existsSync(dirs.data)) mkdirSync(dirs.data, { recursive: true });
598
602
  if (!existsSync(CONFIG_FILE)) {
603
+ // Owner-only from birth: the config accumulates bot tokens and API keys.
599
604
  writeFileAtomic.sync(
600
605
  CONFIG_FILE,
601
606
  JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n",
607
+ { mode: 0o600 },
602
608
  );
603
609
  return true;
604
610
  }
@@ -613,6 +619,7 @@ function ensureConfigFile(): boolean {
613
619
 
614
620
  export function loadConfig(): TalonConfig {
615
621
  ensureConfigFile();
622
+ hardenTalonPermissions();
616
623
  const fileConfig = normalizeDeprecatedFrontendConfig(loadConfigFile());
617
624
 
618
625
  // Runtime frontend override (TALON_FRONTEND_OVERRIDE). Lets the native
@@ -0,0 +1,50 @@
1
+ /**
2
+ * At-rest permission hardening for ~/.talon/.
3
+ *
4
+ * The tree holds credentials (config.json carries bot tokens, API keys and
5
+ * the bridge token; .user-session is a full Telegram login) plus chat
6
+ * history and logs. Node creates most of it with umask defaults, so on a
7
+ * multi-user machine everything is group/world readable. This pass clamps
8
+ * the sensitive surface to owner-only on every boot — idempotent, cheap,
9
+ * and it repairs installs created before the tightened modes existed.
10
+ *
11
+ * Best-effort by design: a missing path just hasn't been created yet, and
12
+ * a chmod failure (read-only mount, foreign owner) must never block boot.
13
+ * Windows has no POSIX modes worth mapping, so the pass is a no-op there.
14
+ */
15
+
16
+ import { chmodSync } from "node:fs";
17
+ import { dirs, files } from "./paths.js";
18
+
19
+ const OWNER_DIR = 0o700;
20
+ const OWNER_FILE = 0o600;
21
+
22
+ /** Owner-only directories: everything under them inherits the protection. */
23
+ const HARDENED_DIRS: readonly string[] = [dirs.root, dirs.data, dirs.keys];
24
+
25
+ /** Belt-and-braces owner-only files, should they ever move out of the tree. */
26
+ const HARDENED_FILES: readonly string[] = [
27
+ files.config,
28
+ files.log,
29
+ files.database,
30
+ files.userSession,
31
+ ];
32
+
33
+ /** Clamp permissions on the sensitive parts of ~/.talon/. */
34
+ export function hardenTalonPermissions(): void {
35
+ if (process.platform === "win32") return;
36
+ for (const dir of HARDENED_DIRS) {
37
+ try {
38
+ chmodSync(dir, OWNER_DIR);
39
+ } catch {
40
+ // absent or not ours — nothing to protect yet
41
+ }
42
+ }
43
+ for (const file of HARDENED_FILES) {
44
+ try {
45
+ chmodSync(file, OWNER_FILE);
46
+ } catch {
47
+ // absent or not ours — nothing to protect yet
48
+ }
49
+ }
50
+ }
package/src/util/log.ts CHANGED
@@ -137,7 +137,8 @@ if (!quiet) {
137
137
  if (!IS_VITEST) {
138
138
  streams.push({
139
139
  level: "trace",
140
- stream: createWriteStream(LOG_FILE, { flags: "a" }),
140
+ // 0600: turns and tool output land here — same sensitivity as history.
141
+ stream: createWriteStream(LOG_FILE, { flags: "a", mode: 0o600 }),
141
142
  });
142
143
  }
143
144