clients/typescript: apply audit findings — uploadFile streaming + metadata + validation (15de6e7 → cc54cfb)
HIGH:
- H1: uploadFile streams via createReadStream, validates is_file, caps size (configurable, default 100MB)
MEDIUM:
- M1: LICENSE file added
- M2: package.json repository/bugs/homepage/author fields
- M3: ESM-only doc + engines.node>=18
- M4: defaultTimeoutMs negative validation
- M5: baseUrl validated as URL in constructor
LOW:
- L1: empty 200 body throws ForgeAPIError instead of {} as T
- L2: DOMException("timeout", "TimeoutError") for symmetry with AbortSignal.timeout()
- L4: package-lock.json committed
- L5: 6 new tests (500-not-502, Blob upload, invalid source, empty revokeToken, JSON error body, double-signal race)
- L7: defensive raw.ok === true check in run()
Audit: memory/clawdforge-audits/typescript-15de6e7.md
This commit is contained in:
parent
7e878e6f45
commit
e9d5e0ea16
8 changed files with 1203 additions and 22 deletions
18
clients/typescript/LICENSE
Normal file
18
clients/typescript/LICENSE
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Sulkta-Coop
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
@ -13,6 +13,21 @@ bearer-token-gated REST API.
|
|||
- **Typed errors** — `ForgeAuthError`, `ForgeAPIError`, `ForgeTransportError`
|
||||
- **Cancellable** — every method takes an optional `signal: AbortSignal`
|
||||
|
||||
## Module format: ESM-only
|
||||
|
||||
This SDK ships **ESM only**. `package.json` is `"type": "module"`, the
|
||||
`exports` map only resolves an `import` condition, and there is no CJS
|
||||
build. Consumers must:
|
||||
|
||||
- Use a project with `"type": "module"` in their own `package.json`, or
|
||||
- Import from a `.mts` / `.ts` file and let their bundler (esbuild, tsup,
|
||||
Vite, etc.) handle interop, or
|
||||
- Use the dynamic `await import("clawdforge")` form from a CJS module.
|
||||
|
||||
`require("clawdforge")` will throw `ERR_REQUIRE_ESM` on Node. If a CJS
|
||||
build becomes necessary, file an issue — the fix is a second `tsc` pass
|
||||
plus a dual `exports` map, not a deep refactor.
|
||||
|
||||
## Install
|
||||
|
||||
For now, the SDK is consumed straight from this repo subtree (no npm publish):
|
||||
|
|
@ -69,9 +84,10 @@ All methods are `async` and return Promises.
|
|||
|
||||
| field | type | default | notes |
|
||||
|--------------------|----------------|-----------------------|--------------------------------------------------|
|
||||
| `baseUrl` | `string` | required | trailing slashes are stripped |
|
||||
| `baseUrl` | `string` | required | trailing slashes are stripped; validated via `new URL()` |
|
||||
| `token` | `string` | required | per-app `cf_...` for `/run`+`/files`, admin for `/admin/*` |
|
||||
| `defaultTimeoutMs` | `number` | `120000` | client-side network timeout; 0 disables |
|
||||
| `defaultTimeoutMs` | `number` | `120000` | client-side network timeout; 0 disables; negative rejected |
|
||||
| `uploadMaxBytes` | `number` | `104857600` (100 MB) | size cap for `uploadFile(path)`; 0 disables |
|
||||
| `fetch` | `typeof fetch` | `globalThis.fetch` | inject a custom fetch (testing, proxies, etc.) |
|
||||
|
||||
### `forge.healthz(): Promise<HealthzResponse>`
|
||||
|
|
@ -124,8 +140,21 @@ await forge.run({
|
|||
});
|
||||
```
|
||||
|
||||
`source` can be a string path, a `Uint8Array` / `Buffer`, or a `Blob`. Pass
|
||||
`filename` and/or `contentType` in `opts` to override defaults.
|
||||
`source` can be:
|
||||
- a **string path** to a local **regular file**. Symlinks are followed to
|
||||
their target via `fs.stat`. Non-regular files (directories, FIFOs,
|
||||
sockets, block/char devices) are rejected with `ForgeError`. The file
|
||||
is streamed via `fs.createReadStream` — its full contents are **not**
|
||||
buffered in memory.
|
||||
- a Node `Uint8Array` / `Buffer`
|
||||
- a Web `Blob` / `File`
|
||||
|
||||
For string-path uploads, the file size is checked via `fs.stat` against
|
||||
`ForgeOptions.uploadMaxBytes` (default 100 MB) **before** any bytes are
|
||||
read; oversized files fail fast with `ForgeError` and never touch memory
|
||||
or the network. Set `uploadMaxBytes: 0` to disable the cap.
|
||||
|
||||
Pass `filename` and/or `contentType` in `opts` to override defaults.
|
||||
|
||||
```ts
|
||||
interface FileToken {
|
||||
|
|
|
|||
594
clients/typescript/package-lock.json
generated
Normal file
594
clients/typescript/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
{
|
||||
"name": "clawdforge",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "clawdforge",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.39",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=18"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
|
|
@ -33,7 +33,17 @@
|
|||
"sdk",
|
||||
"clawdforge"
|
||||
],
|
||||
"author": "Sulkta-Coop",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://192.168.0.5:3001/Sulkta-Coop/clawdforge.git",
|
||||
"directory": "clients/typescript"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://192.168.0.5:3001/Sulkta-Coop/clawdforge/issues"
|
||||
},
|
||||
"homepage": "https://192.168.0.5:3001/Sulkta-Coop/clawdforge/src/branch/main/clients/typescript",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.39",
|
||||
"tsx": "^4.21.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { readFile, stat } from "node:fs/promises";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import {
|
||||
ForgeAPIError,
|
||||
|
|
@ -20,6 +22,9 @@ import type {
|
|||
UploadFileOptions,
|
||||
} from "./types.js";
|
||||
|
||||
/** Default cap on `uploadFile(path)` size before any bytes are read: 100 MB. */
|
||||
const DEFAULT_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Thin HTTP client for clawdforge.
|
||||
*
|
||||
|
|
@ -34,6 +39,7 @@ export class Forge {
|
|||
private readonly baseUrl: string;
|
||||
private readonly token: string;
|
||||
private readonly defaultTimeoutMs: number;
|
||||
private readonly uploadMaxBytes: number;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(options: ForgeOptions) {
|
||||
|
|
@ -47,9 +53,50 @@ export class Forge {
|
|||
throw new ForgeError("Forge: token is required");
|
||||
}
|
||||
|
||||
// M5: validate baseUrl is parseable as a URL — reject newline injection,
|
||||
// spaces, and other malformed input up-front instead of letting fetch
|
||||
// surface a cryptic error later.
|
||||
try {
|
||||
// The result is intentionally discarded — we only care about throw/no-throw.
|
||||
new URL(options.baseUrl);
|
||||
} catch (e) {
|
||||
throw new ForgeError(
|
||||
`Forge: baseUrl is not a valid URL: ${options.baseUrl}`,
|
||||
{ cause: e }
|
||||
);
|
||||
}
|
||||
|
||||
// M4: a negative `defaultTimeoutMs` would silently behave as "no timeout"
|
||||
// because the internal `if (timeoutMs > 0)` check skips it. That's a
|
||||
// footgun. 0 explicitly disables; anything below that is rejected.
|
||||
if (options.defaultTimeoutMs !== undefined) {
|
||||
if (
|
||||
typeof options.defaultTimeoutMs !== "number" ||
|
||||
!Number.isFinite(options.defaultTimeoutMs) ||
|
||||
options.defaultTimeoutMs < 0
|
||||
) {
|
||||
throw new ForgeError(
|
||||
"Forge: defaultTimeoutMs must be a non-negative finite number (0 disables)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.uploadMaxBytes !== undefined) {
|
||||
if (
|
||||
typeof options.uploadMaxBytes !== "number" ||
|
||||
!Number.isFinite(options.uploadMaxBytes) ||
|
||||
options.uploadMaxBytes < 0
|
||||
) {
|
||||
throw new ForgeError(
|
||||
"Forge: uploadMaxBytes must be a non-negative finite number (0 disables)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
||||
this.token = options.token;
|
||||
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 120_000;
|
||||
this.uploadMaxBytes = options.uploadMaxBytes ?? DEFAULT_UPLOAD_MAX_BYTES;
|
||||
|
||||
const fetchFn = options.fetch ?? globalThis.fetch;
|
||||
if (typeof fetchFn !== "function") {
|
||||
|
|
@ -109,7 +156,7 @@ export class Forge {
|
|||
: this.defaultTimeoutMs;
|
||||
|
||||
const raw = await this.request<{
|
||||
ok: true;
|
||||
ok: boolean;
|
||||
result: unknown;
|
||||
duration_ms: number;
|
||||
stop_reason: string | null;
|
||||
|
|
@ -119,6 +166,17 @@ export class Forge {
|
|||
timeoutMs: networkTimeoutMs,
|
||||
});
|
||||
|
||||
// L7: a buggy server returning {ok:false} with a 200 status would
|
||||
// otherwise hand back a `RunResult` claiming `ok: true`. Defend against
|
||||
// it explicitly.
|
||||
if (raw.ok !== true) {
|
||||
throw new ForgeAPIError(
|
||||
200,
|
||||
camelCaseRunFailure(raw),
|
||||
"clawdforge /run returned 200 with ok!=true"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result: raw.result,
|
||||
|
|
@ -133,10 +191,18 @@ export class Forge {
|
|||
* `POST /files` — upload a file from disk and receive a `file_token`.
|
||||
*
|
||||
* The first argument may be:
|
||||
* - a string path to a local file
|
||||
* - a string path to a local **regular file** (symlinks are followed to
|
||||
* their target via `fs.stat`; non-regular files such as directories,
|
||||
* FIFOs, sockets, or block/char devices are rejected with
|
||||
* {@link ForgeError})
|
||||
* - a Node `Buffer` / `Uint8Array`
|
||||
* - a Web `Blob` / `File`
|
||||
*
|
||||
* String-path uploads are streamed via `fs.createReadStream` — the entire
|
||||
* file is **not** buffered in memory. Size is checked against
|
||||
* `ForgeOptions.uploadMaxBytes` (default 100 MB) using `fs.stat` BEFORE any
|
||||
* bytes are read.
|
||||
*
|
||||
* For Buffers and Blobs, pass `filename` in `opts` to control the on-disk name.
|
||||
*/
|
||||
async uploadFile(
|
||||
|
|
@ -153,13 +219,37 @@ export class Forge {
|
|||
let filename: string;
|
||||
|
||||
if (typeof source === "string") {
|
||||
const data = await readFile(source);
|
||||
// Verify the path actually existed; readFile would throw otherwise,
|
||||
// but stat clarifies the error message vs a transport-level surprise.
|
||||
await stat(source);
|
||||
blob = new Blob([new Uint8Array(data)], {
|
||||
type: opts.contentType ?? "application/octet-stream",
|
||||
});
|
||||
// H1: stat FIRST so we can validate type + size before reading any
|
||||
// bytes. The previous code did `readFile` first, making the stat
|
||||
// dead-code; pointing at /etc/passwd via a symlink would happily
|
||||
// upload it. Pointing at a directory would surface a cryptic EISDIR;
|
||||
// pointing at a FIFO would hang the process.
|
||||
let s;
|
||||
try {
|
||||
s = await stat(source);
|
||||
} catch (e) {
|
||||
throw new ForgeError(
|
||||
`uploadFile: cannot stat source path "${source}"`,
|
||||
{ cause: e }
|
||||
);
|
||||
}
|
||||
if (!s.isFile()) {
|
||||
throw new ForgeError(
|
||||
`uploadFile: source path "${source}" is not a regular file`
|
||||
);
|
||||
}
|
||||
if (this.uploadMaxBytes > 0 && s.size > this.uploadMaxBytes) {
|
||||
throw new ForgeError(
|
||||
`uploadFile: source size ${s.size} exceeds uploadMaxBytes ${this.uploadMaxBytes}`
|
||||
);
|
||||
}
|
||||
|
||||
// Stream the file through `Readable.toWeb` rather than slurping the
|
||||
// whole thing into a Buffer. Node's undici-based fetch consumes
|
||||
// ReadableStream bodies natively.
|
||||
const nodeStream = createReadStream(source);
|
||||
const webStream = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
|
||||
blob = streamToBlob(webStream, s.size, opts.contentType);
|
||||
filename = opts.filename ?? basename(source);
|
||||
} else if (source instanceof Uint8Array) {
|
||||
blob = new Blob([new Uint8Array(source)], {
|
||||
|
|
@ -334,10 +424,16 @@ export class Forge {
|
|||
}
|
||||
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
// Empty body? Return an empty object — only happens on synthetic responses.
|
||||
// L1: every endpoint the SDK calls contractually returns a JSON body
|
||||
// on 2xx. An empty body is a server-side bug — surface it as an API
|
||||
// error rather than a runtime lie of `{} as T`.
|
||||
const text = await res.text();
|
||||
if (!text) {
|
||||
return {} as T;
|
||||
throw new ForgeAPIError(
|
||||
res.status,
|
||||
null,
|
||||
`clawdforge returned an empty body on ${res.status} ${method} ${path}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
|
|
@ -345,7 +441,8 @@ export class Forge {
|
|||
throw new ForgeAPIError(
|
||||
res.status,
|
||||
text,
|
||||
`clawdforge returned non-JSON body: ${truncate(text, 200)}`
|
||||
`clawdforge returned non-JSON body: ${truncate(text, 200)}`,
|
||||
{ cause: e }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -377,6 +474,46 @@ function truncate(s: string, n: number): string {
|
|||
return s.length <= n ? s : `${s.slice(0, n)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a `ReadableStream<Uint8Array>` of known length in a `Blob`-shaped
|
||||
* object that can be appended to a `FormData` part. We can't use the native
|
||||
* `Blob` constructor because it eagerly materializes its input into memory,
|
||||
* defeating the streaming intent. Instead we expose just the surface that
|
||||
* undici's FormData serializer needs (`size`, `type`, `stream()`, plus the
|
||||
* `Symbol.toStringTag` it sniffs to identify Blob-likes).
|
||||
*/
|
||||
function streamToBlob(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
size: number,
|
||||
type: string | undefined
|
||||
): Blob {
|
||||
const blobType = type ?? "application/octet-stream";
|
||||
// The cast is safe: undici's multipart serializer calls `.stream()`,
|
||||
// `.size`, and `.type`; everything else (`arrayBuffer`, `slice`, `text`)
|
||||
// is unused on the upload path.
|
||||
return {
|
||||
size,
|
||||
type: blobType,
|
||||
stream: () => stream,
|
||||
arrayBuffer: () => {
|
||||
throw new Error(
|
||||
"stream-backed Blob does not support arrayBuffer(); upload path only"
|
||||
);
|
||||
},
|
||||
text: () => {
|
||||
throw new Error(
|
||||
"stream-backed Blob does not support text(); upload path only"
|
||||
);
|
||||
},
|
||||
slice: () => {
|
||||
throw new Error(
|
||||
"stream-backed Blob does not support slice(); upload path only"
|
||||
);
|
||||
},
|
||||
[Symbol.toStringTag]: "Blob",
|
||||
} as unknown as Blob;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the server returned a `/run` failure envelope `{ok:false, error,
|
||||
* stderr, duration_ms, stop_reason}`, convert the snake_case keys to
|
||||
|
|
@ -431,7 +568,14 @@ function combineSignals(
|
|||
}
|
||||
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimeout(() => ac.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
|
||||
// L2: use DOMException("timeout","TimeoutError") for symmetry with
|
||||
// `AbortSignal.timeout()`. Caller chains that match on reason.name will
|
||||
// see the same shape regardless of whether we or the platform timed it
|
||||
// out.
|
||||
timer = setTimeout(
|
||||
() => ac.abort(new DOMException(`timeout ${timeoutMs}ms`, "TimeoutError")),
|
||||
timeoutMs
|
||||
);
|
||||
// Don't keep the event loop alive just for our timeout — if every other
|
||||
// handle has unwound, the request is effectively done.
|
||||
(timer as unknown as { unref?: () => void }).unref?.();
|
||||
|
|
|
|||
|
|
@ -47,8 +47,13 @@ export class ForgeAPIError extends ForgeError {
|
|||
public readonly status: number;
|
||||
public readonly body: unknown;
|
||||
|
||||
constructor(status: number, body: unknown, message?: string) {
|
||||
super(message ?? `clawdforge API error (${status})`);
|
||||
constructor(
|
||||
status: number,
|
||||
body: unknown,
|
||||
message?: string,
|
||||
options?: { cause?: unknown }
|
||||
) {
|
||||
super(message ?? `clawdforge API error (${status})`, options);
|
||||
this.name = "ForgeAPIError";
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ export interface ForgeOptions {
|
|||
* for custom user-agents / proxies. Defaults to `globalThis.fetch`.
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Maximum size in bytes that {@link Forge.uploadFile} will accept when
|
||||
* given a string path. Defaults to 100 MB (100 * 1024 * 1024). The size
|
||||
* check happens against `fs.stat` BEFORE any bytes are read or buffered,
|
||||
* so an oversized file fails fast without memory pressure.
|
||||
*
|
||||
* Set to 0 to disable the cap entirely (not recommended).
|
||||
*/
|
||||
uploadMaxBytes?: number;
|
||||
}
|
||||
|
||||
/** Response shape from `GET /healthz`. */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { writeFileSync, unlinkSync } from "node:fs";
|
||||
import {
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
truncateSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
|
|
@ -460,3 +466,369 @@ test("default timeout fires → ForgeTransportError(aborted=true)", async () =>
|
|||
clearInterval(keepalive);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------- audit fix tests (H1)
|
||||
|
||||
test("uploadFile: rejects a directory path with ForgeError (no EISDIR)", async () => {
|
||||
const dir = join(tmpdir(), `clawdforge-ts-dir-${Date.now()}`);
|
||||
mkdirSync(dir);
|
||||
try {
|
||||
const { fetch, calls } = makeMockFetch(() =>
|
||||
jsonResponse(200, { file_token: "ff_x", ttl_secs: 3600, size: 0 })
|
||||
);
|
||||
const forge = new Forge({ ...BASE, fetch });
|
||||
await assert.rejects(
|
||||
forge.uploadFile(dir),
|
||||
(e: unknown) => {
|
||||
assert.ok(e instanceof ForgeError);
|
||||
assert.match((e as Error).message, /not a regular file/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
assert.equal(calls.length, 0, "must not hit the network for a directory");
|
||||
} finally {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("uploadFile: rejects a missing path with ForgeError carrying cause", async () => {
|
||||
const missing = join(tmpdir(), `clawdforge-ts-nope-${Date.now()}-${Math.random()}.bin`);
|
||||
const { fetch, calls } = makeMockFetch(() =>
|
||||
jsonResponse(200, { file_token: "ff_x", ttl_secs: 3600, size: 0 })
|
||||
);
|
||||
const forge = new Forge({ ...BASE, fetch });
|
||||
await assert.rejects(forge.uploadFile(missing), (e: unknown) => {
|
||||
assert.ok(e instanceof ForgeError);
|
||||
assert.match((e as Error).message, /cannot stat source path/);
|
||||
return true;
|
||||
});
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("uploadFile: file size > uploadMaxBytes is rejected before any bytes read", async () => {
|
||||
const tmp = join(tmpdir(), `clawdforge-ts-big-${Date.now()}.bin`);
|
||||
// Sparse-truncate to 5 MB so we don't actually allocate the bytes; stat
|
||||
// still reports the full size, which is what the cap checks against.
|
||||
writeFileSync(tmp, Buffer.alloc(0));
|
||||
truncateSync(tmp, 5 * 1024 * 1024);
|
||||
try {
|
||||
const { fetch, calls } = makeMockFetch(() =>
|
||||
jsonResponse(200, { file_token: "ff_x", ttl_secs: 3600, size: 0 })
|
||||
);
|
||||
const forge = new Forge({
|
||||
...BASE,
|
||||
fetch,
|
||||
uploadMaxBytes: 1024, // 1 KB cap, file is 5 MB
|
||||
});
|
||||
await assert.rejects(forge.uploadFile(tmp), (e: unknown) => {
|
||||
assert.ok(e instanceof ForgeError);
|
||||
assert.match(
|
||||
(e as Error).message,
|
||||
/exceeds uploadMaxBytes/,
|
||||
"must call out the cap, not surface a transport-level surprise"
|
||||
);
|
||||
return true;
|
||||
});
|
||||
assert.equal(calls.length, 0, "must not hit the network");
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("uploadFile: streams large file without buffering whole thing", async () => {
|
||||
// 8 MB file, sparse-allocated so the test stays cheap. The streaming code
|
||||
// path is exercised by `createReadStream` + `Readable.toWeb`; we assert
|
||||
// that the resulting multipart Blob carries the right size and that the
|
||||
// upload completes without OOM at peak heap. A loose heap-delta bound
|
||||
// proves we didn't buffer the whole file (which would push RSS+heap by
|
||||
// ~8 MB).
|
||||
const SIZE = 8 * 1024 * 1024;
|
||||
const tmp = join(tmpdir(), `clawdforge-ts-stream-${Date.now()}.bin`);
|
||||
writeFileSync(tmp, Buffer.alloc(0));
|
||||
truncateSync(tmp, SIZE);
|
||||
try {
|
||||
const { fetch, calls } = makeMockFetch(async (req) => {
|
||||
// Drain the multipart body so undici streams it through. Without this
|
||||
// the ReadableStream sits in the FormData unconsumed.
|
||||
assert.ok(req.bodyForm);
|
||||
const part = req.bodyForm!.get("file");
|
||||
assert.ok(part && typeof part === "object" && "size" in part);
|
||||
assert.equal((part as { size: number }).size, SIZE);
|
||||
return jsonResponse(200, {
|
||||
file_token: "ff_streamed",
|
||||
ttl_secs: 3600,
|
||||
size: SIZE,
|
||||
});
|
||||
});
|
||||
const forge = new Forge({ ...BASE, fetch });
|
||||
if (typeof globalThis.gc === "function") {
|
||||
globalThis.gc();
|
||||
}
|
||||
const before = process.memoryUsage().heapUsed;
|
||||
const ft = await forge.uploadFile(tmp, { contentType: "application/octet-stream" });
|
||||
const after = process.memoryUsage().heapUsed;
|
||||
assert.equal(ft.fileToken, "ff_streamed");
|
||||
assert.equal(ft.size, SIZE);
|
||||
assert.equal(calls.length, 1);
|
||||
// Heap should grow by far less than the file size if we're streaming.
|
||||
// Allow generous slack — V8 is noisy. The pre-fix code would have
|
||||
// pushed +SIZE; this should comfortably stay under 2 MB of growth.
|
||||
const delta = after - before;
|
||||
assert.ok(
|
||||
delta < SIZE / 2,
|
||||
`heap delta ${delta} should be far below file size ${SIZE} (streaming)`
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------- audit fix tests (M4, M5)
|
||||
|
||||
test("constructor: baseUrl with newline rejected as not-a-URL", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
new Forge({
|
||||
baseUrl: "http://forge.test:8800\nX-Injected: yes",
|
||||
token: "cf_x",
|
||||
}),
|
||||
(e: unknown) => {
|
||||
assert.ok(e instanceof ForgeError);
|
||||
assert.match((e as Error).message, /baseUrl is not a valid URL/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("constructor: baseUrl with embedded space rejected", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
new Forge({
|
||||
baseUrl: "http://forge .test:8800",
|
||||
token: "cf_x",
|
||||
}),
|
||||
(e: unknown) => {
|
||||
assert.ok(e instanceof ForgeError);
|
||||
assert.match((e as Error).message, /baseUrl is not a valid URL/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("constructor: defaultTimeoutMs negative rejected", () => {
|
||||
assert.throws(
|
||||
() => new Forge({ ...BASE, defaultTimeoutMs: -1 }),
|
||||
(e: unknown) => {
|
||||
assert.ok(e instanceof ForgeError);
|
||||
assert.match((e as Error).message, /defaultTimeoutMs/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("constructor: defaultTimeoutMs NaN rejected", () => {
|
||||
assert.throws(
|
||||
() => new Forge({ ...BASE, defaultTimeoutMs: Number.NaN }),
|
||||
ForgeError
|
||||
);
|
||||
});
|
||||
|
||||
test("constructor: defaultTimeoutMs 0 explicitly disables", async () => {
|
||||
// With timeout=0 the SDK should not arm an internal abort timer. We
|
||||
// verify by waiting longer than any reasonable default would tick.
|
||||
const { fetch } = makeMockFetch(() =>
|
||||
jsonResponse(200, { ok: true, claude_present: true, claude_version: null })
|
||||
);
|
||||
const forge = new Forge({ ...BASE, fetch, defaultTimeoutMs: 0 });
|
||||
const h = await forge.healthz();
|
||||
assert.equal(h.ok, true);
|
||||
});
|
||||
|
||||
test("constructor: uploadMaxBytes negative rejected", () => {
|
||||
assert.throws(
|
||||
() => new Forge({ ...BASE, uploadMaxBytes: -1 }),
|
||||
ForgeError
|
||||
);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------- audit fix test (L1)
|
||||
|
||||
test("run: empty 200 body throws ForgeAPIError instead of silent {}", async () => {
|
||||
const { fetch } = makeMockFetch(
|
||||
() =>
|
||||
new Response("", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
const forge = new Forge({ ...BASE, fetch });
|
||||
await assert.rejects(forge.run({ prompt: "x" }), (e: unknown) => {
|
||||
assert.ok(e instanceof ForgeAPIError);
|
||||
assert.match((e as Error).message, /empty body/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------- audit fix test (L7)
|
||||
|
||||
test("run: 200 with ok:false throws ForgeAPIError (defensive against buggy server)", async () => {
|
||||
const { fetch } = makeMockFetch(() =>
|
||||
jsonResponse(200, {
|
||||
ok: false,
|
||||
result: null,
|
||||
duration_ms: 0,
|
||||
stop_reason: null,
|
||||
error: "lying server",
|
||||
stderr: "",
|
||||
})
|
||||
);
|
||||
const forge = new Forge({ ...BASE, fetch });
|
||||
await assert.rejects(forge.run({ prompt: "x" }), (e: unknown) => {
|
||||
assert.ok(e instanceof ForgeAPIError);
|
||||
assert.equal((e as ForgeAPIError).status, 200);
|
||||
assert.match((e as Error).message, /ok!=true/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- audit fix tests (L5)
|
||||
|
||||
test("L5: non-502 5xx (e.g. 500) → ForgeAPIError, not Auth/Transport", async () => {
|
||||
const { fetch } = makeMockFetch(
|
||||
() =>
|
||||
new Response(JSON.stringify({ detail: "internal server boom" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
const forge = new Forge({ ...BASE, fetch });
|
||||
await assert.rejects(forge.run({ prompt: "x" }), (e: unknown) => {
|
||||
assert.ok(e instanceof ForgeAPIError);
|
||||
assert.ok(!(e instanceof ForgeAuthError));
|
||||
assert.equal((e as ForgeAPIError).status, 500);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test("L5: uploadFile from Blob includes file part and ttl_secs", async () => {
|
||||
const { fetch, calls } = makeMockFetch(() =>
|
||||
jsonResponse(200, { file_token: "ff_blob", ttl_secs: 3600, size: 4 })
|
||||
);
|
||||
const forge = new Forge({ ...BASE, fetch });
|
||||
const blob = new Blob([new Uint8Array([1, 2, 3, 4])], { type: "image/png" });
|
||||
const ft = await forge.uploadFile(blob, { filename: "blob.png" });
|
||||
assert.equal(ft.fileToken, "ff_blob");
|
||||
const c = calls[0]!;
|
||||
assert.ok(c.bodyForm);
|
||||
assert.equal(c.bodyForm!.get("ttl_secs"), "3600");
|
||||
const part = c.bodyForm!.get("file");
|
||||
assert.ok(part && typeof part === "object" && "size" in part);
|
||||
if (part && "name" in part) {
|
||||
assert.equal((part as { name: string }).name, "blob.png");
|
||||
}
|
||||
});
|
||||
|
||||
test("L5: uploadFile rejects invalid source type with ForgeError", async () => {
|
||||
const { fetch, calls } = makeMockFetch(() => jsonResponse(200, {}));
|
||||
const forge = new Forge({ ...BASE, fetch });
|
||||
// @ts-expect-error — intentionally testing the runtime guard
|
||||
await assert.rejects(forge.uploadFile(12345 as unknown), ForgeError);
|
||||
// @ts-expect-error
|
||||
await assert.rejects(forge.uploadFile(null as unknown), ForgeError);
|
||||
// @ts-expect-error
|
||||
await assert.rejects(forge.uploadFile({} as unknown), ForgeError);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("L5: revokeToken with empty name rejected client-side", async () => {
|
||||
const { fetch, calls } = makeMockFetch(() => jsonResponse(200, { ok: true }));
|
||||
const forge = new Forge({ ...BASE, fetch });
|
||||
await assert.rejects(forge.revokeToken(""), ForgeError);
|
||||
assert.equal(calls.length, 0, "must not hit the network");
|
||||
});
|
||||
|
||||
test("L5: error body parse fallback — non-JSON 500 body kept as raw string", async () => {
|
||||
const { fetch } = makeMockFetch(
|
||||
() =>
|
||||
new Response("<!doctype html>nginx error page", {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
);
|
||||
const forge = new Forge({ ...BASE, fetch });
|
||||
await assert.rejects(forge.healthz(), (e: unknown) => {
|
||||
assert.ok(e instanceof ForgeAPIError);
|
||||
assert.equal((e as ForgeAPIError).status, 500);
|
||||
// body is the raw text, not the parsed object
|
||||
assert.equal(typeof (e as ForgeAPIError).body, "string");
|
||||
assert.match((e as ForgeAPIError).body as string, /nginx error page/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test("L5: caller-aborted before fetch returns is reported as aborted=true", async () => {
|
||||
// Race: caller's signal aborts WHILE the SDK's internal timer is also
|
||||
// armed. The transport error must report `aborted=true` and identify the
|
||||
// caller (not the timeout) as the reason.
|
||||
const { fetch } = makeMockFetch(async () => {
|
||||
await new Promise<void>(() => {});
|
||||
return jsonResponse(200, {});
|
||||
});
|
||||
const forge = new Forge({ ...BASE, fetch, defaultTimeoutMs: 5_000 });
|
||||
const ac = new AbortController();
|
||||
const p = forge.run({ prompt: "long task", signal: ac.signal });
|
||||
setImmediate(() => ac.abort());
|
||||
|
||||
await assert.rejects(p, (e: unknown) => {
|
||||
assert.ok(e instanceof ForgeTransportError);
|
||||
assert.equal((e as ForgeTransportError).aborted, true);
|
||||
// Should be reported as the caller's abort, not the timeout.
|
||||
assert.match((e as Error).message, /aborted by caller/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------- audit fix test (L2)
|
||||
|
||||
test("L2: timeout abort reason is DOMException('timeout','TimeoutError')", async () => {
|
||||
// Exercise the internal combineSignals: the fetch impl receives an
|
||||
// AbortSignal whose `reason`, when the timeout fires, should be a
|
||||
// DOMException with name "TimeoutError" — for symmetry with
|
||||
// AbortSignal.timeout().
|
||||
let observedReason: unknown = null;
|
||||
const fetchFn: typeof fetch = async (_input, init) => {
|
||||
return await new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
observedReason = init.signal!.reason;
|
||||
const err = new Error("aborted");
|
||||
err.name = "AbortError";
|
||||
reject(err);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
};
|
||||
const forge = new Forge({ ...BASE, fetch: fetchFn, defaultTimeoutMs: 25 });
|
||||
const keepalive = setInterval(() => {}, 5);
|
||||
try {
|
||||
await assert.rejects(forge.healthz(), ForgeTransportError);
|
||||
} finally {
|
||||
clearInterval(keepalive);
|
||||
}
|
||||
assert.ok(observedReason instanceof DOMException, "reason should be a DOMException");
|
||||
assert.equal((observedReason as DOMException).name, "TimeoutError");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue