@poncho-ai/harness 0.48.0 → 0.50.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/harness@0.48.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.50.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
3
3
  > node scripts/embed-docs.js && tsup src/index.ts --format esm --dts
4
4
 
5
5
  [embed-docs] Generated poncho-docs.ts with 4 topics
@@ -8,9 +8,9 @@
8
8
  CLI tsup v8.5.1
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
- ESM dist/index.js 528.41 KB
12
- ESM dist/isolate-VY35DGLM.js 49.43 KB
13
- ESM ⚡️ Build success in 216ms
11
+ ESM dist/isolate-BNQ6P3HI.js 51.41 KB
12
+ ESM dist/index.js 530.56 KB
13
+ ESM ⚡️ Build success in 259ms
14
14
  DTS Build start
15
- DTS ⚡️ Build success in 7309ms
16
- DTS dist/index.d.ts 87.43 KB
15
+ DTS ⚡️ Build success in 6896ms
16
+ DTS dist/index.d.ts 89.28 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.50.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#129](https://github.com/cesr/poncho-ai/pull/129) [`85b8eec`](https://github.com/cesr/poncho-ai/commit/85b8eeca593b1043e2f7da01d681db6d32b1a969) Thanks [@cesr](https://github.com/cesr)! - harness: provider-backed VFS mounts + binary fetch bodies
8
+
9
+ Two additive isolate/VFS changes.
10
+
11
+ **`MountProvider` for `VirtualMount`.** A virtual mount can now be backed
12
+ by a custom data source instead of a local-disk directory. Set
13
+ `provider: { readdir, stat, readFileBuffer }` instead of `source` on a
14
+ `VirtualMount`. The adapter routes read operations through the provider
15
+ and rejects writes the same way it does for disk-backed mounts. Lets a
16
+ host expose database rows / object-store keys as a VFS subtree without
17
+ materialising them on disk (e.g. PonchOS exposing user uploads at
18
+ `/uploads`). `getAllPaths` advertises only the mount root for provider
19
+ mounts (deep listing would require sync IO over a remote backend);
20
+ shallow listing is sufficient for bash glob/find at the mount root.
21
+
22
+ **Binary `fetch()` bodies in `run_code`.** The isolate fetch polyfill
23
+ used to coerce `init.body` to a string before sending it to the
24
+ `__poncho_fetch` binding, so passing a `Uint8Array`, `ArrayBuffer`, or
25
+ `Blob` arrived server-side as `"1,2,3,..."` — every binary upload
26
+ (image-edit APIs, file uploads) was corrupted. The polyfill now
27
+ base64-encodes binary bodies with a new `bodyEncoding: "base64"` field
28
+ on the binding input; the built-in `createFetchBinding` decodes back to
29
+ raw bytes before fetching. Custom bindings that replace `__poncho_fetch`
30
+ should add the same decoding (cf. PonchOS `createSecretAwareFetchBinding`).
31
+ String bodies are unchanged.
32
+
33
+ ## 0.49.0
34
+
35
+ ### Minor Changes
36
+
37
+ - [#127](https://github.com/cesr/poncho-ai/pull/127) [`87b40d9`](https://github.com/cesr/poncho-ai/commit/87b40d9d6cebba4ac646598d154a767a1d2f3551) Thanks [@cesr](https://github.com/cesr)! - harness: stop truncating main memory by default
38
+
39
+ Main memory injected into the system prompt was hard-truncated at 4000
40
+ characters with a `...[truncated]` marker. Silently dropping the tail of
41
+ a user's memory every turn is a footgun, so the **default is now no
42
+ truncation** — the full memory is injected.
43
+
44
+ New `MemoryConfig.maxPromptChars` (also settable via
45
+ `storage.memory.maxPromptChars`) lets a consumer opt back _into_ a cap
46
+ for prompt-cost control: set a positive number and content beyond it is
47
+ sliced with the `...[truncated]` marker as before.
48
+
49
+ Behavior change: consumers that relied on the implicit 4000-char cap
50
+ will now see full memory in the prompt. To restore the old behavior set
51
+ `maxPromptChars: 4000`.
52
+
3
53
  ## 0.48.0
4
54
 
5
55
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import { LanguageModel } from 'ai';
2
2
  import * as _poncho_ai_sdk from '@poncho-ai/sdk';
3
3
  import { Message, ToolContext, ToolDefinition, JsonSchema, RunResult, AgentFailure, RunInput, AgentEvent, FileInput } from '@poncho-ai/sdk';
4
4
  export { ToolDefinition, defineTool } from '@poncho-ai/sdk';
5
- import { IFileSystem, BufferEncoding, FsStat, FileContent, MkdirOptions, RmOptions, CpOptions, Bash } from 'just-bash';
5
+ import { FsStat, IFileSystem, BufferEncoding, FileContent, MkdirOptions, RmOptions, CpOptions, Bash } from 'just-bash';
6
6
  import { z } from 'zod';
7
7
 
8
8
  interface AgentModelConfig {
@@ -341,6 +341,15 @@ interface MemoryConfig {
341
341
  region?: string;
342
342
  ttl?: number;
343
343
  maxRecallConversations?: number;
344
+ /**
345
+ * Optional cap on the characters of main memory injected into the
346
+ * system prompt each turn. Default is **no cap** — the full memory is
347
+ * injected (silently truncating a user's memory every turn is a
348
+ * footgun). Set a positive number to opt into truncation for
349
+ * prompt-cost control; content beyond it is sliced with a
350
+ * `...[truncated]` marker.
351
+ */
352
+ maxPromptChars?: number;
344
353
  }
345
354
  interface MemoryStore {
346
355
  getMainMemory(): Promise<MainMemory>;
@@ -445,6 +454,7 @@ interface StorageConfig {
445
454
  memory?: {
446
455
  enabled?: boolean;
447
456
  maxRecallConversations?: number;
457
+ maxPromptChars?: number;
448
458
  };
449
459
  limits?: {
450
460
  maxFileSize?: number;
@@ -967,9 +977,34 @@ interface StorageEngine {
967
977
  }
968
978
 
969
979
  /**
970
- * Read-only virtual mount mapping a VFS path prefix to a local filesystem
971
- * directory. All read operations under the prefix resolve via local FS;
972
- * writes throw. The prefix is normalised internally to end with "/".
980
+ * Read-only data source for a virtual mount whose contents don't live on
981
+ * local disk. Lets a host (e.g. PonchOS) back a VFS prefix with a database
982
+ * row set, object-store keys, or anything else expressible as
983
+ * `(tenantId, relative) -> bytes/listing/stat`.
984
+ *
985
+ * All three methods are read-only; writes through the adapter still throw
986
+ * `EROFS` for routed mounts the same way disk-backed mounts do (see
987
+ * `routeToMount` checks in writeFile/mkdir/rm/cp/mv/chmod/utimes/symlink/link).
988
+ * `relative` is the path within the mount; `""` means the mount root.
989
+ */
990
+ interface MountProvider {
991
+ /** List entries directly under `relative`. The mount root is `""`. */
992
+ readdir(tenantId: string, relative: string): Promise<Array<{
993
+ name: string;
994
+ isFile: boolean;
995
+ isDirectory: boolean;
996
+ }>>;
997
+ /** Stat a single entry. Throw ENOENT-like for missing paths. */
998
+ stat(tenantId: string, relative: string): Promise<FsStat>;
999
+ /** Read raw bytes for a file entry. */
1000
+ readFileBuffer(tenantId: string, relative: string): Promise<Uint8Array>;
1001
+ }
1002
+ /**
1003
+ * Read-only virtual mount mapping a VFS path prefix to either a local
1004
+ * filesystem directory (`source`) OR a custom provider (`provider`).
1005
+ * Exactly one of the two must be set. All read operations under the prefix
1006
+ * resolve via the chosen backend; writes throw. The prefix is normalised
1007
+ * internally to end with "/".
973
1008
  */
974
1009
  interface VirtualMount {
975
1010
  /** VFS prefix, e.g. "/system/". Leading slash required; trailing slash
@@ -977,7 +1012,11 @@ interface VirtualMount {
977
1012
  * but no validation is enforced here. */
978
1013
  prefix: string;
979
1014
  /** Absolute local FS path to serve from, e.g. "/srv/poncho/system". */
980
- source: string;
1015
+ source?: string;
1016
+ /** Custom backend serving reads for this mount. Mutually exclusive with
1017
+ * `source` — set exactly one. Use when the data doesn't live on local
1018
+ * disk (e.g. database-backed upload listings). */
1019
+ provider?: MountProvider;
981
1020
  }
982
1021
  declare class PonchoFsAdapter implements IFileSystem {
983
1022
  private engine;
@@ -2099,4 +2138,4 @@ interface RunConversationTurnResult {
2099
2138
  }
2100
2139
  declare const runConversationTurn: (opts: RunConversationTurnOpts) => Promise<RunConversationTurnResult>;
2101
2140
 
2102
- export { type ActiveConversationRun, type ActiveSubagentRun, type AgentFrontmatter, AgentHarness, type AgentIdentity, type AgentLimitsConfig, type AgentModelConfig, AgentOrchestrator, type ApprovalEventItem, type ArchivedToolResult$1 as ArchivedToolResult, type BashConfig, BashEnvironmentManager, type BashExecutionLimits, type BuiltInToolToggles, CALLBACK_LOCK_STALE_MS, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type ContinuationHooks, type Conversation, type ConversationCreateInit, type ConversationState, type ConversationStatusSnapshot, type ConversationStore, type ConversationSummary, type CreateSkillToolsOptions, type CronJobConfig, DEFAULT_AGENT_DESCRIPTION, DEFAULT_AGENT_NAME, DEFAULT_MAX_STEPS, DEFAULT_MODEL_NAME, DEFAULT_MODEL_PROVIDER, DEFAULT_TEMPERATURE, DEFAULT_TIMEOUT, type DefaultAgentDefinitionOptions, type EventSink, type ExecuteTurnResult, type HarnessOptions, type HarnessRunOutput, type HistorySource, InMemoryConversationStore, InMemoryEngine, InMemoryStateStore, type IsolateBinding, type IsolateConfig, LocalMcpBridge, LocalUploadStore, MAX_CONCURRENT_SUBAGENTS, MAX_CONTINUATION_COUNT, MAX_SUBAGENT_CALLBACK_COUNT, MAX_SUBAGENT_NESTING, type MainMemory, type McpConfig, type MemoryConfig, type MemoryStore, type MessagingChannelConfig, type ModelProviderFactory, type NetworkConfig, OPENAI_CODEX_CLIENT_ID, type OpenAICodexAuthConfig, type OpenAICodexDeviceAuthRequest, type OpenAICodexSession, type OrchestratorHooks, type OrchestratorOptions, type OtlpConfig, type OtlpOption, PONCHO_UPLOAD_SCHEME, type ParsedAgent, type PendingSubagentApproval, type PendingSubagentResult, type PendingToolCall, type PonchoConfig, PonchoFsAdapter, PostgresEngine, type ProviderConfig, type Recurrence, type RecurrenceType, type Reminder, type ReminderCreateInput, type ReminderStatus, type ReminderStore, type RemoteMcpServerConfig, type RunConversationTurnOpts, type RunConversationTurnResult, type RunOutcome, type RunRequest, type RuntimeRenderContext, S3UploadStore, STALE_SUBAGENT_THRESHOLD_MS, STORAGE_SCHEMA_VERSION, type SecretsStore, type SkillContextEntry, type SkillMetadata, type SkillSource, SqliteEngine, type StateConfig, type StateProviderName, type StateStore, type StorageConfig, type StorageEngine, type StorageFactoryOptions, type StorageProvider, type StoredApproval, type SubagentManager, type SubagentResult, type SubagentSpawnResult, type SubagentSummary, type SubagentTranscript, type SubagentTranscriptMode, TOOL_RESULT_ARCHIVE_PARAM, type TelemetryConfig, TelemetryEmitter, type TenantTokenPayload, type ToolAccess, type ToolCall, ToolDispatcher, type ToolExecutionResult, type TurnDraftState, type TurnResultMetadata, type TurnSection, type UploadStore, type UploadsConfig, VFS_SCHEME, VercelBlobUploadStore, type VfsDirEntry, type VfsStat, type VirtualMount, applyTurnMetadata, buildAgentDirectoryName, buildApprovalCheckpoints, buildAssistantMetadata, buildSkillContextWindow, buildToolCompletedText, cloneSections, compactMessages, completeOpenAICodexDeviceAuth, computeNextOccurrence, createBashTool, createConversationStore, createConversationStoreFromEngine, createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createMemoryStore, createMemoryStoreFromEngine, createMemoryTools, createModelProvider, createReminderStore, createReminderStoreFromEngine, createReminderTools, createSearchTools, createSecretsStore, createSkillTools, createStateStore, createStorageEngine, createSubagentTools, createTodoStoreFromEngine, createTurnDraftState, createUploadStore, createWriteTool, decodeFileInputData, defaultAgentDefinition, deleteOpenAICodexSession, deriveUploadKey, ensureAgentIdentity, estimateTokens, estimateTotalTokens, executeConversationTurn, findSafeSplitPoint, flushTurnDraft, generateAgentId, getAgentStoreDirectory, getModelContextWindow, getOpenAICodexAccessToken, getOpenAICodexAuthFilePath, getOpenAICodexRequiredScopes, getPonchoStoreRoot, isMessageArray, jsonSchemaToZod, loadCanonicalHistory, loadPonchoConfig, loadRunHistory, loadSkillContext, loadSkillInstructions, loadSkillMetadata, loadSkillMetadataFromDirs, loadVfsSkillMetadata, mergeSkills, normalizeApprovalCheckpoint, normalizeOtlp, normalizeScriptPolicyPath, normalizeToolAccess, parseAgentFile, parseAgentMarkdown, parseSkillFrontmatter, ponchoDocsTool, readOpenAICodexSession, readSkillResource, recordStandardTurnEvent, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveEnv, resolveMemoryConfig, resolveRunRequest, resolveSkillDirs, resolveStateConfig, runConversationTurn, slugifyStorageComponent, startOpenAICodexDeviceAuth, verifyTenantToken, withToolResultArchiveParam, writeOpenAICodexSession };
2141
+ export { type ActiveConversationRun, type ActiveSubagentRun, type AgentFrontmatter, AgentHarness, type AgentIdentity, type AgentLimitsConfig, type AgentModelConfig, AgentOrchestrator, type ApprovalEventItem, type ArchivedToolResult$1 as ArchivedToolResult, type BashConfig, BashEnvironmentManager, type BashExecutionLimits, type BuiltInToolToggles, CALLBACK_LOCK_STALE_MS, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type ContinuationHooks, type Conversation, type ConversationCreateInit, type ConversationState, type ConversationStatusSnapshot, type ConversationStore, type ConversationSummary, type CreateSkillToolsOptions, type CronJobConfig, DEFAULT_AGENT_DESCRIPTION, DEFAULT_AGENT_NAME, DEFAULT_MAX_STEPS, DEFAULT_MODEL_NAME, DEFAULT_MODEL_PROVIDER, DEFAULT_TEMPERATURE, DEFAULT_TIMEOUT, type DefaultAgentDefinitionOptions, type EventSink, type ExecuteTurnResult, type HarnessOptions, type HarnessRunOutput, type HistorySource, InMemoryConversationStore, InMemoryEngine, InMemoryStateStore, type IsolateBinding, type IsolateConfig, LocalMcpBridge, LocalUploadStore, MAX_CONCURRENT_SUBAGENTS, MAX_CONTINUATION_COUNT, MAX_SUBAGENT_CALLBACK_COUNT, MAX_SUBAGENT_NESTING, type MainMemory, type McpConfig, type MemoryConfig, type MemoryStore, type MessagingChannelConfig, type ModelProviderFactory, type MountProvider, type NetworkConfig, OPENAI_CODEX_CLIENT_ID, type OpenAICodexAuthConfig, type OpenAICodexDeviceAuthRequest, type OpenAICodexSession, type OrchestratorHooks, type OrchestratorOptions, type OtlpConfig, type OtlpOption, PONCHO_UPLOAD_SCHEME, type ParsedAgent, type PendingSubagentApproval, type PendingSubagentResult, type PendingToolCall, type PonchoConfig, PonchoFsAdapter, PostgresEngine, type ProviderConfig, type Recurrence, type RecurrenceType, type Reminder, type ReminderCreateInput, type ReminderStatus, type ReminderStore, type RemoteMcpServerConfig, type RunConversationTurnOpts, type RunConversationTurnResult, type RunOutcome, type RunRequest, type RuntimeRenderContext, S3UploadStore, STALE_SUBAGENT_THRESHOLD_MS, STORAGE_SCHEMA_VERSION, type SecretsStore, type SkillContextEntry, type SkillMetadata, type SkillSource, SqliteEngine, type StateConfig, type StateProviderName, type StateStore, type StorageConfig, type StorageEngine, type StorageFactoryOptions, type StorageProvider, type StoredApproval, type SubagentManager, type SubagentResult, type SubagentSpawnResult, type SubagentSummary, type SubagentTranscript, type SubagentTranscriptMode, TOOL_RESULT_ARCHIVE_PARAM, type TelemetryConfig, TelemetryEmitter, type TenantTokenPayload, type ToolAccess, type ToolCall, ToolDispatcher, type ToolExecutionResult, type TurnDraftState, type TurnResultMetadata, type TurnSection, type UploadStore, type UploadsConfig, VFS_SCHEME, VercelBlobUploadStore, type VfsDirEntry, type VfsStat, type VirtualMount, applyTurnMetadata, buildAgentDirectoryName, buildApprovalCheckpoints, buildAssistantMetadata, buildSkillContextWindow, buildToolCompletedText, cloneSections, compactMessages, completeOpenAICodexDeviceAuth, computeNextOccurrence, createBashTool, createConversationStore, createConversationStoreFromEngine, createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createMemoryStore, createMemoryStoreFromEngine, createMemoryTools, createModelProvider, createReminderStore, createReminderStoreFromEngine, createReminderTools, createSearchTools, createSecretsStore, createSkillTools, createStateStore, createStorageEngine, createSubagentTools, createTodoStoreFromEngine, createTurnDraftState, createUploadStore, createWriteTool, decodeFileInputData, defaultAgentDefinition, deleteOpenAICodexSession, deriveUploadKey, ensureAgentIdentity, estimateTokens, estimateTotalTokens, executeConversationTurn, findSafeSplitPoint, flushTurnDraft, generateAgentId, getAgentStoreDirectory, getModelContextWindow, getOpenAICodexAccessToken, getOpenAICodexAuthFilePath, getOpenAICodexRequiredScopes, getPonchoStoreRoot, isMessageArray, jsonSchemaToZod, loadCanonicalHistory, loadPonchoConfig, loadRunHistory, loadSkillContext, loadSkillInstructions, loadSkillMetadata, loadSkillMetadataFromDirs, loadVfsSkillMetadata, mergeSkills, normalizeApprovalCheckpoint, normalizeOtlp, normalizeScriptPolicyPath, normalizeToolAccess, parseAgentFile, parseAgentMarkdown, parseSkillFrontmatter, ponchoDocsTool, readOpenAICodexSession, readSkillResource, recordStandardTurnEvent, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveEnv, resolveMemoryConfig, resolveRunRequest, resolveSkillDirs, resolveStateConfig, runConversationTurn, slugifyStorageComponent, startOpenAICodexDeviceAuth, verifyTenantToken, withToolResultArchiveParam, writeOpenAICodexSession };
package/dist/index.js CHANGED
@@ -544,7 +544,8 @@ var resolveMemoryConfig = (config) => {
544
544
  table: config.storage.table,
545
545
  region: config.storage.region,
546
546
  ttl: resolveTtl(config.storage.ttl, "memory"),
547
- maxRecallConversations: config.storage.memory?.maxRecallConversations ?? config.memory?.maxRecallConversations
547
+ maxRecallConversations: config.storage.memory?.maxRecallConversations ?? config.memory?.maxRecallConversations,
548
+ maxPromptChars: config.storage.memory?.maxPromptChars ?? config.memory?.maxPromptChars
548
549
  };
549
550
  }
550
551
  return config?.memory;
@@ -4690,10 +4691,18 @@ var PonchoFsAdapter = class {
4690
4691
  this.limits = limits;
4691
4692
  this.mounts = mounts.map((m) => {
4692
4693
  const prefix = m.prefix.endsWith("/") ? m.prefix : m.prefix + "/";
4694
+ const hasSource = typeof m.source === "string";
4695
+ const hasProvider = m.provider != null;
4696
+ if (hasSource === hasProvider) {
4697
+ throw new Error(
4698
+ `VirtualMount '${m.prefix}': set exactly one of 'source' or 'provider'`
4699
+ );
4700
+ }
4693
4701
  return {
4694
4702
  prefix,
4695
4703
  prefixNoSlash: prefix.slice(0, -1),
4696
- source: m.source.replace(/\/+$/, "")
4704
+ source: hasSource ? m.source.replace(/\/+$/, "") : void 0,
4705
+ provider: m.provider
4697
4706
  };
4698
4707
  });
4699
4708
  }
@@ -4756,6 +4765,10 @@ var PonchoFsAdapter = class {
4756
4765
  const np = normalize(path);
4757
4766
  const route = this.routeToMount(np);
4758
4767
  if (route) {
4768
+ if (route.mount.provider) {
4769
+ const buf3 = await route.mount.provider.readFileBuffer(this.tenantId, route.relative);
4770
+ return new TextDecoder().decode(buf3);
4771
+ }
4759
4772
  const buf2 = await nodeFs.readFile(this.toLocal(route.mount, route.relative));
4760
4773
  return buf2.toString("utf8");
4761
4774
  }
@@ -4766,6 +4779,9 @@ var PonchoFsAdapter = class {
4766
4779
  const np = normalize(path);
4767
4780
  const route = this.routeToMount(np);
4768
4781
  if (route) {
4782
+ if (route.mount.provider) {
4783
+ return route.mount.provider.readFileBuffer(this.tenantId, route.relative);
4784
+ }
4769
4785
  const buf = await nodeFs.readFile(this.toLocal(route.mount, route.relative));
4770
4786
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
4771
4787
  }
@@ -4775,6 +4791,14 @@ var PonchoFsAdapter = class {
4775
4791
  const np = normalize(path);
4776
4792
  const route = this.routeToMount(np);
4777
4793
  if (route) {
4794
+ if (route.mount.provider) {
4795
+ try {
4796
+ await route.mount.provider.stat(this.tenantId, route.relative);
4797
+ return true;
4798
+ } catch {
4799
+ return false;
4800
+ }
4801
+ }
4778
4802
  try {
4779
4803
  await nodeFs.access(this.toLocal(route.mount, route.relative));
4780
4804
  return true;
@@ -4790,6 +4814,13 @@ var PonchoFsAdapter = class {
4790
4814
  const np = normalize(path);
4791
4815
  const route = this.routeToMount(np);
4792
4816
  if (route) {
4817
+ if (route.mount.provider) {
4818
+ try {
4819
+ return await route.mount.provider.stat(this.tenantId, route.relative);
4820
+ } catch {
4821
+ throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
4822
+ }
4823
+ }
4793
4824
  try {
4794
4825
  const s2 = await nodeFs.stat(this.toLocal(route.mount, route.relative));
4795
4826
  return this.toFsStat(s2);
@@ -4815,6 +4846,10 @@ var PonchoFsAdapter = class {
4815
4846
  const np = normalize(path);
4816
4847
  const route = this.routeToMount(np);
4817
4848
  if (route) {
4849
+ if (route.mount.provider) {
4850
+ const entries = await route.mount.provider.readdir(this.tenantId, route.relative);
4851
+ return entries.map((e) => e.name);
4852
+ }
4818
4853
  return nodeFs.readdir(this.toLocal(route.mount, route.relative));
4819
4854
  }
4820
4855
  let engineNames = [];
@@ -4833,6 +4868,15 @@ var PonchoFsAdapter = class {
4833
4868
  const np = normalize(path);
4834
4869
  const route = this.routeToMount(np);
4835
4870
  if (route) {
4871
+ if (route.mount.provider) {
4872
+ const entries2 = await route.mount.provider.readdir(this.tenantId, route.relative);
4873
+ return entries2.map((e) => ({
4874
+ name: e.name,
4875
+ isFile: e.isFile,
4876
+ isDirectory: e.isDirectory,
4877
+ isSymbolicLink: false
4878
+ }));
4879
+ }
4836
4880
  const entries = await nodeFs.readdir(this.toLocal(route.mount, route.relative), { withFileTypes: true });
4837
4881
  return entries.map((e) => ({
4838
4882
  name: e.name,
@@ -4938,8 +4982,12 @@ var PonchoFsAdapter = class {
4938
4982
  const np = normalize(path);
4939
4983
  const route = this.routeToMount(np);
4940
4984
  if (route) {
4985
+ if (route.mount.provider) {
4986
+ return np;
4987
+ }
4941
4988
  const localResolved = await nodeFs.realpath(this.toLocal(route.mount, route.relative));
4942
- const localRoot = await nodeFs.realpath(route.mount.source).catch(() => route.mount.source);
4989
+ const source = route.mount.source;
4990
+ const localRoot = await nodeFs.realpath(source).catch(() => source);
4943
4991
  if (localResolved === localRoot) return route.mount.prefixNoSlash;
4944
4992
  if (localResolved.startsWith(localRoot + nodePath.sep)) {
4945
4993
  const rel = localResolved.slice(localRoot.length + 1).split(nodePath.sep).join("/");
@@ -4965,6 +5013,7 @@ var PonchoFsAdapter = class {
4965
5013
  const out = new Set(enginePaths);
4966
5014
  for (const m of this.mounts) {
4967
5015
  out.add(m.prefixNoSlash);
5016
+ if (m.provider) continue;
4968
5017
  try {
4969
5018
  const stack = [
4970
5019
  { abs: m.source, vfs: m.prefixNoSlash }
@@ -5018,6 +5067,9 @@ var PonchoFsAdapter = class {
5018
5067
  const np = normalize(path);
5019
5068
  const route = this.routeToMount(np);
5020
5069
  if (route) {
5070
+ if (route.mount.provider) {
5071
+ throw new Error(`EINVAL: not a symbolic link, readlink '${path}'`);
5072
+ }
5021
5073
  return nodeFs.readlink(this.toLocal(route.mount, route.relative));
5022
5074
  }
5023
5075
  return this.engine.vfs.readlink(this.tenantId, np);
@@ -5026,6 +5078,13 @@ var PonchoFsAdapter = class {
5026
5078
  const np = normalize(path);
5027
5079
  const route = this.routeToMount(np);
5028
5080
  if (route) {
5081
+ if (route.mount.provider) {
5082
+ try {
5083
+ return await route.mount.provider.stat(this.tenantId, route.relative);
5084
+ } catch {
5085
+ throw new Error(`ENOENT: no such file or directory, lstat '${path}'`);
5086
+ }
5087
+ }
5029
5088
  try {
5030
5089
  const s2 = await nodeFs.lstat(this.toLocal(route.mount, route.relative));
5031
5090
  return this.toFsStat(s2);
@@ -9891,7 +9950,7 @@ var AgentHarness = class _AgentHarness {
9891
9950
  this.registerIfMissing(createEditFileTool(getFs));
9892
9951
  this.registerIfMissing(createWriteFileTool(getFs));
9893
9952
  if (config?.isolate) {
9894
- const { createRunCodeTool, buildRunCodeDescription, bundleLibraries } = await import("./isolate-VY35DGLM.js");
9953
+ const { createRunCodeTool, buildRunCodeDescription, bundleLibraries } = await import("./isolate-BNQ6P3HI.js");
9895
9954
  let libraryPreamble = null;
9896
9955
  if (config.isolate.libraries?.length) {
9897
9956
  libraryPreamble = await bundleLibraries(config.isolate.libraries, this.workingDir);
@@ -10233,7 +10292,8 @@ Browser sessions (cookies, localStorage, login state) are automatically saved an
10233
10292
  ### Tabs and resources
10234
10293
  Each conversation gets its own browser tab sharing a single browser instance. Call \`browser_close\` when done to free the tab. If you don't close it, the tab stays open and the user can continue interacting with it.` : "";
10235
10294
  const mainMemory = await memoryPromise;
10236
- const boundedMainMemory = mainMemory && mainMemory.content.length > 4e3 ? `${mainMemory.content.slice(0, 4e3)}
10295
+ const memCap = this.memoryConfig?.maxPromptChars ?? 0;
10296
+ const boundedMainMemory = mainMemory && memCap > 0 && mainMemory.content.length > memCap ? `${mainMemory.content.slice(0, memCap)}
10237
10297
  ...[truncated]` : mainMemory?.content;
10238
10298
  const memoryContext = boundedMainMemory && boundedMainMemory.trim().length > 0 ? `
10239
10299
  ## Persistent Memory
@@ -10266,7 +10326,7 @@ Examples:${this.environment !== "production" ? `
10266
10326
  Files in the VFS are accessible to the user via \`/api/vfs/{path}\`. For example, a file at \`/downloads/report.pdf\` can be linked as \`/api/vfs/downloads/report.pdf\`. Use this to share downloadable files with the user.` : "";
10267
10327
  let isolateContext = "";
10268
10328
  if (this.loadedConfig?.isolate && this.dispatcher.get("run_code")) {
10269
- const { generateIsolateTypeStubs } = await import("./isolate-VY35DGLM.js");
10329
+ const { generateIsolateTypeStubs } = await import("./isolate-BNQ6P3HI.js");
10270
10330
  const typeStubs = generateIsolateTypeStubs(this.loadedConfig.isolate);
10271
10331
  isolateContext = `
10272
10332
 
@@ -324,6 +324,11 @@ function createFetchBinding(allowedDomains, network) {
324
324
  method: { type: "string" },
325
325
  headers: { type: "object", additionalProperties: { type: "string" } },
326
326
  body: { type: "string" },
327
+ // "base64" => `body` is base64-encoded raw bytes. The polyfill sets
328
+ // this when init.body is a Uint8Array/ArrayBuffer/Blob, so binary
329
+ // uploads (image-edit APIs, file uploads) reach the server intact
330
+ // instead of being mangled by String(...) coercion.
331
+ bodyEncoding: { type: "string", enum: ["base64"] },
327
332
  binary: { type: "boolean" }
328
333
  },
329
334
  required: ["url"]
@@ -335,10 +340,14 @@ function createFetchBinding(allowedDomains, network) {
335
340
  `Fetch blocked: domain "${url.hostname}" is not in the allowed list [${allowedDomains.join(", ")}]`
336
341
  );
337
342
  }
343
+ const rawBody = input.body;
344
+ const reqBody = rawBody !== void 0 && input.bodyEncoding === "base64" ? new Uint8Array(Buffer.from(rawBody, "base64")) : rawBody;
338
345
  const resp = await fetch(input.url, {
339
346
  method: input.method ?? "GET",
340
347
  headers: input.headers ?? void 0,
341
- body: input.body ?? void 0,
348
+ // Cast: Node's undici fetch accepts Uint8Array at runtime, but the
349
+ // BodyInit type in this lib version doesn't list it.
350
+ body: reqBody,
342
351
  redirect: "follow"
343
352
  });
344
353
  const headers = {};
@@ -846,6 +855,36 @@ var POLYFILL_FETCH = `
846
855
  return arr;
847
856
  }
848
857
 
858
+ function _fetchUint8ToB64(u8) {
859
+ let bin = "";
860
+ for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i]);
861
+ return btoa(bin);
862
+ }
863
+
864
+ // Normalise the init.body into { body, bodyEncoding } the binding accepts.
865
+ // Strings go through as text. Binary inputs (Uint8Array / ArrayBuffer /
866
+ // typed-array views / Blob) are base64-encoded with bodyEncoding="base64"
867
+ // so the host decodes back to the exact bytes before fetch() \u2014 without
868
+ // this branch, String(uint8Array) gave "1,2,3,..." and corrupted every
869
+ // binary upload (image-edit APIs, file uploads, etc.).
870
+ function _fetchEncodeBody(raw) {
871
+ if (raw == null) return { body: undefined };
872
+ if (typeof raw === "string") return { body: raw };
873
+ if (raw instanceof ArrayBuffer) {
874
+ return { body: _fetchUint8ToB64(new Uint8Array(raw)), bodyEncoding: "base64" };
875
+ }
876
+ if (ArrayBuffer.isView(raw)) {
877
+ const u8 = raw instanceof Uint8Array
878
+ ? raw
879
+ : new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
880
+ return { body: _fetchUint8ToB64(u8), bodyEncoding: "base64" };
881
+ }
882
+ if (typeof Blob !== "undefined" && raw instanceof Blob && raw._data) {
883
+ return { body: _fetchUint8ToB64(raw._data), bodyEncoding: "base64" };
884
+ }
885
+ return { body: String(raw) };
886
+ }
887
+
849
888
  globalThis.fetch = async function(input, init) {
850
889
  const url = typeof input === "string" ? input : (input?.url || String(input));
851
890
  const method = init?.method || "GET";
@@ -856,10 +895,17 @@ var POLYFILL_FETCH = `
856
895
  : Object.entries(init.headers);
857
896
  for (const [k, v] of entries) headers[k] = String(v);
858
897
  }
859
- const body = init?.body ? String(init.body) : undefined;
898
+ const encoded = _fetchEncodeBody(init?.body);
860
899
 
861
900
  // Always fetch as binary to preserve data integrity
862
- const result = await __poncho_fetch({ url, method, headers, body, binary: true });
901
+ const result = await __poncho_fetch({
902
+ url,
903
+ method,
904
+ headers,
905
+ body: encoded.body,
906
+ bodyEncoding: encoded.bodyEncoding,
907
+ binary: true,
908
+ });
863
909
  return new Response(result, true);
864
910
  };
865
911
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.48.0",
3
+ "version": "0.50.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/config.ts CHANGED
@@ -21,6 +21,7 @@ export interface StorageConfig {
21
21
  memory?: {
22
22
  enabled?: boolean;
23
23
  maxRecallConversations?: number;
24
+ maxPromptChars?: number;
24
25
  };
25
26
  limits?: {
26
27
  maxFileSize?: number;
@@ -335,6 +336,9 @@ export const resolveMemoryConfig = (
335
336
  maxRecallConversations:
336
337
  config.storage.memory?.maxRecallConversations ??
337
338
  config.memory?.maxRecallConversations,
339
+ maxPromptChars:
340
+ config.storage.memory?.maxPromptChars ??
341
+ config.memory?.maxPromptChars,
338
342
  };
339
343
  }
340
344
  return config?.memory;
package/src/harness.ts CHANGED
@@ -2184,9 +2184,15 @@ Browser sessions (cookies, localStorage, login state) are automatically saved an
2184
2184
  Each conversation gets its own browser tab sharing a single browser instance. Call \`browser_close\` when done to free the tab. If you don't close it, the tab stays open and the user can continue interacting with it.`
2185
2185
  : "";
2186
2186
  const mainMemory = await memoryPromise;
2187
+ // Main memory is injected in full by default — silently dropping the
2188
+ // tail of a user's memory every turn is a footgun. Set
2189
+ // `maxPromptChars` to a positive number to opt into a cap (e.g. for
2190
+ // prompt-cost control); content beyond it is sliced with a
2191
+ // `...[truncated]` marker.
2192
+ const memCap = this.memoryConfig?.maxPromptChars ?? 0;
2187
2193
  const boundedMainMemory =
2188
- mainMemory && mainMemory.content.length > 4000
2189
- ? `${mainMemory.content.slice(0, 4000)}\n...[truncated]`
2194
+ mainMemory && memCap > 0 && mainMemory.content.length > memCap
2195
+ ? `${mainMemory.content.slice(0, memCap)}\n...[truncated]`
2190
2196
  : mainMemory?.content;
2191
2197
  const memoryContext =
2192
2198
  boundedMainMemory && boundedMainMemory.trim().length > 0
package/src/index.ts CHANGED
@@ -21,7 +21,11 @@ export * from "./telemetry.js";
21
21
  export * from "./secrets-store.js";
22
22
  export * from "./storage/index.js";
23
23
  export * from "./storage/store-adapters.js";
24
- export { PonchoFsAdapter, type VirtualMount } from "./vfs/poncho-fs-adapter.js";
24
+ export {
25
+ PonchoFsAdapter,
26
+ type VirtualMount,
27
+ type MountProvider,
28
+ } from "./vfs/poncho-fs-adapter.js";
25
29
  export { BashEnvironmentManager } from "./vfs/bash-manager.js";
26
30
  export { createBashTool } from "./vfs/bash-tool.js";
27
31
  export * from "./tenant-token.js";
@@ -161,6 +161,11 @@ export function createFetchBinding(
161
161
  method: { type: "string" },
162
162
  headers: { type: "object", additionalProperties: { type: "string" } },
163
163
  body: { type: "string" },
164
+ // "base64" => `body` is base64-encoded raw bytes. The polyfill sets
165
+ // this when init.body is a Uint8Array/ArrayBuffer/Blob, so binary
166
+ // uploads (image-edit APIs, file uploads) reach the server intact
167
+ // instead of being mangled by String(...) coercion.
168
+ bodyEncoding: { type: "string", enum: ["base64"] },
164
169
  binary: { type: "boolean" },
165
170
  },
166
171
  required: ["url"],
@@ -173,10 +178,18 @@ export function createFetchBinding(
173
178
  );
174
179
  }
175
180
 
181
+ const rawBody = input.body as string | undefined;
182
+ const reqBody: string | Uint8Array | undefined =
183
+ rawBody !== undefined && input.bodyEncoding === "base64"
184
+ ? new Uint8Array(Buffer.from(rawBody, "base64"))
185
+ : rawBody;
186
+
176
187
  const resp = await fetch(input.url as string, {
177
188
  method: (input.method as string) ?? "GET",
178
189
  headers: (input.headers as Record<string, string>) ?? undefined,
179
- body: (input.body as string) ?? undefined,
190
+ // Cast: Node's undici fetch accepts Uint8Array at runtime, but the
191
+ // BodyInit type in this lib version doesn't list it.
192
+ body: reqBody as unknown as BodyInit | undefined,
180
193
  redirect: "follow",
181
194
  });
182
195
 
@@ -532,6 +532,36 @@ const POLYFILL_FETCH = `
532
532
  return arr;
533
533
  }
534
534
 
535
+ function _fetchUint8ToB64(u8) {
536
+ let bin = "";
537
+ for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i]);
538
+ return btoa(bin);
539
+ }
540
+
541
+ // Normalise the init.body into { body, bodyEncoding } the binding accepts.
542
+ // Strings go through as text. Binary inputs (Uint8Array / ArrayBuffer /
543
+ // typed-array views / Blob) are base64-encoded with bodyEncoding="base64"
544
+ // so the host decodes back to the exact bytes before fetch() — without
545
+ // this branch, String(uint8Array) gave "1,2,3,..." and corrupted every
546
+ // binary upload (image-edit APIs, file uploads, etc.).
547
+ function _fetchEncodeBody(raw) {
548
+ if (raw == null) return { body: undefined };
549
+ if (typeof raw === "string") return { body: raw };
550
+ if (raw instanceof ArrayBuffer) {
551
+ return { body: _fetchUint8ToB64(new Uint8Array(raw)), bodyEncoding: "base64" };
552
+ }
553
+ if (ArrayBuffer.isView(raw)) {
554
+ const u8 = raw instanceof Uint8Array
555
+ ? raw
556
+ : new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
557
+ return { body: _fetchUint8ToB64(u8), bodyEncoding: "base64" };
558
+ }
559
+ if (typeof Blob !== "undefined" && raw instanceof Blob && raw._data) {
560
+ return { body: _fetchUint8ToB64(raw._data), bodyEncoding: "base64" };
561
+ }
562
+ return { body: String(raw) };
563
+ }
564
+
535
565
  globalThis.fetch = async function(input, init) {
536
566
  const url = typeof input === "string" ? input : (input?.url || String(input));
537
567
  const method = init?.method || "GET";
@@ -542,10 +572,17 @@ const POLYFILL_FETCH = `
542
572
  : Object.entries(init.headers);
543
573
  for (const [k, v] of entries) headers[k] = String(v);
544
574
  }
545
- const body = init?.body ? String(init.body) : undefined;
575
+ const encoded = _fetchEncodeBody(init?.body);
546
576
 
547
577
  // Always fetch as binary to preserve data integrity
548
- const result = await __poncho_fetch({ url, method, headers, body, binary: true });
578
+ const result = await __poncho_fetch({
579
+ url,
580
+ method,
581
+ headers,
582
+ body: encoded.body,
583
+ bodyEncoding: encoded.bodyEncoding,
584
+ binary: true,
585
+ });
549
586
  return new Response(result, true);
550
587
  };
551
588
 
package/src/memory.ts CHANGED
@@ -15,6 +15,15 @@ export interface MemoryConfig {
15
15
  region?: string;
16
16
  ttl?: number;
17
17
  maxRecallConversations?: number;
18
+ /**
19
+ * Optional cap on the characters of main memory injected into the
20
+ * system prompt each turn. Default is **no cap** — the full memory is
21
+ * injected (silently truncating a user's memory every turn is a
22
+ * footgun). Set a positive number to opt into truncation for
23
+ * prompt-cost control; content beyond it is sliced with a
24
+ * `...[truncated]` marker.
25
+ */
26
+ maxPromptChars?: number;
18
27
  }
19
28
 
20
29
  export interface MemoryStore {
@@ -85,9 +85,34 @@ const normalize = (path: string): string => {
85
85
  };
86
86
 
87
87
  /**
88
- * Read-only virtual mount mapping a VFS path prefix to a local filesystem
89
- * directory. All read operations under the prefix resolve via local FS;
90
- * writes throw. The prefix is normalised internally to end with "/".
88
+ * Read-only data source for a virtual mount whose contents don't live on
89
+ * local disk. Lets a host (e.g. PonchOS) back a VFS prefix with a database
90
+ * row set, object-store keys, or anything else expressible as
91
+ * `(tenantId, relative) -> bytes/listing/stat`.
92
+ *
93
+ * All three methods are read-only; writes through the adapter still throw
94
+ * `EROFS` for routed mounts the same way disk-backed mounts do (see
95
+ * `routeToMount` checks in writeFile/mkdir/rm/cp/mv/chmod/utimes/symlink/link).
96
+ * `relative` is the path within the mount; `""` means the mount root.
97
+ */
98
+ export interface MountProvider {
99
+ /** List entries directly under `relative`. The mount root is `""`. */
100
+ readdir(
101
+ tenantId: string,
102
+ relative: string,
103
+ ): Promise<Array<{ name: string; isFile: boolean; isDirectory: boolean }>>;
104
+ /** Stat a single entry. Throw ENOENT-like for missing paths. */
105
+ stat(tenantId: string, relative: string): Promise<FsStat>;
106
+ /** Read raw bytes for a file entry. */
107
+ readFileBuffer(tenantId: string, relative: string): Promise<Uint8Array>;
108
+ }
109
+
110
+ /**
111
+ * Read-only virtual mount mapping a VFS path prefix to either a local
112
+ * filesystem directory (`source`) OR a custom provider (`provider`).
113
+ * Exactly one of the two must be set. All read operations under the prefix
114
+ * resolve via the chosen backend; writes throw. The prefix is normalised
115
+ * internally to end with "/".
91
116
  */
92
117
  export interface VirtualMount {
93
118
  /** VFS prefix, e.g. "/system/". Leading slash required; trailing slash
@@ -95,15 +120,20 @@ export interface VirtualMount {
95
120
  * but no validation is enforced here. */
96
121
  prefix: string;
97
122
  /** Absolute local FS path to serve from, e.g. "/srv/poncho/system". */
98
- source: string;
123
+ source?: string;
124
+ /** Custom backend serving reads for this mount. Mutually exclusive with
125
+ * `source` — set exactly one. Use when the data doesn't live on local
126
+ * disk (e.g. database-backed upload listings). */
127
+ provider?: MountProvider;
99
128
  }
100
129
 
101
- /** Internal normalised form: prefix always ends with "/", source has no
102
- * trailing slash. */
130
+ /** Internal normalised form: prefix always ends with "/", source (when set)
131
+ * has no trailing slash. Exactly one of source/provider is populated. */
103
132
  interface NormalisedMount {
104
133
  prefix: string;
105
134
  prefixNoSlash: string;
106
- source: string;
135
+ source?: string;
136
+ provider?: MountProvider;
107
137
  }
108
138
 
109
139
  const READ_ONLY_ERROR = (path: string, op: string): Error =>
@@ -120,10 +150,18 @@ export class PonchoFsAdapter implements IFileSystem {
120
150
  ) {
121
151
  this.mounts = mounts.map((m) => {
122
152
  const prefix = m.prefix.endsWith("/") ? m.prefix : m.prefix + "/";
153
+ const hasSource = typeof m.source === "string";
154
+ const hasProvider = m.provider != null;
155
+ if (hasSource === hasProvider) {
156
+ throw new Error(
157
+ `VirtualMount '${m.prefix}': set exactly one of 'source' or 'provider'`,
158
+ );
159
+ }
123
160
  return {
124
161
  prefix,
125
162
  prefixNoSlash: prefix.slice(0, -1),
126
- source: m.source.replace(/\/+$/, ""),
163
+ source: hasSource ? m.source!.replace(/\/+$/, "") : undefined,
164
+ provider: m.provider,
127
165
  };
128
166
  });
129
167
  }
@@ -157,8 +195,9 @@ export class PonchoFsAdapter implements IFileSystem {
157
195
  }
158
196
 
159
197
  private toLocal(mount: NormalisedMount, relative: string): string {
160
- // nodePath.join handles empty relative -> source dir
161
- return nodePath.join(mount.source, relative);
198
+ // Only callable for disk-backed mounts; provider mounts never call this.
199
+ // nodePath.join handles empty relative -> source dir.
200
+ return nodePath.join(mount.source!, relative);
162
201
  }
163
202
 
164
203
  /** Build an FsStat from a node fs.Stats. */
@@ -193,6 +232,10 @@ export class PonchoFsAdapter implements IFileSystem {
193
232
  const np = normalize(path);
194
233
  const route = this.routeToMount(np);
195
234
  if (route) {
235
+ if (route.mount.provider) {
236
+ const buf = await route.mount.provider.readFileBuffer(this.tenantId, route.relative);
237
+ return new TextDecoder().decode(buf);
238
+ }
196
239
  const buf = await nodeFs.readFile(this.toLocal(route.mount, route.relative));
197
240
  return buf.toString("utf8");
198
241
  }
@@ -204,6 +247,9 @@ export class PonchoFsAdapter implements IFileSystem {
204
247
  const np = normalize(path);
205
248
  const route = this.routeToMount(np);
206
249
  if (route) {
250
+ if (route.mount.provider) {
251
+ return route.mount.provider.readFileBuffer(this.tenantId, route.relative);
252
+ }
207
253
  const buf = await nodeFs.readFile(this.toLocal(route.mount, route.relative));
208
254
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
209
255
  }
@@ -214,6 +260,14 @@ export class PonchoFsAdapter implements IFileSystem {
214
260
  const np = normalize(path);
215
261
  const route = this.routeToMount(np);
216
262
  if (route) {
263
+ if (route.mount.provider) {
264
+ try {
265
+ await route.mount.provider.stat(this.tenantId, route.relative);
266
+ return true;
267
+ } catch {
268
+ return false;
269
+ }
270
+ }
217
271
  try {
218
272
  await nodeFs.access(this.toLocal(route.mount, route.relative));
219
273
  return true;
@@ -232,6 +286,13 @@ export class PonchoFsAdapter implements IFileSystem {
232
286
  const np = normalize(path);
233
287
  const route = this.routeToMount(np);
234
288
  if (route) {
289
+ if (route.mount.provider) {
290
+ try {
291
+ return await route.mount.provider.stat(this.tenantId, route.relative);
292
+ } catch {
293
+ throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
294
+ }
295
+ }
235
296
  try {
236
297
  const s = await nodeFs.stat(this.toLocal(route.mount, route.relative));
237
298
  return this.toFsStat(s);
@@ -259,6 +320,10 @@ export class PonchoFsAdapter implements IFileSystem {
259
320
  const np = normalize(path);
260
321
  const route = this.routeToMount(np);
261
322
  if (route) {
323
+ if (route.mount.provider) {
324
+ const entries = await route.mount.provider.readdir(this.tenantId, route.relative);
325
+ return entries.map((e) => e.name);
326
+ }
262
327
  return nodeFs.readdir(this.toLocal(route.mount, route.relative));
263
328
  }
264
329
  // Engine-backed read; also inject any mount-root segments whose parent
@@ -282,6 +347,15 @@ export class PonchoFsAdapter implements IFileSystem {
282
347
  const np = normalize(path);
283
348
  const route = this.routeToMount(np);
284
349
  if (route) {
350
+ if (route.mount.provider) {
351
+ const entries = await route.mount.provider.readdir(this.tenantId, route.relative);
352
+ return entries.map((e) => ({
353
+ name: e.name,
354
+ isFile: e.isFile,
355
+ isDirectory: e.isDirectory,
356
+ isSymbolicLink: false,
357
+ }));
358
+ }
285
359
  const entries = await nodeFs.readdir(this.toLocal(route.mount, route.relative), { withFileTypes: true });
286
360
  return entries.map((e) => ({
287
361
  name: e.name,
@@ -411,11 +485,17 @@ export class PonchoFsAdapter implements IFileSystem {
411
485
  const np = normalize(path);
412
486
  const route = this.routeToMount(np);
413
487
  if (route) {
488
+ if (route.mount.provider) {
489
+ // Provider mounts have no real disk path and don't support symlinks.
490
+ // The VFS path is its own canonical form.
491
+ return np;
492
+ }
414
493
  // Mount contents on local disk: resolve via node, but report back
415
494
  // in VFS-namespace terms (don't leak the on-disk source path to the
416
495
  // agent — that would be confusing and non-portable).
417
496
  const localResolved = await nodeFs.realpath(this.toLocal(route.mount, route.relative));
418
- const localRoot = await nodeFs.realpath(route.mount.source).catch(() => route.mount.source);
497
+ const source = route.mount.source!;
498
+ const localRoot = await nodeFs.realpath(source).catch(() => source);
419
499
  if (localResolved === localRoot) return route.mount.prefixNoSlash;
420
500
  if (localResolved.startsWith(localRoot + nodePath.sep)) {
421
501
  const rel = localResolved.slice(localRoot.length + 1).split(nodePath.sep).join("/");
@@ -448,12 +528,16 @@ export class PonchoFsAdapter implements IFileSystem {
448
528
  for (const m of this.mounts) {
449
529
  // Always advertise the mount root itself as a directory.
450
530
  out.add(m.prefixNoSlash);
451
- // Walk the local source once and add all paths under the mount.
531
+ // Provider mounts: deep listing would need async IO; advertise the
532
+ // mount root only. Bash glob/find over the mount falls back to a
533
+ // shallow listing — acceptable per the v1 spec (corner case).
534
+ if (m.provider) continue;
535
+ // Disk-backed mount: walk the local source once and add all paths.
452
536
  // Sync IO is acceptable here: bash glob/find call this sporadically and
453
537
  // the source is a small static asset directory on the API container.
454
538
  try {
455
539
  const stack: Array<{ abs: string; vfs: string }> = [
456
- { abs: m.source, vfs: m.prefixNoSlash },
540
+ { abs: m.source!, vfs: m.prefixNoSlash },
457
541
  ];
458
542
  while (stack.length > 0) {
459
543
  const { abs, vfs } = stack.pop()!;
@@ -514,6 +598,9 @@ export class PonchoFsAdapter implements IFileSystem {
514
598
  const np = normalize(path);
515
599
  const route = this.routeToMount(np);
516
600
  if (route) {
601
+ if (route.mount.provider) {
602
+ throw new Error(`EINVAL: not a symbolic link, readlink '${path}'`);
603
+ }
517
604
  // Mount contents are real files; readlink only makes sense for symlinks
518
605
  // we don't expect to have on disk. Node will throw EINVAL for non-links.
519
606
  return nodeFs.readlink(this.toLocal(route.mount, route.relative));
@@ -525,6 +612,14 @@ export class PonchoFsAdapter implements IFileSystem {
525
612
  const np = normalize(path);
526
613
  const route = this.routeToMount(np);
527
614
  if (route) {
615
+ if (route.mount.provider) {
616
+ // Provider mounts can't host symlinks; lstat == stat.
617
+ try {
618
+ return await route.mount.provider.stat(this.tenantId, route.relative);
619
+ } catch {
620
+ throw new Error(`ENOENT: no such file or directory, lstat '${path}'`);
621
+ }
622
+ }
528
623
  try {
529
624
  const s = await nodeFs.lstat(this.toLocal(route.mount, route.relative));
530
625
  return this.toFsStat(s);