@poncho-ai/harness 0.49.0 → 0.50.1

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.49.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.50.1 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/isolate-VY35DGLM.js 49.43 KB
12
- ESM dist/index.js 528.58 KB
13
- ESM ⚡️ Build success in 197ms
11
+ ESM dist/index.js 530.75 KB
12
+ ESM dist/isolate-BNQ6P3HI.js 51.41 KB
13
+ ESM ⚡️ Build success in 224ms
14
14
  DTS Build start
15
- DTS ⚡️ Build success in 5685ms
16
- DTS dist/index.d.ts 87.88 KB
15
+ DTS ⚡️ Build success in 6878ms
16
+ DTS dist/index.d.ts 89.28 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.50.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#131](https://github.com/cesr/poncho-ai/pull/131) [`9e0ccd8`](https://github.com/cesr/poncho-ai/commit/9e0ccd8307ab056f94b7efc3e25a0cb71ee75625) Thanks [@cesr](https://github.com/cesr)! - harness: strip `poncho-upload://` scheme in `S3UploadStore.get` / `.delete`
8
+
9
+ `createUploadStore({ provider: "s3" })` wraps `S3UploadStore` in
10
+ `CachedUploadStore`, whose `put` returns a `poncho-upload://<key>` ref
11
+ and stores the underlying S3 object at the bare `<key>`. On read,
12
+ `CachedUploadStore.get` checks an in-memory cache (10-minute TTL); on
13
+ miss it falls through to `S3UploadStore.get(<ref>)`. Pre-fix, the S3
14
+ store treated the scheme-prefixed ref as a literal S3 key and hit the
15
+ backend with `poncho-upload://<key>` — guaranteed `NoSuchKey`.
16
+
17
+ In practice this meant a chat message with an attached image worked on
18
+ the turn it was uploaded (cache hit) and then started showing as
19
+ "[Attached file: … — file is no longer available]" on every follow-up
20
+ turn ~10 minutes later (cache miss → S3 NoSuchKey → outer catch in the
21
+ harness resolver). The same path worked for the local-fs store, which
22
+ strips the scheme in both `get` and `delete`.
23
+
24
+ `S3UploadStore.get` now strips the scheme before issuing
25
+ `GetObjectCommand`. `S3UploadStore.delete` already stripped `https://`
26
+ and now strips `poncho-upload://` too.
27
+
28
+ ## 0.50.0
29
+
30
+ ### Minor Changes
31
+
32
+ - [#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
33
+
34
+ Two additive isolate/VFS changes.
35
+
36
+ **`MountProvider` for `VirtualMount`.** A virtual mount can now be backed
37
+ by a custom data source instead of a local-disk directory. Set
38
+ `provider: { readdir, stat, readFileBuffer }` instead of `source` on a
39
+ `VirtualMount`. The adapter routes read operations through the provider
40
+ and rejects writes the same way it does for disk-backed mounts. Lets a
41
+ host expose database rows / object-store keys as a VFS subtree without
42
+ materialising them on disk (e.g. PonchOS exposing user uploads at
43
+ `/uploads`). `getAllPaths` advertises only the mount root for provider
44
+ mounts (deep listing would require sync IO over a remote backend);
45
+ shallow listing is sufficient for bash glob/find at the mount root.
46
+
47
+ **Binary `fetch()` bodies in `run_code`.** The isolate fetch polyfill
48
+ used to coerce `init.body` to a string before sending it to the
49
+ `__poncho_fetch` binding, so passing a `Uint8Array`, `ArrayBuffer`, or
50
+ `Blob` arrived server-side as `"1,2,3,..."` — every binary upload
51
+ (image-edit APIs, file uploads) was corrupted. The polyfill now
52
+ base64-encodes binary bodies with a new `bodyEncoding: "base64"` field
53
+ on the binding input; the built-in `createFetchBinding` decodes back to
54
+ raw bytes before fetching. Custom bindings that replace `__poncho_fetch`
55
+ should add the same decoding (cf. PonchOS `createSecretAwareFetchBinding`).
56
+ String bodies are unchanged.
57
+
3
58
  ## 0.49.0
4
59
 
5
60
  ### 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 {
@@ -977,9 +977,34 @@ interface StorageEngine {
977
977
  }
978
978
 
979
979
  /**
980
- * Read-only virtual mount mapping a VFS path prefix to a local filesystem
981
- * directory. All read operations under the prefix resolve via local FS;
982
- * 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 "/".
983
1008
  */
984
1009
  interface VirtualMount {
985
1010
  /** VFS prefix, e.g. "/system/". Leading slash required; trailing slash
@@ -987,7 +1012,11 @@ interface VirtualMount {
987
1012
  * but no validation is enforced here. */
988
1013
  prefix: string;
989
1014
  /** Absolute local FS path to serve from, e.g. "/srv/poncho/system". */
990
- 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;
991
1020
  }
992
1021
  declare class PonchoFsAdapter implements IFileSystem {
993
1022
  private engine;
@@ -2109,4 +2138,4 @@ interface RunConversationTurnResult {
2109
2138
  }
2110
2139
  declare const runConversationTurn: (opts: RunConversationTurnOpts) => Promise<RunConversationTurnResult>;
2111
2140
 
2112
- 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
@@ -2612,16 +2612,17 @@ var S3UploadStore = class {
2612
2612
  }
2613
2613
  return Buffer.from(await response.arrayBuffer());
2614
2614
  }
2615
+ const key = urlOrKey.startsWith(PONCHO_UPLOAD_SCHEME) ? urlOrKey.slice(PONCHO_UPLOAD_SCHEME.length) : urlOrKey;
2615
2616
  await this.ensureClient();
2616
2617
  const result = await this.client.send(
2617
- new this.s3Sdk.GetObjectCommand({ Bucket: this.bucket, Key: urlOrKey })
2618
+ new this.s3Sdk.GetObjectCommand({ Bucket: this.bucket, Key: key })
2618
2619
  );
2619
- if (!result.Body) throw new Error(`uploads: empty body for S3 key ${urlOrKey}`);
2620
+ if (!result.Body) throw new Error(`uploads: empty body for S3 key ${key}`);
2620
2621
  return Buffer.from(await result.Body.transformToByteArray());
2621
2622
  }
