@stackbone/cli 0.3.1 → 0.3.2

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/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.2] - 2026-08-10
11
+
12
+ ### Added
13
+
14
+ - **`stackbone link` refuses a box the control plane could not work with, before
15
+ it writes anything.** The command registered whatever answered the URL, so a
16
+ container too old to be registered from the browser could still be linked from
17
+ the terminal without a word — and the failure surfaced later, on the first
18
+ screen that needed something the box does not speak, with a deployment row on
19
+ file to explain it. `link` now reads the box's public handshake
20
+ (`GET <url>/api/contract`, no credential) right after it resolves the agent and
21
+ before it verifies `--secret`, and refuses with the same sentence the browser
22
+ wizard prints. A box that never answers, one that answers with something that
23
+ is not a handshake, and one that is simply too old each fail in their own
24
+ words. Nothing is written on refusal: no deployment, no local project file.
25
+
26
+ ### Changed
27
+
28
+ - **The agent protocol the box reports goes from 14 to 15, `stackbone dev`
29
+ included.** The box now permits the `stackbone-signature` request header on a
30
+ cross-origin preflight, which is what lets a browser sign a challenge against
31
+ it. Nothing on the wire changes shape and no capability moves — the number is
32
+ the change, and it is what a caller reads to know the box has it. The origin
33
+ allowlist is untouched: one more permitted header is not one more permitted
34
+ origin.
35
+ - **`stackbone package` says how a managed Redis URL is written.** The generated
36
+ `.env` and README carried `redis://host:6379`, which no managed provider
37
+ accepts: they all hand out a password, some require TLS, and a password
38
+ containing `/`, `+`, `=` or `@` has to be percent-encoded or the URL does not
39
+ parse. All three are now spelled out where the operator reads them, next to the
40
+ note that the agent PINGs Redis before starting and stops with the reason if it
41
+ cannot.
42
+
10
43
  ## [0.3.1] - 2026-08-06
11
44
 
12
45
  ### Added
