@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.
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +55 -0
- package/dist/index.d.ts +35 -6
- package/dist/index.js +66 -7
- package/dist/{isolate-VY35DGLM.js → isolate-BNQ6P3HI.js} +49 -3
- package/package.json +1 -1
- package/src/index.ts +5 -1
- package/src/isolate/bindings.ts +14 -1
- package/src/isolate/polyfills.ts +39 -2
- package/src/upload-store.ts +13 -3
- package/src/vfs/poncho-fs-adapter.ts +108 -13
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @poncho-ai/harness@0.
|
|
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
|
[34mCLI[39m tsup v8.5.1
|
|
9
9
|
[34mCLI[39m Target: es2022
|
|
10
10
|
[34mESM[39m Build start
|
|
11
|
-
[32mESM[39m [1mdist/
|
|
12
|
-
[32mESM[39m [1mdist/
|
|
13
|
-
[32mESM[39m ⚡️ Build success in
|
|
11
|
+
[32mESM[39m [1mdist/index.js [22m[32m530.75 KB[39m
|
|
12
|
+
[32mESM[39m [1mdist/isolate-BNQ6P3HI.js [22m[32m51.41 KB[39m
|
|
13
|
+
[32mESM[39m ⚡️ Build success in 224ms
|
|
14
14
|
[34mDTS[39m Build start
|
|
15
|
-
[32mDTS[39m ⚡️ Build success in
|
|
16
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[
|
|
15
|
+
[32mDTS[39m ⚡️ Build success in 6878ms
|
|
16
|
+
[32mDTS[39m [1mdist/index.d.ts [22m[32m89.28 KB[39m
|
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,
|
|
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
|
|
981
|
-
*
|
|
982
|
-
*
|
|
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
|
|
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:
|
|
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 ${
|
|
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
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
|
898
|
+
const encoded = _fetchEncodeBody(init?.body);
|
|
860
899
|
|
|
861
900
|
// Always fetch as binary to preserve data integrity
|
|
862
|
-
const result = await __poncho_fetch({
|
|
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
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 {
|
|
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";
|
package/src/isolate/bindings.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/isolate/polyfills.ts
CHANGED
|
@@ -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
|
|
575
|
+
const encoded = _fetchEncodeBody(init?.body);
|
|
546
576
|
|
|
547
577
|
// Always fetch as binary to preserve data integrity
|
|
548
|
-
const result = await __poncho_fetch({
|
|
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/upload-store.ts
CHANGED
|
@@ -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:
|
|
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 ${
|
|
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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
161
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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);
|