2622
2623
  async delete(urlOrKey) {
2623
2624
  await this.ensureClient();
2624
- const key = urlOrKey.startsWith("https://") ? new URL(urlOrKey).pathname.slice(1) : urlOrKey;
2625
+ const key = urlOrKey.startsWith("https://") ? new URL(urlOrKey).pathname.slice(1) : urlOrKey.startsWith(PONCHO_UPLOAD_SCHEME) ? urlOrKey.slice(PONCHO_UPLOAD_SCHEME.length) : urlOrKey;
2625
2626
  await this.client.send(
2626
2627
  new this.s3Sdk.DeleteObjectCommand({ Bucket: this.bucket, Key: key })
2627
2628
  );
@@ -4691,10 +4692,18 @@ var PonchoFsAdapter = class {
4691
4692
  this.limits = limits;
4692
4693
  this.mounts = mounts.map((m) => {
4693
4694
  const prefix = m.prefix.endsWith("/") ? m.prefix : m.prefix + "/";
4695
+ const hasSource = typeof m.source === "string";
4696
+ const hasProvider = m.provider != null;
4697
+ if (hasSource === hasProvider) {
4698
+ throw new Error(
4699
+ `VirtualMount '${m.prefix}': set exactly one of 'source' or 'provider'`
4700
+ );
4701
+ }
4694
4702
  return {
4695
4703
  prefix,
4696
4704
  prefixNoSlash: prefix.slice(0, -1),
4697
- source: m.source.replace(/\/+$/, "")
4705
+ source: hasSource ? m.source.replace(/\/+$/, "") : void 0,
4706
+ provider: m.provider
4698
4707
  };
4699
4708
  });
4700
4709
  }
@@ -4757,6 +4766,10 @@ var PonchoFsAdapter = class {
4757
4766
  const np = normalize(path);
4758
4767
  const route = this.routeToMount(np);
4759
4768
  if (route) {
4769
+ if (route.mount.provider) {
4770
+ const buf3 = await route.mount.provider.readFileBuffer(this.tenantId, route.relative);
4771
+ return new TextDecoder().decode(buf3);
4772
+ }
4760
4773
  const buf2 = await nodeFs.readFile(this.toLocal(route.mount, route.relative));
4761
4774
  return buf2.toString("utf8");
4762
4775
  }
@@ -4767,6 +4780,9 @@ var PonchoFsAdapter = class {
4767
4780
  const np = normalize(path);
4768
4781
  const route = this.routeToMount(np);
4769
4782
  if (route) {
4783
+ if (route.mount.provider) {
4784
+ return route.mount.provider.readFileBuffer(this.tenantId, route.relative);
4785
+ }
4770
4786
  const buf = await nodeFs.readFile(this.toLocal(route.mount, route.relative));
4771
4787
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
4772
4788
  }
@@ -4776,6 +4792,14 @@ var PonchoFsAdapter = class {
4776
4792
  const np = normalize(path);
4777
4793
  const route = this.routeToMount(np);
4778
4794
  if (route) {
4795
+ if (route.mount.provider) {
4796
+ try {
4797
+ await route.mount.provider.stat(this.tenantId, route.relative);
4798
+ return true;
4799
+ } catch {
4800
+ return false;
4801
+ }
4802
+ }
4779
4803
  try {
4780
4804
  await nodeFs.access(this.toLocal(route.mount, route.relative));
4781
4805
  return true;
@@ -4791,6 +4815,13 @@ var PonchoFsAdapter = class {
4791
4815
  const np = normalize(path);
4792
4816
  const route = this.routeToMount(np);
4793
4817
  if (route) {
4818
+ if (route.mount.provider) {
4819
+ try {
4820
+ return await route.mount.provider.stat(this.tenantId, route.relative);
4821
+ } catch {
4822
+ throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
4823
+ }
4824
+ }
4794
4825
  try {
4795
4826
  const s2 = await nodeFs.stat(this.toLocal(route.mount, route.relative));
4796
4827
  return this.toFsStat(s2);
@@ -4816,6 +4847,10 @@ var PonchoFsAdapter = class {
4816
4847
  const np = normalize(path);
4817
4848
  const route = this.routeToMount(np);
4818
4849
  if (route) {
4850
+ if (route.mount.provider) {
4851
+ const entries = await route.mount.provider.readdir(this.tenantId, route.relative);
4852
+ return entries.map((e) => e.name);
4853
+ }
4819
4854
  return nodeFs.readdir(this.toLocal(route.mount, route.relative));
4820
4855
  }
4821
4856
  let engineNames = [];
@@ -4834,6 +4869,15 @@ var PonchoFsAdapter = class {
4834
4869
  const np = normalize(path);
4835
4870
  const route = this.routeToMount(np);
4836
4871
  if (route) {
4872
+ if (route.mount.provider) {
4873
+ const entries2 = await route.mount.provider.readdir(this.tenantId, route.relative);
4874
+ return entries2.map((e) => ({
4875
+ name: e.name,
4876
+ isFile: e.isFile,
4877
+ isDirectory: e.isDirectory,
4878
+ isSymbolicLink: false
4879
+ }));
4880
+ }
4837
4881
  const entries = await nodeFs.readdir(this.toLocal(route.mount, route.relative), { withFileTypes: true });
4838
4882
  return entries.map((e) => ({
4839
4883
  name: e.name,
@@ -4939,8 +4983,12 @@ var PonchoFsAdapter = class {
4939
4983
  const np = normalize(path);
4940
4984
  const route = this.routeToMount(np);
4941
4985
  if (route) {
4986
+ if (route.mount.provider) {
4987
+ return np;
4988
+ }
4942
4989
  const localResolved = await nodeFs.realpath(this.toLocal(route.mount, route.relative));
4943
- const localRoot = await nodeFs.realpath(route.mount.source).catch(() => route.mount.source);
4990
+ const source = route.mount.source;
4991
+ const localRoot = await nodeFs.realpath(source).catch(() => source);
4944
4992
  if (localResolved === localRoot) return route.mount.prefixNoSlash;
4945
4993
  if (localResolved.startsWith(localRoot + nodePath.sep)) {
4946
4994
  const rel = localResolved.slice(localRoot.length + 1).split(nodePath.sep).join("/");
@@ -4966,6 +5014,7 @@ var PonchoFsAdapter = class {
4966
5014
  const out = new Set(enginePaths);
4967
5015
  for (const m of this.mounts) {
4968
5016
  out.add(m.prefixNoSlash);
5017
+ if (m.provider) continue;
4969
5018
  try {
4970
5019
  const stack = [
4971
5020
  { abs: m.source, vfs: m.prefixNoSlash }
@@ -5019,6 +5068,9 @@ var PonchoFsAdapter = class {
5019
5068
  const np = normalize(path);
5020
5069
  const route = this.routeToMount(np);
5021
5070
  if (route) {
5071
+ if (route.mount.provider) {
5072
+ throw new Error(`EINVAL: not a symbolic link, readlink '${path}'`);
5073
+ }
5022
5074
  return nodeFs.readlink(this.toLocal(route.mount, route.relative));
5023
5075
  }
5024
5076
  return this.engine.vfs.readlink(this.tenantId, np);
@@ -5027,6 +5079,13 @@ var PonchoFsAdapter = class {
5027
5079
  const np = normalize(path);
5028
5080
  const route = this.routeToMount(np);
5029
5081
  if (route) {
5082
+ if (route.mount.provider) {
5083
+ try {
5084
+ return await route.mount.provider.stat(this.tenantId, route.relative);
5085
+ } catch {
5086
+ throw new Error(`ENOENT: no such file or directory, lstat '${path}'`);
5087
+ }
5088
+ }
5030
5089
  try {
5031
5090
  const s2 = await nodeFs.lstat(this.toLocal(route.mount, route.relative));
5032
5091
  return this.toFsStat(s2);
@@ -9892,7 +9951,7 @@ var AgentHarness = class _AgentHarness {
9892
9951
  this.registerIfMissing(createEditFileTool(getFs));
9893
9952
  this.registerIfMissing(createWriteFileTool(getFs));
9894
9953
  if (config?.isolate) {
9895
- const { createRunCodeTool, buildRunCodeDescription, bundleLibraries } = await import("./isolate-VY35DGLM.js");
9954
+ const { createRunCodeTool, buildRunCodeDescription, bundleLibraries } = await import("./isolate-BNQ6P3HI.js");
9896
9955
  let libraryPreamble = null;
9897
9956
  if (config.isolate.libraries?.length) {
9898
9957
  libraryPreamble = await bundleLibraries(config.isolate.libraries, this.workingDir);
@@ -10268,7 +10327,7 @@ Examples:${this.environment !== "production" ? `
10268
10327
  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.` : "";
10269
10328
  let isolateContext = "";
10270
10329
  if (this.loadedConfig?.isolate && this.dispatcher.get("run_code")) {
10271
- const { generateIsolateTypeStubs } = await import("./isolate-VY35DGLM.js");
10330
+ const { generateIsolateTypeStubs } = await import("./isolate-BNQ6P3HI.js");
10272
10331
  const typeStubs = generateIsolateTypeStubs(this.loadedConfig.isolate);
10273
10332
  isolateContext = `
10274
10333
 
@@ -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.49.0",
3
+ "version": "0.50.1",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
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
 
@@ -349,11 +349,19 @@ export class S3UploadStore implements UploadStore {
349
349
  }
350
350
  return Buffer.from(await response.arrayBuffer());
351
351
  }
352
+ // Strip the `poncho-upload://` scheme — LocalUploadStore does this for
353
+ // both get/delete but S3 used to treat the full URI as a literal S3
354
+ // key, so resolving a persisted file part (data: "poncho-upload://…")
355
+ // on a follow-up turn 404'd against R2 and the harness emitted a
356
+ // "file is no longer available" placeholder.
357
+ const key = urlOrKey.startsWith(PONCHO_UPLOAD_SCHEME)
358
+ ? urlOrKey.slice(PONCHO_UPLOAD_SCHEME.length)
359
+ : urlOrKey;
352
360
  await this.ensureClient();
353
361
  const result = await this.client.send(
354
- new this.s3Sdk.GetObjectCommand({ Bucket: this.bucket, Key: urlOrKey }),
362
+ new this.s3Sdk.GetObjectCommand({ Bucket: this.bucket, Key: key }),
355
363
  );
356
- if (!result.Body) throw new Error(`uploads: empty body for S3 key ${urlOrKey}`);
364
+ if (!result.Body) throw new Error(`uploads: empty body for S3 key ${key}`);
357
365
  return Buffer.from(await result.Body.transformToByteArray());
358
366
  }
359
367
 
@@ -361,7 +369,9 @@ export class S3UploadStore implements UploadStore {
361
369
  await this.ensureClient();
362
370
  const key = urlOrKey.startsWith("https://")
363
371
  ? new URL(urlOrKey).pathname.slice(1)
364
- : urlOrKey;
372
+ : urlOrKey.startsWith(PONCHO_UPLOAD_SCHEME)
373
+ ? urlOrKey.slice(PONCHO_UPLOAD_SCHEME.length)
374
+ : urlOrKey;
365
375
  await this.client.send(
366
376
  new this.s3Sdk.DeleteObjectCommand({ Bucket: this.bucket, Key: key }),
367
377
  );
@@ -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);