@securecoms/agent-runtime 0.9.2 → 0.9.3

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
@@ -286,7 +286,7 @@ to a host the operator did not name.
286
286
  | `fetch_url` | `url` | Fetches a page and returns extracted text (HTML is stripped to text). Up to 3 redirects, 10 s timeout, 2 MB response cap. |
287
287
  | `web_search` | `query`, `count` (1–10, default 5) | `requires: [search_provider]` — needs a readable, non-empty `SECURECOMS_BRAVE_KEY_FILE` or `SECURECOMS_TAVILY_KEY_FILE`; Brave is used first when both are set. |
288
288
  | `http_request` | `name` (a configured `endpoint:<name>`), `path` (a query string is kept, a `#fragment` is dropped), `method` (GET/POST/PUT/DELETE), `body` | `requires: [any_endpoint]`. The model names an endpoint and a path, never a host; a path that would leave the endpoint's `baseUrl` origin is refused. |
289
- | `sql_query` | `name` (a configured `database:<name>`), `sql` (read-only `SELECT` only; a `;` or a write keyword anywhere in the text — inside a string literal or a comment included — is refused) | `requires: [any_database]`. SQLite or Postgres (`pg` required separately for Postgres). Results capped at 200 rows. |
289
+ | `sql_query` | `name` (a configured `database:<name>`), `sql` (read-only `SELECT` only; a `;` or a write keyword anywhere in the text — inside a string literal or a comment included — is refused) | `requires: [any_database]`. SQLite or Postgres (`pg` required separately for Postgres). Results capped at 200 rows. Postgres values come back as the server's text (json/jsonb as a JSON string, arrays like `{1,2}`, timestamps, int8 and numeric as strings); rows past a 1 MiB value budget are dropped, and `truncated_reason` (`row_limit` or `size_limit`) says why rows are missing. |
290
290
 
291
291
  ## skills.json
292
292
 
package/dist/bin.js CHANGED
@@ -2449,6 +2449,7 @@ var httpRequest = async (args, ctx) => {
2449
2449
  // src/builtins/sql-query.ts
2450
2450
  import { readFileSync as readFileSync5 } from "node:fs";
2451
2451
  import { spawn } from "node:child_process";
2452
+ import { createConnection } from "node:net";
2452
2453
 
2453
2454
  // src/sql-guard.ts
2454
2455
  var SqlNotReadOnlyError = class extends Error {
@@ -2641,15 +2642,181 @@ function executeSqliteQuery(path, sql, options = {}) {
2641
2642
  }));
2642
2643
  });
2643
2644
  }
