@crewhaus/tool-containers 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The OCI distribution client: one GET, one 401, one token, one retry.
3
+ *
4
+ * Everything registry-specific is read off the challenge (see `./challenge`),
5
+ * so Docker Hub, ghcr.io, quay.io and a self-hosted Harbor all take the same
6
+ * code path. The only registry-specific thing in this package is the
7
+ * `library/` namespace rewrite in `./ref`, which is a naming rule rather than a
8
+ * protocol difference.
9
+ *
10
+ * The client is per CALL, not per process: the token cache lives in the object
11
+ * the tool creates and dies with it. A module-level cache would make one call's
12
+ * result depend on an earlier call's, which is the opposite of what a
13
+ * deterministic tool is for — and would keep a bearer token alive in memory
14
+ * long after the call that earned it.
15
+ */
16
+ import { bearerChallenge, parseAuthenticateHeader, readTokenBody, tokenRequestUrl, } from "./challenge";
17
+ import { RegistryHttpError, deadlineSignal, httpGet } from "./http";
18
+ export class RegistryError extends Error {
19
+ name = "RegistryError";
20
+ status;
21
+ constructor(message, status = 0) {
22
+ super(message);
23
+ this.status = status;
24
+ }
25
+ }
26
+ /** A token body is small; anything larger is not a token endpoint. */
27
+ const MAX_TOKEN_BYTES = 256 * 1024;
28
+ const DEFAULT_TIMEOUT_MS = 20_000;
29
+ /**
30
+ * A ceiling on requests per tool call. `withDigests` fans out one GET per tag
31
+ * and each may cost a token exchange, so without this a single call could ask a
32
+ * registry a few hundred questions and earn a rate-limit ban for the caller.
33
+ */
34
+ const MAX_REQUESTS = 140;
35
+ export function newClient(host, signal, timeoutMs = DEFAULT_TIMEOUT_MS) {
36
+ return {
37
+ host,
38
+ tokens: new Map(),
39
+ requests: 0,
40
+ timeoutMs,
41
+ ...(signal !== undefined ? { signal } : {}),
42
+ };
43
+ }
44
+ /** `https://<host><path>` — the only place a registry URL is constructed. */
45
+ export function registryUrl(host, path) {
46
+ return new URL(`https://${host}${path}`);
47
+ }
48
+ async function send(client, url, accept, authorization, maxBytes) {
49
+ if (client.requests >= MAX_REQUESTS) {
50
+ throw new RegistryError(`refusing to make more than ${MAX_REQUESTS} registry requests in one call — narrow the request (fewer tags, or drop withDigests)`);
51
+ }
52
+ client.requests++;
53
+ const { signal, dispose } = deadlineSignal(client.signal, client.timeoutMs);
54
+ try {
55
+ return await httpGet(url, {
56
+ maxBytes,
57
+ signal,
58
+ ...(accept !== undefined ? { accept } : {}),
59
+ ...(authorization !== undefined ? { authorization } : {}),
60
+ });
61
+ }
62
+ finally {
63
+ dispose();
64
+ }
65
+ }
66
+ /**
67
+ * GET a registry path, performing the anonymous token dance if the registry
68
+ * asks for one. Returns the raw bytes; the caller decides whether to hash them,
69
+ * parse them, or both — in that order.
70
+ */
71
+ export async function registryGet(client, path, options) {
72
+ return registryGetUrl(client, registryUrl(client.host, path), options);
73
+ }
74
+ /**
75
+ * The same dance against an already-built URL — what paging needs, since the
76
+ * next page arrives as a `Link` header rather than a path we compose. The
77
+ * caller has already checked that the URL is on the registry's own origin.
78
+ */
79
+ export async function registryGetUrl(client, url, options) {
80
+ const cached = client.tokens.get(options.scope);
81
+ let res = await send(client, url, options.accept, cached === undefined ? undefined : `Bearer ${cached}`, options.maxBytes);
82
+ if (res.status === 401) {
83
+ const token = await obtainToken(client, res, options.scope, cached !== undefined);
84
+ client.tokens.set(options.scope, token);
85
+ res = await send(client, url, options.accept, `Bearer ${token}`, options.maxBytes);
86
+ // A 401 that survives a freshly minted anonymous token is not a transient
87
+ // failure and not a missing image: it is a repository we are not allowed to
88
+ // read. Saying so is more useful than a second round of the same dance.
89
+ if (res.status === 401)
90
+ throw privateRefusal(client.host, options.scope);
91
+ }
92
+ if (res.status >= 200 && res.status < 300)
93
+ return res;
94
+ throw describeFailure(res, client.host, `${url.pathname}${url.search}`);
95
+ }
96
+ /**
97
+ * Turn a 401 into a bearer token, or into a refusal that says which kind of
98
+ * "no" this was. `hadToken` distinguishes "we have not authenticated yet" from
99
+ * "we authenticated and the answer is still no", which is almost always a
100
+ * private repository — and no amount of retrying fixes that.
101
+ */
102
+ async function obtainToken(client, res, scope, hadToken) {
103
+ const header = res.headers.get("www-authenticate");
104
+ if (header === null) {
105
+ throw new RegistryError(`${client.host} refused the request with 401 and no WWW-Authenticate challenge — nothing to authenticate against`, 401);
106
+ }
107
+ const challenges = parseAuthenticateHeader(header);
108
+ const bearer = bearerChallenge(challenges);
109
+ if (bearer === undefined) {
110
+ const offered = challenges.map((c) => c.scheme).join(", ") || "none";
111
+ throw new RegistryError(`${client.host} offers ${offered} auth, not Bearer — these tools do the anonymous Bearer flow only, and hold no credentials`, 401);
112
+ }
113
+ if (hadToken)
114
+ throw privateRefusal(client.host, scope);
115
+ const tokenUrl = tokenRequestUrl(bearer, scope);
116
+ // No Authorization header here on purpose: this is the anonymous flow, and
117
+ // the realm is a host the registry named, not one we chose.
118
+ const tokenRes = await send(client, tokenUrl, "application/json", undefined, MAX_TOKEN_BYTES);
119
+ if (tokenRes.status < 200 || tokenRes.status >= 300) {
120
+ throw new RegistryError(`token request to ${tokenUrl.origin}${tokenUrl.pathname} failed with HTTP ${tokenRes.status} for scope ${scope}`, tokenRes.status);
121
+ }
122
+ return readTokenBody(new TextDecoder("utf-8", { fatal: false }).decode(tokenRes.bytes));
123
+ }
124
+ /**
125
+ * Registries report errors in a documented envelope
126
+ * (`{"errors":[{"code":…,"message":…}]}`). Surfacing that code is the
127
+ * difference between "MANIFEST_UNKNOWN: no such tag" and "HTTP 404", which a
128
+ * caller cannot act on.
129
+ */
130
+ function privateRefusal(host, scope) {
131
+ return new RegistryError(`${host} refused the request even with an anonymous token for ${scope} — the repository is private, and these tools hold no credentials`, 401);
132
+ }
133
+ export function describeFailure(res, host, path) {
134
+ const detail = registryErrorDetail(res.bytes);
135
+ const suffix = detail === "" ? "" : ` — ${detail}`;
136
+ if (res.status === 404) {
137
+ return new RegistryError(`${host} has no ${path.replace(/^\/v2\//, "")}${suffix}`, 404);
138
+ }
139
+ if (res.status === 429) {
140
+ return new RegistryError(`${host} rate-limited this request (HTTP 429)${suffix} — anonymous pulls are throttled per IP`, 429);
141
+ }
142
+ if (res.status === 403) {
143
+ return new RegistryError(`${host} denied access to ${path} (HTTP 403)${suffix}`, 403);
144
+ }
145
+ return new RegistryError(`${host} answered HTTP ${res.status} for ${path}${suffix}`, res.status);
146
+ }
147
+ function registryErrorDetail(bytes) {
148
+ if (bytes.byteLength === 0 || bytes.byteLength > 64 * 1024)
149
+ return "";
150
+ let parsed;
151
+ try {
152
+ parsed = JSON.parse(new TextDecoder("utf-8", { fatal: false }).decode(bytes));
153
+ }
154
+ catch {
155
+ return "";
156
+ }
157
+ const errors = parsed?.errors;
158
+ if (!Array.isArray(errors) || errors.length === 0)
159
+ return "";
160
+ const first = errors[0];
161
+ const code = typeof first.code === "string" ? first.code : "";
162
+ const message = typeof first.message === "string" ? first.message : "";
163
+ const joined = [code, message].filter((s) => s !== "").join(": ");
164
+ return joined.length > 300 ? `${joined.slice(0, 300)}…` : joined;
165
+ }
166
+ export { RegistryHttpError };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Tag lists: paging, filtering, and the sort that makes them readable.
3
+ *
4
+ * Real tag lists are mostly not semver. A typical repository holds `latest`,
5
+ * `edge`, `sha-9f3c1a`, `0.6`, `v0.6.0-rc.1` and `3.19-alpine` side by side, so
6
+ * a sort that assumes semver either throws or silently reorders the half it
7
+ * could not read. This one partitions: versions ordered by semver precedence
8
+ * first, everything else after it in plain lexicographic order, and nothing
9
+ * throws on a tag it cannot parse.
10
+ */
11
+ export declare class TagListError extends Error {
12
+ readonly name = "TagListError";
13
+ }
14
+ export type ParsedVersion = {
15
+ readonly major: number;
16
+ readonly minor: number;
17
+ readonly patch: number;
18
+ /** `null` for a release; an identifier list for a prerelease. */
19
+ readonly prerelease: readonly string[] | null;
20
+ };
21
+ /**
22
+ * Parse a tag as a version, tolerating the two things registries do that
23
+ * semver does not: a leading `v`, and a truncated version (`0.6`, `3`).
24
+ * Build metadata is accepted and ignored for precedence, exactly as semver
25
+ * says, but the tag string itself still breaks ties so the sort stays total.
26
+ */
27
+ export declare function parseTagVersion(tag: string): ParsedVersion | null;
28
+ /** Semver §11 precedence. Returns <0, 0 or >0. */
29
+ export declare function compareVersions(a: ParsedVersion, b: ParsedVersion): number;
30
+ export type TagSort = "semver" | "name" | "registry";
31
+ /**
32
+ * Order a tag list.
33
+ *
34
+ * `semver` is newest-first, because that is the question people actually have
35
+ * ("what is the latest?"), with unparseable tags collected at the end rather
36
+ * than interleaved by a guess. `name` is lexicographic ascending. `registry`
37
+ * preserves the order the registry returned, which for most registries is the
38
+ * order tags were pushed and for some is nothing in particular.
39
+ */
40
+ export declare function sortTags(tags: readonly string[], sort: TagSort): string[];
41
+ /**
42
+ * Compile a glob into a matcher. Only `*` and `?` are special.
43
+ *
44
+ * This is deliberately NOT a regex. `*` compiles to `[^]*`, and `^[^]*a[^]*a…$`
45
+ * is the textbook catastrophic backtracker: against a tag of n characters an
46
+ * 8-wildcard pattern costs C(n, 8) steps — for a full-length 128-character tag
47
+ * that is ~10^12, hours of CPU inside one synchronous `RegExp.test` that
48
+ * neither `timeoutMs` nor `ctx.signal` can interrupt, because both only
49
+ * unblock between awaits. The two-pointer glob match below is the standard
50
+ * O(pattern x tag) algorithm with one backtrack point, so the worst case is
51
+ * bounded by the product rather than by a binomial.
52
+ *
53
+ * Comparison is by code point, so `?` means one character rather than one
54
+ * UTF-16 unit — the regex version needed two `?` to match an astral character.
55
+ */
56
+ export declare function compileGlob(pattern: string): (value: string) => boolean;
57
+ export declare function filterTags(tags: readonly string[], pattern: string | undefined): string[];
58
+ export type TagsPage = {
59
+ readonly tags: readonly string[];
60
+ /** Entries that were not tags. Counted, never silently discarded. */
61
+ readonly malformed: number;
62
+ };
63
+ /**
64
+ * Read the `tags` array out of a `/tags/list` response. Registries have shipped
65
+ * `"tags": null` for an empty repository, which is not what the spec says and
66
+ * is exactly the shape that turns into `Cannot read properties of null`.
67
+ *
68
+ * Entries outside the tag grammar are DROPPED here rather than carried
69
+ * downstream, because the rest of the package treats this array as trusted and
70
+ * it is not: it is the one place where a string the registry chose becomes a
71
+ * URL path (`withDigests`) and a regex subject (`match`). A registry that says
72
+ * its tags include `../../victim/manifests/latest`, or a 4 KB string, is not
73
+ * describing something anyone can pull. The count is reported, so the answer
74
+ * still says the registry sent something odd.
75
+ */
76
+ export declare function readTagsPage(document: Record<string, unknown>): TagsPage;
77
+ /**
78
+ * Follow RFC 5988 paging — with the link kept on the registry's own origin.
79
+ *
80
+ * The `Link` header is server-controlled: a registry that answers page 1 with
81
+ * `Link: <https://evil.example/v2/...>; rel="next"` would otherwise have the
82
+ * client walk off to a host the caller never named, carrying an Accept header
83
+ * and a request pattern that says which images are being audited. Same origin
84
+ * or we stop and say why.
85
+ */
86
+ export declare function nextPageUrl(header: string | null, base: URL): URL | null;
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Tag lists: paging, filtering, and the sort that makes them readable.
3
+ *
4
+ * Real tag lists are mostly not semver. A typical repository holds `latest`,
5
+ * `edge`, `sha-9f3c1a`, `0.6`, `v0.6.0-rc.1` and `3.19-alpine` side by side, so
6
+ * a sort that assumes semver either throws or silently reorders the half it
7
+ * could not read. This one partitions: versions ordered by semver precedence
8
+ * first, everything else after it in plain lexicographic order, and nothing
9
+ * throws on a tag it cannot parse.
10
+ */
11
+ import { isValidTag } from "./ref";
12
+ export class TagListError extends Error {
13
+ name = "TagListError";
14
+ }
15
+ /**
16
+ * Parse a tag as a version, tolerating the two things registries do that
17
+ * semver does not: a leading `v`, and a truncated version (`0.6`, `3`).
18
+ * Build metadata is accepted and ignored for precedence, exactly as semver
19
+ * says, but the tag string itself still breaks ties so the sort stays total.
20
+ */
21
+ export function parseTagVersion(tag) {
22
+ const m = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/.exec(tag);
23
+ if (!m)
24
+ return null;
25
+ const prerelease = m[4] === undefined ? null : m[4].split(".");
26
+ return {
27
+ major: Number.parseInt(m[1], 10),
28
+ minor: m[2] === undefined ? 0 : Number.parseInt(m[2], 10),
29
+ patch: m[3] === undefined ? 0 : Number.parseInt(m[3], 10),
30
+ prerelease,
31
+ };
32
+ }
33
+ /** Semver §11 precedence. Returns <0, 0 or >0. */
34
+ export function compareVersions(a, b) {
35
+ if (a.major !== b.major)
36
+ return a.major - b.major;
37
+ if (a.minor !== b.minor)
38
+ return a.minor - b.minor;
39
+ if (a.patch !== b.patch)
40
+ return a.patch - b.patch;
41
+ // A release outranks any prerelease of the same version: 1.0.0 > 1.0.0-rc.1.
42
+ if (a.prerelease === null && b.prerelease === null)
43
+ return 0;
44
+ if (a.prerelease === null)
45
+ return 1;
46
+ if (b.prerelease === null)
47
+ return -1;
48
+ const len = Math.max(a.prerelease.length, b.prerelease.length);
49
+ for (let i = 0; i < len; i++) {
50
+ const x = a.prerelease[i];
51
+ const y = b.prerelease[i];
52
+ // A shorter identifier list is lower precedence when all earlier ones tie.
53
+ if (x === undefined)
54
+ return -1;
55
+ if (y === undefined)
56
+ return 1;
57
+ const xNum = /^\d+$/.test(x);
58
+ const yNum = /^\d+$/.test(y);
59
+ if (xNum && yNum) {
60
+ const diff = Number.parseInt(x, 10) - Number.parseInt(y, 10);
61
+ if (diff !== 0)
62
+ return diff;
63
+ continue;
64
+ }
65
+ // Numeric identifiers always have lower precedence than alphanumeric ones.
66
+ if (xNum !== yNum)
67
+ return xNum ? -1 : 1;
68
+ if (x !== y)
69
+ return x < y ? -1 : 1;
70
+ }
71
+ return 0;
72
+ }
73
+ /**
74
+ * Order a tag list.
75
+ *
76
+ * `semver` is newest-first, because that is the question people actually have
77
+ * ("what is the latest?"), with unparseable tags collected at the end rather
78
+ * than interleaved by a guess. `name` is lexicographic ascending. `registry`
79
+ * preserves the order the registry returned, which for most registries is the
80
+ * order tags were pushed and for some is nothing in particular.
81
+ */
82
+ export function sortTags(tags, sort) {
83
+ if (sort === "registry")
84
+ return [...tags];
85
+ if (sort === "name")
86
+ return [...tags].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
87
+ const versions = new Map();
88
+ for (const tag of tags)
89
+ versions.set(tag, parseTagVersion(tag));
90
+ return [...tags].sort((a, b) => {
91
+ const va = versions.get(a) ?? null;
92
+ const vb = versions.get(b) ?? null;
93
+ if (va !== null && vb !== null) {
94
+ const byPrecedence = compareVersions(vb, va); // descending: newest first
95
+ return byPrecedence !== 0 ? byPrecedence : a < b ? -1 : a > b ? 1 : 0;
96
+ }
97
+ if (va !== null)
98
+ return -1;
99
+ if (vb !== null)
100
+ return 1;
101
+ return a < b ? -1 : a > b ? 1 : 0;
102
+ });
103
+ }
104
+ /**
105
+ * Compile a glob into a matcher. Only `*` and `?` are special.
106
+ *
107
+ * This is deliberately NOT a regex. `*` compiles to `[^]*`, and `^[^]*a[^]*a…$`
108
+ * is the textbook catastrophic backtracker: against a tag of n characters an
109
+ * 8-wildcard pattern costs C(n, 8) steps — for a full-length 128-character tag
110
+ * that is ~10^12, hours of CPU inside one synchronous `RegExp.test` that
111
+ * neither `timeoutMs` nor `ctx.signal` can interrupt, because both only
112
+ * unblock between awaits. The two-pointer glob match below is the standard
113
+ * O(pattern x tag) algorithm with one backtrack point, so the worst case is
114
+ * bounded by the product rather than by a binomial.
115
+ *
116
+ * Comparison is by code point, so `?` means one character rather than one
117
+ * UTF-16 unit — the regex version needed two `?` to match an astral character.
118
+ */
119
+ export function compileGlob(pattern) {
120
+ const pat = [...pattern];
121
+ return (value) => globMatch(pat, [...value]);
122
+ }
123
+ function globMatch(pat, text) {
124
+ let p = 0;
125
+ let t = 0;
126
+ // The last `*` seen, and how much of the text it had consumed at the time.
127
+ // Re-entering there is the only backtrack this matcher ever performs.
128
+ let star = -1;
129
+ let starText = 0;
130
+ while (t < text.length) {
131
+ if (p < pat.length && (pat[p] === "?" || pat[p] === text[t])) {
132
+ p++;
133
+ t++;
134
+ }
135
+ else if (p < pat.length && pat[p] === "*") {
136
+ star = p;
137
+ starText = t;
138
+ p++;
139
+ }
140
+ else if (star !== -1) {
141
+ starText++;
142
+ t = starText;
143
+ p = star + 1;
144
+ }
145
+ else {
146
+ return false;
147
+ }
148
+ }
149
+ while (p < pat.length && pat[p] === "*")
150
+ p++;
151
+ return p === pat.length;
152
+ }
153
+ export function filterTags(tags, pattern) {
154
+ if (pattern === undefined || pattern === "")
155
+ return [...tags];
156
+ const match = compileGlob(pattern);
157
+ return tags.filter((t) => match(t));
158
+ }
159
+ /**
160
+ * Read the `tags` array out of a `/tags/list` response. Registries have shipped
161
+ * `"tags": null` for an empty repository, which is not what the spec says and
162
+ * is exactly the shape that turns into `Cannot read properties of null`.
163
+ *
164
+ * Entries outside the tag grammar are DROPPED here rather than carried
165
+ * downstream, because the rest of the package treats this array as trusted and
166
+ * it is not: it is the one place where a string the registry chose becomes a
167
+ * URL path (`withDigests`) and a regex subject (`match`). A registry that says
168
+ * its tags include `../../victim/manifests/latest`, or a 4 KB string, is not
169
+ * describing something anyone can pull. The count is reported, so the answer
170
+ * still says the registry sent something odd.
171
+ */
172
+ export function readTagsPage(document) {
173
+ const raw = document["tags"];
174
+ if (raw === null || raw === undefined)
175
+ return { tags: [], malformed: 0 };
176
+ if (!Array.isArray(raw))
177
+ throw new TagListError("tags/list response has a non-array 'tags' field");
178
+ const tags = [];
179
+ let malformed = 0;
180
+ for (const entry of raw) {
181
+ if (typeof entry === "string" && isValidTag(entry))
182
+ tags.push(entry);
183
+ else
184
+ malformed++;
185
+ }
186
+ return { tags, malformed };
187
+ }
188
+ /**
189
+ * Follow RFC 5988 paging — with the link kept on the registry's own origin.
190
+ *
191
+ * The `Link` header is server-controlled: a registry that answers page 1 with
192
+ * `Link: <https://evil.example/v2/...>; rel="next"` would otherwise have the
193
+ * client walk off to a host the caller never named, carrying an Accept header
194
+ * and a request pattern that says which images are being audited. Same origin
195
+ * or we stop and say why.
196
+ */
197
+ export function nextPageUrl(header, base) {
198
+ if (header === null || header.trim() === "")
199
+ return null;
200
+ for (const part of splitLinks(header)) {
201
+ const m = /^\s*<([^>]*)>\s*(.*)$/.exec(part);
202
+ if (!m)
203
+ continue;
204
+ const params = m[2];
205
+ if (!/\brel\s*=\s*"?next"?/i.test(params))
206
+ continue;
207
+ let url;
208
+ try {
209
+ url = new URL(m[1], base);
210
+ }
211
+ catch {
212
+ throw new TagListError(`registry sent an unparseable Link header: ${header}`);
213
+ }
214
+ if (url.origin !== base.origin) {
215
+ throw new TagListError(`registry's next-page Link points at ${url.origin}, not ${base.origin} — refusing to follow paging off the registry`);
216
+ }
217
+ return url;
218
+ }
219
+ return null;
220
+ }
221
+ /** Split a Link header on commas that separate entries, not ones inside <> or "". */
222
+ function splitLinks(header) {
223
+ const parts = [];
224
+ let current = "";
225
+ let inAngle = false;
226
+ let inQuote = false;
227
+ for (const ch of header) {
228
+ if (ch === "<" && !inQuote)
229
+ inAngle = true;
230
+ else if (ch === ">" && !inQuote)
231
+ inAngle = false;
232
+ else if (ch === '"')
233
+ inQuote = !inQuote;
234
+ if (ch === "," && !inAngle && !inQuote) {
235
+ parts.push(current);
236
+ current = "";
237
+ continue;
238
+ }
239
+ current += ch;
240
+ }
241
+ if (current.trim() !== "")
242
+ parts.push(current);
243
+ return parts;
244
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@crewhaus/tool-containers",
3
+ "version": "0.7.0",
4
+ "type": "module",
5
+ "description": "Deterministic OCI registry reads: resolve an image tag to its digest and list tags, with no daemon and no pull",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "bun test src"
16
+ },
17
+ "dependencies": {
18
+ "@crewhaus/errors": "0.7.0",
19
+ "@crewhaus/tool-builder": "0.7.0",
20
+ "@crewhaus/tool-catalog": "0.7.0",
21
+ "@crewhaus/tool-fetch": "0.7.0",
22
+ "zod": "^3.23.8"
23
+ },
24
+ "devDependencies": {
25
+ "@crewhaus/tool-executor": "workspace:*"
26
+ },
27
+ "license": "Apache-2.0",
28
+ "author": {
29
+ "name": "Max Meier",
30
+ "email": "max@crewhaus.ai",
31
+ "url": "https://crewhaus.ai"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/crewhaus/factory.git",
36
+ "directory": "packages/tool-containers"
37
+ },
38
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-containers#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/crewhaus/factory/issues"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
46
+ }