@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 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": "workspace",
8
- "path": "plugins/openai-compatible",
11
+ "source": "npm",
12
+ "npm": "@capybara-hq/plugin-openai-compatible",
9
13
  "version": "0.1.0-rc.0",
10
- "integrity": "sha512-LSPFl9BK9xLShBzfUBu0NZvSG/0JyE2tJL2Hrv3QEdCFxmU4xPy1SlvKtzOEanftSuTR5eEVX13VvVYXrpo7Mw==",
14
+ "integrity": "sha512-NYyjRfC2U6bAjjlFU0rmJuDs3IyuAwektV9KLBpnFcDVRDfRgBpcSBV/jp0tzXhv9ee5MWqzOKN20WNdKWO9fQ==",
11
15
  "required": true
12
16
  },
13
17
  {
14
18
  "name": "object-store-s3",
15
- "source": "workspace",
16
- "path": "plugins/object-store-s3",
19
+ "source": "npm",
20
+ "npm": "@capybara-hq/plugin-object-store-s3",
17
21
  "version": "0.1.0-rc.0",
18
- "integrity": "sha512-8vYvq0EyrEfvJwgx67nSEbFW9rnO+uaP/gtL6Esqh3I1XEJEmMJnSH0LgcsD5zFQRiDe/ND0Ij3EnWBWVTr0UQ==",
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,
@@ -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.ZodDefault<z.ZodObject<{
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>;
@@ -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/Dockerfile; pre-pull it on every Docker node). The E2B extension ignores it and uses its own template setting",
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
- .default({ level: "info" }),
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 (job) => {
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,
@@ -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 pinned by the bootstrap
10
- * manifest (`packages/kernel/bootstrap.json`) from the tgz files baked into the image. It replaced
11
- * the builtin extensions.
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
- * - Every file (`<dir>/<name>-<version>.tgz`) is checked against the entry's sha512 before anything
14
- * is read or written; a missing file is `bootstrap.missing_file`, a mismatch
15
- * `bootstrap.integrity_mismatch` (both name the plugin), and then nothing is written. It never
16
- * touches the network.
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); a declarative plugin's catalog
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
- dir: string;
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 { bootstrapFileName, parseBootstrapManifest, SDK_VERSION, } from "@capybara-hq/plugin-sdk";
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 pinned by the bootstrap
19
- * manifest (`packages/kernel/bootstrap.json`) from the tgz files baked into the image. It replaced
20
- * the builtin extensions.
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
- * - Every file (`<dir>/<name>-<version>.tgz`) is checked against the entry's sha512 before anything
23
- * is read or written; a missing file is `bootstrap.missing_file`, a mismatch
24
- * `bootstrap.integrity_mismatch` (both name the plugin), and then nothing is written. It never
25
- * touches the network.
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); a declarative plugin's catalog
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 files = await this.verifyFiles(o.manifest, o.dir);
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
- if (done) {
131
- return { manifestSha256, results: done.results };
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({ trustProxy: fastifyTrustProxy(opts.trustProxy ?? false) }), { logger: new NestLogAdapter(opts.log), abortOnError: false });
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 的头)