2644
- function envelope(columns, rows, truncated) {
2645
+ var PG_WIRE_BYTE_CAP = 4 * 1024 * 1024;
2646
+ var PG_ROW_BYTE_BUDGET = SQLITE_IPC_CAP_BYTES;
2647
+ var PG_IDLE_IN_TRANSACTION_MS = 15e3;
2648
+ var PG_CURSOR = "sc_q";
2649
+ var PG_SCALAR_PARSERS = {
2650
+ 16: (t) => t === "t",
2651
+ // bool
2652
+ 21: Number,
2653
+ // int2
2654
+ 23: Number,
2655
+ // int4
2656
+ 26: Number,
2657
+ // oid
2658
+ 700: Number,
2659
+ // float4
2660
+ 701: Number
2661
+ // float8
2662
+ };
2663
+ var PG_TEXT_TYPES = {
2664
+ getTypeParser(oid, format) {
2665
+ if (format === "binary") return (value) => value;
2666
+ return PG_SCALAR_PARSERS[oid] ?? ((value) => value);
2667
+ }
2668
+ };
2669
+ var PgWireByteCapError = class extends Error {
2670
+ constructor() {
2671
+ super("database result exceeded the size limit");
2672
+ this.name = "PgWireByteCapError";
2673
+ }
2674
+ };
2675
+ var PgWireBoundUnavailableError = class extends Error {
2676
+ constructor() {
2677
+ super(
2678
+ 'the installed "pg" driver does not expose its socket, so the result size cannot be bounded; the query was not run'
2679
+ );
2680
+ this.name = "PgWireBoundUnavailableError";
2681
+ }
2682
+ };
2683
+ var PgDriverMissingError = class extends Error {
2684
+ };
2685
+ function isPgStream(value) {
2686
+ return typeof value === "object" && value !== null && typeof value.on === "function" && typeof value.destroy === "function";
2687
+ }
2688
+ async function loadInstalledPg() {
2689
+ return import("pg");
2690
+ }
2691
+ function rowValueBytes(row) {
2692
+ let n = 0;
2693
+ for (const v of row) n += typeof v === "string" ? Buffer.byteLength(v) : 8;
2694
+ return n;
2695
+ }
2696
+ function sendCancelRequest(client) {
2697
+ const { processID, secretKey, host, port } = client;
2698
+ if (typeof processID !== "number" || typeof secretKey !== "number") return;
2699
+ if (typeof host !== "string" || typeof port !== "number") return;
2700
+ const packet = Buffer.alloc(16);
2701
+ packet.writeInt32BE(16, 0);
2702
+ packet.writeInt32BE(80877102, 4);
2703
+ packet.writeInt32BE(processID, 8);
2704
+ packet.writeInt32BE(secretKey, 12);
2705
+ const socket = host.startsWith("/") ? createConnection(`${host}/.s.PGSQL.${port}`) : createConnection(port, host);
2706
+ socket.setTimeout(PG_CANCEL_TIMEOUT_MS, () => socket.destroy());
2707
+ socket.on("error", () => socket.destroy());
2708
+ socket.once("connect", () => socket.end(packet));
2709
+ socket.unref();
2710
+ }
2711
+ var PG_CANCEL_TIMEOUT_MS = 5e3;
2712
+ async function executePostgresQuery(connectionString, sql, options = {}) {
2713
+ const timeoutMs = Math.max(1, Math.floor(options.timeoutMs ?? SQL_TIMEOUT_MS));
2714
+ const wireByteCap = options.wireByteCap ?? PG_WIRE_BYTE_CAP;
2715
+ if (options.signal?.aborted) throw new Error("Postgres query cancelled");
2716
+ let pg;
2717
+ try {
2718
+ pg = await (options.loadDriver ?? loadInstalledPg)();
2719
+ } catch {
2720
+ throw new PgDriverMissingError('the optional "pg" module is not installed (npm i -g pg)');
2721
+ }
2722
+ const Client = pg.Client ?? pg.default?.Client;
2723
+ if (!Client) throw new Error(`the installed "pg" module does not export a Client`);
2724
+ const client = new Client({
2725
+ connectionString,
2726
+ // never logged (D4)
2727
+ statement_timeout: timeoutMs,
2728
+ connectionTimeoutMillis: timeoutMs,
2729
+ types: PG_TEXT_TYPES
2730
+ });
2731
+ client.on("error", () => {
2732
+ });
2733
+ let stopped;
2734
+ const stop = (reason) => {
2735
+ if (stopped) return;
2736
+ stopped = reason;
2737
+ sendCancelRequest(client);
2738
+ const stream = client.connection?.stream;
2739
+ if (isPgStream(stream)) stream.destroy();
2740
+ else void client.end().catch(() => {
2741
+ });
2742
+ };
2743
+ const onAbort = () => stop("cancelled");
2744
+ const deadline = setTimeout(() => stop("deadline"), timeoutMs);
2745
+ options.signal?.addEventListener("abort", onAbort, { once: true });
2746
+ const stoppedError = () => {
2747
+ if (stopped === "capped") return new PgWireByteCapError();
2748
+ if (stopped === "deadline") return new Error(`Postgres query timed out after ${timeoutMs}ms`);
2749
+ if (stopped === "cancelled") return new Error("Postgres query cancelled");
2750
+ return void 0;
2751
+ };
2752
+ let began = false;
2753
+ try {
2754
+ try {
2755
+ await client.connect();
2756
+ } catch (error) {
2757
+ throw stoppedError() ?? error;
2758
+ }
2759
+ const early = stoppedError();
2760
+ if (early) throw early;
2761
+ const stream = client.connection?.stream;
2762
+ if (!isPgStream(stream)) throw new PgWireBoundUnavailableError();
2763
+ let wireBytes = 0;
2764
+ stream.on("data", (chunk) => {
2765
+ if (stopped) return;
2766
+ wireBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength;
2767
+ if (wireBytes > wireByteCap) stop("capped");
2768
+ });
2769
+ const run = async (text, rowMode) => {
2770
+ try {
2771
+ const result = await client.query(rowMode ? { text, rowMode } : text);
2772
+ const late = stoppedError();
2773
+ if (late) throw late;
2774
+ return result;
2775
+ } catch (error) {
2776
+ throw stoppedError() ?? error;
2777
+ }
2778
+ };
2779
+ began = true;
2780
+ await run("BEGIN READ ONLY");
2781
+ await run(`SET LOCAL statement_timeout = ${timeoutMs}`);
2782
+ await run(`SET LOCAL idle_in_transaction_session_timeout = ${Math.max(timeoutMs, PG_IDLE_IN_TRANSACTION_MS)}`);
2783
+ await run(`DECLARE ${PG_CURSOR} NO SCROLL CURSOR FOR ${sql}`);
2784
+ const fetched = await run(`FETCH FORWARD ${SQL_ROW_CAP + 1} FROM ${PG_CURSOR}`, "array");
2785
+ const columns = fetched.fields.map((f) => f.name);
2786
+ const all = fetched.rows;
2787
+ let truncatedReason = all.length > SQL_ROW_CAP ? "row_limit" : void 0;
2788
+ const rows = [];
2789
+ let bytes = 0;
2790
+ for (const row of all.slice(0, SQL_ROW_CAP)) {
2791
+ bytes += rowValueBytes(row);
2792
+ if (bytes > PG_ROW_BYTE_BUDGET) {
2793
+ truncatedReason = "size_limit";
2794
+ break;
2795
+ }
2796
+ rows.push(row);
2797
+ }
2798
+ return { columns, rows, truncated: truncatedReason !== void 0, truncatedReason };
2799
+ } finally {
2800
+ options.signal?.removeEventListener("abort", onAbort);
2801
+ if (began && !stopped) await client.query("ROLLBACK").catch(() => {
2802
+ });
2803
+ if (stopped) void client.end().catch(() => {
2804
+ });
2805
+ else await client.end().catch(() => {
2806
+ });
2807
+ clearTimeout(deadline);
2808
+ }
2809
+ }
2810
+ function envelope(columns, rows, truncated, reason) {
2645
2811
  let kept = rows;
2646
- let cut = truncated;
2812
+ let why = truncated ? reason ?? "row_limit" : void 0;
2813
+ const render = () => JSON.stringify(why ? { columns, rows: kept, truncated: true, truncated_reason: why } : { columns, rows: kept, truncated: false });
2647
2814
  try {
2648
- let out = JSON.stringify({ columns, rows: kept, truncated: cut });
2815
+ let out = render();
2649
2816
  while (resultBytes(out) > RESULT_CAP && kept.length > 0) {
2650
2817
  kept = kept.slice(0, Math.floor(kept.length / 2));
2651
- cut = true;
2652
- out = JSON.stringify({ columns, rows: kept, truncated: cut });
2818
+ why = "size_limit";
2819
+ out = render();
2653
2820
  }
2654
2821
  if (resultBytes(out) <= RESULT_CAP) return out;
2655
2822
  } catch {
@@ -2674,46 +2841,21 @@ var sqlQuery = async (args, ctx) => {
2674
2841
  try {
2675
2842
  if ("sqlite" in db) {
2676
2843
  const result = await executeSqliteQuery(db.sqlite, sql, { signal: ctx.signal });
2677
- return envelope(result.columns, result.rows, result.truncated);
2844
+ return envelope(result.columns, result.rows, result.truncated, result.truncatedReason);
2678
2845
  }
2679
- let pg;
2680
- try {
2681
- pg = await import("pg");
2682
- } catch {
2683
- return toolError(`database "${name}" is Postgres, but the optional "pg" module is not installed (npm i -g pg)`);
2684
- }
2685
- const Client = pg.Client ?? pg.default?.Client;
2686
- if (!Client) return toolError(`the installed "pg" module does not export a Client`);
2687
- const client = new Client({
2688
- connectionString: readFileSync5(db.postgresUrlFile, "utf8").trim(),
2689
- // never logged (D4)
2690
- statement_timeout: SQL_TIMEOUT_MS,
2691
- connectionTimeoutMillis: SQL_TIMEOUT_MS,
2692
- query_timeout: SQL_TIMEOUT_MS
2693
- });
2694
- let cancelled = ctx.signal?.aborted ?? false;
2695
- const cancel = () => {
2696
- cancelled = true;
2697
- void client.end().catch(() => {
2698
- });
2699
- };
2700
- if (cancelled) throw new Error("Postgres query cancelled");
2701
- ctx.signal?.addEventListener("abort", cancel, { once: true });
2702
2846
  try {
2703
- await client.connect();
2704
- if (cancelled) throw new Error("Postgres query cancelled");
2705
- await client.query("BEGIN; SET TRANSACTION READ ONLY;");
2706
- const res = await client.query({ text: sql, rowMode: "array" });
2707
- if (cancelled) throw new Error("Postgres query cancelled");
2708
- const columns = res.fields.map((f) => f.name);
2709
- const rows = res.rows.slice(0, SQL_ROW_CAP);
2710
- return envelope(columns, rows, res.rows.length > SQL_ROW_CAP);
2711
- } finally {
2712
- ctx.signal?.removeEventListener("abort", cancel);
2713
- if (!cancelled) await client.query("ROLLBACK").catch(() => {
2714
- });
2715
- await client.end().catch(() => {
2716
- });
2847
+ const result = await executePostgresQuery(
2848
+ readFileSync5(db.postgresUrlFile, "utf8").trim(),
2849
+ // never logged (D4)
2850
+ sql,
2851
+ { signal: ctx.signal }
2852
+ );
2853
+ return envelope(result.columns, result.rows, result.truncated, result.truncatedReason);
2854
+ } catch (e) {
2855
+ if (e instanceof PgDriverMissingError) {
2856
+ return toolError(`database "${name}" is Postgres, but ${e.message}`);
2857
+ }
2858
+ throw e;
2717
2859
  }
2718
2860
  } catch (e) {
2719
2861
  return toolError(e instanceof Error ? e.message : String(e));
@@ -3340,6 +3482,49 @@ import { readFileSync as readFileSync7 } from "node:fs";
3340
3482
  import { WebSocket } from "node:http";
3341
3483
  import { homedir as homedir3 } from "node:os";
3342
3484
  import { join as join5 } from "node:path";
3485
+
3486
+ // src/status-reporter.ts
3487
+ function createStatusReporter(log, fetchImpl = fetch) {
3488
+ const channels = /* @__PURE__ */ new Map();
3489
+ async function post(endpoint, channelId, state) {
3490
+ try {
3491
+ const res = await fetchImpl(`${endpoint.httpBase}/v1/status`, {
3492
+ method: "POST",
3493
+ headers: { "content-type": "application/json", authorization: `Bearer ${endpoint.token}` },
3494
+ body: JSON.stringify({ channel_id: channelId, state })
3495
+ });
3496
+ if (!res.ok) log("warn", "transport.status_failed", { state: logLabel(state), http_status: res.status });
3497
+ await res.body?.cancel();
3498
+ } catch (e) {
3499
+ log("warn", "transport.status_failed", { state: logLabel(state) }, e);
3500
+ }
3501
+ }
3502
+ async function drain(channelId, entry) {
3503
+ while (entry.desired !== entry.sent) {
3504
+ const state = entry.desired;
3505
+ entry.sent = state;
3506
+ await post(entry.endpoint, channelId, state);
3507
+ }
3508
+ entry.inFlight = false;
3509
+ if (channels.get(channelId) === entry) channels.delete(channelId);
3510
+ }
3511
+ return {
3512
+ set(endpoint, channelId, state) {
3513
+ let entry = channels.get(channelId);
3514
+ if (!entry) {
3515
+ entry = { desired: state, endpoint, sent: null, inFlight: false };
3516
+ channels.set(channelId, entry);
3517
+ }
3518
+ entry.desired = state;
3519
+ entry.endpoint = endpoint;
3520
+ if (entry.inFlight) return;
3521
+ entry.inFlight = true;
3522
+ void drain(channelId, entry);
3523
+ }
3524
+ };
3525
+ }
3526
+
3527
+ // src/data-plane.ts
3343
3528
  var NO_TURN_MEMORY = Object.freeze({
3344
3529
  recent: Object.freeze([]),
3345
3530
  facts: Object.freeze([])
@@ -3473,6 +3658,8 @@ var SecureComsDataPlane = class {
3473
3658
  seq = 0;
3474
3659
  /** in_reply_to → timing of a reply handed to the daemon, until its `sent` frame. */
3475
3660
  pendingReplies = /* @__PURE__ */ new Map();
3661
+ /** #910: working/idle for the web "thinking" strip; fire-and-forget, per-channel ordered. */
3662
+ status;
3476
3663
  constructor(cfg, onMessage) {
3477
3664
  this.identityId = cfg.identityId;
3478
3665
  const dir = cfg.connectionsDir ?? join5(homedir3(), ".securecoms", "connections");
@@ -3492,6 +3679,7 @@ var SecureComsDataPlane = class {
3492
3679
  this.onMessage = onMessage;
3493
3680
  this.noticeReplies = cfg.noticeReplies ?? /* @__PURE__ */ new Set();
3494
3681
  this.clock = cfg.clock ?? (() => performance.now());
3682
+ this.status = createStatusReporter(this.log);
3495
3683
  }
3496
3684
  elapsed(from, to = this.clock()) {
3497
3685
  return Math.max(0, Math.round(to - from));
@@ -3759,6 +3947,7 @@ var SecureComsDataPlane = class {
3759
3947
  let handlerMs = 0;
3760
3948
  let sendMs = 0;
3761
3949
  let outcome = "failed";
3950
+ let reportedWorking = false;
3762
3951
  try {
3763
3952
  if (!this.seen.has(messageId)) {
3764
3953
  const receiptStart = this.clock();
@@ -3785,9 +3974,14 @@ var SecureComsDataPlane = class {
3785
3974
  this.turns.set(ws, socketTurns);
3786
3975
  }
3787
3976
  socketTurns.add(turnController);
3977
+ const text = String(frame.text ?? "");
3978
+ if (text.trim()) {
3979
+ reportedWorking = true;
3980
+ this.status.set({ httpBase, token }, channelId, "working");
3981
+ }
3788
3982
  const handlerStart = this.clock();
3789
3983
  const reply = await this.onMessage({
3790
- text: String(frame.text ?? ""),
3984
+ text,
3791
3985
  senderId: String(frame.sender_id ?? ""),
3792
3986
  channelId,
3793
3987
  messageId,
@@ -3797,14 +3991,14 @@ var SecureComsDataPlane = class {
3797
3991
  }, turnController.signal);
3798
3992
  handlerMs = this.elapsed(handlerStart);
3799
3993
  if (reply != null) {
3800
- const text = String(reply);
3994
+ const text2 = String(reply);
3801
3995
  const sendStart = this.clock();
3802
3996
  ws.send(JSON.stringify({
3803
3997
  type: "send",
3804
3998
  channel_id: channelId,
3805
- text,
3999
+ text: text2,
3806
4000
  in_reply_to: messageId,
3807
- ...this.noticeReplies.has(text) ? { notice: true } : {}
4001
+ ...this.noticeReplies.has(text2) ? { notice: true } : {}
3808
4002
  }));
3809
4003
  const sentAt = this.clock();
3810
4004
  sendMs = this.elapsed(sendStart, sentAt);
@@ -3823,6 +4017,7 @@ var SecureComsDataPlane = class {
3823
4017
  this.log("error", "transport.handler_failed", {}, e);
3824
4018
  this.failTurn(ws, messageId, "turn_failed");
3825
4019
  } finally {
4020
+ if (reportedWorking) this.status.set({ httpBase, token }, channelId, "idle");
3826
4021
  if (turnController) {
3827
4022
  const socketTurns = this.turns.get(ws);
3828
4023
  socketTurns?.delete(turnController);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-runtime",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "securecoms-agent": "dist/bin.js"
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {},
34
34
  "peerDependencies": {
35
- "pg": ">=8"
35
+ "pg": ">=8.11 <9"
36
36
  },
37
37
  "peerDependenciesMeta": {
38
38
  "pg": {
@@ -42,6 +42,7 @@
42
42
  "devDependencies": {
43
43
  "@types/ws": "^8.5.12",
44
44
  "esbuild": "0.24.0",
45
+ "pg": "~8.23.0",
45
46
  "typescript": "^5.4.0",
46
47
  "vitest": "^3.2.6",
47
48
  "ws": "^8.21.0"
@@ -18,4 +18,10 @@ database the operator configured; never invent a connection string. A semicolon
18
18
  or a write keyword inside a string literal or a comment is also refused, so
19
19
  rewrite such a value out of the query (a bound-looking literal like '--' or
20
20
  'DROP' will not pass). If a query is refused, rewrite it as a SELECT rather than
21
- retrying the same statement.
21
+ retrying the same statement. Values come back as the database's text: a
22
+ Postgres json or jsonb value is a JSON string, arrays look like {1,2}, and
23
+ timestamps, int8 and numeric are strings. When `truncated` is true,
24
+ `truncated_reason` says why: `row_limit` means more rows exist than were
25
+ returned, and `size_limit` means rows were dropped because they were too large
26
+ (a single row over 1 MiB comes back as no rows with `size_limit`, not as an
27
+ empty result). Select fewer or narrower columns rather than retrying as is.