@stackbone/cli 0.3.1 → 0.3.3

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/main.js CHANGED
@@ -11,13 +11,13 @@ import pino from "pino";
11
11
  import pretty from "pino-pretty";
12
12
  import { exec, execFile, spawn, spawnSync } from "node:child_process";
13
13
  import { fileURLToPath, pathToFileURL } from "node:url";
14
+ import { DeleteObjectCommand, GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
14
15
  import sodium from "libsodium-wrappers";
15
16
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
16
17
  import { drizzle } from "drizzle-orm/postgres-js";
17
18
  import postgres from "postgres";
18
19
  import { extractText } from "unpdf";
19
20
  import OpenAI from "openai";
20
- import { DeleteObjectCommand, GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
21
21
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
22
22
  import { format, promisify } from "node:util";
23
23
  import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
@@ -153,7 +153,7 @@ var init_installations = __esmMin((() => {
153
153
  organizationSlug: organizationSlugSchema,
154
154
  agentSlug: z.string().min(1).max(80)
155
155
  });
156
- })), agentTemplateStatusSchema, agentTemplateResponseSchema, agentInstallationResponseSchema, DEFAULT_RAG_EMBEDDING_MODEL, agentManifestRagSchema, agentManifestProtocolSchema, runtimeSchema, agentManifestConnectionsSchema, recipeTriggerRefSchema, recipeActionRefSchema, agentManifestRecipeSchema, agentManifestAutomationsSchema, DEFAULT_AGENT_SCHEMA_PATH, DEFAULT_AGENT_MIGRATIONS_PATH, BUNDLE_MIGRATIONS_PATH, WORKSPACE_MANIFEST_PATH, WORKFLOW_WELL_KNOWN_DIR, WORKFLOW_STEP_FILE, WORKFLOW_VM_FILE, WORKFLOW_MANIFEST_FILE, WORKFLOW_ARTIFACTS, WORKFLOW_SCHEMAS_FILE, MANIFEST_ENTRY_KINDS, MANIFEST_ENTRY_MODES, MIN_IMAGE_VERSION_FOR_MANIFEST_V2, workflowStartSurface, workflowChatSurface, DEEP_AGENT_CHAT_SURFACE, agentManifestSchema;
156
+ })), agentTemplateStatusSchema, agentTemplateResponseSchema, agentInstallationResponseSchema, DEFAULT_RAG_EMBEDDING_MODEL, agentManifestRagSchema, agentManifestProtocolSchema, runtimeSchema, agentManifestConnectionsSchema, recipeTriggerRefSchema, recipeActionRefSchema, agentManifestRecipeSchema, agentManifestAutomationsSchema, DEFAULT_AGENT_SCHEMA_PATH, DEFAULT_AGENT_MIGRATIONS_PATH, BUNDLE_MIGRATIONS_PATH, WORKSPACE_MANIFEST_PATH, CONFIG_SCHEMA_ARTIFACT_PATH, WORKFLOW_WELL_KNOWN_DIR, WORKFLOW_STEP_FILE, WORKFLOW_VM_FILE, WORKFLOW_MANIFEST_FILE, WORKFLOW_ARTIFACTS, WORKFLOW_SCHEMAS_FILE, MANIFEST_ENTRY_KINDS, MANIFEST_ENTRY_MODES, MIN_IMAGE_VERSION_FOR_MANIFEST_V2, workflowStartSurface, workflowChatSurface, DEEP_AGENT_CHAT_SURFACE, agentManifestSchema;
157
157
  var init_agents = __esmMin((() => {
158
158
  init_json_object();
159
159
  init_connectors();
@@ -270,6 +270,7 @@ var init_agents = __esmMin((() => {
270
270
  DEFAULT_AGENT_MIGRATIONS_PATH = "./.stackbone/migrations";
271
271
  BUNDLE_MIGRATIONS_PATH = ".well-known/migrations/v1";
272
272
  WORKSPACE_MANIFEST_PATH = ".well-known/workspace.json";
273
+ CONFIG_SCHEMA_ARTIFACT_PATH = ".well-known/config/v1/config-schema.json";
273
274
  WORKFLOW_WELL_KNOWN_DIR = ".well-known/workflow/v1";
274
275
  WORKFLOW_STEP_FILE = "step.mjs";
275
276
  WORKFLOW_VM_FILE = "workflow.vm.js";
@@ -501,6 +502,25 @@ var init_approvals = __esmMin((() => {
501
502
  //#endregion
502
503
  //#region ../../libs/shared/validators/src/lib/agents/contract.ts
503
504
  /**
505
+ * Whether a box speaking protocol `version` can be registered as a deployment.
506
+ * Pure and total: the caller passes the number the handshake reported.
507
+ */
508
+ function supportsDeploymentRegistration(version) {
509
+ return version >= 15;
510
+ }
511
+ /**
512
+ * The one sentence both doors print when a box is below the floor.
513
+ *
514
+ * Shared, not copied, because the wizard and `stackbone link` refuse by the same
515
+ * rule and an operator who tries the other door must not read a different story.
516
+ * For the same reason it names no door: there is no "register it from the
517
+ * terminal instead" any more, and the only way out it can honestly offer is the
518
+ * one that works everywhere — run a current image.
519
+ */
520
+ function deploymentRegistrationRefusal(version) {
521
+ return `This box speaks protocol version ${version}. Registering a deployment needs version 15 or later, because an older box refuses the signed call that proves its secret. Update the image the box runs to a current one and try again.`;
522
+ }
523
+ /**
504
524
  * Validates that the negotiated `contract.version` is at or above the
505
525
  * version the SDK still executes against. Pure, table-driven, never throws.
506
526
  *
@@ -604,7 +624,7 @@ var init_deployments = __esmMin((() => {
604
624
  agentSlug: agentSlugSchema,
605
625
  url: z.url(),
606
626
  hmacSecret: z.string().min(1),
607
- imageTag: z.string().min(1).max(200)
627
+ imageTag: z.string().min(1).max(200).optional()
608
628
  });
609
629
  z.object({ url: z.url() });
610
630
  deploymentResponseSchema = z.object({
@@ -3326,6 +3346,47 @@ var bootWorkspaceRuntime = async (opts, deps = {}) => {
3326
3346
  };
3327
3347
  };
3328
3348
  //#endregion
3349
+ //#region ../../libs/shared/s3-client/src/lib/create-s3-compat-client.ts
3350
+ /**
3351
+ * The single S3-compatible client factory. Behind this seam it resolves the
3352
+ * addressing style (path-style unless the caller says otherwise), falls back to
3353
+ * `'us-east-1'` when the caller pins no region, and shapes the flat
3354
+ * `accessKeyId` / `secretAccessKey` pair into the SDK's `credentials` object.
3355
+ *
3356
+ * Call sites pin their region explicitly: studio and the SDK pass the one they
3357
+ * resolved, the emulator pins `'us-east-1'`.
3358
+ */
3359
+ /**
3360
+ * Read the addressing style out of an environment value.
3361
+ *
3362
+ * Lives here, next to the factory, so the SDK and the runtime cannot drift on
3363
+ * what counts as "off". Anything unset, empty or unrecognised means path-style
3364
+ * — the safe default, since a typo'd value silently flipping a working box to
3365
+ * subdomain addressing would break every call with an error that names neither
3366
+ * the variable nor the typo.
3367
+ */
3368
+ function parseForcePathStyle(raw) {
3369
+ const value = raw?.trim().toLowerCase();
3370
+ return !(value === "false" || value === "0" || value === "no" || value === "off");
3371
+ }
3372
+ function createS3CompatClient(options) {
3373
+ return new S3Client({
3374
+ endpoint: options.endpoint,
3375
+ region: options.region ?? "us-east-1",
3376
+ forcePathStyle: options.forcePathStyle ?? true,
3377
+ credentials: {
3378
+ accessKeyId: options.accessKeyId,
3379
+ secretAccessKey: options.secretAccessKey
3380
+ }
3381
+ });
3382
+ }
3383
+ var init_create_s3_compat_client = __esmMin((() => {}));
3384
+ //#endregion
3385
+ //#region ../../libs/shared/s3-client/src/index.ts
3386
+ var init_src$12 = __esmMin((() => {
3387
+ init_create_s3_compat_client();
3388
+ }));
3389
+ //#endregion
3329
3390
  //#region ../../libs/sdk/src/config.ts
3330
3391
  /**
3331
3392
  * Single seam in the SDK for reading `process.env`. Every downstream module
@@ -3356,6 +3417,7 @@ function resolveConfig(config) {
3356
3417
  s3Endpoint: config.s3?.endpoint ?? env["STACKBONE_S3_ENDPOINT"],
3357
3418
  s3Bucket: config.s3?.bucket ?? env["STACKBONE_S3_BUCKET"],
3358
3419
  s3Region: config.s3?.region ?? env["STACKBONE_S3_REGION"] ?? "us-east-1",
3420
+ s3ForcePathStyle: config.s3?.forcePathStyle ?? parseForcePathStyle(env["STACKBONE_S3_FORCE_PATH_STYLE"]),
3359
3421
  otelExporterOtlpEndpoint: config.otel?.exporterOtlpEndpoint ?? env["OTEL_EXPORTER_OTLP_ENDPOINT"],
3360
3422
  requireContract: env["STACKBONE_REQUIRE_CONTRACT"] !== "0",
3361
3423
  contractTtlMs: parseTtlMs(env["STACKBONE_CONTRACT_TTL_MS"]),
@@ -3375,7 +3437,9 @@ function parseTtlMs(raw) {
3375
3437
  if (!Number.isFinite(n) || n <= 0) return void 0;
3376
3438
  return n;
3377
3439
  }
3378
- var init_config$1 = __esmMin((() => {}));
3440
+ var init_config$1 = __esmMin((() => {
3441
+ init_src$12();
3442
+ }));
3379
3443
  //#endregion
3380
3444
  //#region ../../libs/sdk/src/result.ts
3381
3445
  function ok$1(data) {
@@ -3461,7 +3525,7 @@ function createContractStore(resolved = resolveConfig({})) {
3461
3525
  };
3462
3526
  }
3463
3527
  async function performHandshake(baseUrl, fetchImpl) {
3464
- const url = `${baseUrl}${CONTRACT_PATH}`;
3528
+ const url = `${baseUrl}${CONTRACT_PATH$1}`;
3465
3529
  let response;
3466
3530
  try {
3467
3531
  response = await fetchImpl(url, { method: "GET" });
@@ -3547,12 +3611,12 @@ function maybeLogDebug(debugLogged, key, contract) {
3547
3611
  debugLogged.add(key);
3548
3612
  console.warn(`[stackbone] contract handshake ok: baseUrl=${key} version=${contract.version} capabilities=${contract.capabilities.length}`);
3549
3613
  }
3550
- var CONTRACT_PATH;
3614
+ var CONTRACT_PATH$1;
3551
3615
  var init_contract_handshake = __esmMin((() => {
3552
3616
  init_src$13();
3553
3617
  init_config$1();
3554
3618
  init_result$1();
3555
- CONTRACT_PATH = "/api/contract";
3619
+ CONTRACT_PATH$1 = "/api/contract";
3556
3620
  }));
3557
3621
  //#endregion
3558
3622
  //#region ../../libs/sdk/src/contract/capability-registry.ts
@@ -3958,7 +4022,7 @@ var init_stackbone_signature = __esmMin((() => {
3958
4022
  }));
3959
4023
  //#endregion
3960
4024
  //#region ../../libs/shared/crypto/src/index.ts
3961
- var init_src$12 = __esmMin((() => {
4025
+ var init_src$11 = __esmMin((() => {
3962
4026
  init_secret_cipher();
3963
4027
  init_constant_time();
3964
4028
  init_workflow_signature();
@@ -4035,7 +4099,7 @@ function isDecision(value) {
4035
4099
  }
4036
4100
  var SIGNATURE_HEADER, DEFAULT_TOLERANCE_SECONDS, FUTURE_DRIFT_TOLERANCE_SECONDS;
4037
4101
  var init_verify = __esmMin((() => {
4038
- init_src$12();
4102
+ init_src$11();
4039
4103
  init_result$1();
4040
4104
  init_approval();
4041
4105
  SIGNATURE_HEADER = STACKBONE_SIGNATURE_HEADER;
@@ -4723,7 +4787,7 @@ var init_compile = __esmMin((() => {
4723
4787
  }));
4724
4788
  //#endregion
4725
4789
  //#region ../../libs/shared/prompt-compiler/src/index.ts
4726
- var init_src$11 = __esmMin((() => {
4790
+ var init_src$10 = __esmMin((() => {
4727
4791
  init_compile();
4728
4792
  }));
4729
4793
  //#endregion
@@ -4793,7 +4857,7 @@ function toIso$2(value) {
4793
4857
  }
4794
4858
  var PG_UNDEFINED_TABLE$4, PG_UNIQUE_VIOLATION$2, PromptsFacade;
4795
4859
  var init_prompts = __esmMin((() => {
4796
- init_src$11();
4860
+ init_src$10();
4797
4861
  init_result$1();
4798
4862
  PG_UNDEFINED_TABLE$4 = "42P01";
4799
4863
  PG_UNIQUE_VIOLATION$2 = "23505";
@@ -5914,7 +5978,7 @@ var init_jobs = __esmMin((() => {
5914
5978
  }));
5915
5979
  //#endregion
5916
5980
  //#region ../../libs/shared/rag-core/src/index.ts
5917
- var init_src$10 = __esmMin((() => {
5981
+ var init_src$9 = __esmMin((() => {
5918
5982
  init_result();
5919
5983
  init_limits();
5920
5984
  init_rag_contract();
@@ -6129,7 +6193,7 @@ function resolveRetrieveModel(request, resolved) {
6129
6193
  }
6130
6194
  var DEFAULT_BATCH_SIZE, RagModule;
6131
6195
  var init_rag = __esmMin((() => {
6132
- init_src$10();
6196
+ init_src$9();
6133
6197
  init_src$13();
6134
6198
  init_module_gate();
6135
6199
  init_result$1();
@@ -6299,7 +6363,7 @@ var init_rag = __esmMin((() => {
6299
6363
  //#region ../../libs/sdk/src/surfaces/agent-local/secrets/secrets.ts
6300
6364
  var SecretsFacade;
6301
6365
  var init_secrets = __esmMin((() => {
6302
- init_src$12();
6366
+ init_src$11();
6303
6367
  init_result$1();
6304
6368
  SecretsFacade = class {
6305
6369
  constructor(_resolved, _getDatabase) {
@@ -7104,35 +7168,6 @@ var init_call_connector = __esmMin((() => {
7104
7168
  connection = ((id) => connectorHandle(id));
7105
7169
  }));
7106
7170
  //#endregion
7107
- //#region ../../libs/shared/s3-client/src/lib/create-s3-compat-client.ts
7108
- /**
7109
- * The single S3-compatible client factory. Behind this seam it always sets
7110
- * `forcePathStyle: true` (MinIO and most S3-compatible backends do not support
7111
- * virtual-host buckets), falls back to `'us-east-1'` when the caller pins no
7112
- * region, and shapes the flat `accessKeyId` / `secretAccessKey` pair into the
7113
- * SDK's `credentials` object.
7114
- *
7115
- * Call sites pin their region explicitly: studio and the SDK pass the one they
7116
- * resolved, the emulator pins `'us-east-1'`.
7117
- */
7118
- function createS3CompatClient(options) {
7119
- return new S3Client({
7120
- endpoint: options.endpoint,
7121
- region: options.region ?? "us-east-1",
7122
- forcePathStyle: true,
7123
- credentials: {
7124
- accessKeyId: options.accessKeyId,
7125
- secretAccessKey: options.secretAccessKey
7126
- }
7127
- });
7128
- }
7129
- var init_create_s3_compat_client = __esmMin((() => {}));
7130
- //#endregion
7131
- //#region ../../libs/shared/s3-client/src/index.ts
7132
- var init_src$9 = __esmMin((() => {
7133
- init_create_s3_compat_client();
7134
- }));
7135
- //#endregion
7136
7171
  //#region ../../libs/sdk/src/surfaces/external/storage/storage.ts
7137
7172
  function stripEtag$1(etag) {
7138
7173
  return etag?.replace(/^"|"$/g, "");
@@ -7161,7 +7196,7 @@ function toStorageError(message, cause) {
7161
7196
  }
7162
7197
  var DEFAULT_SIGNED_URL_TTL_SECONDS, StorageModule, StorageBucket, S3_CREDENTIALS_MISSING_MESSAGE, S3_BUCKET_MISSING_MESSAGE, AGENT_ID_MISSING_MESSAGE;
7163
7198
  var init_storage = __esmMin((() => {
7164
- init_src$9();
7199
+ init_src$12();
7165
7200
  init_module_gate();
7166
7201
  init_result$1();
7167
7202
  DEFAULT_SIGNED_URL_TTL_SECONDS = 3600;
@@ -7197,7 +7232,8 @@ var init_storage = __esmMin((() => {
7197
7232
  endpoint,
7198
7233
  region,
7199
7234
  accessKeyId,
7200
- secretAccessKey
7235
+ secretAccessKey,
7236
+ forcePathStyle: this._resolved.s3ForcePathStyle
7201
7237
  });
7202
7238
  return ok$1({
7203
7239
  client: this._s3,
@@ -7713,7 +7749,7 @@ function createDatabaseProfilePersistence() {
7713
7749
  }
7714
7750
  var AGENT_IDENTITY, errMessage$23, BrowserAuthProfileStore;
7715
7751
  var init_browser_profile_store = __esmMin((() => {
7716
- init_src$12();
7752
+ init_src$11();
7717
7753
  init_result$1();
7718
7754
  init_ambient_client();
7719
7755
  init_browser_profile_state();
@@ -8388,7 +8424,7 @@ var init_src$8 = __esmMin((() => {
8388
8424
  init_browser_tools();
8389
8425
  init_browser_mode_resolver();
8390
8426
  init_browser_profile_store();
8391
- init_src$12();
8427
+ init_src$11();
8392
8428
  init_ambient_client();
8393
8429
  }));
8394
8430
  //#endregion
@@ -10197,6 +10233,7 @@ var resolveCorsAllowlist = (input) => {
10197
10233
  };
10198
10234
  //#endregion
10199
10235
  //#region ../../libs/runtime/workspace-runtime/src/lib/server/agent-config.ts
10236
+ init_src$13();
10200
10237
  var StudioAgentConfigValidationError = class extends Error {
10201
10238
  constructor(errors) {
10202
10239
  super("agent_config_invalid");
@@ -10211,15 +10248,6 @@ var StudioAgentConfigVersionNotFoundError = class extends Error {
10211
10248
  }
10212
10249
  };
10213
10250
  /**
10214
- * Literal location of the config JSON Schema the dev build extracts from the
10215
- * creator's `config.schema.ts`, relative to the project root. Shared artifact
10216
- * contract across the whole feature (`config_schema_dynamic_config`): the CLI
10217
- * extractor writes it, the SDK type codegen reads it, and the emulator (here)
10218
- * resolves it for the Config editor + write validation. Kept as a literal so the
10219
- * emulator never imports `apps/cli`.
10220
- */
10221
- var CONFIG_SCHEMA_ARTIFACT_PATH = ".well-known/config/v1/config-schema.json";
10222
- /**
10223
10251
  * Read the extracted config JSON Schema off disk for a given project root.
10224
10252
  *
10225
10253
  * Reads `<projectRoot>/.well-known/config/v1/config-schema.json` (the build
@@ -10415,15 +10443,17 @@ var init_well_known = __esmMin((() => {
10415
10443
  }));
10416
10444
  //#endregion
10417
10445
  //#region ../../libs/runtime/workspace-runtime/src/lib/server/box-auth-gate.ts
10418
- init_src$12();
10446
+ init_src$11();
10419
10447
  init_well_known();
10420
10448
  var PROXY_CHALLENGE_PATH = "/api/proxy-challenge";
10449
+ var CONTROL_PLANE_IDENTITY_PATH = "/api/control-plane-identity";
10421
10450
  var PUBLIC_PREFIXES = [
10422
10451
  "/api/contract",
10423
10452
  "/api/health",
10424
10453
  "/live",
10425
10454
  "/health",
10426
10455
  PROXY_CHALLENGE_PATH,
10456
+ CONTROL_PLANE_IDENTITY_PATH,
10427
10457
  "/api/connect/callback",
10428
10458
  "/api/connect/connected",
10429
10459
  "/api/connect/token",
@@ -10470,6 +10500,14 @@ var rejectToken = (c) => c.json({
10470
10500
  code: "invalid_token",
10471
10501
  message: "Identity token is missing, expired, or invalid."
10472
10502
  }, 401);
10503
+ var rejectTokenTenant = (c) => c.json({
10504
+ code: "invalid_token_tenant",
10505
+ message: "Identity token is valid, but it was issued for a different organization or installation than this box serves."
10506
+ }, 401);
10507
+ var rejectUntaught = (c) => c.json({
10508
+ code: "box_not_registered",
10509
+ message: "This box has not been told which installation it answers for, so it cannot accept any identity token yet. Register it from the control plane (or re-run `stackbone link`) and try again."
10510
+ }, 401);
10473
10511
  var BEARER_RE = /^Bearer\s+(.+)$/i;
10474
10512
  var extractBearer = (header) => {
10475
10513
  if (!header) return null;
@@ -10490,9 +10528,12 @@ var extractIdentity = (payload) => {
10490
10528
  };
10491
10529
  /**
10492
10530
  * Build the identity-token verifier ONCE, so the JWKS resolver (and its in-memory
10493
- * cache) lives for the process instead of being rebuilt per request. Returns the
10494
- * verified identity, or `null` for every failure mode — missing, malformed,
10495
- * expired, wrong key, wrong issuer/audience, or minted for another tenant.
10531
+ * cache) lives for the process instead of being rebuilt per request.
10532
+ *
10533
+ * Signature + expiry + issuer/audience are verified BEFORE any tenant comparison,
10534
+ * so a token that failed verification is never classified from claims nobody
10535
+ * proved: an attacker cannot mint an unsigned token with a foreign `org` and have
10536
+ * the box answer "wrong tenant", which would confirm the box does not serve it.
10496
10537
  */
10497
10538
  var createIdentityTokenVerifier = (options) => {
10498
10539
  const getKey = options.createJwks?.() ?? createRemoteJWKSet(new URL(options.jwksUrl), {
@@ -10501,7 +10542,10 @@ var createIdentityTokenVerifier = (options) => {
10501
10542
  });
10502
10543
  return async (c) => {
10503
10544
  const token = extractBearer(c.req.header("authorization"));
10504
- if (!token) return null;
10545
+ if (!token) return {
10546
+ ok: false,
10547
+ reason: "absent"
10548
+ };
10505
10549
  let payload;
10506
10550
  try {
10507
10551
  ({payload} = await jwtVerify(token, getKey, {
@@ -10509,13 +10553,41 @@ var createIdentityTokenVerifier = (options) => {
10509
10553
  audience: options.audience
10510
10554
  }));
10511
10555
  } catch {
10512
- return null;
10556
+ return {
10557
+ ok: false,
10558
+ reason: "unverifiable"
10559
+ };
10513
10560
  }
10514
10561
  const identity = extractIdentity(payload);
10515
- if (!identity) return null;
10516
- if (identity.org !== options.expectedOrg) return null;
10517
- if (identity.install !== void 0 && identity.install !== options.expectedInstall) return null;
10518
- return identity;
10562
+ if (!identity) return {
10563
+ ok: false,
10564
+ reason: "unverifiable"
10565
+ };
10566
+ if (identity.org === void 0) return {
10567
+ ok: false,
10568
+ reason: "unverifiable"
10569
+ };
10570
+ const answersFor = await options.answersFor();
10571
+ if (!answersFor) return {
10572
+ ok: false,
10573
+ reason: "untaught"
10574
+ };
10575
+ if (identity.org !== answersFor.organizationId) return {
10576
+ ok: false,
10577
+ reason: "wrong_tenant"
10578
+ };
10579
+ if (identity.install === void 0) return {
10580
+ ok: false,
10581
+ reason: "unverifiable"
10582
+ };
10583
+ if (identity.install !== answersFor.installationId) return {
10584
+ ok: false,
10585
+ reason: "wrong_tenant"
10586
+ };
10587
+ return {
10588
+ ok: true,
10589
+ identity
10590
+ };
10519
10591
  };
10520
10592
  };
10521
10593
  /**
@@ -10549,9 +10621,12 @@ var createBoxAuthGate = (options) => {
10549
10621
  const proxySecret = options.proxyAuth?.secret;
10550
10622
  const verifyIdentityToken = options.jwtAuth ? createIdentityTokenVerifier(options.jwtAuth) : void 0;
10551
10623
  if (!proxySecret && !verifyIdentityToken) return async (_c, next) => next();
10552
- const reject = (c) => {
10553
- if (verifyIdentityToken && extractBearer(c.req.header("authorization")) || !proxySecret) return rejectToken(c);
10554
- return rejectSignature(c);
10624
+ const reject = (c, check) => {
10625
+ const reason = check?.ok === false ? check.reason : void 0;
10626
+ if (reason === "untaught") return rejectUntaught(c);
10627
+ if (reason === "wrong_tenant") return rejectTokenTenant(c);
10628
+ if (reason === "unverifiable") return rejectToken(c);
10629
+ return proxySecret ? rejectSignature(c) : rejectToken(c);
10555
10630
  };
10556
10631
  return async (c, next) => {
10557
10632
  if (c.req.method === "OPTIONS") return next();
@@ -10560,23 +10635,21 @@ var createBoxAuthGate = (options) => {
10560
10635
  c.set(AUTHENTICATED_CALLER_VAR, { provenance: "proxy" });
10561
10636
  return next();
10562
10637
  }
10563
- if (verifyIdentityToken) {
10564
- const identity = await verifyIdentityToken(c);
10565
- if (identity) {
10566
- c.set(AUTHENTICATED_CALLER_VAR, {
10567
- provenance: "identity",
10568
- ...identity
10569
- });
10570
- return next();
10571
- }
10638
+ const check = await verifyIdentityToken?.(c);
10639
+ if (check?.ok) {
10640
+ c.set(AUTHENTICATED_CALLER_VAR, {
10641
+ provenance: "identity",
10642
+ ...check.identity
10643
+ });
10644
+ return next();
10572
10645
  }
10573
- return reject(c);
10646
+ return reject(c, check);
10574
10647
  };
10575
10648
  };
10576
10649
  //#endregion
10577
10650
  //#region ../../libs/runtime/workspace-runtime/src/lib/server/contract.ts
10578
10651
  init_src$13();
10579
- var STACKBONE_CLI_BUILD_VERSION = "0.3.1";
10652
+ var STACKBONE_CLI_BUILD_VERSION = "0.3.3";
10580
10653
  /**
10581
10654
  * Capabilities the local emulator advertises on `GET /api/contract`, derived
10582
10655
  * from the route registry above so the advertised set and the mounted routes
@@ -10597,7 +10670,7 @@ var EMULATOR_CAPABILITIES = Object.keys({
10597
10670
  "browser.provider": "/api/browser-provider"
10598
10671
  });
10599
10672
  var buildContractResponse = () => contractResponseSchema.parse({
10600
- version: 14,
10673
+ version: 15,
10601
10674
  minSupported: 1,
10602
10675
  capabilities: [...EMULATOR_CAPABILITIES],
10603
10676
  build: {
@@ -10975,7 +11048,7 @@ async function runLogTail(stream, filters, startTailer) {
10975
11048
  }
10976
11049
  //#endregion
10977
11050
  //#region ../../libs/runtime/workspace-runtime/src/lib/handlers/actor.ts
10978
- init_src$12();
11051
+ init_src$11();
10979
11052
  function readActorUserId(c) {
10980
11053
  const verified = readVerifiedIdentity(c);
10981
11054
  if (verified?.userId) return verified.userId;
@@ -11036,7 +11109,7 @@ var requireCapability = (capability) => {
11036
11109
  };
11037
11110
  //#endregion
11038
11111
  //#region ../../libs/runtime/workspace-runtime/src/lib/handlers/prompts-handlers.ts
11039
- init_src$11();
11112
+ init_src$10();
11040
11113
  init_src$13();
11041
11114
  var PG_UNIQUE_VIOLATION$1 = "23505";
11042
11115
  var withClient$3 = async (opts, fn) => {
@@ -11327,8 +11400,8 @@ var createPromptsRouter = (opts) => {
11327
11400
  };
11328
11401
  //#endregion
11329
11402
  //#region ../../libs/runtime/workspace-runtime/src/lib/services/storage.ts
11330
- init_src$10();
11331
11403
  init_src$9();
11404
+ init_src$12();
11332
11405
  var ObjectNotFoundError = class extends Error {
11333
11406
  constructor(message = "object_not_found") {
11334
11407
  super(message);
@@ -11339,7 +11412,8 @@ var buildClient = (options) => createS3CompatClient({
11339
11412
  endpoint: options.endpoint,
11340
11413
  region: options.region || "us-east-1",
11341
11414
  accessKeyId: options.accessKey,
11342
- secretAccessKey: options.secretKey
11415
+ secretAccessKey: options.secretKey,
11416
+ ...options.forcePathStyle === void 0 ? {} : { forcePathStyle: options.forcePathStyle }
11343
11417
  });
11344
11418
  var listBuckets$1 = async (options) => ({ items: [{
11345
11419
  name: options.bucket,
@@ -11595,7 +11669,7 @@ var createStorageRouter = (storageOptions) => {
11595
11669
  };
11596
11670
  //#endregion
11597
11671
  //#region ../../libs/runtime/workspace-runtime/src/lib/services/secrets.ts
11598
- init_src$12();
11672
+ init_src$11();
11599
11673
  init_src$13();
11600
11674
  var StudioSecretNotFoundError = class extends Error {
11601
11675
  constructor() {
@@ -24816,7 +24890,7 @@ var init_resume_interrupt = __esmMin((() => {
24816
24890
  }));
24817
24891
  //#endregion
24818
24892
  //#region ../../libs/runtime/workspace-runtime/src/lib/evals/eval-approval-answer.ts
24819
- init_src$12();
24893
+ init_src$11();
24820
24894
  init_resume_interrupt();
24821
24895
  init_guardrail_park();
24822
24896
  /** The route the box records a decision on. */
@@ -27185,7 +27259,7 @@ var createSessionsRouter = (deps) => {
27185
27259
  };
27186
27260
  //#endregion
27187
27261
  //#region ../../libs/runtime/workspace-runtime/src/lib/handlers/approval-callback.ts
27188
- init_src$12();
27262
+ init_src$11();
27189
27263
  init_src$13();
27190
27264
  var buildCallbackPayload = (approval, decision) => ({
27191
27265
  status: decision.decision === "approve" ? "approved" : "rejected",
@@ -28293,7 +28367,7 @@ var mapRunToRagJob = (run) => ({
28293
28367
  });
28294
28368
  //#endregion
28295
28369
  //#region ../../libs/runtime/workspace-runtime/src/lib/handlers/rag-handlers.ts
28296
- init_src$10();
28370
+ init_src$9();
28297
28371
  init_src$13();
28298
28372
  var RAG_DIMENSIONS$1 = 1536;
28299
28373
  var RAG_DISTANCE$1 = "cosine";
@@ -28571,7 +28645,7 @@ var init_readiness$1 = __esmMin((() => {
28571
28645
  }));
28572
28646
  //#endregion
28573
28647
  //#region ../../libs/runtime/workspace-runtime/src/lib/handlers/rag-write-handlers.ts
28574
- init_src$10();
28648
+ init_src$9();
28575
28649
  init_readiness$1();
28576
28650
  var createMinioRagWriteStorage = (storage) => ({
28577
28651
  putManagedObject: async (key, body, contentType) => {
@@ -28837,7 +28911,7 @@ var basename$1 = (key) => {
28837
28911
  //#endregion
28838
28912
  //#region ../../libs/runtime/workspace-runtime/src/lib/handlers/rag-router.ts
28839
28913
  init_src$13();
28840
- init_src$10();
28914
+ init_src$9();
28841
28915
  /**
28842
28916
  * Builds the Hono router mounted at `/api/rag` by the emulator server. Studio RAG
28843
28917
  * explorer + managed WRITE surface (V9 + feature 30, F76/F5). Reads/writes the
@@ -32393,7 +32467,7 @@ var init_src$1 = __esmMin((() => {
32393
32467
  init_token_cache_key();
32394
32468
  init_get_token();
32395
32469
  init_src$3();
32396
- init_src$12();
32470
+ init_src$11();
32397
32471
  init_schema();
32398
32472
  init_grant_refresh_ports();
32399
32473
  init_grant_refresh_runtime();
@@ -32459,7 +32533,7 @@ function errorMessage$1(err) {
32459
32533
  }
32460
32534
  //#endregion
32461
32535
  //#region ../../libs/runtime/workspace-runtime/src/lib/handlers/connect-handlers.ts
32462
- init_src$12();
32536
+ init_src$11();
32463
32537
  init_src$1();
32464
32538
  init_src$3();
32465
32539
  init_src$2();
@@ -40152,7 +40226,7 @@ var init_src = __esmMin((() => {
40152
40226
  //#region ../../libs/runtime/workspace-runtime/src/lib/catalog/system-entries.ts
40153
40227
  var RESERVED_ROLES, isSystemEntry;
40154
40228
  var init_system_entries = __esmMin((() => {
40155
- init_src$10();
40229
+ init_src$9();
40156
40230
  init_src();
40157
40231
  RESERVED_ROLES = {
40158
40232
  agent: new Set([RAG_INGEST_ROLE]),
@@ -40738,8 +40812,7 @@ var redisConnection = (redisUrl) => {
40738
40812
  const db = path === "" ? void 0 : Number(path);
40739
40813
  if (db !== void 0 && !Number.isInteger(db)) throw new Error(`Invalid Redis database in URL path: '${url.pathname}' (expected an integer)`);
40740
40814
  return {
40741
- host: url.hostname,
40742
- port: url.port ? Number(url.port) : 6379,
40815
+ url: redisUrl,
40743
40816
  ...db === void 0 ? {} : { db },
40744
40817
  maxRetriesPerRequest: null
40745
40818
  };
@@ -41152,6 +41225,7 @@ var init_connections = __esmMin((() => {
41152
41225
  s3SecretKey: "STACKBONE_S3_SECRET_KEY",
41153
41226
  s3Bucket: "STACKBONE_S3_BUCKET",
41154
41227
  s3Region: "STACKBONE_S3_REGION",
41228
+ s3ForcePathStyle: "STACKBONE_S3_FORCE_PATH_STYLE",
41155
41229
  agentConfig: "STACKBONE_AGENT_CONFIG",
41156
41230
  port: "PORT",
41157
41231
  mode: "STACKBONE_MODE",
@@ -41160,7 +41234,7 @@ var init_connections = __esmMin((() => {
41160
41234
  buildAgentEnv = (base, injection, options) => {
41161
41235
  const env = {
41162
41236
  ...base,
41163
- [STACKBONE_ENV_KEYS.contractVersion]: String(14),
41237
+ [STACKBONE_ENV_KEYS.contractVersion]: String(15),
41164
41238
  [STACKBONE_ENV_KEYS.agentId]: injection.agentId,
41165
41239
  [STACKBONE_ENV_KEYS.postgresUrl]: injection.services.postgresUrl,
41166
41240
  [STACKBONE_ENV_KEYS.s3Endpoint]: injection.services.s3Endpoint,
@@ -41168,6 +41242,7 @@ var init_connections = __esmMin((() => {
41168
41242
  [STACKBONE_ENV_KEYS.s3SecretKey]: injection.services.s3SecretKey,
41169
41243
  [STACKBONE_ENV_KEYS.s3Bucket]: injection.services.s3Bucket,
41170
41244
  [STACKBONE_ENV_KEYS.s3Region]: injection.services.s3Region,
41245
+ [STACKBONE_ENV_KEYS.s3ForcePathStyle]: String(injection.services.s3ForcePathStyle),
41171
41246
  [STACKBONE_ENV_KEYS.port]: String(injection.agentPort),
41172
41247
  [STACKBONE_ENV_KEYS.mode]: options.mode,
41173
41248
  [STACKBONE_ENV_KEYS.secretKey]: options.secretKey
@@ -42777,7 +42852,7 @@ async function failApprovalHookStep(params) {
42777
42852
  var deepAgentBridgeLogger, deriveWorkflowSteps, degradedReasonFor, resolveWorkflowRefs, applySdkContractEnv, extractWorldRunIdFromFlowBody, loadProjectRuntime, mountWorkflowRuntime;
42778
42853
  var init_runtime_mount = __esmMin((() => {
42779
42854
  init_connections();
42780
- init_src$10();
42855
+ init_src$9();
42781
42856
  init_src();
42782
42857
  init_registry$1();
42783
42858
  init_readiness$1();
@@ -44912,7 +44987,8 @@ var init_mount = __esmMin((() => {
44912
44987
  [STACKBONE_ENV_KEYS.s3AccessKey, services.s3AccessKey],
44913
44988
  [STACKBONE_ENV_KEYS.s3SecretKey, services.s3SecretKey],
44914
44989
  [STACKBONE_ENV_KEYS.s3Bucket, services.s3Bucket],
44915
- [STACKBONE_ENV_KEYS.s3Region, services.s3Region]
44990
+ [STACKBONE_ENV_KEYS.s3Region, services.s3Region],
44991
+ [STACKBONE_ENV_KEYS.s3ForcePathStyle, String(services.s3ForcePathStyle)]
44916
44992
  ];
44917
44993
  const seeded = [];
44918
44994
  for (const [name, value] of wanted) if (env[name] === void 0 || env[name] === "") {
@@ -44999,8 +45075,8 @@ var init_mount = __esmMin((() => {
44999
45075
  //#endregion
45000
45076
  //#region ../../libs/runtime/workspace-runtime/src/lib/server/emulator-server.ts
45001
45077
  init_src$13();
45002
- init_src$12();
45003
- init_src$10();
45078
+ init_src$11();
45079
+ init_src$9();
45004
45080
  init_workflow_guardrails();
45005
45081
  init_src$1();
45006
45082
  init_recurring_jobs_registry();
@@ -45021,6 +45097,28 @@ init_well_known();
45021
45097
  * agents run IN-PROCESS behind it, not on a port of their own.
45022
45098
  */
45023
45099
  var DEV_INSTALLATION_ID = "local-dev";
45100
+ /**
45101
+ * STA-594 — the teach call's body: the control plane's installation id and the
45102
+ * organization it belongs to. `null` when the body is not that, so a signed call
45103
+ * with a mistyped field is refused loudly instead of recording an empty identity
45104
+ * the box would then answer for.
45105
+ */
45106
+ var readControlPlaneIdentityBody = (raw) => {
45107
+ let parsed;
45108
+ try {
45109
+ parsed = JSON.parse(raw);
45110
+ } catch {
45111
+ return null;
45112
+ }
45113
+ if (!parsed || typeof parsed !== "object") return null;
45114
+ const { installationId, organizationId } = parsed;
45115
+ if (typeof installationId !== "string" || installationId.trim() === "") return null;
45116
+ if (typeof organizationId !== "string" || organizationId.trim() === "") return null;
45117
+ return {
45118
+ installationId,
45119
+ organizationId
45120
+ };
45121
+ };
45024
45122
  var buildEmulatorApp = (opts, hooks = {}) => {
45025
45123
  const app = new Hono();
45026
45124
  const agentBaseUrl = `http://127.0.0.1:${opts.injection.agentPort}`;
@@ -45043,7 +45141,8 @@ var buildEmulatorApp = (opts, hooks = {}) => {
45043
45141
  "x-stackbone-session",
45044
45142
  "x-stackbone-conversation",
45045
45143
  "x-api-key",
45046
- "anthropic-version"
45144
+ "anthropic-version",
45145
+ STACKBONE_SIGNATURE_HEADER
45047
45146
  ],
45048
45147
  exposeHeaders: ["X-Stackbone-Run-Id"]
45049
45148
  }));
@@ -45053,7 +45152,7 @@ var buildEmulatorApp = (opts, hooks = {}) => {
45053
45152
  }));
45054
45153
  if (opts.proxyChallenge?.secret) {
45055
45154
  const challengeSecret = opts.proxyChallenge.secret;
45056
- app.get(PROXY_CHALLENGE_PATH, (c) => {
45155
+ app.get(PROXY_CHALLENGE_PATH, async (c) => {
45057
45156
  const url = new URL(c.req.url);
45058
45157
  const ok = verifyStackboneSignature({
45059
45158
  secret: challengeSecret,
@@ -45066,10 +45165,43 @@ var buildEmulatorApp = (opts, hooks = {}) => {
45066
45165
  });
45067
45166
  const body = {
45068
45167
  ok,
45069
- identityVerification: opts.jwtAuth ? "armed" : "unarmed"
45168
+ identityVerification: !opts.jwtAuth ? "unarmed" : await opts.jwtAuth.answersFor() ? "armed" : "untaught"
45070
45169
  };
45071
45170
  return c.json(body, ok ? 200 : 401);
45072
45171
  });
45172
+ const learn = opts.learnControlPlaneIdentity;
45173
+ if (learn) app.post(CONTROL_PLANE_IDENTITY_PATH, async (c) => {
45174
+ const url = new URL(c.req.url);
45175
+ const raw = await c.req.raw.clone().text();
45176
+ if (!verifyStackboneSignature({
45177
+ secret: challengeSecret,
45178
+ header: c.req.header("stackbone-signature"),
45179
+ payload: proxyRequestPayload({
45180
+ method: "POST",
45181
+ path: url.pathname + url.search,
45182
+ body: raw
45183
+ })
45184
+ })) return c.json({
45185
+ ok: false,
45186
+ code: "invalid_signature"
45187
+ }, 401);
45188
+ const learned = readControlPlaneIdentityBody(raw);
45189
+ if (!learned) return c.json({
45190
+ ok: false,
45191
+ code: "invalid_body",
45192
+ message: "Expected a JSON body naming installationId and organizationId, both non-empty strings."
45193
+ }, 400);
45194
+ try {
45195
+ await learn(learned);
45196
+ } catch (err) {
45197
+ return c.json({
45198
+ ok: false,
45199
+ code: "not_recorded",
45200
+ message: err instanceof Error ? err.message : String(err)
45201
+ }, 500);
45202
+ }
45203
+ return c.json({ ok: true });
45204
+ });
45073
45205
  }
45074
45206
  app.get("/api/contract", (c) => c.json(buildContractResponse()));
45075
45207
  app.get("/", (c) => c.html(renderStudioStub({
@@ -45175,6 +45307,7 @@ var buildEmulatorApp = (opts, hooks = {}) => {
45175
45307
  secretKey: opts.injection.services.s3SecretKey,
45176
45308
  bucket: opts.injection.services.s3Bucket,
45177
45309
  region: opts.injection.services.s3Region,
45310
+ forcePathStyle: opts.injection.services.s3ForcePathStyle,
45178
45311
  agentId: opts.agent.slug
45179
45312
  };
45180
45313
  app.route("/api/storage", createStorageRouter(storageOptions));
@@ -45194,12 +45327,12 @@ var buildEmulatorApp = (opts, hooks = {}) => {
45194
45327
  invocations: opts.invocations,
45195
45328
  ...hooks.resolveRagIngestTrigger ? { resolveRagIngestTrigger: hooks.resolveRagIngestTrigger } : {}
45196
45329
  }));
45197
- const secretsOptions = { connectionString: opts.injection.services.postgresUrl };
45198
- app.route("/api/secrets", createSecretsRouter(secretsOptions));
45199
- const privilegedSecretsOptions = {
45330
+ const secretsOptions = {
45200
45331
  connectionString: opts.injection.services.postgresUrl,
45201
45332
  ...opts.secretKey ? { cipherKeyBase64: opts.secretKey } : {}
45202
45333
  };
45334
+ app.route("/api/secrets", createSecretsRouter(secretsOptions));
45335
+ const privilegedSecretsOptions = secretsOptions;
45203
45336
  app.route("/api/model-provider", createModelProviderRouter(privilegedSecretsOptions, { ...opts.bootEnv ?? {} }, { ...hooks.retryDegradedDeepAgents ? { onProviderSaved: hooks.retryDegradedDeepAgents } : {} }));
45204
45337
  app.route("/api/browser-provider", createBrowserProviderRouter(privilegedSecretsOptions));
45205
45338
  app.route("/api/browser-profiles", createBrowserProfilesRouter({ connectionString: opts.injection.services.postgresUrl }));
@@ -45916,7 +46049,8 @@ var buildDevServices = (ports) => ({
45916
46049
  s3AccessKey: DEV_SERVICE_CREDENTIALS.s3AccessKey,
45917
46050
  s3SecretKey: DEV_SERVICE_CREDENTIALS.s3SecretKey,
45918
46051
  s3Bucket: DEV_SERVICE_CREDENTIALS.s3Bucket,
45919
- s3Region: DEV_SERVICE_CREDENTIALS.s3Region
46052
+ s3Region: DEV_SERVICE_CREDENTIALS.s3Region,
46053
+ s3ForcePathStyle: true
45920
46054
  });
45921
46055
  var buildDevRedisUrl = (redisPort) => `redis://127.0.0.1:${redisPort}`;
45922
46056
  var DEV_AGENT_SECRET_KEY = createHash("sha256").update(STUDIO_LOCAL_DEV_ENCRYPTION_KEY).digest("base64");
@@ -46229,7 +46363,7 @@ var calls_workflow_ts_default = "import { z } from \"zod\";\nimport { streamDeep
46229
46363
  var plain_workflow_ts_default = "import { z } from \"zod\";\n\n/**\n * Workflow \"{{name}}\" — a durable pipeline.\n *\n * Each `\"use step\"` is a durable checkpoint: kill the runtime mid-run and it\n * resumes from the last completed step instead of replaying everything.\n */\n\n// THE contract — sibling exports next to the bare workflow function. The build\n// harvests these (by convention) into the manifest + a live validator. The\n// parameter type is DERIVED from `inputSchema` (`z.infer`), so there is no\n// second type that drifts.\nexport const inputSchema = z.object({\n message: z.string().describe(\"The input to process\"),\n});\n\nexport const outputSchema = z.object({\n message: z.string(),\n processed: z.boolean(),\n});\n\ntype {{Name}}Input = z.infer<typeof inputSchema>;\n\nexport async function {{export}}(input: {{Name}}Input) {\n \"use workflow\";\n\n const result = await processMessage(input); // step 1 — durable checkpoint\n return result;\n}\n\nasync function processMessage(input: {{Name}}Input) {\n \"use step\";\n // Replace this with the deterministic work of your step (DB write, HTTP call,\n // an LLM call, …). Returning a value records it as the step's checkpoint.\n return { message: input.message, processed: true };\n}\n";
46230
46364
  //#endregion
46231
46365
  //#region src/services/scaffold/template-loader.ts
46232
- var SDK_TEMPLATE_VERSION = "0.3.0";
46366
+ var SDK_TEMPLATE_VERSION = "0.3.1";
46233
46367
  var PREFIX = "./templates/";
46234
46368
  var templates = new Map(Object.entries(/* @__PURE__ */ Object.assign({
46235
46369
  "./templates/deep-agents/index.ts.tmpl": index_ts_default,
@@ -46515,10 +46649,10 @@ var createIdentityTokenProvider = (opts) => {
46515
46649
  let token = null;
46516
46650
  return {
46517
46651
  current: () => token,
46518
- refresh: async () => {
46652
+ refresh: async (target) => {
46519
46653
  let minted;
46520
46654
  try {
46521
- minted = await opts.mint();
46655
+ minted = await opts.mint(target);
46522
46656
  } catch {
46523
46657
  minted = null;
46524
46658
  }
@@ -46530,34 +46664,28 @@ var createIdentityTokenProvider = (opts) => {
46530
46664
  //#endregion
46531
46665
  //#region src/services/identity-token.service.ts
46532
46666
  /** Route the control plane mints the box-facing identity JWT on (better-auth jwt plugin). */
46533
- var IDENTITY_TOKEN_PATH = "/api/auth/token";
46667
+ var IDENTITY_TOKEN_PATH = "/api/auth/token/organization";
46534
46668
  /**
46535
46669
  * Builds the provider for `ctx`, which MUST be the control-plane context — the
46536
46670
  * token is minted by the control plane, never by the box it is presented to.
46537
- */
46538
- var createIdentityTokenService = (ctx) => createIdentityTokenProvider({ mint: async () => {
46539
- return (await ctx.http.get(IDENTITY_TOKEN_PATH))?.token;
46540
- } });
46541
- /**
46542
- * The organization id a minted identity token carries, or `null` when it carries
46543
- * none (or is not a readable JWT).
46544
46671
  *
46545
- * Read WITHOUT verifying, on purpose: the signature is the box's business, not
46546
- * ours. All the CLI needs is which tenant the control plane minted for, so it can
46547
- * refuse to address a box that serves a different one instead of sending a token
46548
- * the box will reject. `/api/auth/token` is session-scoped and mints for the
46549
- * caller's earliest-joined organization, so an operator who belongs to several
46550
- * gets a token for one of them and 401s on the rest.
46551
- */
46552
- var identityTokenOrganizationId = (token) => {
46553
- const payload = token.split(".")[1];
46554
- if (!payload) return null;
46555
- try {
46556
- const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
46557
- return typeof claims.org === "string" && claims.org.length > 0 ? claims.org : null;
46558
- } catch {
46559
- return null;
46560
- }
46672
+ * `organization` is the slug of the organization the target box runs in, and
46673
+ * `installation` is the control plane's id for that box. A caller with no
46674
+ * membership there, or naming a box that is not one of that organization's
46675
+ * installations, gets no token: the control plane refuses, `refresh()` resolves
46676
+ * to `null`, and nothing is substituted.
46677
+ */
46678
+ var createIdentityTokenService = (ctx, organization, installation) => {
46679
+ const provider = createIdentityTokenProvider({ mint: async (target) => {
46680
+ return (await ctx.http.get(`${IDENTITY_TOKEN_PATH}?org=${encodeURIComponent(target.organization)}&install=${encodeURIComponent(target.installation)}`))?.token;
46681
+ } });
46682
+ return {
46683
+ current: () => provider.current(),
46684
+ refresh: () => provider.refresh({
46685
+ organization,
46686
+ installation
46687
+ })
46688
+ };
46561
46689
  };
46562
46690
  //#endregion
46563
46691
  //#region src/services/organization.service.ts
@@ -46714,28 +46842,6 @@ var directoryLookupFailed = (installationId, cause) => {
46714
46842
  });
46715
46843
  };
46716
46844
  /**
46717
- * Refuse a box that serves a DIFFERENT organization than the one the minted
46718
- * identity token names, before a single call is addressed to it.
46719
- *
46720
- * `GET /api/auth/token` is session-scoped, not organization-scoped: it mints one
46721
- * `org` claim per account, the earliest-joined membership. The box hard-rejects a
46722
- * token whose `org` is not its own tenant, so for an operator who belongs to more
46723
- * than one organization every box outside that first one answers 401 — and the
46724
- * retry cannot help, because the re-mint asks the same endpoint and gets the same
46725
- * token back. Left alone the operator reads "Run `stackbone login`", a remedy
46726
- * that can never work.
46727
- *
46728
- * Only a DEFINITE mismatch refuses. A token with no `org` claim (or one this
46729
- * build cannot read) falls through to the box's own answer rather than inventing
46730
- * a second verdict about a shape we do not understand.
46731
- */
46732
- var assertIdentityServesOrg = async (ctx, token, org, installationId) => {
46733
- const mintedFor = identityTokenOrganizationId(token);
46734
- if (mintedFor === null || mintedFor === org.id) return;
46735
- const owner = (await getMyOrganizations(ctx).catch(() => [])).find((o) => o.id === mintedFor);
46736
- throwAuth(`Agent ${installationId} belongs to organization "${org.slug}", but your identity credential is minted for ${owner ? `"${owner.slug}"` : "another organization"}, and the agent's box refuses a credential from anywhere else.`, "Stackbone mints one identity credential per account, for the organization you joined first. Reaching a box in another organization is not supported yet.");
46737
- };
46738
- /**
46739
46845
  * Resolves the full studio routing context a surface verb needs: the
46740
46846
  * installation it targets (`resolveAgentTarget`) plus the datapath (the
46741
46847
  * customer's own box vs the running local emulator) and the effective
@@ -46827,10 +46933,8 @@ var resolveStudioTarget = async (ctx, flagValue, deps = {}) => {
46827
46933
  throw directoryLookupFailed(installationId, err);
46828
46934
  }
46829
46935
  if (!runtimeUrl) return throwRuntimeNotConfigured(installationId);
46830
- const identity = makeIdentity(ctx);
46831
- const token = await identity.refresh();
46832
- if (!token) return throwAuth(`Agent ${installationId} is registered, but this session could not mint the credential it requires.`, "Run `stackbone login` to refresh your session, then retry.");
46833
- await assertIdentityServesOrg(ctx, token, org, installationId);
46936
+ const identity = makeIdentity(ctx, org.slug, installationId);
46937
+ if (!await identity.refresh()) return throwAuth(`Agent ${installationId} is registered in organization "${org.slug}", but the control plane did not return the credential its box requires for that organization.`, `Retry — if the control plane is unreachable or erroring, that is the likeliest cause and nothing here needs fixing. If it keeps failing, run \`stackbone login\` to refresh your session; and if that does not help either, check you are still a member of "${org.slug}" with \`stackbone organization list\`.`);
46834
46938
  const http = makeAdapter({
46835
46939
  apiUrl: runtimeUrl,
46836
46940
  tokenStore: ctx.tokenStore,
@@ -47799,10 +47903,19 @@ var buildWorkspaceBundle = async (options) => {
47799
47903
  migrationsDir: options.migrationsDir,
47800
47904
  outDir
47801
47905
  });
47906
+ const hasConfigSchema = options.hasConfigSchema ?? await exists$1(resolve(projectRoot, "config.schema.ts"));
47907
+ if (hasConfigSchema) {
47908
+ const configSchema = await options.resolveConfigSchema?.() ?? null;
47909
+ if (configSchema) {
47910
+ const artifactPath = resolve(outDir, CONFIG_SCHEMA_ARTIFACT_PATH);
47911
+ await mkdir(dirname(artifactPath), { recursive: true });
47912
+ await writeFile(artifactPath, JSON.stringify(configSchema), "utf8");
47913
+ } else logger?.warn(`This workspace declares ${CONFIG_SCHEMA_SOURCE}, but no config JSON Schema was produced for the bundle. Studio's config editor will fall back to a raw JSON field and writes will not be validated.`);
47914
+ }
47802
47915
  const manifest = deriveWorkspaceManifest({
47803
47916
  workspace,
47804
47917
  workflowManifest,
47805
- hasConfigSchema: options.hasConfigSchema ?? await exists$1(resolve(projectRoot, "config.schema.ts")),
47918
+ hasConfigSchema,
47806
47919
  defaults: options.defaults,
47807
47920
  deepAgentExternals
47808
47921
  });
@@ -47864,6 +47977,181 @@ var resolveAgentDbConfig = (agentRoot) => {
47864
47977
  };
47865
47978
  };
47866
47979
  //#endregion
47980
+ //#region src/dev/extract-config-schema.ts
47981
+ /** Directory the extracted artifact lives in, relative to the workspace root.
47982
+ * A subdir of its own, parallel to the workflow well-known tree. */
47983
+ var CONFIG_WELL_KNOWN_DIR = ".well-known/config/v1";
47984
+ /** The shipped artifact: the single JSON Schema (JSON only, no live sidecar). */
47985
+ var CONFIG_SCHEMA_FILE = "config-schema.json";
47986
+ /** The intermediate esbuild bundle (harmless to leave behind, like the workflow
47987
+ * sidecar). NOT the deliverable — `config-schema.json` is. */
47988
+ var CONFIG_SCHEMA_BUNDLE = "config-schema.mjs";
47989
+ /**
47990
+ * Extract the workspace's single config JSON Schema. Defensive by contract: an
47991
+ * absent `config.schema.ts` degrades to `null` SILENTLY (the editor stays
47992
+ * free-form); a DECLARED-but-inconvertible schema degrades to `null` plus one
47993
+ * noisy warning; and a total bundle failure degrades to `null` plus one coarse
47994
+ * warning. It NEVER throws — a broken config schema must not break the dev build.
47995
+ */
47996
+ var extractConfigSchema = async (projectRoot, opts = {}) => {
47997
+ const loader = opts.loader ?? defaultConfigSchemaLoader;
47998
+ const warnings = [];
47999
+ const emit = (msg) => {
48000
+ warnings.push(msg);
48001
+ opts.logger?.warn(msg);
48002
+ };
48003
+ let loaded;
48004
+ try {
48005
+ loaded = await loader(projectRoot);
48006
+ } catch (err) {
48007
+ emit(`[config-schema] config schema extraction bundle failed; no config schema is available — the Config editor will fall back to a raw JSON field. Cause: ${errMessage$2(err)}`);
48008
+ return {
48009
+ schema: null,
48010
+ warnings
48011
+ };
48012
+ }
48013
+ if (loaded.configSchema == null) return {
48014
+ schema: null,
48015
+ warnings
48016
+ };
48017
+ try {
48018
+ return {
48019
+ schema: loaded.toJsonSchema(loaded.configSchema),
48020
+ warnings
48021
+ };
48022
+ } catch (err) {
48023
+ emit(`[config-schema] config.schema.ts could not be converted to JSON Schema — the Config editor will fall back to a raw JSON field. Cause: ${errMessage$2(err)}`);
48024
+ return {
48025
+ schema: null,
48026
+ warnings
48027
+ };
48028
+ }
48029
+ };
48030
+ /**
48031
+ * Extract the config schema and WRITE the artifact. When the schema is non-null,
48032
+ * writes `<projectRoot>/.well-known/config/v1/config-schema.json` (compact JSON,
48033
+ * like `manifest.json`) and returns `written: true`. When the schema is null
48034
+ * (absent or inconvertible), REMOVES any stale `config-schema.json` so a deleted
48035
+ * or broken schema does not leave a phantom artifact, and returns `written:
48036
+ * false`. Never throws (mirrors `extractConfigSchema`).
48037
+ */
48038
+ var buildConfigSchema = async (projectRoot, opts = {}) => {
48039
+ const { schema, warnings } = await extractConfigSchema(projectRoot, opts);
48040
+ const outfile = resolve(projectRoot, CONFIG_WELL_KNOWN_DIR, CONFIG_SCHEMA_FILE);
48041
+ if (schema == null) {
48042
+ await rm(outfile, { force: true });
48043
+ return {
48044
+ schema: null,
48045
+ warnings,
48046
+ written: false
48047
+ };
48048
+ }
48049
+ await mkdir(resolve(projectRoot, CONFIG_WELL_KNOWN_DIR), { recursive: true });
48050
+ await writeFile(outfile, JSON.stringify(schema), "utf8");
48051
+ return {
48052
+ schema,
48053
+ warnings,
48054
+ written: true
48055
+ };
48056
+ };
48057
+ var errMessage$2 = (err) => err instanceof Error ? err.message : String(err);
48058
+ /**
48059
+ * The default loader: when `config.schema.ts` exists at the project root, emit an
48060
+ * entry that re-exports ONLY its `configSchema` (static member access for
48061
+ * tree-shaking), esbuild-bundle it to
48062
+ * `<projectRoot>/.well-known/config/v1/config-schema.mjs`, import it back, and
48063
+ * read `configSchema` + `toJsonSchema`. When the file is ABSENT, return
48064
+ * `{ configSchema: null, toJsonSchema: () => null }` WITHOUT bundling — an absent
48065
+ * schema is silent, not a warning.
48066
+ *
48067
+ * Resolved FROM THE PROJECT: esbuild + zod come from the workspace's own
48068
+ * node_modules (like `buildWorkflows` resolves `@workflow/builders`), so the
48069
+ * bundle uses the SAME zod the schema was authored with and the heavy esbuild
48070
+ * tree stays out of the published CLI bundle.
48071
+ */
48072
+ var defaultConfigSchemaLoader = async (projectRoot) => {
48073
+ try {
48074
+ await access(resolve(projectRoot, CONFIG_SCHEMA_SOURCE));
48075
+ } catch {
48076
+ return {
48077
+ configSchema: null,
48078
+ toJsonSchema: () => null
48079
+ };
48080
+ }
48081
+ const projectRequire = createRequire(resolve(projectRoot, "package.json"));
48082
+ let esbuild;
48083
+ try {
48084
+ esbuild = await import(pathToFileURL(projectRequire.resolve("esbuild")).href);
48085
+ } catch (err) {
48086
+ throw new Error(`Cannot resolve esbuild from the workspace (needed to extract the config schema). Cause: ${errMessage$2(err)}`);
48087
+ }
48088
+ const entry = buildEntrySource$1();
48089
+ const outfile = resolve(projectRoot, CONFIG_WELL_KNOWN_DIR, CONFIG_SCHEMA_BUNDLE);
48090
+ await esbuild.build({
48091
+ stdin: {
48092
+ contents: entry,
48093
+ resolveDir: projectRoot,
48094
+ sourcefile: "config-schema-entry.ts",
48095
+ loader: "ts"
48096
+ },
48097
+ bundle: true,
48098
+ packages: "external",
48099
+ format: "esm",
48100
+ platform: "node",
48101
+ target: "node22",
48102
+ treeShaking: true,
48103
+ outfile,
48104
+ logLevel: "silent"
48105
+ });
48106
+ const mod = await import(`${pathToFileURL(outfile).href}?v=${Date.now()}`);
48107
+ if (typeof mod.toJsonSchema !== "function") throw new Error(`config-schema.mjs did not export the expected { configSchema, toJsonSchema } shape.`);
48108
+ return {
48109
+ configSchema: mod.configSchema ?? null,
48110
+ toJsonSchema: mod.toJsonSchema
48111
+ };
48112
+ };
48113
+ /**
48114
+ * Generate the bundle entry source. It NAMESPACE-imports `config.schema.ts` and
48115
+ * reads `configSchema` with DIRECT static member access — so esbuild tree-shakes
48116
+ * everything else and a MISSING export is simply `undefined` (→ `null`), never a
48117
+ * bundle-breaking "no matching export".
48118
+ *
48119
+ * The converter is REPLICATED here (not imported from the workflow extractor): it
48120
+ * runs inside the emitted `.mjs` with the PROJECT's own zod. It is byte-for-byte
48121
+ * the workflow extractor's converter MINUS the `half` parameter — config is a
48122
+ * single input-shaped object, so the Standard-Schema fallback invokes the
48123
+ * `.input` converter directly.
48124
+ */
48125
+ var buildEntrySource$1 = () => {
48126
+ return [
48127
+ `import { z } from 'zod';`,
48128
+ `import * as cfg from './config.schema.ts';`,
48129
+ `// Non-Zod Standard Schema fallback. Per @standard-schema/spec, ~standard`,
48130
+ `// .jsonSchema is a Converter { input(opts), output(opts) } of FUNCTIONS — so`,
48131
+ `// we INVOKE the input converter to get the actual JSON Schema (config is an`,
48132
+ `// input-shaped object). Returning the Converter object verbatim folds`,
48133
+ `// non-serialisable functions that JSON.stringify drops → a phantom {} schema.`,
48134
+ `const std = (s) => {`,
48135
+ ` const c = s && s['~standard'] && s['~standard'].jsonSchema;`,
48136
+ ` const fn = c && c.input;`,
48137
+ ` return typeof fn === 'function' ? fn({ target: 'draft-2020-12' }) : null;`,
48138
+ `};`,
48139
+ `const carriesFns = (o) =>`,
48140
+ ` o != null && typeof o === 'object' && Object.values(o).some((v) => typeof v === 'function');`,
48141
+ `export const toJsonSchema = (s) => {`,
48142
+ ` if (s == null) return null;`,
48143
+ ` let out;`,
48144
+ ` try { out = z.toJSONSchema(s); }`,
48145
+ ` catch (e) { out = std(s); if (out == null) throw e; }`,
48146
+ ` // A result carrying functions is a non-serialisable phantom; degrade to`,
48147
+ ` // null + a noisy warning instead of writing garbage to the artifact.`,
48148
+ ` if (carriesFns(out)) throw new Error('schema is non-serialisable (carries functions)');`,
48149
+ ` return out;`,
48150
+ `};`,
48151
+ `export const configSchema = cfg.configSchema ?? null;`
48152
+ ].join("\n");
48153
+ };
48154
+ //#endregion
47867
48155
  //#region src/dev/detect-workflow-streaming.ts
47868
48156
  /** `getWritable` imported from the literal `'workflow'` package (single or
47869
48157
  * double quotes, other named imports around it allowed). */
@@ -47933,7 +48221,7 @@ var extractWorkflowSchemas = async (projectRoot, workflows, opts) => {
47933
48221
  schemas[w.name] = { ...NULL_PAIR };
47934
48222
  schedules[w.name] = [];
47935
48223
  }
47936
- emit(`[workflow-schema] schema extraction bundle failed; no workflow input/output schemas are available — Playgrounds will fall back to a raw JSON field. Cause: ${errMessage$2(err)}`);
48224
+ emit(`[workflow-schema] schema extraction bundle failed; no workflow input/output schemas are available — Playgrounds will fall back to a raw JSON field. Cause: ${errMessage$1(err)}`);
47937
48225
  return {
47938
48226
  schemas,
47939
48227
  schedules,
@@ -47996,11 +48284,11 @@ var convertHalf = (workflowName, half, schema, toJsonSchema, emit) => {
47996
48284
  try {
47997
48285
  return toJsonSchema(schema, half);
47998
48286
  } catch (err) {
47999
- emit(`[workflow-schema] workflow "${workflowName}" declares an ${half}Schema that could not be converted to JSON Schema — the Playground will show a raw JSON field for it. Cause: ${errMessage$2(err)}`);
48287
+ emit(`[workflow-schema] workflow "${workflowName}" declares an ${half}Schema that could not be converted to JSON Schema — the Playground will show a raw JSON field for it. Cause: ${errMessage$1(err)}`);
48000
48288
  return null;
48001
48289
  }
48002
48290
  };
48003
- var errMessage$2 = (err) => err instanceof Error ? err.message : String(err);
48291
+ var errMessage$1 = (err) => err instanceof Error ? err.message : String(err);
48004
48292
  /**
48005
48293
  * The default loader: generate an entry that re-exports ONLY each workflow's
48006
48294
  * `inputSchema`/`outputSchema` (resolved from the workflow module or a
@@ -48022,9 +48310,9 @@ var defaultWorkflowSchemaLoader = async (projectRoot, workflows, outDir) => {
48022
48310
  try {
48023
48311
  esbuild = await import(pathToFileURL(projectRequire.resolve("esbuild")).href);
48024
48312
  } catch (err) {
48025
- throw new Error(`Cannot resolve esbuild from the workspace (needed to extract workflow schemas). Cause: ${errMessage$2(err)}`);
48313
+ throw new Error(`Cannot resolve esbuild from the workspace (needed to extract workflow schemas). Cause: ${errMessage$1(err)}`);
48026
48314
  }
48027
- const entry = await buildEntrySource$1(projectRoot, workflows);
48315
+ const entry = await buildEntrySource(projectRoot, workflows);
48028
48316
  const outfile = resolve(outDir, WORKFLOW_WELL_KNOWN_DIR, WORKFLOW_SCHEMAS_FILE);
48029
48317
  await esbuild.build({
48030
48318
  stdin: {
@@ -48059,7 +48347,7 @@ var defaultWorkflowSchemaLoader = async (projectRoot, workflows, outDir) => {
48059
48347
  * Schema source per workflow: a `<base>.contract.ts` sibling when it exists (the
48060
48348
  * clean variant — only zod, no closure), else the workflow module itself.
48061
48349
  */
48062
- var buildEntrySource$1 = async (projectRoot, workflows) => {
48350
+ var buildEntrySource = async (projectRoot, workflows) => {
48063
48351
  const lines = [
48064
48352
  `import { z } from 'zod';`,
48065
48353
  `// Non-Zod Standard Schema fallback. Per @standard-schema/spec, ~standard`,
@@ -48709,7 +48997,7 @@ var withEntry = (workflows, seed) => {
48709
48997
  export: seed.export
48710
48998
  }].sort((a, b) => a.name.localeCompare(b.name));
48711
48999
  };
48712
- var errMessage$1 = (err) => err instanceof Error ? err.message : String(err);
49000
+ var errMessage = (err) => err instanceof Error ? err.message : String(err);
48713
49001
  /**
48714
49002
  * Resolve both platform-managed workflows and return the workspace's workflow
48715
49003
  * list with them spliced in, ready for the compiler (which already knows how to
@@ -48725,14 +49013,14 @@ var seedManagedWorkflows = async (projectRoot, workflows, logger, deps = {}) =>
48725
49013
  logger.info(`rag-ingest workflow: using the ${seed.active === "default" ? "platform default" : "creator override"} (${seed.module})`);
48726
49014
  result = withEntry(result, seed);
48727
49015
  } catch (err) {
48728
- logger.warn({ err: errMessage$1(err) }, "failed to seed the default rag-ingest workflow — RAG upload ingest may be unavailable");
49016
+ logger.warn({ err: errMessage(err) }, "failed to seed the default rag-ingest workflow — RAG upload ingest may be unavailable");
48729
49017
  }
48730
49018
  try {
48731
49019
  const seed = await seedMappingSuggest(projectRoot);
48732
49020
  logger.info(`mapping-suggest workflow: using the ${seed.active === "default" ? "platform default" : "creator override"} (${seed.module})`);
48733
49021
  result = withEntry(result, seed);
48734
49022
  } catch (err) {
48735
- logger.warn({ err: errMessage$1(err) }, "failed to seed the default mapping-suggest workflow — Auto-map may be unavailable");
49023
+ logger.warn({ err: errMessage(err) }, "failed to seed the default mapping-suggest workflow — Auto-map may be unavailable");
48736
49024
  }
48737
49025
  return resolveSystemWorkflows(result);
48738
49026
  };
@@ -48969,6 +49257,7 @@ var buildWorkspace = async (ctx, outDirArg, deps = {}) => {
48969
49257
  const discover = deps.discoverWorkspace ?? discoverWorkspace;
48970
49258
  const build = deps.buildWorkspaceBundle ?? buildWorkspaceBundle;
48971
49259
  const seedManaged = deps.seedManagedWorkflows ?? seedManagedWorkflows;
49260
+ const extractSchema = deps.extractConfigSchema ?? extractConfigSchema;
48972
49261
  const projectRoot = ctx.cwd;
48973
49262
  const outDir = resolve(projectRoot, outDirArg ?? "dist/workspace-bundle");
48974
49263
  const workspace = await discover(projectRoot);
@@ -48991,6 +49280,7 @@ var buildWorkspace = async (ctx, outDirArg, deps = {}) => {
48991
49280
  workflows: workspace.workflows,
48992
49281
  logger: { warn: (msg) => ctx.logger.warn(msg) }
48993
49282
  }),
49283
+ resolveConfigSchema: () => extractSchema(projectRoot, { logger: { warn: (msg) => ctx.logger.warn(msg) } }).then((result) => result.schema),
48994
49284
  logger: {
48995
49285
  info: (msg) => ctx.logger.info(msg),
48996
49286
  warn: (msg) => ctx.logger.warn(msg)
@@ -49067,8 +49357,48 @@ var RUNTIME_PORT = 8080;
49067
49357
  var POSTGRES_IMAGE$1 = "pgvector/pgvector:pg17";
49068
49358
  /** Redis image the generated compose runs (BullMQ backs the durable World). */
49069
49359
  var REDIS_IMAGE$1 = "redis:7-alpine";
49360
+ /**
49361
+ * S3-compatible object storage the generated compose runs.
49362
+ *
49363
+ * The compose already ships a pgvector Postgres BECAUSE of managed RAG — and
49364
+ * managed RAG stores the uploaded ORIGINAL in object storage before it chunks
49365
+ * and embeds it (`putManagedObject`). Shipping the vector database without a
49366
+ * bucket delivers half of that feature: ingestion fails at the first upload,
49367
+ * `client.storage` returns `s3_credentials_missing`, and Studio's file browser
49368
+ * has nothing to talk to — while `/health` still answers `ok`, because it only
49369
+ * probes Postgres and Redis.
49370
+ *
49371
+ * Same image and pin as `stackbone dev`'s compose, so the emulator and the
49372
+ * deliverable run the identical backend.
49373
+ */
49374
+ var MINIO_IMAGE$1 = "minio/minio:RELEASE.2025-03-12T18-04-18Z";
49375
+ /** Access key the bundled MinIO is created with. The secret is generated per package. */
49376
+ var MINIO_ACCESS_KEY = "stackbone";
49377
+ /** Bucket the init container creates, and the agent's `STACKBONE_S3_BUCKET`. */
49378
+ var MINIO_BUCKET = "stackbone";
49379
+ /**
49380
+ * Host port the bundled MinIO's S3 API is published on.
49381
+ *
49382
+ * Published, unlike Postgres and Redis, and that is deliberate: Studio's file
49383
+ * browser hands the operator's BROWSER a presigned download URL, and a presigned
49384
+ * URL carries the endpoint it was signed with. Signed against the compose-internal
49385
+ * `http://minio:9000`, the link resolves only inside the compose network. The
49386
+ * README explains the one-line fix (point `STACKBONE_S3_ENDPOINT` at a host both
49387
+ * the container and the browser can reach) — publishing the port is what makes
49388
+ * that fix possible at all.
49389
+ */
49390
+ var MINIO_PORT = 9e3;
49070
49391
  /** A fresh 32-byte key, base64 — the format `STACKBONE_SECRET_KEY` expects. */
49071
49392
  var generateSecretKey = () => randomBytes(32).toString("base64");
49393
+ /**
49394
+ * A fresh root password for the bundled MinIO.
49395
+ *
49396
+ * `base64url`, not `base64`: this value travels through a `.env` line, a compose
49397
+ * interpolation and a `/bin/sh -c` entrypoint, and `+` `/` `=` survive that trip
49398
+ * badly. Generated per package rather than hardcoded because, unlike Postgres and
49399
+ * Redis, this service publishes a port on the host.
49400
+ */
49401
+ var generateStoragePassword = () => randomBytes(24).toString("base64url");
49072
49402
  var renderDockerfile = (input) => `# Generated by \`stackbone package\`. Two lines is the whole build: the Stackbone
49073
49403
  # runtime base already carries the server and its pinned dependencies, so this
49074
49404
  # image adds only your workspace. No monorepo, no npm install, seconds to build.
@@ -49077,10 +49407,10 @@ COPY ${input.workspaceDirName}/ /app/workspace/
49077
49407
  `;
49078
49408
  var renderCompose = (input) => {
49079
49409
  const image = input.imageTag;
49080
- return `# Generated by \`stackbone package\`. Brings up the agent with a Postgres and a
49081
- # Redis of its own. If you already run those, delete both services here and point
49082
- # DATABASE_URL / WORKFLOW_REDIS_URL in .env at yours — read the README first, the
49083
- # database has one requirement.
49410
+ return `# Generated by \`stackbone package\`. Brings up the agent with a Postgres, a
49411
+ # Redis and an object store of its own. If you already run those, delete the
49412
+ # services here and point DATABASE_URL / WORKFLOW_REDIS_URL / STACKBONE_S3_* in
49413
+ # .env at yours — read the README first, the database has one requirement.
49084
49414
 
49085
49415
  services:
49086
49416
  postgres:
@@ -49112,6 +49442,45 @@ services:
49112
49442
  retries: 20
49113
49443
  restart: unless-stopped
49114
49444
 
49445
+ minio:
49446
+ # S3-compatible object storage: the agent's own files (\`client.storage\`), the
49447
+ # originals managed RAG ingests, and what Studio's file browser lists.
49448
+ image: ${MINIO_IMAGE$1}
49449
+ command: ['server', '/data']
49450
+ environment:
49451
+ MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
49452
+ MINIO_ROOT_PASSWORD: \${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env}
49453
+ ports:
49454
+ # Published so a presigned download link can be opened from a browser —
49455
+ # see "Downloads from the file browser" in the README.
49456
+ - '${MINIO_PORT}:9000'
49457
+ volumes:
49458
+ - agent-objects:/data
49459
+ healthcheck:
49460
+ test: ['CMD', 'mc', 'ready', 'local']
49461
+ interval: 5s
49462
+ timeout: 5s
49463
+ retries: 20
49464
+ restart: unless-stopped
49465
+
49466
+ minio-create-bucket:
49467
+ # Creates the bucket once, then exits 0. \`mc mb -p\` is idempotent, so it is a
49468
+ # no-op on every later start. The agent waits for it to SUCCEED rather than
49469
+ # for MinIO to be healthy: a bucket that does not exist yet fails the agent's
49470
+ # first upload with a NoSuchBucket nobody would connect back to this file.
49471
+ image: ${MINIO_IMAGE$1}
49472
+ restart: 'no'
49473
+ depends_on:
49474
+ minio:
49475
+ condition: service_healthy
49476
+ entrypoint: >
49477
+ /bin/sh -c "
49478
+ mc alias set local http://minio:9000 ${MINIO_ACCESS_KEY} \\"$$MINIO_ROOT_PASSWORD\\" &&
49479
+ mc mb -p local/${MINIO_BUCKET}
49480
+ "
49481
+ environment:
49482
+ MINIO_ROOT_PASSWORD: \${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env}
49483
+
49115
49484
  agent:
49116
49485
  ${input.imageArchiveName !== void 0 ? ` # Loaded from ${input.imageArchiveName} — there is nothing to build here.
49117
49486
  image: ${image}` : ` build:
@@ -49121,9 +49490,17 @@ ${input.imageArchiveName !== void 0 ? ` # Loaded from ${input.imageArchiveNam
49121
49490
  env_file:
49122
49491
  - .env
49123
49492
  environment:
49124
- # Point the agent at the two services above. Override in .env to use your own.
49493
+ # Point the agent at the three services above. Override in .env to use your own.
49125
49494
  DATABASE_URL: \${DATABASE_URL:-postgres://stackbone:\${POSTGRES_PASSWORD:-stackbone}@postgres:5432/stackbone}
49126
49495
  WORKFLOW_REDIS_URL: \${WORKFLOW_REDIS_URL:-redis://redis:6379}
49496
+ STACKBONE_S3_ENDPOINT: \${STACKBONE_S3_ENDPOINT:-http://minio:9000}
49497
+ STACKBONE_S3_ACCESS_KEY: \${STACKBONE_S3_ACCESS_KEY:-${MINIO_ACCESS_KEY}}
49498
+ STACKBONE_S3_SECRET_KEY: \${STACKBONE_S3_SECRET_KEY:-\${MINIO_ROOT_PASSWORD}}
49499
+ STACKBONE_S3_BUCKET: \${STACKBONE_S3_BUCKET:-${MINIO_BUCKET}}
49500
+ STACKBONE_S3_REGION: \${STACKBONE_S3_REGION:-us-east-1}
49501
+ # MinIO addresses buckets by path. A managed bucket that only answers to the
49502
+ # bucket-as-subdomain form (AWS S3, Railway Buckets) needs this set to false.
49503
+ STACKBONE_S3_FORCE_PATH_STYLE: \${STACKBONE_S3_FORCE_PATH_STYLE:-true}
49127
49504
  ports:
49128
49505
  # The agent serves on ${RUNTIME_PORT} inside the container. Change the LEFT
49129
49506
  # number to publish it elsewhere on the host.
@@ -49133,25 +49510,51 @@ ${input.imageArchiveName !== void 0 ? ` # Loaded from ${input.imageArchiveNam
49133
49510
  condition: service_healthy
49134
49511
  redis:
49135
49512
  condition: service_healthy
49513
+ minio-create-bucket:
49514
+ condition: service_completed_successfully
49136
49515
  restart: unless-stopped
49137
49516
 
49138
49517
  volumes:
49139
49518
  agent-postgres:
49140
49519
  agent-redis:
49520
+ agent-objects:
49141
49521
  `;
49142
49522
  };
49143
- var renderEnvFile = (input, secretKey) => `# Generated by \`stackbone package\` for ${input.agentName}.
49523
+ /**
49524
+ * The `.env` and the `.env.example` are ONE template rendered twice.
49525
+ *
49526
+ * They differ only in the two secret lines: the real file carries freshly minted
49527
+ * values so `docker compose up -d` works untouched, the example carries
49528
+ * placeholders so it can be committed next to the folder. Keeping them as one
49529
+ * string is what stops the committed template from drifting into a stale list of
49530
+ * variables the runtime no longer reads.
49531
+ */
49532
+ var renderEnv = (input, secrets) => {
49533
+ const generated = secrets !== null;
49534
+ return `${generated ? `# Generated by \`stackbone package\` for ${input.agentName}.
49535
+ #
49536
+ # Bringing this up with the bundled Postgres, Redis and MinIO needs NOTHING else
49537
+ # filled in — the secrets below are already generated and the endpoints default
49538
+ # to the compose services. Set them explicitly only to use your own infrastructure.` : `# Template for the \`.env\` \`stackbone package\` generates for ${input.agentName}.
49144
49539
  #
49145
- # Bringing this up with the bundled Postgres and Redis needs NOTHING else filled
49146
- # in — the key below is already generated and the two URLs default to the compose
49147
- # services. Set them explicitly only to use your own infrastructure.
49540
+ # The packaged folder already contains a working \`.env\` with both secrets minted.
49541
+ # This file is the committable copy: it documents every variable the agent reads
49542
+ # and carries NO secret values. Filling it in by hand is only needed when you
49543
+ # rebuild the folder somewhere the original \`.env\` did not travel.`}
49148
49544
 
49149
49545
  # ---- Keep this safe ----------------------------------------------------------
49150
49546
  # Every secret this agent stores in its database is encrypted with this key. It
49151
49547
  # cannot live in the database it protects. LOSE IT AND YOU LOSE THOSE SECRETS —
49152
49548
  # there is no recovery, they have to be entered again. Changing it has the same
49153
49549
  # effect as losing it.
49154
- STACKBONE_SECRET_KEY=${secretKey}
49550
+ ${generated ? `STACKBONE_SECRET_KEY=${secrets.secretKey}` : `# Generate with: openssl rand -base64 32
49551
+ STACKBONE_SECRET_KEY=`}
49552
+
49553
+ # Root password of the bundled object store, which also serves as the agent's
49554
+ # STACKBONE_S3_SECRET_KEY. Unlike the database and Redis, this service publishes
49555
+ # a port on the host — leave this value strong, or do not publish the port.
49556
+ ${generated ? `MINIO_ROOT_PASSWORD=${secrets.storagePassword}` : `# Generate with: openssl rand -base64 24 | tr '+/' '-_' | tr -d '='
49557
+ MINIO_ROOT_PASSWORD=`}
49155
49558
 
49156
49559
  # ---- Your infrastructure (optional here) -------------------------------------
49157
49560
  # Uncomment to point the agent at a Postgres and a Redis you already run, instead
@@ -49159,7 +49562,31 @@ STACKBONE_SECRET_KEY=${secretKey}
49159
49562
  # agent creates its own schema on first boot. It must have the pgvector extension
49160
49563
  # available — see the README.
49161
49564
  # DATABASE_URL=postgres://user:password@host:5432/dbname?sslmode=require
49162
- # WORKFLOW_REDIS_URL=redis://host:6379
49565
+ # WORKFLOW_REDIS_URL=redis://default:password@host:6379
49566
+ # Managed Redis always wants a password, and some require TLS — use rediss:// for
49567
+ # those. Percent-encode a password containing / + = or @, or the URL will not parse.
49568
+
49569
+ # ---- Object storage ----------------------------------------------------------
49570
+ # The agent stores its own files here (\`client.storage\`), and managed RAG keeps
49571
+ # every ingested original here before it chunks and embeds it. Left alone, these
49572
+ # point at the MinIO this compose file starts.
49573
+ #
49574
+ # Uncomment to use a managed bucket instead (AWS S3, Cloudflare R2, Azure Blob,
49575
+ # Google Cloud Storage, Railway Buckets — anything S3-compatible). Create the
49576
+ # bucket first; the agent never creates one.
49577
+ # STACKBONE_S3_ENDPOINT=https://s3.eu-west-1.amazonaws.com
49578
+ # STACKBONE_S3_ACCESS_KEY=
49579
+ # STACKBONE_S3_SECRET_KEY=
49580
+ # STACKBONE_S3_BUCKET=
49581
+ # STACKBONE_S3_REGION=eu-west-1
49582
+ #
49583
+ # Addressing style. \`true\` puts the bucket in the PATH
49584
+ # (\`https://host/bucket/key\`), which is what MinIO and Cloudflare R2 accept.
49585
+ # Set it to \`false\` for a bucket that only answers to the SUBDOMAIN form
49586
+ # (\`https://bucket.host/key\`) — AWS S3 and Railway Buckets are the common ones.
49587
+ # Symptom of the wrong value: every call fails with NoSuchBucket or a 403 even
49588
+ # though the credentials are right.
49589
+ # STACKBONE_S3_FORCE_PATH_STYLE=true
49163
49590
 
49164
49591
  # ---- Optional ----------------------------------------------------------------
49165
49592
  # Set if your database role is not allowed to create schemas; you then apply the
@@ -49167,13 +49594,19 @@ STACKBONE_SECRET_KEY=${secretKey}
49167
49594
  # where it lives.
49168
49595
  # STACKBONE_SKIP_MIGRATIONS=1
49169
49596
  `;
49597
+ };
49598
+ var renderEnvFile = (input, secrets) => renderEnv(input, secrets);
49599
+ /** The committable, secret-free twin of the generated `.env`. */
49600
+ var renderEnvExample = (input) => renderEnv(input, null);
49170
49601
  var renderInventory = (label, names) => names.length === 0 ? `No ${label}.` : names.map((n) => `- \`${n}\``).join("\n");
49171
49602
  var renderReadme = (input) => {
49172
49603
  const offline = input.imageArchiveName;
49173
49604
  return `# ${input.agentName}
49174
49605
 
49175
- A Stackbone agent, packaged to run in your own environment. It talks to a Postgres
49176
- and a Redis that you control, and needs no connection to Stackbone to run.
49606
+ A Stackbone agent, packaged to run in your own environment. It talks to a Postgres,
49607
+ a Redis and an object store that you control, and needs no connection to Stackbone
49608
+ to run. This folder starts all three for you; swapping in your own is a few lines
49609
+ in \`.env\`.
49177
49610
 
49178
49611
  ## What you are running
49179
49612
 
@@ -49214,10 +49647,36 @@ prints it again. You need that value only if you later connect this deployment t
49214
49647
  a Stackbone control plane. Copy it somewhere safe now if there is any chance you
49215
49648
  will. Anyone who can read these logs can read it too.
49216
49649
 
49217
- ## Using your own Postgres and Redis
49650
+ ## Downloads from the file browser
49651
+
49652
+ Everything the agent itself does with files works out of the box. One thing does
49653
+ not, and it is worth thirty seconds of your time now rather than a puzzled bug
49654
+ report later.
49655
+
49656
+ When Studio's file browser offers you a download, it hands your BROWSER a signed,
49657
+ time-limited link. That link points at whatever address the agent knows the object
49658
+ store by — and by default that address is \`http://minio:9000\`, a name that only
49659
+ exists inside this compose network. Your browser cannot resolve it, so the
49660
+ download fails while every upload, every RAG ingestion and every listing works.
49661
+
49662
+ If anyone will download files from the file browser, set \`STACKBONE_S3_ENDPOINT\`
49663
+ in \`.env\` to an address that BOTH the container and the browser can reach — the
49664
+ host's own name or IP, on the published port:
49218
49665
 
49219
- Delete the \`postgres\` and \`redis\` services from \`docker-compose.yml\`, then set
49220
- \`DATABASE_URL\` and \`WORKFLOW_REDIS_URL\` in \`.env\`.
49666
+ \`\`\`bash
49667
+ STACKBONE_S3_ENDPOINT=http://the-host-you-run-this-on:${MINIO_PORT}
49668
+ \`\`\`
49669
+
49670
+ Port \`${MINIO_PORT}\` is published for exactly this. If nobody needs those downloads,
49671
+ delete the \`ports:\` block from the \`minio\` service and keep the object store off
49672
+ the network entirely.
49673
+
49674
+ ## Using your own Postgres, Redis and bucket
49675
+
49676
+ Delete the \`postgres\`, \`redis\`, \`minio\` and \`minio-create-bucket\` services from
49677
+ \`docker-compose.yml\` (and the \`depends_on\` entries naming them), then set
49678
+ \`DATABASE_URL\`, \`WORKFLOW_REDIS_URL\` and the \`STACKBONE_S3_*\` variables in \`.env\`.
49679
+ Do them one at a time if you prefer — the four services are independent.
49221
49680
 
49222
49681
  **The database has one requirement: the \`pgvector\` extension must be available.**
49223
49682
  The agent runs \`CREATE EXTENSION IF NOT EXISTS vector\` while creating its schema.
@@ -49244,6 +49703,32 @@ and apply these by hand first, in filename order:
49244
49703
 
49245
49704
  Both live inside the image; \`docker run --rm ${input.imageTag} ls <path>\` lists them.
49246
49705
 
49706
+ Redis needs no preparation, only a reachable URL. Managed instances hand out a
49707
+ password, so the URL carries one — \`redis://default:password@host:6379\`. Use
49708
+ \`rediss://\` where the provider requires TLS (Upstash, ElastiCache with encryption
49709
+ in transit), and percent-encode a password containing \`/\`, \`+\`, \`=\` or \`@\`. The
49710
+ agent PINGs Redis before it starts and stops with the reason if it cannot: a
49711
+ rejected password fails right away, and a Redis still starting up is retried for
49712
+ 30 seconds first.
49713
+
49714
+ For the bucket, any S3-compatible storage works — AWS S3, Cloudflare R2, Azure
49715
+ Blob, Google Cloud Storage, Railway Buckets, another MinIO. **Create the bucket
49716
+ first**; the agent never creates one. Then fill in the five \`STACKBONE_S3_*\`
49717
+ variables, and check the sixth:
49718
+
49719
+ \`\`\`bash
49720
+ STACKBONE_S3_FORCE_PATH_STYLE=true # bucket in the path: https://host/bucket/key
49721
+ STACKBONE_S3_FORCE_PATH_STYLE=false # bucket as a subdomain: https://bucket.host/key
49722
+ \`\`\`
49723
+
49724
+ MinIO and Cloudflare R2 take the first. AWS S3 and Railway Buckets want the
49725
+ second. Get it wrong and every call fails with \`NoSuchBucket\` or a bare 403 while
49726
+ the credentials are perfectly valid — which is a genuinely confusing hour, so it
49727
+ is worth checking before you conclude the keys are wrong.
49728
+
49729
+ The agent writes only under a prefix of its own inside that bucket, so a bucket
49730
+ shared with something else stays tidy.
49731
+
49247
49732
  ## Running it somewhere other than Compose
49248
49733
 
49249
49734
  The agent serves on port **${RUNTIME_PORT}** by default, and binds whatever
@@ -49335,6 +49820,7 @@ var runPackage = async (ctx, args, deps = {}) => {
49335
49820
  const build = deps.buildWorkspace ?? buildWorkspace;
49336
49821
  const docker = deps.runDocker ?? runDockerCommand;
49337
49822
  const makeKey = deps.generateSecretKey ?? generateSecretKey;
49823
+ const makeStoragePassword = deps.generateStoragePassword ?? generateStoragePassword;
49338
49824
  const projectRoot = ctx.cwd;
49339
49825
  const outDir = resolve(projectRoot, args["out-dir"] ?? "dist/deploy");
49340
49826
  const { bundle } = await build(ctx, void 0, deps);
@@ -49354,16 +49840,21 @@ var runPackage = async (ctx, args, deps = {}) => {
49354
49840
  recursive: true,
49355
49841
  force: true
49356
49842
  });
49357
- const secretKey = makeKey();
49843
+ const secrets = {
49844
+ secretKey: makeKey(),
49845
+ storagePassword: makeStoragePassword()
49846
+ };
49358
49847
  const written = [
49359
49848
  "Dockerfile",
49360
49849
  "docker-compose.yml",
49361
49850
  ".env",
49851
+ ".env.example",
49362
49852
  "README.md"
49363
49853
  ];
49364
49854
  await writeFile(join(outDir, "Dockerfile"), renderDockerfile(templateInput), "utf8");
49365
49855
  await writeFile(join(outDir, "docker-compose.yml"), renderCompose(templateInput), "utf8");
49366
- await writeFile(join(outDir, ".env"), renderEnvFile(templateInput, secretKey), "utf8");
49856
+ await writeFile(join(outDir, ".env"), renderEnvFile(templateInput, secrets), "utf8");
49857
+ await writeFile(join(outDir, ".env.example"), renderEnvExample(templateInput), "utf8");
49367
49858
  await writeFile(join(outDir, "README.md"), renderReadme(templateInput), "utf8");
49368
49859
  if (args.offline) {
49369
49860
  ctx.logger.info(`Building ${imageTag} and saving it to ${IMAGE_ARCHIVE_NAME} (~150 MB)`);
@@ -49392,9 +49883,11 @@ var runPackage = async (ctx, args, deps = {}) => {
49392
49883
  ...degraded.length > 0 ? [` degraded ${degraded.map((d) => d.name).join(", ")} — shipped, but they will not run`] : [],
49393
49884
  ` serves on ${RUNTIME_PORT} (or PORT, when the host assigns one)`,
49394
49885
  "",
49395
- "The .env holds a freshly generated STACKBONE_SECRET_KEY. Everything this",
49396
- "agent stores is encrypted with it and it cannot be recovered — hand the",
49397
- "folder over intact, and keep a copy of that key somewhere safe.",
49886
+ "The .env holds two freshly generated secrets: STACKBONE_SECRET_KEY, which",
49887
+ "encrypts everything this agent stores and cannot be recovered, and",
49888
+ "MINIO_ROOT_PASSWORD for the bundled object store. Hand the folder over",
49889
+ "intact, and keep a copy of that key somewhere safe. The .env.example next",
49890
+ "to it carries the same variables with no values, for committing.",
49398
49891
  ...args.offline ? [] : [
49399
49892
  "",
49400
49893
  `Whoever runs this needs to reach ${templateInput.baseImage} once, to pull it.`,
@@ -49440,181 +49933,6 @@ var createPackageCommand = defineVerb({
49440
49933
  handler: ({ ctx, args }) => runPackage(ctx, args)
49441
49934
  });
49442
49935
  //#endregion
49443
- //#region src/dev/extract-config-schema.ts
49444
- /** Directory the extracted artifact lives in, relative to the workspace root.
49445
- * A subdir of its own, parallel to the workflow well-known tree. */
49446
- var CONFIG_WELL_KNOWN_DIR = ".well-known/config/v1";
49447
- /** The shipped artifact: the single JSON Schema (JSON only, no live sidecar). */
49448
- var CONFIG_SCHEMA_FILE = "config-schema.json";
49449
- /** The intermediate esbuild bundle (harmless to leave behind, like the workflow
49450
- * sidecar). NOT the deliverable — `config-schema.json` is. */
49451
- var CONFIG_SCHEMA_BUNDLE = "config-schema.mjs";
49452
- /**
49453
- * Extract the workspace's single config JSON Schema. Defensive by contract: an
49454
- * absent `config.schema.ts` degrades to `null` SILENTLY (the editor stays
49455
- * free-form); a DECLARED-but-inconvertible schema degrades to `null` plus one
49456
- * noisy warning; and a total bundle failure degrades to `null` plus one coarse
49457
- * warning. It NEVER throws — a broken config schema must not break the dev build.
49458
- */
49459
- var extractConfigSchema = async (projectRoot, opts = {}) => {
49460
- const loader = opts.loader ?? defaultConfigSchemaLoader;
49461
- const warnings = [];
49462
- const emit = (msg) => {
49463
- warnings.push(msg);
49464
- opts.logger?.warn(msg);
49465
- };
49466
- let loaded;
49467
- try {
49468
- loaded = await loader(projectRoot);
49469
- } catch (err) {
49470
- emit(`[config-schema] config schema extraction bundle failed; no config schema is available — the Config editor will fall back to a raw JSON field. Cause: ${errMessage(err)}`);
49471
- return {
49472
- schema: null,
49473
- warnings
49474
- };
49475
- }
49476
- if (loaded.configSchema == null) return {
49477
- schema: null,
49478
- warnings
49479
- };
49480
- try {
49481
- return {
49482
- schema: loaded.toJsonSchema(loaded.configSchema),
49483
- warnings
49484
- };
49485
- } catch (err) {
49486
- emit(`[config-schema] config.schema.ts could not be converted to JSON Schema — the Config editor will fall back to a raw JSON field. Cause: ${errMessage(err)}`);
49487
- return {
49488
- schema: null,
49489
- warnings
49490
- };
49491
- }
49492
- };
49493
- /**
49494
- * Extract the config schema and WRITE the artifact. When the schema is non-null,
49495
- * writes `<projectRoot>/.well-known/config/v1/config-schema.json` (compact JSON,
49496
- * like `manifest.json`) and returns `written: true`. When the schema is null
49497
- * (absent or inconvertible), REMOVES any stale `config-schema.json` so a deleted
49498
- * or broken schema does not leave a phantom artifact, and returns `written:
49499
- * false`. Never throws (mirrors `extractConfigSchema`).
49500
- */
49501
- var buildConfigSchema = async (projectRoot, opts = {}) => {
49502
- const { schema, warnings } = await extractConfigSchema(projectRoot, opts);
49503
- const outfile = resolve(projectRoot, CONFIG_WELL_KNOWN_DIR, CONFIG_SCHEMA_FILE);
49504
- if (schema == null) {
49505
- await rm(outfile, { force: true });
49506
- return {
49507
- schema: null,
49508
- warnings,
49509
- written: false
49510
- };
49511
- }
49512
- await mkdir(resolve(projectRoot, CONFIG_WELL_KNOWN_DIR), { recursive: true });
49513
- await writeFile(outfile, JSON.stringify(schema), "utf8");
49514
- return {
49515
- schema,
49516
- warnings,
49517
- written: true
49518
- };
49519
- };
49520
- var errMessage = (err) => err instanceof Error ? err.message : String(err);
49521
- /**
49522
- * The default loader: when `config.schema.ts` exists at the project root, emit an
49523
- * entry that re-exports ONLY its `configSchema` (static member access for
49524
- * tree-shaking), esbuild-bundle it to
49525
- * `<projectRoot>/.well-known/config/v1/config-schema.mjs`, import it back, and
49526
- * read `configSchema` + `toJsonSchema`. When the file is ABSENT, return
49527
- * `{ configSchema: null, toJsonSchema: () => null }` WITHOUT bundling — an absent
49528
- * schema is silent, not a warning.
49529
- *
49530
- * Resolved FROM THE PROJECT: esbuild + zod come from the workspace's own
49531
- * node_modules (like `buildWorkflows` resolves `@workflow/builders`), so the
49532
- * bundle uses the SAME zod the schema was authored with and the heavy esbuild
49533
- * tree stays out of the published CLI bundle.
49534
- */
49535
- var defaultConfigSchemaLoader = async (projectRoot) => {
49536
- try {
49537
- await access(resolve(projectRoot, CONFIG_SCHEMA_SOURCE));
49538
- } catch {
49539
- return {
49540
- configSchema: null,
49541
- toJsonSchema: () => null
49542
- };
49543
- }
49544
- const projectRequire = createRequire(resolve(projectRoot, "package.json"));
49545
- let esbuild;
49546
- try {
49547
- esbuild = await import(pathToFileURL(projectRequire.resolve("esbuild")).href);
49548
- } catch (err) {
49549
- throw new Error(`Cannot resolve esbuild from the workspace (needed to extract the config schema). Cause: ${errMessage(err)}`);
49550
- }
49551
- const entry = buildEntrySource();
49552
- const outfile = resolve(projectRoot, CONFIG_WELL_KNOWN_DIR, CONFIG_SCHEMA_BUNDLE);
49553
- await esbuild.build({
49554
- stdin: {
49555
- contents: entry,
49556
- resolveDir: projectRoot,
49557
- sourcefile: "config-schema-entry.ts",
49558
- loader: "ts"
49559
- },
49560
- bundle: true,
49561
- packages: "external",
49562
- format: "esm",
49563
- platform: "node",
49564
- target: "node22",
49565
- treeShaking: true,
49566
- outfile,
49567
- logLevel: "silent"
49568
- });
49569
- const mod = await import(`${pathToFileURL(outfile).href}?v=${Date.now()}`);
49570
- if (typeof mod.toJsonSchema !== "function") throw new Error(`config-schema.mjs did not export the expected { configSchema, toJsonSchema } shape.`);
49571
- return {
49572
- configSchema: mod.configSchema ?? null,
49573
- toJsonSchema: mod.toJsonSchema
49574
- };
49575
- };
49576
- /**
49577
- * Generate the bundle entry source. It NAMESPACE-imports `config.schema.ts` and
49578
- * reads `configSchema` with DIRECT static member access — so esbuild tree-shakes
49579
- * everything else and a MISSING export is simply `undefined` (→ `null`), never a
49580
- * bundle-breaking "no matching export".
49581
- *
49582
- * The converter is REPLICATED here (not imported from the workflow extractor): it
49583
- * runs inside the emitted `.mjs` with the PROJECT's own zod. It is byte-for-byte
49584
- * the workflow extractor's converter MINUS the `half` parameter — config is a
49585
- * single input-shaped object, so the Standard-Schema fallback invokes the
49586
- * `.input` converter directly.
49587
- */
49588
- var buildEntrySource = () => {
49589
- return [
49590
- `import { z } from 'zod';`,
49591
- `import * as cfg from './config.schema.ts';`,
49592
- `// Non-Zod Standard Schema fallback. Per @standard-schema/spec, ~standard`,
49593
- `// .jsonSchema is a Converter { input(opts), output(opts) } of FUNCTIONS — so`,
49594
- `// we INVOKE the input converter to get the actual JSON Schema (config is an`,
49595
- `// input-shaped object). Returning the Converter object verbatim folds`,
49596
- `// non-serialisable functions that JSON.stringify drops → a phantom {} schema.`,
49597
- `const std = (s) => {`,
49598
- ` const c = s && s['~standard'] && s['~standard'].jsonSchema;`,
49599
- ` const fn = c && c.input;`,
49600
- ` return typeof fn === 'function' ? fn({ target: 'draft-2020-12' }) : null;`,
49601
- `};`,
49602
- `const carriesFns = (o) =>`,
49603
- ` o != null && typeof o === 'object' && Object.values(o).some((v) => typeof v === 'function');`,
49604
- `export const toJsonSchema = (s) => {`,
49605
- ` if (s == null) return null;`,
49606
- ` let out;`,
49607
- ` try { out = z.toJSONSchema(s); }`,
49608
- ` catch (e) { out = std(s); if (out == null) throw e; }`,
49609
- ` // A result carrying functions is a non-serialisable phantom; degrade to`,
49610
- ` // null + a noisy warning instead of writing garbage to the artifact.`,
49611
- ` if (carriesFns(out)) throw new Error('schema is non-serialisable (carries functions)');`,
49612
- ` return out;`,
49613
- `};`,
49614
- `export const configSchema = cfg.configSchema ?? null;`
49615
- ].join("\n");
49616
- };
49617
- //#endregion
49618
49936
  //#region src/dev/generate-config-types.ts
49619
49937
  /** Path of the generated artifact relative to the agent root. */
49620
49938
  var CONFIG_TYPES_RELATIVE_PATH = join(".stackbone", "config.d.ts");
@@ -54060,7 +54378,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
54060
54378
  tunnelUrl: session.publicUrl,
54061
54379
  localUrl: session.emulatorUrl,
54062
54380
  deeplink,
54063
- contractVersion: 14,
54381
+ contractVersion: 15,
54064
54382
  capabilityCount: EMULATOR_CAPABILITIES.length,
54065
54383
  agentName: agent.name,
54066
54384
  colors: process.stdout.isTTY === true
@@ -55386,9 +55704,10 @@ new version means deploying a new image and re-running \`link\`.
55386
55704
 
55387
55705
  Turn this project into a folder someone else can run, with no access to
55388
55706
  Stackbone and no knowledge of it. The folder holds your compiled workspace and
55389
- four generated files: a two-line \`Dockerfile\`, a \`docker-compose.yml\` that also
55390
- brings up a Postgres and a Redis, an \`.env\` carrying a freshly generated
55391
- \`STACKBONE_SECRET_KEY\`, and a \`README.md\` written for whoever receives it.
55707
+ five generated files: a two-line \`Dockerfile\`, a \`docker-compose.yml\` that also
55708
+ brings up a Postgres, a Redis and an object store, an \`.env\` carrying freshly
55709
+ generated secrets, an \`.env.example\` you can commit, and a \`README.md\` written
55710
+ for whoever receives it.
55392
55711
 
55393
55712
  \`\`\`sh
55394
55713
  stackbone package # writes dist/deploy
@@ -55423,9 +55742,10 @@ What lands in the output directory:
55423
55742
  dist/deploy/
55424
55743
  workspace/ # the compiled bundle, exactly what \`stackbone build\` writes
55425
55744
  Dockerfile # FROM the published runtime base, COPY workspace/
55426
- docker-compose.yml # the agent, a Postgres with pgvector, a Redis
55427
- .env # a generated STACKBONE_SECRET_KEY; nothing you must fill in
55428
- README.md # how to start it, use your own database, run it without Compose
55745
+ docker-compose.yml # the agent, a Postgres with pgvector, a Redis, a MinIO
55746
+ .env # generated secrets; nothing you must fill in
55747
+ .env.example # the same variables with no values, safe to commit
55748
+ README.md # how to start it, use your own services, run it without Compose
55429
55749
  agent-image.tar # only with --offline
55430
55750
  \`\`\`
55431
55751
 
@@ -55442,8 +55762,9 @@ to build. That variant needs Docker installed and running where you package, and
55442
55762
  it emits no \`build:\` block in the compose file: building is the thing the flag
55443
55763
  exists to avoid.
55444
55764
 
55445
- **Two things to tell whoever runs it.** Both are already in the generated
55446
- README, and both fail in a way that reads like a bug in your agent.
55765
+ **Three things to tell whoever runs it.** All three are already in the
55766
+ generated README, and all three fail in a way that reads like a bug in your
55767
+ agent.
55447
55768
 
55448
55769
  The database needs the **\`pgvector\` extension available**. The first boot runs
55449
55770
  \`CREATE EXTENSION IF NOT EXISTS vector\` while creating its schema, so a stock
@@ -55453,6 +55774,16 @@ Postgres applies most of the schema and then stops with
55453
55774
  of your own and the extension has to be available there, which on many managed
55454
55775
  databases only an administrator can create.
55455
55776
 
55777
+ **Downloads from the dashboard's file browser need a reachable endpoint.** The
55778
+ bundled MinIO answers to \`http://minio:9000\`, a name that only exists inside the
55779
+ Compose network. Everything the agent itself does with files works with that
55780
+ address, and so does RAG ingestion. A download link is the exception: the
55781
+ dashboard signs it and hands it to a **browser**, which cannot resolve that
55782
+ name. If anyone will download files, set \`STACKBONE_S3_ENDPOINT\` in \`.env\` to an
55783
+ address both the container and the browser reach, such as
55784
+ \`http://the-host-you-run-this-on:9000\`. Port \`9000\` is published for exactly
55785
+ that.
55786
+
55456
55787
  **The agent serves on port 8080, or on \`PORT\` when the host sets one.** Hosts
55457
55788
  that assign a port and expect the application to adopt it (Cloud Run, Railway,
55458
55789
  Heroku, App Runner) need no extra configuration. Point their health check at
@@ -55460,9 +55791,23 @@ Heroku, App Runner) need no extra configuration. Point their health check at
55460
55791
  while \`/health\` also probes the database, which the very first boot is still
55461
55792
  creating.
55462
55793
 
55794
+ **Using a managed bucket instead.** Any S3-compatible storage works (AWS S3,
55795
+ Cloudflare R2, Azure Blob, Google Cloud Storage, another MinIO). Create the
55796
+ bucket first, because the agent never creates one, then fill in the
55797
+ \`STACKBONE_S3_*\` variables in \`.env\`. One of them decides how the bucket is
55798
+ addressed: \`STACKBONE_S3_FORCE_PATH_STYLE=true\` puts the bucket in the path
55799
+ (\`https://host/bucket/key\`), which MinIO and Cloudflare R2 accept, and \`false\`
55800
+ puts it in a subdomain (\`https://bucket.host/key\`), which AWS S3 expects. The
55801
+ wrong value fails every call with \`NoSuchBucket\` or a bare 403 while the
55802
+ credentials are valid, so check it before you suspect the keys.
55803
+
55463
55804
  **Hand the folder over intact.** The generated \`.env\` holds the only copy of
55464
55805
  \`STACKBONE_SECRET_KEY\`, and everything the agent stores is encrypted with it.
55465
- Losing it or changing it makes those secrets unreadable, with no recovery.
55806
+ Losing it or changing it makes those secrets unreadable, with no recovery. It
55807
+ also holds \`MINIO_ROOT_PASSWORD\` for the bundled object store, which the Compose
55808
+ file refuses to start without. The \`.env.example\` next to it carries the same
55809
+ variables with no values, so the folder can go into a repository while the \`.env\`
55810
+ stays out of it.
55466
55811
 
55467
55812
  **JSON payload**
55468
55813
 
@@ -55470,7 +55815,7 @@ Losing it or changing it makes those secrets unreadable, with no recovery.
55470
55815
  {
55471
55816
  "schema_version": 1,
55472
55817
  "outDir": "/abs/path/dist/deploy",
55473
- "files": ["Dockerfile", "docker-compose.yml", ".env", "README.md"],
55818
+ "files": ["Dockerfile", "docker-compose.yml", ".env", ".env.example", "README.md"],
55474
55819
  "imageTag": "acme-agent:latest",
55475
55820
  "baseImage": "ghcr.io/stackbone/stackbone-workspace-runtime:node24-base",
55476
55821
  "workflows": ["onboarding", "rag-ingest", "mapping-suggest"],
@@ -55504,6 +55849,7 @@ What lands in the output directory:
55504
55849
  \`\`\`text
55505
55850
  dist/workspace-bundle/
55506
55851
  .well-known/workspace.json # the manifest the runtime reads first
55852
+ .well-known/config/v1/config-schema.json # your config.schema.ts, as JSON Schema
55507
55853
  .well-known/workflow/v1/step.mjs # your compiled workflow steps
55508
55854
  .well-known/workflow/v1/workflow.vm.js # the workflow bundle the VM runs
55509
55855
  .well-known/workflow/v1/manifest.json # workflow ids, schemas, schedules
@@ -55523,6 +55869,14 @@ the container logs a warning naming the file and starts runs **unvalidated** —
55523
55869
  a start that your laptop rejects would be accepted in production. The warning is
55524
55870
  the thing to grep for after a deploy.
55525
55871
 
55872
+ \`config-schema.json\` is your \`config.schema.ts\`, converted to JSON Schema at
55873
+ build time. The container has no TypeScript, so it reads this file to render the
55874
+ config form in the dashboard and to validate every save. Write no
55875
+ \`config.schema.ts\` and the build ships no file, which is fine: config stays
55876
+ free-form. Write one the build cannot convert and it warns instead of failing,
55877
+ ships without the file, and the dashboard falls back to a raw JSON field with no
55878
+ validation on save. See [\`stackbone.config\`](/docs/sdk/config#typed-config).
55879
+
55526
55880
  Each deep agent is bundled with its own dependencies inlined, except for the
55527
55881
  packages that must stay a single copy per process. If one of your dependencies
55528
55882
  cannot survive bundling, keep it out with \`build.external\`. See
@@ -56808,19 +57162,20 @@ datastores locally as in production**. You import that client directly
56808
57162
  inside a tool's execution or a workflow \`'use step'\`; there is no handler
56809
57163
  wiring to configure.
56810
57164
 
56811
- | Variable | Points at |
56812
- | ------------------------- | ------------------------------------------------------------------------------------------ |
56813
- | \`PORT\` | The port the emulator listens on. |
56814
- | \`STACKBONE_POSTGRES_URL\` | Local Postgres on \`:5433\` with the \`stackbone_dev\` database. |
56815
- | \`STACKBONE_S3_ENDPOINT\` | MinIO on \`:9004\`. |
56816
- | \`STACKBONE_S3_BUCKET\` | \`stackbone-dev\`. |
56817
- | \`STACKBONE_S3_ACCESS_KEY\` | \`stackbone\`. |
56818
- | \`STACKBONE_S3_SECRET_KEY\` | \`stackbone-secret\`. |
56819
- | \`STACKBONE_S3_REGION\` | \`us-east-1\` (MinIO accepts any region). |
56820
- | \`STACKBONE_AGENT_ID\` | The slug of the workspace (the multi-tenant key prefix for storage and observability). |
56821
- | \`WORKFLOW_REDIS_URL\` | Local Redis on \`:6380\`, the durable backend the workflow runtime replays from. |
56822
- | \`MODEL_PROVIDER_API_KEY\` | The model-provider key, when one resolves (see below). Absent for a keyless local gateway. |
56823
- | \`MODEL_PROVIDER_BASE_URL\` | The OpenAI-compatible base URL, when one resolves. Absent means the OpenRouter default. |
57165
+ | Variable | Points at |
57166
+ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
57167
+ | \`PORT\` | The port the emulator listens on. |
57168
+ | \`STACKBONE_POSTGRES_URL\` | Local Postgres on \`:5433\` with the \`stackbone_dev\` database. |
57169
+ | \`STACKBONE_S3_ENDPOINT\` | MinIO on \`:9004\`. |
57170
+ | \`STACKBONE_S3_BUCKET\` | \`stackbone-dev\`. |
57171
+ | \`STACKBONE_S3_ACCESS_KEY\` | \`stackbone\`. |
57172
+ | \`STACKBONE_S3_SECRET_KEY\` | \`stackbone-secret\`. |
57173
+ | \`STACKBONE_S3_REGION\` | \`us-east-1\` (MinIO accepts any region). |
57174
+ | \`STACKBONE_S3_FORCE_PATH_STYLE\` | \`true\`, the addressing style MinIO speaks. See [\`stackbone.storage\`](/docs/sdk/storage#path-style-or-subdomain). |
57175
+ | \`STACKBONE_AGENT_ID\` | The slug of the workspace (the multi-tenant key prefix for storage and observability). |
57176
+ | \`WORKFLOW_REDIS_URL\` | Local Redis on \`:6380\`, the durable backend the workflow runtime replays from. |
57177
+ | \`MODEL_PROVIDER_API_KEY\` | The model-provider key, when one resolves (see below). Absent for a keyless local gateway. |
57178
+ | \`MODEL_PROVIDER_BASE_URL\` | The OpenAI-compatible base URL, when one resolves. Absent means the OpenRouter default. |
56824
57179
 
56825
57180
  > See [Configuration → Environment variables](/docs/cli/configuration#environment-variables)
56826
57181
  > for the full list.
@@ -58823,14 +59178,31 @@ A deployed agent never configures storage by hand. The runtime injects these
58823
59178
  environment variables when your container boots, and the ambient \`stackbone\`
58824
59179
  client reads them on first use:
58825
59180
 
58826
- | Env var the runtime injects | Holds |
58827
- | --------------------------- | -------------------------------------------------------------- |
58828
- | \`STACKBONE_S3_ACCESS_KEY\` | Access key for the agent's physical bucket. |
58829
- | \`STACKBONE_S3_SECRET_KEY\` | Secret access key. |
58830
- | \`STACKBONE_S3_ENDPOINT\` | S3-compatible endpoint (MinIO in \`stackbone dev\`). |
58831
- | \`STACKBONE_S3_BUCKET\` | The physical bucket your logical buckets are namespaced under. |
58832
- | \`STACKBONE_S3_REGION\` | Region, defaults to \`'us-east-1'\` when unset. |
58833
- | \`STACKBONE_AGENT_ID\` | The agent identity prepended to every key. |
59181
+ | Env var the runtime injects | Holds |
59182
+ | ------------------------------- | -------------------------------------------------------------- |
59183
+ | \`STACKBONE_S3_ACCESS_KEY\` | Access key for the agent's physical bucket. |
59184
+ | \`STACKBONE_S3_SECRET_KEY\` | Secret access key. |
59185
+ | \`STACKBONE_S3_ENDPOINT\` | S3-compatible endpoint (MinIO in \`stackbone dev\`). |
59186
+ | \`STACKBONE_S3_BUCKET\` | The physical bucket your logical buckets are namespaced under. |
59187
+ | \`STACKBONE_S3_REGION\` | Region, defaults to \`'us-east-1'\` when unset. |
59188
+ | \`STACKBONE_S3_FORCE_PATH_STYLE\` | How the bucket is addressed. Defaults to \`true\`. |
59189
+ | \`STACKBONE_AGENT_ID\` | The agent identity prepended to every key. |
59190
+
59191
+ ### Path-style or subdomain
59192
+
59193
+ \`STACKBONE_S3_FORCE_PATH_STYLE\` decides where the bucket name goes in the URL,
59194
+ and different backends disagree:
59195
+
59196
+ | Value | URL shape | Backends that want it |
59197
+ | ---------------- | ------------------------- | ----------------------- |
59198
+ | \`true\` (default) | \`https://host/bucket/key\` | MinIO, Cloudflare R2 |
59199
+ | \`false\` | \`https://bucket.host/key\` | AWS S3, Railway Buckets |
59200
+
59201
+ The default is \`true\` because that is what MinIO speaks, and MinIO is what
59202
+ \`stackbone dev\` runs. If your deployment points at a bucket that only answers to
59203
+ the subdomain form, every call fails with \`NoSuchBucket\` or a bare 403 even
59204
+ though the credentials are correct. That is the symptom of this one variable, so
59205
+ check it before you conclude the keys are wrong.
58834
59206
 
58835
59207
  If you need explicit configuration (for a local script or a test) you can
58836
59208
  pass an \`s3\` block to \`createClient()\` instead of relying on the injected env:
@@ -58844,7 +59216,8 @@ const sb = createClient({
58844
59216
  secretAccessKey: '…',
58845
59217
  endpoint: 'http://localhost:9000',
58846
59218
  bucket: 'my-bucket',
58847
- // region defaults to 'auto'
59219
+ // region defaults to 'us-east-1'
59220
+ // forcePathStyle defaults to true; set false for AWS S3
58848
59221
  },
58849
59222
  agentId: 'local-agent',
58850
59223
  });
@@ -58852,9 +59225,9 @@ const bucket = sb.storage.from('uploads');
58852
59225
  \`\`\`
58853
59226
 
58854
59227
  Missing any required value surfaces \`s3_credentials_missing\`,
58855
- \`s3_bucket_missing\` or \`agent_id_missing\` with a hint naming what to set:
58856
- \`region\` is the only one that never trips the missing-credential
58857
- errors because of the \`'auto'\` default.
59228
+ \`s3_bucket_missing\` or \`agent_id_missing\` with a hint naming what to set.
59229
+ \`region\` and \`forcePathStyle\` never trip those errors, because both carry a
59230
+ default (\`'us-east-1'\` and \`true\`).
58858
59231
 
58859
59232
  ## Upload, download, list, remove
58860
59233
 
@@ -60134,7 +60507,10 @@ export const configSchema = z.object({
60134
60507
 
60135
60508
  That one file drives three things at once: the form the operator sees in
60136
60509
  the dashboard, the validation applied when they save, and your types.
60137
- Generate the types with:
60510
+
60511
+ \`stackbone build\` converts the schema to JSON Schema and writes it into
60512
+ the bundle, so a deployed container renders the same form your laptop
60513
+ does. Generate the types with:
60138
60514
 
60139
60515
  \`\`\`sh
60140
60516
  stackbone config types
@@ -60621,22 +60997,23 @@ workspace works zero-config locally. Each variable falls back to a typed
60621
60997
  config field if you pass one to \`createClient\`, but you rarely need to:
60622
60998
  the runtime provides them all:
60623
60999
 
60624
- | Env var | Feeds | Provided by \`stackbone dev\` |
60625
- | --------------------------- | --------------------------------------- | --------------------------- |
60626
- | \`STACKBONE_API_URL\` | Control-plane calls | ✅ (local tunnel) |
60627
- | \`STACKBONE_INSTALLATION_ID\` | Identifies the install | ✅ |
60628
- | \`STACKBONE_AGENT_ID\` | Workspace identity + storage key prefix | ✅ |
60629
- | \`HMAC_SECRET\` | Signs Stackbone Connect broker calls | ✅ |
60630
- | \`STACKBONE_POSTGRES_URL\` | \`stackbone.database\` + \`stackbone.rag\` | ✅ (local Postgres) |
60631
- | \`STACKBONE_SECRET_KEY\` | Decrypts \`stackbone.secrets\` | ✅ |
60632
- | \`WORKFLOW_REDIS_URL\` | Durable workflow execution backend | ✅ (local Redis) |
60633
- | \`STACKBONE_S3_ENDPOINT\` | \`stackbone.storage\` | ✅ (local MinIO) |
60634
- | \`STACKBONE_S3_BUCKET\` | \`stackbone.storage\` | ✅ (\`stackbone-dev\`) |
60635
- | \`STACKBONE_S3_ACCESS_KEY\` | \`stackbone.storage\` | ✅ |
60636
- | \`STACKBONE_S3_SECRET_KEY\` | \`stackbone.storage\` | ✅ |
60637
- | \`STACKBONE_S3_REGION\` | \`stackbone.storage\` | ✅ |
60638
- | \`MODEL_PROVIDER_API_KEY\` | \`stackbone.ai\` | ❗ **you supply it** |
60639
- | \`MODEL_PROVIDER_BASE_URL\` | \`stackbone.ai\` (override) | ❗ **you supply it** |
61000
+ | Env var | Feeds | Provided by \`stackbone dev\` |
61001
+ | ------------------------------- | --------------------------------------- | --------------------------- |
61002
+ | \`STACKBONE_API_URL\` | Control-plane calls | ✅ (local tunnel) |
61003
+ | \`STACKBONE_INSTALLATION_ID\` | Identifies the install | ✅ |
61004
+ | \`STACKBONE_AGENT_ID\` | Workspace identity + storage key prefix | ✅ |
61005
+ | \`HMAC_SECRET\` | Signs Stackbone Connect broker calls | ✅ |
61006
+ | \`STACKBONE_POSTGRES_URL\` | \`stackbone.database\` + \`stackbone.rag\` | ✅ (local Postgres) |
61007
+ | \`STACKBONE_SECRET_KEY\` | Decrypts \`stackbone.secrets\` | ✅ |
61008
+ | \`WORKFLOW_REDIS_URL\` | Durable workflow execution backend | ✅ (local Redis) |
61009
+ | \`STACKBONE_S3_ENDPOINT\` | \`stackbone.storage\` | ✅ (local MinIO) |
61010
+ | \`STACKBONE_S3_BUCKET\` | \`stackbone.storage\` | ✅ (\`stackbone-dev\`) |
61011
+ | \`STACKBONE_S3_ACCESS_KEY\` | \`stackbone.storage\` | ✅ |
61012
+ | \`STACKBONE_S3_SECRET_KEY\` | \`stackbone.storage\` | ✅ |
61013
+ | \`STACKBONE_S3_REGION\` | \`stackbone.storage\` | ✅ |
61014
+ | \`STACKBONE_S3_FORCE_PATH_STYLE\` | \`stackbone.storage\` | ✅ (\`true\`) |
61015
+ | \`MODEL_PROVIDER_API_KEY\` | \`stackbone.ai\` | ❗ **you supply it** |
61016
+ | \`MODEL_PROVIDER_BASE_URL\` | \`stackbone.ai\` (override) | ❗ **you supply it** |
60640
61017
 
60641
61018
  The two model-provider values are the only ones you bring yourself, and you
60642
61019
  have two ways to do it: export them in your shell, or save them once on the
@@ -63304,7 +63681,7 @@ var createInitCommand = (ctx) => defineCommand({
63304
63681
  });
63305
63682
  //#endregion
63306
63683
  //#region src/services/deployments.service.ts
63307
- init_src$12();
63684
+ init_src$11();
63308
63685
  init_src$13();
63309
63686
  var registerDeployment = async (ctx, body) => {
63310
63687
  return parseResponse$1(deploymentResponseSchema, await ctx.http.post("/api/v1/deployments", body), "POST /api/v1/deployments");
@@ -63318,7 +63695,7 @@ var readIdentityVerification = async (response) => {
63318
63695
  return "unknown";
63319
63696
  }
63320
63697
  const state = body?.identityVerification;
63321
- return state === "armed" || state === "unarmed" ? state : "unknown";
63698
+ return state === "armed" || state === "unarmed" || state === "untaught" ? state : "unknown";
63322
63699
  };
63323
63700
  var verifyDeploymentSecret = async (args) => {
63324
63701
  const fetchImpl = args.fetchImpl ?? fetch;
@@ -63361,6 +63738,151 @@ var verifyDeploymentSecret = async (args) => {
63361
63738
  });
63362
63739
  return readIdentityVerification(response);
63363
63740
  };
63741
+ /**
63742
+ * STA-594/595 — tell the box which installation it answers for, the step between
63743
+ * proving the secret and writing the deployment.
63744
+ *
63745
+ * The browser wizard has always done this; `link` did not, and the box that
63746
+ * results is worse than unregistered: it refuses every browser and every CLI call
63747
+ * with a 401, because a box that was never taught answers for nothing. So this is
63748
+ * fail-closed like the challenge above it — a box that cannot be taught is not
63749
+ * registered at all, rather than registered and mute.
63750
+ *
63751
+ * Signed with the SAME secret the challenge proved, over METHOD.path.BODY, so the
63752
+ * exact bytes sent are the exact bytes signed: a body swapped in flight no longer
63753
+ * verifies and the box cannot be pointed at somebody else's installation.
63754
+ */
63755
+ var teachDeploymentIdentity = async (args) => {
63756
+ const fetchImpl = args.fetchImpl ?? fetch;
63757
+ const teachUrl = new URL(CONTROL_PLANE_IDENTITY_PATH, args.url).toString();
63758
+ const body = JSON.stringify({
63759
+ installationId: args.installationId,
63760
+ organizationId: args.organizationId
63761
+ });
63762
+ const header = buildStackboneSignatureHeader({
63763
+ secrets: [args.secret],
63764
+ payload: proxyRequestPayload({
63765
+ method: "POST",
63766
+ path: CONTROL_PLANE_IDENTITY_PATH,
63767
+ body
63768
+ })
63769
+ });
63770
+ let response;
63771
+ try {
63772
+ response = await fetchImpl(teachUrl, {
63773
+ method: "POST",
63774
+ headers: {
63775
+ "stackbone-signature": header,
63776
+ "content-type": "application/json"
63777
+ },
63778
+ body
63779
+ });
63780
+ } catch (cause) {
63781
+ throw new StackboneCliError({
63782
+ code: "generic",
63783
+ message: `Could not reach the deployed container at ${args.url} to tell it which installation it answers for`,
63784
+ suggestion: "Check the box is still reachable, then re-run `stackbone link`. Nothing was registered.",
63785
+ cause
63786
+ });
63787
+ }
63788
+ if (response.ok) return;
63789
+ if (response.status === 404) throw new StackboneCliError({
63790
+ code: "generic",
63791
+ message: `The container at ${args.url} runs an image from before boxes could be told which installation they answer for, so it would refuse every browser and every CLI call`,
63792
+ suggestion: "Upgrade the image and re-run `stackbone link`. Nothing was registered."
63793
+ });
63794
+ throw new StackboneCliError({
63795
+ code: "generic",
63796
+ message: `The container at ${args.url} could not record which installation it answers for: ${await readRefusal(response)}`,
63797
+ suggestion: "Fix what the box reported, then re-run `stackbone link`. Nothing was registered."
63798
+ });
63799
+ };
63800
+ /** The box's own sentence when it has one, and its status code when it does not. */
63801
+ var readRefusal = async (response) => {
63802
+ try {
63803
+ const message = (await response.json())?.message;
63804
+ if (typeof message === "string" && message.length > 0) return message;
63805
+ } catch {}
63806
+ return `it answered ${response.status}`;
63807
+ };
63808
+ //#endregion
63809
+ //#region src/services/handshake.service.ts
63810
+ init_src$13();
63811
+ /** Public, credential-free handshake every deployed box serves. */
63812
+ var CONTRACT_PATH = "/api/contract";
63813
+ /**
63814
+ * Something is at that address, but it is not a Stackbone box: a gateway's error
63815
+ * page, a company site, a load balancer in front of the wrong service. Kept
63816
+ * distinct from "nothing answered" because the remedy is different — one is an
63817
+ * address to correct, the other a machine to go and look at.
63818
+ */
63819
+ var notAHandshake = (url, status, cause) => new StackboneCliError({
63820
+ code: "generic",
63821
+ message: `${url} answered with HTTP ${status}, but not with a Stackbone box handshake`,
63822
+ suggestion: "Check --url points at the box itself: a company site, or a load balancer in front of the wrong service, answers just like this.",
63823
+ cause
63824
+ });
63825
+ /**
63826
+ * Asks a box which protocol version it speaks, before it is registered.
63827
+ *
63828
+ * Uses a DIRECT `fetch` — `ctx.http` refuses absolute URLs on purpose (it only
63829
+ * ever talks to the control plane), and this is a cross-host call to the
63830
+ * customer's own box. The handshake is public and takes no credential, which is
63831
+ * what makes it usable here: at this point in `link` nothing has been registered,
63832
+ * so there is no identity to present anyway.
63833
+ */
63834
+ var readBoxContract = async (args) => {
63835
+ const fetchImpl = args.fetchImpl ?? fetch;
63836
+ let contractUrl;
63837
+ try {
63838
+ contractUrl = new URL(CONTRACT_PATH, args.url).toString();
63839
+ } catch (cause) {
63840
+ throw new StackboneCliError({
63841
+ code: "generic",
63842
+ message: `--url is not a valid URL: ${args.url}`,
63843
+ suggestion: "Pass the full public URL of the deployed agent, e.g. https://agent.example.com",
63844
+ cause
63845
+ });
63846
+ }
63847
+ let response;
63848
+ try {
63849
+ response = await fetchImpl(contractUrl, { method: "GET" });
63850
+ } catch (cause) {
63851
+ throw new StackboneCliError({
63852
+ code: "generic",
63853
+ message: `Nothing answered at ${args.url}, so there is no way to read which protocol version that box speaks`,
63854
+ suggestion: "Check --url is the public address of the running container and that this machine can reach it.",
63855
+ cause
63856
+ });
63857
+ }
63858
+ let body;
63859
+ try {
63860
+ body = await response.json();
63861
+ } catch (cause) {
63862
+ throw notAHandshake(args.url, response.status, cause);
63863
+ }
63864
+ const parsed = contractResponseSchema.safeParse(body);
63865
+ if (!response.ok || !parsed.success) throw notAHandshake(args.url, response.status, parsed.success ? body : parsed.error);
63866
+ return parsed.data;
63867
+ };
63868
+ /**
63869
+ * Refuses a box this product cannot register, whatever door it came through.
63870
+ *
63871
+ * Pure and total: it reads the version the handshake reported and nothing else.
63872
+ * The floor is `MIN_SUPPORTED_DEPLOYMENT_REGISTRATION_CONTRACT_VERSION`, NOT the
63873
+ * SDK-wide `MIN_SUPPORTED_CONTRACT_VERSION` — that one is 1, the handshake schema
63874
+ * already refuses anything below 1, and a guard built on it could never fire.
63875
+ * The sentence comes from the shared source the browser wizard prints too, so an
63876
+ * operator who tries the other door reads the same story about the same box.
63877
+ */
63878
+ var assertBoxCanBeRegistered = (contract) => {
63879
+ if (supportsDeploymentRegistration(contract.version)) return;
63880
+ throw new StackboneCliError({
63881
+ code: "generic",
63882
+ message: deploymentRegistrationRefusal(contract.version),
63883
+ suggestion: "Re-run `stackbone link` once the box is running a current image."
63884
+ });
63885
+ };
63364
63886
  //#endregion
63365
63887
  //#region src/commands/link/link.command.ts
63366
63888
  var requireLinkField = (value, flag, description) => {
@@ -63390,6 +63912,7 @@ var requireLinkField = (value, flag, description) => {
63390
63912
  */
63391
63913
  var identityVerificationWarning = (state) => {
63392
63914
  if (state === "armed") return null;
63915
+ if (state === "untaught") return "This box verifies browser identity but has not been told which installation it answers for, so it refuses every browser and every CLI call. Register it — `stackbone link` does this for you.";
63393
63916
  if (state === "unarmed") return "This box does not verify browser identity, so Studio and the CLI cannot reach it — the deployment is registered, but every screen and every box command will be refused with a 401. Set STACKBONE_CONTROL_PLANE_URL on the container to your control plane address and restart it.";
63394
63917
  return "This box does not report whether it verifies browser identity — it runs an image from before boxes said so. The deployment is registered either way; upgrade the image to find out whether Studio can reach it at all.";
63395
63918
  };
@@ -63448,23 +63971,34 @@ var runLink = async (ctx, args) => {
63448
63971
  const activeOrg = await getActiveOrganization(ctx);
63449
63972
  const agent = await getAgentByOwnerAndSlug(ctx, activeOrg.slug, chosenSlug);
63450
63973
  if (!ctx.flags.json) log.step(`Linking ${targetDir} to ${agent.slug}`);
63974
+ if (!ctx.flags.json) log.step("Reading the box protocol version");
63975
+ assertBoxCanBeRegistered(await readBoxContract({ url }));
63451
63976
  if (!ctx.flags.json) log.step("Verifying the --secret against the deployed container");
63452
- const identityVerification = await verifyDeploymentSecret({
63977
+ const challengeVerification = await verifyDeploymentSecret({
63453
63978
  url,
63454
63979
  secret
63455
63980
  });
63456
- const identityWarning = identityVerificationWarning(identityVerification);
63457
- if (identityWarning && !ctx.flags.json) log.warn(identityWarning);
63458
- const [localDev, deployment] = await Promise.all([upsertLocalDevInstallation(ctx, {
63981
+ const localDev = await upsertLocalDevInstallation(ctx, {
63459
63982
  creatorOrganizationSlug: activeOrg.slug,
63460
63983
  agentTemplateSlug: agent.slug
63461
- }), registerDeployment(ctx, {
63984
+ });
63985
+ if (!ctx.flags.json) log.step("Telling the box which installation it answers for");
63986
+ await teachDeploymentIdentity({
63987
+ url,
63988
+ secret,
63989
+ installationId: localDev.id,
63990
+ organizationId: localDev.organizationId
63991
+ });
63992
+ const identityVerification = challengeVerification === "untaught" ? "armed" : challengeVerification;
63993
+ const identityWarning = identityVerificationWarning(identityVerification);
63994
+ if (identityWarning && !ctx.flags.json) log.warn(identityWarning);
63995
+ const deployment = await registerDeployment(ctx, {
63462
63996
  organizationSlug: activeOrg.slug,
63463
63997
  agentSlug: agent.slug,
63464
63998
  url,
63465
63999
  hmacSecret: secret,
63466
64000
  imageTag: tag
63467
- })]);
64001
+ });
63468
64002
  await writeProjectConfig(ctx, agent, {
63469
64003
  root: targetDir,
63470
64004
  force: true
@@ -65746,7 +66280,7 @@ if (shouldReexecUnderWinpty({
65746
66280
  if (isVersionFlag(rawArgs)) {
65747
66281
  process.stdout.write(`${formatVersionOutput({
65748
66282
  cliVersion: STACKBONE_CLI_BUILD_VERSION,
65749
- contractVersion: 14
66283
+ contractVersion: 15
65750
66284
  })}\n`);
65751
66285
  process.exit(0);
65752
66286
  }