package/main.js CHANGED
@@ -501,6 +501,25 @@ var init_approvals = __esmMin((() => {
501
501
  //#endregion
502
502
  //#region ../../libs/shared/validators/src/lib/agents/contract.ts
503
503
  /**
504
+ * Whether a box speaking protocol `version` can be registered as a deployment.
505
+ * Pure and total: the caller passes the number the handshake reported.
506
+ */
507
+ function supportsDeploymentRegistration(version) {
508
+ return version >= 15;
509
+ }
510
+ /**
511
+ * The one sentence both doors print when a box is below the floor.
512
+ *
513
+ * Shared, not copied, because the wizard and `stackbone link` refuse by the same
514
+ * rule and an operator who tries the other door must not read a different story.
515
+ * For the same reason it names no door: there is no "register it from the
516
+ * terminal instead" any more, and the only way out it can honestly offer is the
517
+ * one that works everywhere — run a current image.
518
+ */
519
+ function deploymentRegistrationRefusal(version) {
520
+ 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.`;
521
+ }
522
+ /**
504
523
  * Validates that the negotiated `contract.version` is at or above the
505
524
  * version the SDK still executes against. Pure, table-driven, never throws.
506
525
  *
@@ -604,7 +623,7 @@ var init_deployments = __esmMin((() => {
604
623
  agentSlug: agentSlugSchema,
605
624
  url: z.url(),
606
625
  hmacSecret: z.string().min(1),
607
- imageTag: z.string().min(1).max(200)
626
+ imageTag: z.string().min(1).max(200).optional()
608
627
  });
609
628
  z.object({ url: z.url() });
610
629
  deploymentResponseSchema = z.object({
@@ -3461,7 +3480,7 @@ function createContractStore(resolved = resolveConfig({})) {
3461
3480
  };
3462
3481
  }
3463
3482
  async function performHandshake(baseUrl, fetchImpl) {
3464
- const url = `${baseUrl}${CONTRACT_PATH}`;
3483
+ const url = `${baseUrl}${CONTRACT_PATH$1}`;
3465
3484
  let response;
3466
3485
  try {
3467
3486
  response = await fetchImpl(url, { method: "GET" });
@@ -3547,12 +3566,12 @@ function maybeLogDebug(debugLogged, key, contract) {
3547
3566
  debugLogged.add(key);
3548
3567
  console.warn(`[stackbone] contract handshake ok: baseUrl=${key} version=${contract.version} capabilities=${contract.capabilities.length}`);
3549
3568
  }
3550
- var CONTRACT_PATH;
3569
+ var CONTRACT_PATH$1;
3551
3570
  var init_contract_handshake = __esmMin((() => {
3552
3571
  init_src$13();
3553
3572
  init_config$1();
3554
3573
  init_result$1();
3555
- CONTRACT_PATH = "/api/contract";
3574
+ CONTRACT_PATH$1 = "/api/contract";
3556
3575
  }));
3557
3576
  //#endregion
3558
3577
  //#region ../../libs/sdk/src/contract/capability-registry.ts
@@ -10576,7 +10595,7 @@ var createBoxAuthGate = (options) => {
10576
10595
  //#endregion
10577
10596
  //#region ../../libs/runtime/workspace-runtime/src/lib/server/contract.ts
10578
10597
  init_src$13();
10579
- var STACKBONE_CLI_BUILD_VERSION = "0.3.1";
10598
+ var STACKBONE_CLI_BUILD_VERSION = "0.3.2";
10580
10599
  /**
10581
10600
  * Capabilities the local emulator advertises on `GET /api/contract`, derived
10582
10601
  * from the route registry above so the advertised set and the mounted routes
@@ -10597,7 +10616,7 @@ var EMULATOR_CAPABILITIES = Object.keys({
10597
10616
  "browser.provider": "/api/browser-provider"
10598
10617
  });
10599
10618
  var buildContractResponse = () => contractResponseSchema.parse({
10600
- version: 14,
10619
+ version: 15,
10601
10620
  minSupported: 1,
10602
10621
  capabilities: [...EMULATOR_CAPABILITIES],
10603
10622
  build: {
@@ -40738,8 +40757,7 @@ var redisConnection = (redisUrl) => {
40738
40757
  const db = path === "" ? void 0 : Number(path);
40739
40758
  if (db !== void 0 && !Number.isInteger(db)) throw new Error(`Invalid Redis database in URL path: '${url.pathname}' (expected an integer)`);
40740
40759
  return {
40741
- host: url.hostname,
40742
- port: url.port ? Number(url.port) : 6379,
40760
+ url: redisUrl,
40743
40761
  ...db === void 0 ? {} : { db },
40744
40762
  maxRetriesPerRequest: null
40745
40763
  };
@@ -41160,7 +41178,7 @@ var init_connections = __esmMin((() => {
41160
41178
  buildAgentEnv = (base, injection, options) => {
41161
41179
  const env = {
41162
41180
  ...base,
41163
- [STACKBONE_ENV_KEYS.contractVersion]: String(14),
41181
+ [STACKBONE_ENV_KEYS.contractVersion]: String(15),
41164
41182
  [STACKBONE_ENV_KEYS.agentId]: injection.agentId,
41165
41183
  [STACKBONE_ENV_KEYS.postgresUrl]: injection.services.postgresUrl,
41166
41184
  [STACKBONE_ENV_KEYS.s3Endpoint]: injection.services.s3Endpoint,
@@ -45043,7 +45061,8 @@ var buildEmulatorApp = (opts, hooks = {}) => {
45043
45061
  "x-stackbone-session",
45044
45062
  "x-stackbone-conversation",
45045
45063
  "x-api-key",
45046
- "anthropic-version"
45064
+ "anthropic-version",
45065
+ STACKBONE_SIGNATURE_HEADER
45047
45066
  ],
45048
45067
  exposeHeaders: ["X-Stackbone-Run-Id"]
45049
45068
  }));
@@ -49159,7 +49178,9 @@ STACKBONE_SECRET_KEY=${secretKey}
49159
49178
  # agent creates its own schema on first boot. It must have the pgvector extension
49160
49179
  # available — see the README.
49161
49180
  # DATABASE_URL=postgres://user:password@host:5432/dbname?sslmode=require
49162
- # WORKFLOW_REDIS_URL=redis://host:6379
49181
+ # WORKFLOW_REDIS_URL=redis://default:password@host:6379
49182
+ # Managed Redis always wants a password, and some require TLS — use rediss:// for
49183
+ # those. Percent-encode a password containing / + = or @, or the URL will not parse.
49163
49184
 
49164
49185
  # ---- Optional ----------------------------------------------------------------
49165
49186
  # Set if your database role is not allowed to create schemas; you then apply the
@@ -49244,6 +49265,14 @@ and apply these by hand first, in filename order:
49244
49265
 
49245
49266
  Both live inside the image; \`docker run --rm ${input.imageTag} ls <path>\` lists them.
49246
49267
 
49268
+ Redis needs no preparation, only a reachable URL. Managed instances hand out a
49269
+ password, so the URL carries one — \`redis://default:password@host:6379\`. Use
49270
+ \`rediss://\` where the provider requires TLS (Upstash, ElastiCache with encryption
49271
+ in transit), and percent-encode a password containing \`/\`, \`+\`, \`=\` or \`@\`. The
49272
+ agent PINGs Redis before it starts and stops with the reason if it cannot: a
49273
+ rejected password fails right away, and a Redis still starting up is retried for
49274
+ 30 seconds first.
49275
+
49247
49276
  ## Running it somewhere other than Compose
49248
49277
 
49249
49278
  The agent serves on port **${RUNTIME_PORT}** by default, and binds whatever
@@ -54060,7 +54089,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
54060
54089
  tunnelUrl: session.publicUrl,
54061
54090
  localUrl: session.emulatorUrl,
54062
54091
  deeplink,
54063
- contractVersion: 14,
54092
+ contractVersion: 15,
54064
54093
  capabilityCount: EMULATOR_CAPABILITIES.length,
54065
54094
  agentName: agent.name,
54066
54095
  colors: process.stdout.isTTY === true
@@ -63362,6 +63391,84 @@ var verifyDeploymentSecret = async (args) => {
63362
63391
  return readIdentityVerification(response);
63363
63392
  };
63364
63393
  //#endregion
63394
+ //#region src/services/handshake.service.ts
63395
+ init_src$13();
63396
+ /** Public, credential-free handshake every deployed box serves. */
63397
+ var CONTRACT_PATH = "/api/contract";
63398
+ /**
63399
+ * Something is at that address, but it is not a Stackbone box: a gateway's error
63400
+ * page, a company site, a load balancer in front of the wrong service. Kept
63401
+ * distinct from "nothing answered" because the remedy is different — one is an
63402
+ * address to correct, the other a machine to go and look at.
63403
+ */
63404
+ var notAHandshake = (url, status, cause) => new StackboneCliError({
63405
+ code: "generic",
63406
+ message: `${url} answered with HTTP ${status}, but not with a Stackbone box handshake`,
63407
+ 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.",
63408
+ cause
63409
+ });
63410
+ /**
63411
+ * Asks a box which protocol version it speaks, before it is registered.
63412
+ *
63413
+ * Uses a DIRECT `fetch` — `ctx.http` refuses absolute URLs on purpose (it only
63414
+ * ever talks to the control plane), and this is a cross-host call to the
63415
+ * customer's own box. The handshake is public and takes no credential, which is
63416
+ * what makes it usable here: at this point in `link` nothing has been registered,
63417
+ * so there is no identity to present anyway.
63418
+ */
63419
+ var readBoxContract = async (args) => {
63420
+ const fetchImpl = args.fetchImpl ?? fetch;
63421
+ let contractUrl;
63422
+ try {
63423
+ contractUrl = new URL(CONTRACT_PATH, args.url).toString();
63424
+ } catch (cause) {
63425
+ throw new StackboneCliError({
63426
+ code: "generic",
63427
+ message: `--url is not a valid URL: ${args.url}`,
63428
+ suggestion: "Pass the full public URL of the deployed agent, e.g. https://agent.example.com",
63429
+ cause
63430
+ });
63431
+ }
63432
+ let response;
63433
+ try {
63434
+ response = await fetchImpl(contractUrl, { method: "GET" });
63435
+ } catch (cause) {
63436
+ throw new StackboneCliError({
63437
+ code: "generic",
63438
+ message: `Nothing answered at ${args.url}, so there is no way to read which protocol version that box speaks`,
63439
+ suggestion: "Check --url is the public address of the running container and that this machine can reach it.",
63440
+ cause
63441
+ });
63442
+ }
63443
+ let body;
63444
+ try {
63445
+ body = await response.json();
63446
+ } catch (cause) {
63447
+ throw notAHandshake(args.url, response.status, cause);
63448
+ }
63449
+ const parsed = contractResponseSchema.safeParse(body);
63450
+ if (!response.ok || !parsed.success) throw notAHandshake(args.url, response.status, parsed.success ? body : parsed.error);
63451
+ return parsed.data;
63452
+ };
63453
+ /**
63454
+ * Refuses a box this product cannot register, whatever door it came through.
63455
+ *
63456
+ * Pure and total: it reads the version the handshake reported and nothing else.
63457
+ * The floor is `MIN_SUPPORTED_DEPLOYMENT_REGISTRATION_CONTRACT_VERSION`, NOT the
63458
+ * SDK-wide `MIN_SUPPORTED_CONTRACT_VERSION` — that one is 1, the handshake schema
63459
+ * already refuses anything below 1, and a guard built on it could never fire.
63460
+ * The sentence comes from the shared source the browser wizard prints too, so an
63461
+ * operator who tries the other door reads the same story about the same box.
63462
+ */
63463
+ var assertBoxCanBeRegistered = (contract) => {
63464
+ if (supportsDeploymentRegistration(contract.version)) return;
63465
+ throw new StackboneCliError({
63466
+ code: "generic",
63467
+ message: deploymentRegistrationRefusal(contract.version),
63468
+ suggestion: "Re-run `stackbone link` once the box is running a current image."
63469
+ });
63470
+ };
63471
+ //#endregion
63365
63472
  //#region src/commands/link/link.command.ts
63366
63473
  var requireLinkField = (value, flag, description) => {
63367
63474
  const trimmed = (value ?? "").trim();
@@ -63448,6 +63555,8 @@ var runLink = async (ctx, args) => {
63448
63555
  const activeOrg = await getActiveOrganization(ctx);
63449
63556
  const agent = await getAgentByOwnerAndSlug(ctx, activeOrg.slug, chosenSlug);
63450
63557
  if (!ctx.flags.json) log.step(`Linking ${targetDir} to ${agent.slug}`);
63558
+ if (!ctx.flags.json) log.step("Reading the box protocol version");
63559
+ assertBoxCanBeRegistered(await readBoxContract({ url }));
63451
63560
  if (!ctx.flags.json) log.step("Verifying the --secret against the deployed container");
63452
63561
  const identityVerification = await verifyDeploymentSecret({
63453
63562
  url,
@@ -65746,7 +65855,7 @@ if (shouldReexecUnderWinpty({
65746
65855
  if (isVersionFlag(rawArgs)) {
65747
65856
  process.stdout.write(`${formatVersionOutput({
65748
65857
  cliVersion: STACKBONE_CLI_BUILD_VERSION,
65749
- contractVersion: 14
65858
+ contractVersion: 15
65750
65859
  })}\n`);
65751
65860
  process.exit(0);
65752
65861
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackbone/cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "bin": {