@capybara-hq/kernel 0.1.0-rc.0 → 0.1.0-rc.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/bootstrap.json +10 -6
- package/dist/channels/outbound.js +2 -1
- package/dist/config/schema.d.ts +15 -1
- package/dist/config/schema.js +44 -3
- package/dist/conversations/titles.js +2 -1
- package/dist/ctx/queue.js +3 -2
- package/dist/errors.d.ts +1 -0
- package/dist/errors.js +1 -0
- package/dist/evals/worker.js +2 -1
- package/dist/extensions/bootstrap.d.ts +39 -12
- package/dist/extensions/bootstrap.js +132 -38
- package/dist/http/app.js +51 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/kernel.d.ts +12 -9
- package/dist/kernel.js +7 -7
- package/dist/log-config.d.ts +9 -0
- package/dist/log-config.js +27 -0
- package/dist/log-context.d.ts +32 -0
- package/dist/log-context.js +40 -0
- package/dist/log-loki.d.ts +34 -0
- package/dist/log-loki.js +145 -0
- package/dist/log.d.ts +41 -10
- package/dist/log.js +91 -17
- package/dist/net/public-address.js +3 -2
- package/dist/runtime/maintenance.js +2 -1
- package/dist/runtime/run-worker.js +2 -1
- package/dist/runtime/usage-report.js +2 -1
- package/dist/tools/builtins/sandbox/common.d.ts +5 -3
- package/dist/tools/builtins/sandbox/common.js +5 -3
- package/package.json +4 -2
package/bootstrap.json
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://capybarahq.com/schemas/bootstrap.v1.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
+
"marketplace": {
|
|
5
|
+
"url": "https://github.com/capybara-hq/plugins.git",
|
|
6
|
+
"commit": "08e336dafe124615622534683887120c81ec416b"
|
|
7
|
+
},
|
|
4
8
|
"plugins": [
|
|
5
9
|
{
|
|
6
10
|
"name": "openai-compatible",
|
|
7
|
-
"source": "
|
|
8
|
-
"
|
|
11
|
+
"source": "npm",
|
|
12
|
+
"npm": "@capybara-hq/plugin-openai-compatible",
|
|
9
13
|
"version": "0.1.0-rc.0",
|
|
10
|
-
"integrity": "sha512-
|
|
14
|
+
"integrity": "sha512-NYyjRfC2U6bAjjlFU0rmJuDs3IyuAwektV9KLBpnFcDVRDfRgBpcSBV/jp0tzXhv9ee5MWqzOKN20WNdKWO9fQ==",
|
|
11
15
|
"required": true
|
|
12
16
|
},
|
|
13
17
|
{
|
|
14
18
|
"name": "object-store-s3",
|
|
15
|
-
"source": "
|
|
16
|
-
"
|
|
19
|
+
"source": "npm",
|
|
20
|
+
"npm": "@capybara-hq/plugin-object-store-s3",
|
|
17
21
|
"version": "0.1.0-rc.0",
|
|
18
|
-
"integrity": "sha512-
|
|
22
|
+
"integrity": "sha512-jFbstLMsMeZ3EqGEm/5OnYWm4aGM9nIw71DmIuPu/frk4/Y+nTnaBWgT1SUDEr6NbFb34V31NIVNMJSoxmvMww==",
|
|
19
23
|
"required": true
|
|
20
24
|
}
|
|
21
25
|
]
|
|
@@ -4,6 +4,7 @@ import { DelayedError, Queue, UnrecoverableError, Worker } from "bullmq";
|
|
|
4
4
|
import { sql } from "kysely";
|
|
5
5
|
import { conversationTopic, } from "../conversations/event-log.js";
|
|
6
6
|
import { ApiError } from "../errors.js";
|
|
7
|
+
import { withJobContext } from "../log-context.js";
|
|
7
8
|
import { readOrgSettings } from "../orgs/settings.service.js";
|
|
8
9
|
import { ConversationLock } from "../runtime/conversation-lock.js";
|
|
9
10
|
import { bullConnection } from "../runtime/run-queue.js";
|
|
@@ -206,7 +207,7 @@ export class ChannelOutbound {
|
|
|
206
207
|
return;
|
|
207
208
|
const { jobLockMs, stalledIntervalMs } = this.d.config().runtime;
|
|
208
209
|
this.workerConn = bullConnection(this.d.redisUrl, this.d.log);
|
|
209
|
-
this.worker = new Worker(CHANNEL_QUEUE_NAME, (job, token) => this.process(job, token), {
|
|
210
|
+
this.worker = new Worker(CHANNEL_QUEUE_NAME, (job, token) => withJobContext(CHANNEL_QUEUE_NAME, job, () => this.process(job, token)), {
|
|
210
211
|
connection: this.workerConn,
|
|
211
212
|
concurrency: this.d.config().channels.deliveryConcurrency,
|
|
212
213
|
lockDuration: jobLockMs,
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -72,6 +72,9 @@ export declare const KernelConfigSchema: z.ZodObject<{
|
|
|
72
72
|
npmToken: z.ZodOptional<z.ZodString>;
|
|
73
73
|
maxBytes: z.ZodDefault<z.ZodNumber>;
|
|
74
74
|
}, z.core.$strip>>;
|
|
75
|
+
bootstrap: z.ZodPrefault<z.ZodObject<{
|
|
76
|
+
dir: z.ZodDefault<z.ZodString>;
|
|
77
|
+
}, z.core.$strip>>;
|
|
75
78
|
catalog: z.ZodPrefault<z.ZodObject<{
|
|
76
79
|
staleInstallMinutes: z.ZodDefault<z.ZodNumber>;
|
|
77
80
|
}, z.core.$strip>>;
|
|
@@ -211,13 +214,24 @@ export declare const KernelConfigSchema: z.ZodObject<{
|
|
|
211
214
|
usageReportConcurrency: z.ZodDefault<z.ZodNumber>;
|
|
212
215
|
usageReportKeepHours: z.ZodDefault<z.ZodNumber>;
|
|
213
216
|
}, z.core.$strip>>;
|
|
214
|
-
log: z.
|
|
217
|
+
log: z.ZodPrefault<z.ZodObject<{
|
|
215
218
|
level: z.ZodDefault<z.ZodEnum<{
|
|
216
219
|
debug: "debug";
|
|
217
220
|
error: "error";
|
|
218
221
|
info: "info";
|
|
219
222
|
warn: "warn";
|
|
220
223
|
}>>;
|
|
224
|
+
format: z.ZodDefault<z.ZodEnum<{
|
|
225
|
+
auto: "auto";
|
|
226
|
+
json: "json";
|
|
227
|
+
pretty: "pretty";
|
|
228
|
+
}>>;
|
|
229
|
+
loki: z.ZodPrefault<z.ZodObject<{
|
|
230
|
+
url: z.ZodOptional<z.ZodURL>;
|
|
231
|
+
username: z.ZodOptional<z.ZodString>;
|
|
232
|
+
password: z.ZodOptional<z.ZodString>;
|
|
233
|
+
labels: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
234
|
+
}, z.core.$strip>>;
|
|
221
235
|
}, z.core.$strip>>;
|
|
222
236
|
}, z.core.$strip>;
|
|
223
237
|
export type KernelConfig = z.infer<typeof KernelConfigSchema>;
|
package/dist/config/schema.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isIP } from "node:net";
|
|
2
2
|
import { DOCUMENT_MEDIA_TYPES, IMAGE_MEDIA_TYPES, validateAllowHost, } from "@capybara-hq/plugin-sdk";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { LOG_LEVELS } from "../log.js";
|
|
4
|
+
import { LOG_FORMATS, LOG_LEVELS } from "../log.js";
|
|
5
5
|
import { noProxyEntryProblem, proxyUrlProblem } from "../net/proxy.js";
|
|
6
6
|
import { privateHostEntryProblem } from "../net/public-address.js";
|
|
7
7
|
/**
|
|
@@ -277,6 +277,17 @@ export const KernelConfigSchema = z.object({
|
|
|
277
277
|
})),
|
|
278
278
|
})
|
|
279
279
|
.prefault({}),
|
|
280
|
+
bootstrap: z
|
|
281
|
+
.object({
|
|
282
|
+
dir: z
|
|
283
|
+
.string()
|
|
284
|
+
.min(1)
|
|
285
|
+
.default("bootstrap")
|
|
286
|
+
.meta(meta({
|
|
287
|
+
description: "Offline directory for the bootstrap plugins, resolved against the working directory (/app/bootstrap in the image): a .tgz file here whose sha512 matches a pinned plugin is installed instead of downloading it from packages.npmRegistry",
|
|
288
|
+
})),
|
|
289
|
+
})
|
|
290
|
+
.prefault({}),
|
|
280
291
|
catalog: z
|
|
281
292
|
.object({
|
|
282
293
|
staleInstallMinutes: z
|
|
@@ -936,7 +947,7 @@ export const KernelConfigSchema = z.object({
|
|
|
936
947
|
.min(1)
|
|
937
948
|
.default("capybara-sandbox:latest")
|
|
938
949
|
.meta(meta({
|
|
939
|
-
description: "Image for conversation sandboxes, used by the Docker sandbox extension (built from plugins/sandbox-docker/image/
|
|
950
|
+
description: "Image for conversation sandboxes, used by the Docker sandbox extension (built from plugins/sandbox-docker/image of capybara-hq/plugins, e.g. by the compose sandbox profile; pre-pull it on every Docker node). The E2B extension ignores it and uses its own template setting",
|
|
940
951
|
})),
|
|
941
952
|
lockWaitSeconds: z
|
|
942
953
|
.number()
|
|
@@ -1146,6 +1157,36 @@ export const KernelConfigSchema = z.object({
|
|
|
1146
1157
|
.enum(LOG_LEVELS)
|
|
1147
1158
|
.default("info")
|
|
1148
1159
|
.meta(meta({ hot: true, description: "Log level" })),
|
|
1160
|
+
format: z
|
|
1161
|
+
.enum(LOG_FORMATS)
|
|
1162
|
+
.default("auto")
|
|
1163
|
+
.meta(meta({
|
|
1164
|
+
description: "Console log format: pretty (one readable line per record, colored on a terminal), json (one JSON line per record) or auto (pretty when stdout is a terminal, else json, so containers log JSON)",
|
|
1165
|
+
})),
|
|
1166
|
+
loki: z
|
|
1167
|
+
.object({
|
|
1168
|
+
url: z
|
|
1169
|
+
.url({ protocol: /^https?$/ })
|
|
1170
|
+
.optional()
|
|
1171
|
+
.meta(meta({
|
|
1172
|
+
description: "Loki base URL (e.g. http://loki:3100) or its full push URL; when set, every log record is also pushed there (batched, JSON lines). Unset: nothing is sent",
|
|
1173
|
+
})),
|
|
1174
|
+
username: z
|
|
1175
|
+
.string()
|
|
1176
|
+
.optional()
|
|
1177
|
+
.meta(meta({ description: "Loki basic auth user name" })),
|
|
1178
|
+
password: z
|
|
1179
|
+
.string()
|
|
1180
|
+
.optional()
|
|
1181
|
+
.meta(meta({ secret: true, description: "Loki basic auth password" })),
|
|
1182
|
+
labels: z
|
|
1183
|
+
.record(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/), z.string())
|
|
1184
|
+
.default({})
|
|
1185
|
+
.meta(meta({
|
|
1186
|
+
description: 'Extra Loki stream labels, e.g. {"env":"prod"}; every stream also has job (capybara unless set here), role (the process\'s roles) and level',
|
|
1187
|
+
})),
|
|
1188
|
+
})
|
|
1189
|
+
.prefault({}),
|
|
1149
1190
|
})
|
|
1150
|
-
.
|
|
1191
|
+
.prefault({}),
|
|
1151
1192
|
});
|
|
@@ -2,6 +2,7 @@ import { isModelError, } from "@capybara-hq/plugin-sdk";
|
|
|
2
2
|
import { Queue, Worker } from "bullmq";
|
|
3
3
|
import { sql } from "kysely";
|
|
4
4
|
import { audit } from "../identity/audit.js";
|
|
5
|
+
import { withJobContext } from "../log-context.js";
|
|
5
6
|
import { PurposeAttemptError } from "../models/purposes.js";
|
|
6
7
|
import { fireObserve, hookDeniedAudit, hookDeniedEvent, hookRequestOf, } from "../runtime/hook-calls.js";
|
|
7
8
|
import { bullConnection } from "../runtime/run-queue.js";
|
|
@@ -383,7 +384,7 @@ export class TitleQueue {
|
|
|
383
384
|
return;
|
|
384
385
|
const { jobLockMs, stalledIntervalMs } = this.d.config().runtime;
|
|
385
386
|
this.workerConn = bullConnection(this.d.redisUrl, this.d.log);
|
|
386
|
-
this.worker = new Worker(TITLE_QUEUE, (j) => this.process(j), {
|
|
387
|
+
this.worker = new Worker(TITLE_QUEUE, (j) => withJobContext(TITLE_QUEUE, j, () => this.process(j)), {
|
|
387
388
|
connection: this.workerConn,
|
|
388
389
|
concurrency: 4,
|
|
389
390
|
lockDuration: jobLockMs,
|
package/dist/ctx/queue.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Queue, Worker } from "bullmq";
|
|
2
2
|
import { Redis } from "ioredis";
|
|
3
|
+
import { withJobContext } from "../log-context.js";
|
|
3
4
|
import { assertNamespace } from "./namespace.js";
|
|
4
5
|
/** 未注入 logger 时的兜底:输出到 stderr。 */
|
|
5
6
|
const consoleLog = {
|
|
@@ -60,7 +61,7 @@ export function createQueue(redisUrl, namespace, opts) {
|
|
|
60
61
|
return;
|
|
61
62
|
const st = { n: 0, idle: [] };
|
|
62
63
|
active.set(name, st);
|
|
63
|
-
const worker = new Worker(queueName(name), async (
|
|
64
|
+
const worker = new Worker(queueName(name), (job) => withJobContext(queueName(name), job, async () => {
|
|
64
65
|
st.n += 1;
|
|
65
66
|
try {
|
|
66
67
|
await handler(job.data);
|
|
@@ -71,7 +72,7 @@ export function createQueue(redisUrl, namespace, opts) {
|
|
|
71
72
|
for (const w of st.idle.splice(0))
|
|
72
73
|
w();
|
|
73
74
|
}
|
|
74
|
-
},
|
|
75
|
+
}),
|
|
75
76
|
// 撤销插件实例时到点仍未完成的任务被强制留在队列里(锁过期后按 stalled 交回),新实例再执行一次:
|
|
76
77
|
// 任务是至少一次。stalled 次数放宽到 3,连续几次撤销 / 升级不至于把任务判为失败
|
|
77
78
|
{ connection: newConn(), concurrency: o?.concurrency ?? 1, maxStalledCount: 3 });
|
package/dist/errors.d.ts
CHANGED
|
@@ -61,6 +61,7 @@ export declare const ERROR_CODES: {
|
|
|
61
61
|
readonly "extension.not_disabled": 409;
|
|
62
62
|
readonly "packages.npm_failed": 502;
|
|
63
63
|
readonly "bootstrap.integrity_mismatch": 500;
|
|
64
|
+
readonly "bootstrap.download_failed": 502;
|
|
64
65
|
readonly "bootstrap.missing_file": 500;
|
|
65
66
|
readonly "catalog.invalid_manifest": 422;
|
|
66
67
|
readonly "catalog.legacy_format": 422;
|
package/dist/errors.js
CHANGED
|
@@ -61,6 +61,7 @@ export const ERROR_CODES = {
|
|
|
61
61
|
"extension.not_disabled": 409,
|
|
62
62
|
"packages.npm_failed": 502,
|
|
63
63
|
"bootstrap.integrity_mismatch": 500,
|
|
64
|
+
"bootstrap.download_failed": 502,
|
|
64
65
|
"bootstrap.missing_file": 500,
|
|
65
66
|
"catalog.invalid_manifest": 422,
|
|
66
67
|
"catalog.legacy_format": 422,
|
package/dist/evals/worker.js
CHANGED
|
@@ -6,6 +6,7 @@ import { sql } from "kysely";
|
|
|
6
6
|
import { loadTemplates } from "../agents/templates.js";
|
|
7
7
|
import { ApiError } from "../errors.js";
|
|
8
8
|
import { ExtensionUnloadedError } from "../extensions/inflight.js";
|
|
9
|
+
import { withJobContext } from "../log-context.js";
|
|
9
10
|
import { PurposeAttemptError } from "../models/purposes.js";
|
|
10
11
|
import { MediaUnavailableError, RunError } from "../runtime/model-call.js";
|
|
11
12
|
import { ProviderListError } from "../runtime/providers.js";
|
|
@@ -149,7 +150,7 @@ export class EvalWorker {
|
|
|
149
150
|
return;
|
|
150
151
|
const { runtime, evals } = this.d.config();
|
|
151
152
|
this.conn = bullConnection(this.d.redisUrl, this.d.log);
|
|
152
|
-
this.worker = new Worker(EVAL_QUEUE_NAME, (job, token) => this.process(job, token), {
|
|
153
|
+
this.worker = new Worker(EVAL_QUEUE_NAME, (job, token) => withJobContext(EVAL_QUEUE_NAME, job, () => this.process(job, token)), {
|
|
153
154
|
name: `${hostname()}-${process.pid}`,
|
|
154
155
|
connection: this.conn,
|
|
155
156
|
concurrency: evals.workerConcurrency,
|
|
@@ -2,18 +2,25 @@ import { type BootstrapManifest, type Logger } from "@capybara-hq/plugin-sdk";
|
|
|
2
2
|
import { type PluginImport } from "../catalog/plugin-import.js";
|
|
3
3
|
import type { KernelConfig } from "../config/schema.js";
|
|
4
4
|
import type { KernelDb } from "../db/kernel-schema.js";
|
|
5
|
+
import { fetchNpmPackage } from "../packages/npm.js";
|
|
5
6
|
import type { ExtensionAdminOps } from "./admin-ops.js";
|
|
6
7
|
import type { ExtensionInstalls } from "./installs.js";
|
|
7
8
|
import type { ExtensionSource } from "./sources.js";
|
|
8
9
|
/**
|
|
9
|
-
* The bootstrap step (package ecosystem spec §7): installs the plugins
|
|
10
|
-
* manifest (`packages/kernel/bootstrap.json`)
|
|
11
|
-
*
|
|
10
|
+
* The bootstrap step (package ecosystem spec §7, startup bootstrap spec): installs the plugins
|
|
11
|
+
* pinned by the bootstrap manifest (`packages/kernel/bootstrap.json`). It replaced the builtin
|
|
12
|
+
* extensions.
|
|
12
13
|
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* - When the run is already done for the manifest nothing is read: no directory, no network, so a
|
|
15
|
+
* restart of an installed deployment never needs npm.
|
|
16
|
+
* - Otherwise, inside the lock and before anything is written, every entry's tgz is obtained
|
|
17
|
+
* ({@link resolveBootstrapPackages}): a `*.tgz` file of the optional offline directory whose
|
|
18
|
+
* sha512 equals the entry's `integrity` (the file name does not matter), else the npm download
|
|
19
|
+
* of a `source: "npm"` entry (`packages.npmRegistry` / `npmToken` / `maxBytes`, verified against
|
|
20
|
+
* the pin only). A workspace entry without a matching file is `bootstrap.missing_file`, a failed
|
|
21
|
+
* download `bootstrap.download_failed`, a download that differs from the pin
|
|
22
|
+
* `bootstrap.integrity_mismatch` (all name the plugin), and then nothing is written. Downloading
|
|
23
|
+
* inside the lock means a fleet starting together downloads once.
|
|
17
24
|
* - The whole run holds the session advisory lock `capybara.bootstrap` on a connection of its own
|
|
18
25
|
* (taken with `pg_try_advisory_lock` in a backoff loop that observes the signal, and closed
|
|
19
26
|
* afterwards, so the lock never outlives the run): when several replicas start together only one
|
|
@@ -49,8 +56,8 @@ import type { ExtensionSource } from "./sources.js";
|
|
|
49
56
|
* loses `required` -> `required_cleared`.
|
|
50
57
|
* A failed entry does not stop the others; the run then throws {@link BootstrapFailedError} naming
|
|
51
58
|
* them. The packages are kept in PG (`blob_store = 'pg'`), so installing an extension never needs
|
|
52
|
-
* the object store (object-store-s3 is itself a bootstrap plugin)
|
|
53
|
-
* files do.
|
|
59
|
+
* the object store (object-store-s3 is itself a bootstrap plugin) and the directory and npm are
|
|
60
|
+
* never needed again; a declarative plugin's catalog files do need the object store.
|
|
54
61
|
*/
|
|
55
62
|
/** The bootstrap manifest that ships with the kernel package. */
|
|
56
63
|
export declare const BOOTSTRAP_MANIFEST_FILE: string;
|
|
@@ -89,6 +96,27 @@ export declare class BootstrapFailedError extends Error {
|
|
|
89
96
|
export declare function bootstrapManifestSha256(manifest: BootstrapManifest): string;
|
|
90
97
|
/** Reads and parses a bootstrap manifest file; an invalid one throws with its problems. */
|
|
91
98
|
export declare function readBootstrapManifest(file?: string): Promise<BootstrapManifest>;
|
|
99
|
+
/** What {@link resolveBootstrapPackages} needs from the kernel config. */
|
|
100
|
+
export interface BootstrapResolveOptions {
|
|
101
|
+
/**
|
|
102
|
+
* The offline directory: every `*.tgz` directly in it is matched to the entries by sha512.
|
|
103
|
+
* Omitted or missing: no offline packages.
|
|
104
|
+
*/
|
|
105
|
+
dir?: string;
|
|
106
|
+
config: Pick<KernelConfig, "packages">;
|
|
107
|
+
signal?: AbortSignal;
|
|
108
|
+
log?: Pick<Logger, "warn">;
|
|
109
|
+
/** The npm download (default {@link fetchNpmPackage}; tests replace it). */
|
|
110
|
+
fetch?: typeof fetchNpmPackage;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Obtains every entry's tgz, checked against its pinned sha512: from the offline directory when a
|
|
114
|
+
* file there has that sha512, else (a `source: "npm"` entry) downloaded from the npm registry and
|
|
115
|
+
* verified against the pin (the packument's `dist.integrity` is not consulted). Throws an
|
|
116
|
+
* {@link ApiError} naming the plugin for the first entry it cannot obtain
|
|
117
|
+
* (`bootstrap.missing_file`, `bootstrap.download_failed`, `bootstrap.integrity_mismatch`).
|
|
118
|
+
*/
|
|
119
|
+
export declare function resolveBootstrapPackages(manifest: BootstrapManifest, o: BootstrapResolveOptions): Promise<Map<string, Uint8Array>>;
|
|
92
120
|
export interface BootstrapServiceDeps {
|
|
93
121
|
db: KernelDb;
|
|
94
122
|
/** The lock connection is opened separately (and closed after every run). */
|
|
@@ -119,7 +147,8 @@ export declare class BootstrapService {
|
|
|
119
147
|
*/
|
|
120
148
|
run(o: {
|
|
121
149
|
manifest: BootstrapManifest;
|
|
122
|
-
|
|
150
|
+
/** The offline directory (see {@link resolveBootstrapPackages}); omitted: npm only. */
|
|
151
|
+
dir?: string;
|
|
123
152
|
signal?: AbortSignal;
|
|
124
153
|
carry?: readonly BootstrapEntryResult[];
|
|
125
154
|
}): Promise<BootstrapResult>;
|
|
@@ -131,8 +160,6 @@ export declare class BootstrapService {
|
|
|
131
160
|
private locked;
|
|
132
161
|
/** The entry names the last completed run handled (undefined before any run completed). */
|
|
133
162
|
private lastRunNames;
|
|
134
|
-
/** Reads every entry's file and checks its sha512 (before the lock: nothing is written yet). */
|
|
135
|
-
private verifyFiles;
|
|
136
163
|
private entry;
|
|
137
164
|
/** A declarative plugin: catalog only. Returns false when that version is already imported. */
|
|
138
165
|
private importOnly;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import {
|
|
5
|
+
import { parseBootstrapManifest, SDK_VERSION, } from "@capybara-hq/plugin-sdk";
|
|
6
6
|
import { sql } from "kysely";
|
|
7
7
|
import pg from "pg";
|
|
8
8
|
import semver from "semver";
|
|
@@ -11,18 +11,25 @@ import { PluginPackageError, readPluginPackage } from "../catalog/plugin-package
|
|
|
11
11
|
import { pgConnectOptions } from "../db/pool.js";
|
|
12
12
|
import { ApiError } from "../errors.js";
|
|
13
13
|
import { audit } from "../identity/audit.js";
|
|
14
|
+
import { fetchNpmPackage, IntegrityMismatchError, NpmFetchError, PackageTooLargeError, } from "../packages/npm.js";
|
|
14
15
|
import { writeSnapshot } from "./approvals.js";
|
|
15
16
|
import { ExtensionPackageError, permissionSetOfPackage, readExtensionPackage } from "./package.js";
|
|
16
17
|
import { diffPermissions } from "./permission-set.js";
|
|
17
18
|
/**
|
|
18
|
-
* The bootstrap step (package ecosystem spec §7): installs the plugins
|
|
19
|
-
* manifest (`packages/kernel/bootstrap.json`)
|
|
20
|
-
*
|
|
19
|
+
* The bootstrap step (package ecosystem spec §7, startup bootstrap spec): installs the plugins
|
|
20
|
+
* pinned by the bootstrap manifest (`packages/kernel/bootstrap.json`). It replaced the builtin
|
|
21
|
+
* extensions.
|
|
21
22
|
*
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* - When the run is already done for the manifest nothing is read: no directory, no network, so a
|
|
24
|
+
* restart of an installed deployment never needs npm.
|
|
25
|
+
* - Otherwise, inside the lock and before anything is written, every entry's tgz is obtained
|
|
26
|
+
* ({@link resolveBootstrapPackages}): a `*.tgz` file of the optional offline directory whose
|
|
27
|
+
* sha512 equals the entry's `integrity` (the file name does not matter), else the npm download
|
|
28
|
+
* of a `source: "npm"` entry (`packages.npmRegistry` / `npmToken` / `maxBytes`, verified against
|
|
29
|
+
* the pin only). A workspace entry without a matching file is `bootstrap.missing_file`, a failed
|
|
30
|
+
* download `bootstrap.download_failed`, a download that differs from the pin
|
|
31
|
+
* `bootstrap.integrity_mismatch` (all name the plugin), and then nothing is written. Downloading
|
|
32
|
+
* inside the lock means a fleet starting together downloads once.
|
|
26
33
|
* - The whole run holds the session advisory lock `capybara.bootstrap` on a connection of its own
|
|
27
34
|
* (taken with `pg_try_advisory_lock` in a backoff loop that observes the signal, and closed
|
|
28
35
|
* afterwards, so the lock never outlives the run): when several replicas start together only one
|
|
@@ -58,8 +65,8 @@ import { diffPermissions } from "./permission-set.js";
|
|
|
58
65
|
* loses `required` -> `required_cleared`.
|
|
59
66
|
* A failed entry does not stop the others; the run then throws {@link BootstrapFailedError} naming
|
|
60
67
|
* them. The packages are kept in PG (`blob_store = 'pg'`), so installing an extension never needs
|
|
61
|
-
* the object store (object-store-s3 is itself a bootstrap plugin)
|
|
62
|
-
* files do.
|
|
68
|
+
* the object store (object-store-s3 is itself a bootstrap plugin) and the directory and npm are
|
|
69
|
+
* never needed again; a declarative plugin's catalog files do need the object store.
|
|
63
70
|
*/
|
|
64
71
|
/** The bootstrap manifest that ships with the kernel package. */
|
|
65
72
|
export const BOOTSTRAP_MANIFEST_FILE = fileURLToPath(new URL("../../bootstrap.json", import.meta.url));
|
|
@@ -95,6 +102,101 @@ export async function readBootstrapManifest(file = BOOTSTRAP_MANIFEST_FILE) {
|
|
|
95
102
|
return parsed;
|
|
96
103
|
}
|
|
97
104
|
const integrityOf = (bytes) => `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
|
|
105
|
+
/**
|
|
106
|
+
* Obtains every entry's tgz, checked against its pinned sha512: from the offline directory when a
|
|
107
|
+
* file there has that sha512, else (a `source: "npm"` entry) downloaded from the npm registry and
|
|
108
|
+
* verified against the pin (the packument's `dist.integrity` is not consulted). Throws an
|
|
109
|
+
* {@link ApiError} naming the plugin for the first entry it cannot obtain
|
|
110
|
+
* (`bootstrap.missing_file`, `bootstrap.download_failed`, `bootstrap.integrity_mismatch`).
|
|
111
|
+
*/
|
|
112
|
+
export async function resolveBootstrapPackages(manifest, o) {
|
|
113
|
+
const out = new Map();
|
|
114
|
+
if (manifest.plugins.length === 0)
|
|
115
|
+
return out;
|
|
116
|
+
const { npmRegistry, npmToken, maxBytes } = o.config.packages;
|
|
117
|
+
const offline = o.dir !== undefined ? await scanOfflineDir(o.dir, maxBytes, o.log) : new Map();
|
|
118
|
+
const fetchPackage = o.fetch ?? fetchNpmPackage;
|
|
119
|
+
const signal = o.signal ?? new AbortController().signal;
|
|
120
|
+
const where = o.dir !== undefined ? o.dir : "the bootstrap directory (config bootstrap.dir)";
|
|
121
|
+
for (const e of manifest.plugins) {
|
|
122
|
+
signal.throwIfAborted();
|
|
123
|
+
const found = offline.get(e.integrity);
|
|
124
|
+
if (found !== undefined) {
|
|
125
|
+
out.set(e.name, found);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (e.source === "workspace") {
|
|
129
|
+
throw new ApiError("bootstrap.missing_file", `Bootstrap plugin ${e.name} ${e.version}: no file in ${where} has its pinned sha512 (${e.integrity.slice(0, 19)}…), and a workspace entry cannot be downloaded; put its package tgz (pnpm pack-plugin ${e.path}) in that directory`);
|
|
130
|
+
}
|
|
131
|
+
const registry = registryOrigin(npmRegistry);
|
|
132
|
+
const offlineHint = `to install offline, put the package tgz (npm pack ${e.npm}@${e.version}, any file name) in ${where}`;
|
|
133
|
+
try {
|
|
134
|
+
const r = await fetchPackage({ registry: npmRegistry, ...(npmToken !== undefined && { token: npmToken }), maxBytes }, e.npm, e.version, signal, { expectedIntegrity: e.integrity });
|
|
135
|
+
out.set(e.name, r.bytes);
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
if (signal.aborted)
|
|
139
|
+
throw err;
|
|
140
|
+
if (err instanceof IntegrityMismatchError) {
|
|
141
|
+
throw new ApiError("bootstrap.integrity_mismatch", `Bootstrap plugin ${e.name} ${e.version}: the package ${e.npm}@${e.version} downloaded from ${registry} does not match its pinned sha512 (expected ${e.integrity.slice(0, 19)}…, got ${err.actual.slice(0, 19)}…); refusing to install anything`);
|
|
142
|
+
}
|
|
143
|
+
const reason = err instanceof NpmFetchError || err instanceof PackageTooLargeError
|
|
144
|
+
? err.message
|
|
145
|
+
: err instanceof Error
|
|
146
|
+
? err.message
|
|
147
|
+
: String(err);
|
|
148
|
+
throw new ApiError("bootstrap.download_failed", `Bootstrap plugin ${e.name} ${e.version}: cannot download ${e.npm}@${e.version} from ${registry} (${reason}); ${offlineHint}, or set packages.npmRegistry to a reachable mirror`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/** The registry's origin, for messages (never its credentials or path). */
|
|
154
|
+
function registryOrigin(registry) {
|
|
155
|
+
try {
|
|
156
|
+
return new URL(registry).origin;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return "the configured npm registry";
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The `*.tgz` files directly in `dir` by their sha512 integrity. A missing directory is an empty
|
|
164
|
+
* one; a file larger than `maxBytes` is skipped with a warning.
|
|
165
|
+
*/
|
|
166
|
+
async function scanOfflineDir(dir, maxBytes, log) {
|
|
167
|
+
const out = new Map();
|
|
168
|
+
let names;
|
|
169
|
+
try {
|
|
170
|
+
names = await readdir(dir);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
const code = err.code;
|
|
174
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
175
|
+
return out;
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
for (const name of names.sort()) {
|
|
179
|
+
if (!name.endsWith(".tgz"))
|
|
180
|
+
continue;
|
|
181
|
+
const file = join(dir, name);
|
|
182
|
+
const st = await stat(file).catch(() => undefined);
|
|
183
|
+
if (!st?.isFile())
|
|
184
|
+
continue;
|
|
185
|
+
if (st.size > maxBytes) {
|
|
186
|
+
log?.warn("bootstrap: a file in the bootstrap directory exceeds packages.maxBytes; skipped", {
|
|
187
|
+
file,
|
|
188
|
+
size: st.size,
|
|
189
|
+
maxBytes,
|
|
190
|
+
});
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const bytes = new Uint8Array(await readFile(file));
|
|
194
|
+
const integrity = integrityOf(bytes);
|
|
195
|
+
if (!out.has(integrity))
|
|
196
|
+
out.set(integrity, bytes);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
98
200
|
/** The advisory lock around a whole run. */
|
|
99
201
|
const LOCK_KEY = "capybara.bootstrap";
|
|
100
202
|
export class BootstrapService {
|
|
@@ -120,16 +222,30 @@ export class BootstrapService {
|
|
|
120
222
|
*/
|
|
121
223
|
async run(o) {
|
|
122
224
|
const manifestSha256 = bootstrapManifestSha256(o.manifest);
|
|
123
|
-
const
|
|
124
|
-
return this.locked(o.signal, async () => {
|
|
225
|
+
const recorded = async () => {
|
|
125
226
|
const done = await this.d.db
|
|
126
227
|
.selectFrom("bootstrap_runs")
|
|
127
228
|
.select("results")
|
|
128
229
|
.where("manifest_sha256", "=", manifestSha256)
|
|
129
230
|
.executeTakeFirst();
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
231
|
+
return done && { manifestSha256, results: done.results };
|
|
232
|
+
};
|
|
233
|
+
// Done: no lock, no directory, no network
|
|
234
|
+
const before = await recorded();
|
|
235
|
+
if (before)
|
|
236
|
+
return before;
|
|
237
|
+
return this.locked(o.signal, async () => {
|
|
238
|
+
// Another replica may have finished it while this one waited for the lock
|
|
239
|
+
const done = await recorded();
|
|
240
|
+
if (done)
|
|
241
|
+
return done;
|
|
242
|
+
// Every package is obtained and checked before the first write
|
|
243
|
+
const files = await resolveBootstrapPackages(o.manifest, {
|
|
244
|
+
...(o.dir !== undefined && { dir: o.dir }),
|
|
245
|
+
config: this.d.config(),
|
|
246
|
+
...(o.signal !== undefined && { signal: o.signal }),
|
|
247
|
+
log: this.d.log,
|
|
248
|
+
});
|
|
133
249
|
const handled = await this.lastRunNames();
|
|
134
250
|
const results = [];
|
|
135
251
|
const failures = [];
|
|
@@ -225,28 +341,6 @@ export class BootstrapService {
|
|
|
225
341
|
.filter((r) => r.action !== "required_cleared")
|
|
226
342
|
.map((r) => r.name));
|
|
227
343
|
}
|
|
228
|
-
/** Reads every entry's file and checks its sha512 (before the lock: nothing is written yet). */
|
|
229
|
-
async verifyFiles(manifest, dir) {
|
|
230
|
-
const out = new Map();
|
|
231
|
-
for (const e of manifest.plugins) {
|
|
232
|
-
const file = join(dir, bootstrapFileName(e));
|
|
233
|
-
let bytes;
|
|
234
|
-
try {
|
|
235
|
-
bytes = new Uint8Array(await readFile(file));
|
|
236
|
-
}
|
|
237
|
-
catch (err) {
|
|
238
|
-
if (err.code !== "ENOENT")
|
|
239
|
-
throw err;
|
|
240
|
-
throw new ApiError("bootstrap.missing_file", `Bootstrap plugin ${e.name} ${e.version}: ${file} is missing; the image must carry every package bootstrap.json pins`);
|
|
241
|
-
}
|
|
242
|
-
const actual = integrityOf(bytes);
|
|
243
|
-
if (actual !== e.integrity) {
|
|
244
|
-
throw new ApiError("bootstrap.integrity_mismatch", `Bootstrap plugin ${e.name} ${e.version}: ${file} does not match its pinned sha512 (expected ${e.integrity.slice(0, 19)}…, got ${actual.slice(0, 19)}…); refusing to install anything`);
|
|
245
|
-
}
|
|
246
|
-
out.set(e.name, bytes);
|
|
247
|
-
}
|
|
248
|
-
return out;
|
|
249
|
-
}
|
|
250
344
|
async entry(e, bytes, handled) {
|
|
251
345
|
const pkg = await readPluginPackage(bytes, {
|
|
252
346
|
maxBytes: this.d.config().packages.maxBytes,
|
package/dist/http/app.js
CHANGED
|
@@ -5,12 +5,14 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
7
|
import "reflect-metadata";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
8
9
|
import fastifyCookie from "@fastify/cookie";
|
|
9
10
|
import { Module } from "@nestjs/common";
|
|
10
11
|
import { APP_FILTER, NestFactory } from "@nestjs/core";
|
|
11
12
|
import { FastifyAdapter } from "@nestjs/platform-fastify";
|
|
12
13
|
import { ApiError, errorBody } from "../errors.js";
|
|
13
14
|
import { authorizeEnterpriseAdmin } from "../identity/auth.guard.js";
|
|
15
|
+
import { withLogContext } from "../log-context.js";
|
|
14
16
|
import { ErrorFilter, redactPath } from "./error.filter.js";
|
|
15
17
|
import { registerGatewayRoutes } from "./gateway.module.js";
|
|
16
18
|
import { HealthController } from "./health.controller.js";
|
|
@@ -37,6 +39,48 @@ export const PACKAGE_UPLOAD_ROUTES = new Set([
|
|
|
37
39
|
function fastifyTrustProxy(tp) {
|
|
38
40
|
return typeof tp === "number" ? (_addr, hop) => hop < tp : tp;
|
|
39
41
|
}
|
|
42
|
+
const REQUEST_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
|
43
|
+
/** Paths without an access log line (probes). */
|
|
44
|
+
const QUIET_PATHS = new Set(["/healthz", "/readyz"]);
|
|
45
|
+
/** The request id: a valid incoming `x-request-id`, else a random one. */
|
|
46
|
+
function requestId(req) {
|
|
47
|
+
const h = req.headers["x-request-id"];
|
|
48
|
+
return typeof h === "string" && REQUEST_ID.test(h) ? h : randomUUID();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Request ids and the access log. Every request runs in a log context with its `reqId` (records
|
|
52
|
+
* logged while handling it carry it) and answers with `x-request-id` (set on the raw response, so
|
|
53
|
+
* hijacked SSE streams carry it too). One access line per request when the response is done:
|
|
54
|
+
* `info`, except event streams (`debug`, when they close) and the probes (none).
|
|
55
|
+
*/
|
|
56
|
+
function registerRequestLog(fastify, log) {
|
|
57
|
+
fastify.addHook("onRequest", (req, reply, done) => {
|
|
58
|
+
reply.raw.setHeader("x-request-id", req.id);
|
|
59
|
+
const started = performance.now();
|
|
60
|
+
const path = req.url.split("?")[0] ?? "";
|
|
61
|
+
let logged = false;
|
|
62
|
+
const accessLine = () => {
|
|
63
|
+
if (logged || QUIET_PATHS.has(path))
|
|
64
|
+
return;
|
|
65
|
+
logged = true;
|
|
66
|
+
const type = String(reply.raw.getHeader("content-type") ?? "");
|
|
67
|
+
const fields = {
|
|
68
|
+
method: req.method,
|
|
69
|
+
url: redactPath(req.url),
|
|
70
|
+
status: reply.raw.statusCode,
|
|
71
|
+
ms: Math.round(performance.now() - started),
|
|
72
|
+
reqId: req.id,
|
|
73
|
+
};
|
|
74
|
+
if (type.startsWith("text/event-stream"))
|
|
75
|
+
log.debug?.("request", fields);
|
|
76
|
+
else
|
|
77
|
+
log.info("request", fields);
|
|
78
|
+
};
|
|
79
|
+
reply.raw.once("finish", accessLine);
|
|
80
|
+
reply.raw.once("close", accessLine);
|
|
81
|
+
withLogContext({ reqId: req.id }, done);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
40
84
|
/** 允许较大请求体的路由(Fastify 路由模板,只读常量表)及其上限。 */
|
|
41
85
|
const LARGE_BODY_ROUTES = new Set([
|
|
42
86
|
"/api/v1/orgs/:orgId/connectors",
|
|
@@ -67,10 +111,16 @@ function rootModule(opts) {
|
|
|
67
111
|
* 插件加载前 `/plugins/*` 一律 404。未匹配的内核路由同样返回 404 错误信封。
|
|
68
112
|
*/
|
|
69
113
|
export async function createHttpApp(opts) {
|
|
70
|
-
const app = await NestFactory.create(rootModule(opts), new FastifyAdapter({
|
|
114
|
+
const app = await NestFactory.create(rootModule(opts), new FastifyAdapter({
|
|
115
|
+
trustProxy: fastifyTrustProxy(opts.trustProxy ?? false),
|
|
116
|
+
// x-request-id is read by requestId() (validated), never trusted as is
|
|
117
|
+
requestIdHeader: false,
|
|
118
|
+
genReqId: requestId,
|
|
119
|
+
}), { logger: new NestLogAdapter(opts.log), abortOnError: false });
|
|
71
120
|
// Cookie 解析(会话 Cookie);必须在 init 之前注册
|
|
72
121
|
await app.register(fastifyCookie);
|
|
73
122
|
const fastify = app.getHttpAdapter().getInstance();
|
|
123
|
+
registerRequestLog(fastify, opts.log);
|
|
74
124
|
const replicaId = opts.replicaId;
|
|
75
125
|
if (replicaId !== undefined) {
|
|
76
126
|
// 直接设在原始响应上:hijack 后自己 writeHead 的 SSE 流也会带上(writeHead 合并已 setHeader 的头)
|