@poncho-ai/harness 0.43.0 → 0.44.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": "@poncho-ai/harness",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/harness.ts CHANGED
@@ -20,7 +20,7 @@ const costLog = createLogger("cost");
20
20
  const mcpLog = createLogger("mcp");
21
21
  const modelLog = createLogger("model");
22
22
  import type { UploadStore } from "./upload-store.js";
23
- import { PONCHO_UPLOAD_SCHEME, VFS_SCHEME, deriveUploadKey } from "./upload-store.js";
23
+ import { PONCHO_UPLOAD_SCHEME, VFS_SCHEME, decodeFileInputData, deriveUploadKey } from "./upload-store.js";
24
24
  import type { StorageEngine } from "./storage/engine.js";
25
25
  import { createStorageEngine, type StorageProvider } from "./storage/index.js";
26
26
  import {
@@ -30,6 +30,8 @@ import {
30
30
  createReminderStoreFromEngine,
31
31
  } from "./storage/store-adapters.js";
32
32
  import { BashEnvironmentManager } from "./vfs/bash-manager.js";
33
+ import type { VirtualMount } from "./vfs/poncho-fs-adapter.js";
34
+ export type { VirtualMount } from "./vfs/poncho-fs-adapter.js";
33
35
  import { createBashTool } from "./vfs/bash-tool.js";
34
36
  import { createReadFileTool } from "./vfs/read-file-tool.js";
35
37
  import { createEditFileTool } from "./vfs/edit-file-tool.js";
@@ -123,6 +125,15 @@ export interface HarnessOptions {
123
125
  * `resolveStateConfig`, etc.) run as today regardless of source.
124
126
  */
125
127
  config?: PonchoConfig;
128
+ /**
129
+ * Read-only virtual mounts overlaid on the VFS. Each mount maps a VFS
130
+ * prefix (e.g. "/system/") to a local filesystem directory; reads under
131
+ * the prefix are served from local disk, writes are rejected. Used by
132
+ * platforms like PonchOS to expose deployment-shipped defaults (system
133
+ * jobs, system skills) without storing them in each tenant's VFS.
134
+ * Empty by default — no system mounts in the CLI / dev workflow.
135
+ */
136
+ virtualMounts?: VirtualMount[];
126
137
  }
127
138
 
128
139
  export interface HarnessRunOutput {
@@ -855,6 +866,8 @@ export class AgentHarness {
855
866
  storageEngine?: StorageEngine;
856
867
  /** Bash environment manager (creates per-tenant bash instances). */
857
868
  private bashManager?: BashEnvironmentManager;
869
+ /** Read-only virtual mounts overlaid on the VFS. Empty by default. */
870
+ private virtualMounts: VirtualMount[] = [];
858
871
 
859
872
  private resolveToolAccess(toolName: string): ToolAccess {
860
873
  const tools = this.loadedConfig?.tools;
@@ -1048,6 +1061,7 @@ export class AgentHarness {
1048
1061
  this.storageEngine = options.storageEngine;
1049
1062
  this.injectedStorageEngine = true;
1050
1063
  }
1064
+ this.virtualMounts = options.virtualMounts ?? [];
1051
1065
 
1052
1066
  if (options.toolDefinitions?.length) {
1053
1067
  this.dispatcher.registerMany(options.toolDefinitions);
@@ -1255,6 +1269,21 @@ export class AgentHarness {
1255
1269
  // "__default__" so dev-mode (no auth) conversations see the same VFS
1256
1270
  // namespace the Files sidebar writes to.
1257
1271
  const effectiveTenant = tenantId || "__default__";
1272
+ // Refresh the engine's path cache before fingerprinting. The cache is
1273
+ // the only thing `computeVfsSkillFingerprint` reads, and historically
1274
+ // it was only populated by `bash-manager.refreshPathCache` — chat-only
1275
+ // flows (no bash) left it empty, the patched writeFile's incremental
1276
+ // update became a no-op (it skips when the cache isn't loaded), the
1277
+ // fingerprint stuck at "" across runs, and any skill the agent (or a
1278
+ // client like PonchOS's iOS Files browser) authored after the harness
1279
+ // was first instantiated was invisible from the next turn onward.
1280
+ // One SELECT per turn is the cost of correctness here.
1281
+ const engineWithRefresh = this.storageEngine as unknown as {
1282
+ refreshPathCache?: (tenantId: string) => Promise<void>;
1283
+ };
1284
+ if (typeof engineWithRefresh.refreshPathCache === "function") {
1285
+ await engineWithRefresh.refreshPathCache(effectiveTenant);
1286
+ }
1258
1287
  const fingerprint = this.computeVfsSkillFingerprint(effectiveTenant);
1259
1288
  const cached = this.skillCache.get(effectiveTenant);
1260
1289
  if (cached && cached.fingerprint === fingerprint) {
@@ -1695,6 +1724,7 @@ export class AgentHarness {
1695
1724
  bashWorkingDir,
1696
1725
  config?.bash,
1697
1726
  config?.network,
1727
+ this.virtualMounts,
1698
1728
  );
1699
1729
  // Register VFS tools
1700
1730
  this.registerIfMissing(createBashTool(this.bashManager));
@@ -2256,7 +2286,7 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
2256
2286
  ];
2257
2287
  for (const file of input.files) {
2258
2288
  if (this.uploadStore) {
2259
- const buf = Buffer.from(file.data, "base64");
2289
+ const buf = await decodeFileInputData(file.data);
2260
2290
  const key = deriveUploadKey(buf, file.mediaType);
2261
2291
  const ref = await this.uploadStore.put(key, buf, file.mediaType);
2262
2292
  parts.push({
@@ -24,7 +24,7 @@ import type { AgentEvent, FileInput, Message } from "@poncho-ai/sdk";
24
24
  import { createLogger } from "@poncho-ai/sdk";
25
25
  import type { AgentHarness } from "../harness.js";
26
26
  import type { ConversationStore } from "../state.js";
27
- import { deriveUploadKey } from "../upload-store.js";
27
+ import { decodeFileInputData, deriveUploadKey } from "../upload-store.js";
28
28
  import { withToolResultArchiveParam } from "./continuation.js";
29
29
  import { resolveRunRequest } from "./history.js";
30
30
  import {
@@ -104,7 +104,7 @@ export const runConversationTurn = async (
104
104
  if (opts.files && opts.files.length > 0 && opts.harness.uploadStore) {
105
105
  const uploadedParts = await Promise.all(
106
106
  opts.files.map(async (f) => {
107
- const buf = Buffer.from(f.data, "base64");
107
+ const buf = await decodeFileInputData(f.data);
108
108
  const key = deriveUploadKey(buf, f.mediaType);
109
109
  const ref = await opts.harness.uploadStore!.put(key, buf, f.mediaType);
110
110
  return {
@@ -195,4 +195,23 @@ export const migrations: Migration[] = [
195
195
  WHERE parent_message_id IS NOT NULL`,
196
196
  ],
197
197
  },
198
+ {
199
+ version: 7,
200
+ name: "fix_reminder_scheduled_at_precision",
201
+ // Postgres maps `REAL` to float4 (4 bytes, ~7 digit precision),
202
+ // which silently rounds millisecond epochs (13 digits) — every
203
+ // reminder write+read on Postgres returned a different value than
204
+ // it stored. SQLite's REAL is float8 (15+ digit precision) so it
205
+ // was always fine there.
206
+ //
207
+ // Convert the Postgres column to BIGINT so future writes are exact
208
+ // (already-stored rounded values aren't recoverable but they were
209
+ // never correct in the first place).
210
+ up: (d) => {
211
+ if (d === "sqlite") return [];
212
+ return [
213
+ `ALTER TABLE reminders ALTER COLUMN scheduled_at TYPE BIGINT USING scheduled_at::bigint`,
214
+ ];
215
+ },
216
+ },
198
217
  ];
@@ -572,11 +572,16 @@ export abstract class SqlStorageEngine implements StorageEngine {
572
572
  const pattern = `%${query}%`;
573
573
  // SQLite uses positional ? so we can't reuse $2, need separate params
574
574
  const params: unknown[] = [this.agentId, pattern, pattern];
575
+ // Postgres rejects `jsonb LIKE text` — cast `data` to text in
576
+ // Postgres so the LIKE applies to its JSON serialization. SQLite's
577
+ // `data` column is TEXT (raw JSON string) so no cast needed.
578
+ const dataMatch =
579
+ this.dialect.tag === "postgresql" ? "data::text LIKE $3" : "data LIKE $3";
575
580
  let sql = `SELECT id, title, updated_at, created_at, owner_id, tenant_id,
576
581
  message_count, parent_conversation_id, parent_message_id,
577
582
  has_pending_approvals, channel_meta
578
583
  FROM conversations
579
- WHERE agent_id = $1 AND (title LIKE $2 OR data LIKE $3)`;
584
+ WHERE agent_id = $1 AND (title LIKE $2 OR ${dataMatch})`;
580
585
  if (filterTenant) {
581
586
  sql += ` AND tenant_id = $4`;
582
587
  params.push(tid);
@@ -1190,7 +1195,10 @@ export abstract class SqlStorageEngine implements StorageEngine {
1190
1195
  return {
1191
1196
  id: row.id as string,
1192
1197
  task: row.task as string,
1193
- scheduledAt: row.scheduled_at as number,
1198
+ // Postgres-js returns BIGINT columns as strings to avoid silent
1199
+ // precision loss in JS. Coerce to Number — ms epochs max out at
1200
+ // ~10^16 in year 2286, well under Number.MAX_SAFE_INTEGER (2^53).
1201
+ scheduledAt: Number(row.scheduled_at),
1194
1202
  timezone: (row.timezone as string) ?? undefined,
1195
1203
  status: row.status as Reminder["status"],
1196
1204
  createdAt: new Date(row.created_at as string).getTime(),
@@ -1198,7 +1206,7 @@ export abstract class SqlStorageEngine implements StorageEngine {
1198
1206
  ownerId: (row.owner_id as string) ?? undefined,
1199
1207
  tenantId: tid === DEFAULT_TENANT ? null : tid,
1200
1208
  recurrence,
1201
- occurrenceCount: (row.occurrence_count as number) ?? 0,
1209
+ occurrenceCount: Number(row.occurrence_count ?? 0),
1202
1210
  };
1203
1211
  }
1204
1212
 
@@ -108,6 +108,33 @@ export const deriveUploadKey = (
108
108
  return `${hash}${ext}`;
109
109
  };
110
110
 
111
+ const DATA_URI_PREFIX = /^data:[^,]*?;base64,/;
112
+
113
+ /**
114
+ * Decode the `FileInput.data` field per its documented contract: it may be a
115
+ * raw base64 string, a `data:<mime>;base64,<…>` URI, or an `https?://` URL.
116
+ * Returns the decoded bytes. Older versions of the harness called
117
+ * `Buffer.from(data, "base64")` unconditionally; that silently produced
118
+ * garbage bytes for data URIs (the `:` / `;` / `,` in the prefix are not
119
+ * valid base64 chars but Node's decoder ignores them rather than throwing).
120
+ */
121
+ export const decodeFileInputData = async (data: string): Promise<Buffer> => {
122
+ if (data.startsWith("http://") || data.startsWith("https://")) {
123
+ const resp = await fetch(data);
124
+ if (!resp.ok) {
125
+ throw new Error(
126
+ `uploads: failed to fetch file at ${data}: ${resp.status} ${resp.statusText}`,
127
+ );
128
+ }
129
+ return Buffer.from(await resp.arrayBuffer());
130
+ }
131
+ const match = data.match(DATA_URI_PREFIX);
132
+ if (match) {
133
+ return Buffer.from(data.slice(match[0].length), "base64");
134
+ }
135
+ return Buffer.from(data, "base64");
136
+ };
137
+
111
138
  const MIME_EXT_MAP: Record<string, string> = {
112
139
  "image/jpeg": ".jpg",
113
140
  "image/png": ".png",
@@ -11,7 +11,7 @@ import type {
11
11
  } from "just-bash";
12
12
  import type { StorageEngine } from "../storage/engine.js";
13
13
  import type { BashConfig, NetworkConfig } from "../config.js";
14
- import { PonchoFsAdapter } from "./poncho-fs-adapter.js";
14
+ import { PonchoFsAdapter, type VirtualMount } from "./poncho-fs-adapter.js";
15
15
  import { createBashFs } from "./create-bash-fs.js";
16
16
  import type { PostgresEngine } from "../storage/postgres-engine.js";
17
17
 
@@ -69,6 +69,7 @@ export class BashEnvironmentManager {
69
69
  private filesystems = new Map<string, IFileSystem>();
70
70
  private readonly workingDir: string | null;
71
71
  private readonly bashOptions: Partial<BashOptions>;
72
+ private readonly virtualMounts: VirtualMount[];
72
73
 
73
74
  constructor(
74
75
  private engine: StorageEngine,
@@ -76,16 +77,18 @@ export class BashEnvironmentManager {
76
77
  workingDir: string | null,
77
78
  bashConfig?: BashConfig,
78
79
  network?: NetworkConfig,
80
+ virtualMounts: VirtualMount[] = [],
79
81
  ) {
80
82
  this.workingDir = workingDir;
81
83
  this.bashOptions = toBashOptions(bashConfig, network);
84
+ this.virtualMounts = virtualMounts;
82
85
  }
83
86
 
84
87
  /** Return the combined IFileSystem (VFS + optional /project mount) for a tenant. */
85
88
  getFs(tenantId: string): IFileSystem {
86
89
  let fs = this.filesystems.get(tenantId);
87
90
  if (!fs) {
88
- const adapter = new PonchoFsAdapter(this.engine, tenantId, this.limits);
91
+ const adapter = new PonchoFsAdapter(this.engine, tenantId, this.limits, this.virtualMounts);
89
92
  fs = createBashFs(adapter, this.workingDir);
90
93
  this.filesystems.set(tenantId, fs);
91
94
  }
@@ -107,7 +110,7 @@ export class BashEnvironmentManager {
107
110
  }
108
111
 
109
112
  getAdapter(tenantId: string): PonchoFsAdapter {
110
- return new PonchoFsAdapter(this.engine, tenantId, this.limits);
113
+ return new PonchoFsAdapter(this.engine, tenantId, this.limits, this.virtualMounts);
111
114
  }
112
115
 
113
116
  /** Refresh the PostgreSQL path cache before a bash.exec() call. */