@stackbone/cli 0.5.2 → 0.5.4

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,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.4] - 2026-10-01
11
+
12
+ ### Added
13
+
14
+ - **`stackbone dev` says why the tunnel closed.** When `frpc` stops after the
15
+ tunnel came up, `stackbone dev` keeps running and prints the cause: the
16
+ ticket reached its 8-hour limit, the control plane revoked the grant, or
17
+ another cause, with the last lines `frpc` printed. Each message says to
18
+ restart `stackbone dev` for a new URL. Before, the tunnel closed without a
19
+ word (PLA-1892).
20
+
21
+ ### Changed
22
+
23
+ - **`frpc` now sends the grant's signed ticket to the relay.** The ticket is
24
+ written to the `frpc` config as global metadata, with a heartbeat every 30
25
+ seconds so the relay sees it on every ping as well as on login. The shared
26
+ token is still sent. A control plane that mints no ticket still opens the
27
+ tunnel on the shared token alone (PLA-1892).
28
+
29
+ ### Fixed
30
+
31
+ - **An expired tunnel grant could clear the tunnel URL of an unrelated
32
+ `stackbone dev` session.** The control plane matched a lapsed grant to local
33
+ installations by searching their URL text for its subdomain, so one grant
34
+ could null another session's URL, in any organization. `stackbone dev` now
35
+ registers its local-dev installation before the tunnel opens, or registers it
36
+ again when the id in `.stackbone/project.json` no longer resolves, and sends
37
+ that id with the grant request. The control plane writes the tunnel URL when
38
+ it mints the grant and clears it on revoke or expiry, so the CLI no longer
39
+ PATCHes the URL on start or on Ctrl-C. If no installation can be registered,
40
+ the tunnel still opens, with an unbound grant (PLA-1691).
41
+
42
+ - **`stackbone logs --since 5` read the number as a date in 2001 and filtered
43
+ nothing.** A bare number is a duration with the unit forgotten; `--since`
44
+ and `--until` now reject it and suggest one, e.g. `5m` (PLA-1796).
45
+
46
+ ## [0.5.3] - 2026-09-25
47
+
48
+ ### Fixed
49
+
50
+ - **`stackbone dev` failed at `Starting docker services` with `401 UNAUTHORIZED`
51
+ from quay.io.** MinIO closed anonymous pulls of `minio/minio` there too, after
52
+ Docker Hub. The compose files `stackbone dev` and `stackbone package` write now
53
+ pull the community fork `pgsty/minio:RELEASE.2026-08-04T00-00-00Z`, which needs
54
+ no account (PLA-1794).
55
+
10
56
  ## [0.5.2] - 2026-09-23
11
57
 
12
58
  ### Fixed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @stackbone/cli
2
2
 
3
- The CLI for [Stackbone](https://stackbone.ai) — the marketplace and runtime for containerized AI agents.
3
+ The CLI for [Stackbone](https://stackbone.ai): scaffold, run and deploy agents and workflows written in TypeScript, on Stackbone Cloud or your own infrastructure.
4
4
 
5
5
  > **Status:** `alpha`. The contract may break between releases. Pin a version or rely on the `alpha` dist-tag.
6
6
 
@@ -15,7 +15,7 @@ cd my-workspace && npm install
15
15
  npx @stackbone/cli dev
16
16
  ```
17
17
 
18
- That's it. `init` scaffolds a new workspace shell (agents + workflows + coding-agent skills) and links it to your organization, so you sign in first; `dev` boots the whole workspace locally — Postgres, Redis, MinIO and Stackbone Studio at `http://localhost:4242` — so you can iterate without publishing.
18
+ That's it. `init` scaffolds a new workspace shell (agents + workflows + coding-agent skills) and links it to your organization, so you sign in first; `dev` boots the whole workspace locally (Postgres, Redis, MinIO and Stackbone Studio at `http://localhost:4242`) so you can iterate before you deploy.
19
19
 
20
20
  ## Commands
21
21
 
@@ -45,7 +45,7 @@ Run any command with `--help` for the full flag reference. The full documentatio
45
45
  ## Links
46
46
 
47
47
  - Documentation: <https://docs.stackbone.ai>
48
- - Marketplace: <https://stackbone.ai>
48
+ - Website: <https://stackbone.ai>
49
49
  - Issues & feedback: contact the team directly.
50
50
 
51
51
  ## License
package/main.js CHANGED
@@ -9,7 +9,7 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep }
9
9
  import semver from "semver";
10
10
  import pino from "pino";
11
11
  import pretty from "pino-pretty";
12
- import { exec, execFile, spawn, spawnSync } from "node:child_process";
12
+ import { execFile, spawn, spawnSync } from "node:child_process";
13
13
  import { fileURLToPath, pathToFileURL } from "node:url";
14
14
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
15
15
  import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
@@ -37,9 +37,9 @@ import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
37
37
  import PgQueryFactory from "pg-query-emscripten";
38
38
  import { createRemoteJWKSet, errors, jwtVerify } from "jose";
39
39
  import { CLIENT_CAPABILITIES_META_KEY, McpServer, createMcpHandler, inputRequired, inputResponse } from "@modelcontextprotocol/server";
40
+ import { COMPOSED_HANDLER } from "hono/utils/constants";
40
41
  import Ajv from "ajv/dist/2020.js";
41
42
  import addFormats from "ajv-formats";
42
- import { COMPOSED_HANDLER } from "hono/utils/constants";
43
43
  import { parse } from "yaml";
44
44
  import { Queue, Worker } from "bullmq";
45
45
  import Ajv$1 from "ajv";
@@ -2045,12 +2045,15 @@ var init_user_code = __esmMin((() => {
2045
2045
  if (clean.length !== 8) return raw;
2046
2046
  return `${clean.slice(0, 4)}-${clean.slice(4)}`;
2047
2047
  };
2048
- })), DEV_TUNNEL_GRANT_MAX_TTL_SECONDS, devTunnelGrantResponseSchema;
2048
+ })), DEV_TUNNEL_GRANT_HARD_CAP_SECONDS, DEV_TUNNEL_GRANT_MAX_TTL_SECONDS, buildCanonicalTunnelUrl, devTunnelGrantResponseSchema, devTunnelRevocationListResponseSchema;
2049
2049
  var init_dev_tunnel = __esmMin((() => {
2050
2050
  init_organizations();
2051
+ DEV_TUNNEL_GRANT_HARD_CAP_SECONDS = 480 * 60;
2051
2052
  DEV_TUNNEL_GRANT_MAX_TTL_SECONDS = 86400;
2053
+ buildCanonicalTunnelUrl = (subdomain, relayHost) => `https://${subdomain}.${relayHost}`;
2052
2054
  z.object({
2053
2055
  agentSlug: agentSlugSchema,
2056
+ installationId: z.uuid().optional(),
2054
2057
  ttlSeconds: z.number().int().positive().max(DEV_TUNNEL_GRANT_MAX_TTL_SECONDS, { message: `ttlSeconds must not exceed ${DEV_TUNNEL_GRANT_MAX_TTL_SECONDS}` }).optional()
2055
2058
  });
2056
2059
  z.object({ subdomain: z.string().min(8).max(64).regex(/^[a-z0-9-]+$/, "subdomain must be lowercase alphanumeric with optional hyphens") });
@@ -2059,8 +2062,18 @@ var init_dev_tunnel = __esmMin((() => {
2059
2062
  relayPort: z.number().int().positive(),
2060
2063
  subdomain: z.string().min(1),
2061
2064
  frpcToken: z.string().min(1),
2065
+ ticket: z.string().min(1),
2062
2066
  expiresAt: z.iso.datetime()
2063
2067
  });
2068
+ devTunnelRevocationListResponseSchema = z.object({ revocationList: z.string().min(1) });
2069
+ z.object({ keys: z.array(z.object({
2070
+ kty: z.literal("OKP"),
2071
+ crv: z.literal("Ed25519"),
2072
+ x: z.string().min(1),
2073
+ kid: z.string().min(1),
2074
+ alg: z.literal("EdDSA"),
2075
+ use: z.literal("sig")
2076
+ })) });
2064
2077
  }));
2065
2078
  //#endregion
2066
2079
  //#region ../../libs/shared/validators/src/lib/studio/studio-config.ts
@@ -4846,7 +4859,7 @@ var maybeWarnCliUpgrade = (opts) => {
4846
4859
  if (!required && !belowMinimum) return;
4847
4860
  const channel = channelForVersion$1(cliVersion);
4848
4861
  const target = minVersion ?? "the latest release";
4849
- logger.warn(`Your Stackbone CLI (${cliVersion}) is older than the minimum supported by the control plane (${target}). Upgrade with \`npm i -g @stackbone/cli@${channel}\`.`);
4862
+ logger.warn(`Your Stackbone CLI (${cliVersion}) is older than the minimum supported by the control plane (${target}). Upgrade with \`pnpm add -g @stackbone/cli@${channel}\`.`);
4850
4863
  };
4851
4864
  //#endregion
4852
4865
  //#region src/infra/url.ts
@@ -4991,7 +5004,7 @@ var statusToError = (status, body) => {
4991
5004
  default: code = "generic";
4992
5005
  }
4993
5006
  const inferredMessage = withIssueLines(typeof body === "object" && body !== null && "message" in body ? String(body.message) : `HTTP ${status}`, body);
4994
- const serverUpgrade = readUpgradeCommand(body) ?? "npm i -g @stackbone/cli";
5007
+ const serverUpgrade = readUpgradeCommand(body) ?? "pnpm add -g @stackbone/cli";
4995
5008
  const suggestion = code === "auth" ? "Run `stackbone login`" : code === "cli_upgrade_required" ? `Upgrade with \`${serverUpgrade}\`, then retry.` : void 0;
4996
5009
  return new StackboneCliError({
4997
5010
  code,
@@ -5273,7 +5286,7 @@ var formatUnsupportedPlatformBanner = (colors) => {
5273
5286
  ` ${dim("•")} Git Bash: open a Git Bash terminal and re-run the command`,
5274
5287
  ` ${dim("•")} WSL2:`,
5275
5288
  ` ${dim("1.")} wsl --install -d Ubuntu ${dim("# one-time, requires reboot")}`,
5276
- ` ${dim("2.")} (in the Ubuntu shell) pnpm install -g @stackbone/cli`,
5289
+ ` ${dim("2.")} (in the Ubuntu shell) pnpm add -g @stackbone/cli`,
5277
5290
  ` ${dim("3.")} stackbone <command>`,
5278
5291
  ""
5279
5292
  ].join("\n");
@@ -5391,7 +5404,7 @@ var isNewerVersion = (latest, current) => {
5391
5404
  }
5392
5405
  };
5393
5406
  /** Discreet two-line banner, terminated with a newline. */
5394
- var formatUpdateBanner = (current, latest, channel) => `\nA new release of the Stackbone CLI is available: ${current} → ${latest}\nUpgrade with: npm i -g @stackbone/cli@${channel}\n`;
5407
+ var formatUpdateBanner = (current, latest, channel) => `\nA new release of the Stackbone CLI is available: ${current} → ${latest}\nUpgrade with: pnpm add -g @stackbone/cli@${channel}\n`;
5395
5408
  var isSilenced = (opts) => {
5396
5409
  const { env } = opts;
5397
5410
  if (env["STACKBONE_NO_UPDATE_CHECK"]) return true;
@@ -15739,7 +15752,7 @@ var emulatorCors = (allowlist) => cors({
15739
15752
  var STACKBONE_CLI_BUILD_VERSION, EMULATOR_CAPABILITY_ROUTES, EMULATOR_CAPABILITIES, buildContractResponse;
15740
15753
  var init_contract = __esmMin((() => {
15741
15754
  init_src$19();
15742
- STACKBONE_CLI_BUILD_VERSION = "0.5.2";
15755
+ STACKBONE_CLI_BUILD_VERSION = "0.5.4";
15743
15756
  EMULATOR_CAPABILITY_ROUTES = {
15744
15757
  "database.postgres_direct": "/api/db",
15745
15758
  "rag.basic": "/api/rag",
@@ -23226,7 +23239,7 @@ var renderStudioStub = (input) => {
23226
23239
  <li><b>GET</b> <a href="/api/docs">/api/docs</a> <span>· full API reference (Scalar, generated from this box's own route table)</span></li>
23227
23240
  <li><b>GET</b> <a href="/api/contract">/api/contract</a> <span>· Stackbone Agent Protocol handshake</span></li>
23228
23241
  <li><b>GET</b> <a href="/api/health">/api/health</a> <span>· emulator status</span></li>
23229
- <li><b>GET</b> <a href="/api/services">/api/services</a> <span>· postgres / s3 connection info</span></li>
23242
+ <li><b>GET</b> <a href="/api/services">/api/services</a> <span>· postgres / s3 endpoints, password removed (a deployed box asks for a credential)</span></li>
23230
23243
  <li><b>GET</b> <a href="/api/discovery">/api/discovery</a> <span>· deep agents + workflows in this workspace</span></li>
23231
23244
  <li><b>POST</b> <code>/openai/v1/chat/completions</code> <span>· chat with a deep agent (OpenAI-compatible; pick the agent via <code>model</code>)</span></li>
23232
23245
  <li><b>POST</b> <code>/anthropic/v1/messages</code> <span>· chat with a deep agent (Anthropic-compatible)</span></li>
@@ -23239,19 +23252,71 @@ var renderStudioStub = (input) => {
23239
23252
  <h2>Try it</h2>
23240
23253
  <p>Chat with a deep agent from any OpenAI-compatible client — paste this base URL + any bearer key, and select the agent by its name in the <code>model</code> field:</p>
23241
23254
  <p><code>curl -N -X POST ${e(input.emulatorUrl)}/openai/v1/chat/completions -H 'content-type: application/json' -H 'authorization: Bearer dev' -d '{"model":"&lt;name&gt;","messages":[{"role":"user","content":"hi"}],"stream":true}'</code></p>
23242
-
23243
- <h2>Connections</h2>
23244
- <ul>
23245
- <li><b>Postgres</b> <span>· ${e(input.postgresUrl)}</span></li>
23246
- <li><b>S3 (MinIO)</b> <span>· ${e(input.s3Endpoint)}</span></li>
23247
- <li><b>Agent</b> <span>· ${e(input.agentUrl)}</span></li>
23248
- </ul>
23249
23255
  </main>
23250
23256
  </body>
23251
23257
  </html>
23252
23258
  `;
23253
23259
  };
23254
23260
  //#endregion
23261
+ //#region ../../libs/runtime/workspace-runtime/src/lib/db/postgres-dsn.ts
23262
+ /** Query parameters libpq reads a password from. */
23263
+ var PASSWORD_PARAM = /^(ssl)?password=/i;
23264
+ /**
23265
+ * The DSN without the userinfo password and the `password` / `sslpassword`
23266
+ * query parameters; everything else stays byte for byte.
23267
+ *
23268
+ * Answers `''` when `URL` cannot parse it (a multi-host DSN) or misread the
23269
+ * userinfo (an unencoded `#`, `?` or `/` in the password), since the password
23270
+ * would survive in the raw string.
23271
+ */
23272
+ var withoutPostgresPassword = (dsn) => {
23273
+ const url = URL.parse(dsn);
23274
+ if (url === null || url.username === "" && dsn.includes("@")) return "";
23275
+ url.password = "";
23276
+ url.search = url.search.slice(1).split("&").filter((pair) => !PASSWORD_PARAM.test(pair)).join("&");
23277
+ return url.toString();
23278
+ };
23279
+ //#endregion
23280
+ //#region ../../libs/runtime/workspace-runtime/src/lib/http/require-capability.ts
23281
+ var CAPABILITY_NATURE, CAPABILITY_GATE, requireCapability, capabilityGateOf, identityHolds;
23282
+ var init_require_capability = __esmMin((() => {
23283
+ init_box_auth_gate();
23284
+ CAPABILITY_NATURE = {
23285
+ "activity:read": "read",
23286
+ "db:query": "read",
23287
+ "logs:read": "read",
23288
+ "secrets:reveal": "read",
23289
+ "api-keys:manage": "write",
23290
+ "config:write": "write",
23291
+ "connections:manage": "write",
23292
+ "evals:run": "write",
23293
+ "hitl:decide": "write",
23294
+ "rag:write": "write",
23295
+ "runs:invoke": "write",
23296
+ "secrets:rotate": "write"
23297
+ };
23298
+ CAPABILITY_GATE = Symbol("stackbone.capabilityGate");
23299
+ requireCapability = (capability) => {
23300
+ const gate = async (c, next) => {
23301
+ const identity = readVerifiedIdentity(c);
23302
+ if (!identity) return next();
23303
+ if (!identityHolds(identity, capability)) return c.json({
23304
+ code: "forbidden",
23305
+ message: `This action requires the ${capability} capability.`
23306
+ }, 403);
23307
+ return next();
23308
+ };
23309
+ return Object.assign(gate, { [CAPABILITY_GATE]: capability });
23310
+ };
23311
+ capabilityGateOf = (handler) => {
23312
+ if (typeof handler !== "function") return void 0;
23313
+ if (CAPABILITY_GATE in handler) return handler[CAPABILITY_GATE];
23314
+ const composed = handler[COMPOSED_HANDLER];
23315
+ return composed === void 0 ? void 0 : capabilityGateOf(composed);
23316
+ };
23317
+ identityHolds = (identity, capability) => identity.caps?.includes(capability) ?? false;
23318
+ }));
23319
+ //#endregion
23255
23320
  //#region ../../libs/runtime/workspace-runtime/src/lib/agent-contract/log-tail.ts
23256
23321
  init_src$19();
23257
23322
  function recordMatchesFilters(record, filters) {
@@ -25325,6 +25390,7 @@ var SimulatedMailbox = class {
25325
25390
  from: draft.from,
25326
25391
  to: [...draft.to],
25327
25392
  body: draft.body,
25393
+ headers: [...draft.headers ?? []],
25328
25394
  receivedAt: this.now().toISOString()
25329
25395
  };
25330
25396
  const sent = this.runs.get(runId) ?? [];
@@ -25423,7 +25489,8 @@ var OutlookDouble = class {
25423
25489
  from: mailboxOwner(args),
25424
25490
  to: recipients,
25425
25491
  subject: textOf(message["subject"]),
25426
- body: bodyContent(message["body"])
25492
+ body: bodyContent(message["body"]),
25493
+ headers: internetHeaders(message["internetMessageHeaders"])
25427
25494
  });
25428
25495
  return {
25429
25496
  ok: true,
@@ -25446,7 +25513,8 @@ var OutlookDouble = class {
25446
25513
  from: mailboxOwner(args),
25447
25514
  to: [parent.from],
25448
25515
  subject: replySubject(parent.subject),
25449
- body
25516
+ body,
25517
+ headers: internetHeaders(draft["internetMessageHeaders"])
25450
25518
  });
25451
25519
  return {
25452
25520
  ok: true,
@@ -25486,9 +25554,18 @@ function toGraphMessage(message) {
25486
25554
  },
25487
25555
  from: { emailAddress: { address: message.from } },
25488
25556
  toRecipients: message.to.map((address) => ({ emailAddress: { address } })),
25489
- receivedDateTime: message.receivedAt
25557
+ receivedDateTime: message.receivedAt,
25558
+ internetMessageHeaders: message.headers.length > 0 ? message.headers.map((h) => ({ ...h })) : null
25490
25559
  };
25491
25560
  }
25561
+ /** `[{ name, value }]` → the headers, dropping malformed entries. */
25562
+ function internetHeaders(value) {
25563
+ if (!Array.isArray(value)) return [];
25564
+ return value.map((entry) => ({
25565
+ name: textOf(asObject$2(entry)["name"]),
25566
+ value: textOf(asObject$2(entry)["value"])
25567
+ })).filter((header) => header.name !== "");
25568
+ }
25492
25569
  /**
25493
25570
  * Whose mailbox this is: Graph names it in the `{user-id}` path segment, so the
25494
25571
  * double sends AS that address, the way the real provider would.
@@ -26037,46 +26114,6 @@ var isMutationOutsideCatalog = (method, path) => matches(MUTATIONS_OUTSIDE_CATAL
26037
26114
  /** Whether `method`+`path` is a read that happens to travel as POST. Exact, never a prefix. */
26038
26115
  var isPostShapedRead = (method, path) => matches(POST_SHAPED_READS, method, path);
26039
26116
  //#endregion
26040
- //#region ../../libs/runtime/workspace-runtime/src/lib/http/require-capability.ts
26041
- var CAPABILITY_NATURE, CAPABILITY_GATE, requireCapability, capabilityGateOf, identityHolds;
26042
- var init_require_capability = __esmMin((() => {
26043
- init_box_auth_gate();
26044
- CAPABILITY_NATURE = {
26045
- "activity:read": "read",
26046
- "db:query": "read",
26047
- "logs:read": "read",
26048
- "secrets:reveal": "read",
26049
- "api-keys:manage": "write",
26050
- "config:write": "write",
26051
- "connections:manage": "write",
26052
- "evals:run": "write",
26053
- "hitl:decide": "write",
26054
- "rag:write": "write",
26055
- "runs:invoke": "write",
26056
- "secrets:rotate": "write"
26057
- };
26058
- CAPABILITY_GATE = Symbol("stackbone.capabilityGate");
26059
- requireCapability = (capability) => {
26060
- const gate = async (c, next) => {
26061
- const identity = readVerifiedIdentity(c);
26062
- if (!identity) return next();
26063
- if (!identityHolds(identity, capability)) return c.json({
26064
- code: "forbidden",
26065
- message: `This action requires the ${capability} capability.`
26066
- }, 403);
26067
- return next();
26068
- };
26069
- return Object.assign(gate, { [CAPABILITY_GATE]: capability });
26070
- };
26071
- capabilityGateOf = (handler) => {
26072
- if (typeof handler !== "function") return void 0;
26073
- if (CAPABILITY_GATE in handler) return handler[CAPABILITY_GATE];
26074
- const composed = handler[COMPOSED_HANDLER];
26075
- return composed === void 0 ? void 0 : capabilityGateOf(composed);
26076
- };
26077
- identityHolds = (identity, capability) => identity.caps?.includes(capability) ?? false;
26078
- }));
26079
- //#endregion
26080
26117
  //#region ../../libs/runtime/workspace-runtime/src/lib/server/route-registry-generation.ts
26081
26118
  var generations = /* @__PURE__ */ new WeakMap();
26082
26119
  /** The generation `app`'s registry is at. Zero until something bumps it. */
@@ -72090,6 +72127,7 @@ init_invocation_guard();
72090
72127
  init_well_known();
72091
72128
  init_contract();
72092
72129
  init_error_boundary();
72130
+ init_require_capability();
72093
72131
  init_route_helpers();
72094
72132
  init_resource_ledger();
72095
72133
  init_teardown_order();
@@ -72137,11 +72175,12 @@ var SUBSYSTEM_HEALTH = named("SubsystemHealth", z.object({
72137
72175
  * spell out (a shape a SECOND author reads goes to `@stackbone/validators` beside
72138
72176
  * its surface) has nothing to bind here.
72139
72177
  *
72140
- * The secret key is deliberately NOT in it: `s3SecretKey` is on the endpoints
72141
- * object this reads from, and the answer carries the access key alone.
72178
+ * No secret is in it. `s3SecretKey` is on the endpoints object this reads from,
72179
+ * and the answer carries the access key alone; the Postgres DSN goes out through
72180
+ * {@link withoutPostgresPassword} (PLA-1754).
72142
72181
  */
72143
72182
  var boxServicesSchema = z.object({
72144
- postgres: z.object({ url: z.string().openapi({ description: "The Postgres DSN the agent was given, credentials included. Under `stackbone dev` it points at the local stack; on a deployed box it is whatever the operator configured." }) }),
72183
+ postgres: z.object({ url: z.string().openapi({ description: "The Postgres DSN the agent was given, with the password removed. Under `stackbone dev` it points at the local stack; on a deployed box it is whatever the operator configured. Empty when the box cannot tell where a password in it ends." }) }),
72145
72184
  storage: z.object({
72146
72185
  endpoint: z.string().openapi({ description: "The S3-compatible endpoint the agent writes to." }),
72147
72186
  bucket: z.string(),
@@ -72190,10 +72229,7 @@ var buildEmulatorApp = (opts, hooks = {}) => {
72190
72229
  app.get("/", (c) => c.html(renderStudioStub({
72191
72230
  agentSlug: opts.agent.slug,
72192
72231
  agentName: opts.agent.name,
72193
- emulatorUrl: ctx.emulatorBaseUrl,
72194
- agentUrl: ctx.agentBaseUrl,
72195
- postgresUrl: opts.injection.services.postgresUrl,
72196
- s3Endpoint: opts.injection.services.s3Endpoint
72232
+ emulatorUrl: new URL(c.req.url).origin
72197
72233
  })));
72198
72234
  const hostKind = opts.host;
72199
72235
  app.openapi(createRoute({
@@ -72258,15 +72294,20 @@ var buildEmulatorApp = (opts, hooks = {}) => {
72258
72294
  checks
72259
72295
  }, 503);
72260
72296
  });
72297
+ const servicesPostgresUrl = withoutPostgresPassword(opts.injection.services.postgresUrl);
72261
72298
  app.openapi(createRoute({
72262
72299
  method: "get",
72263
72300
  path: "/api/services",
72264
72301
  tags: [LIFECYCLE_TAG],
72302
+ middleware: [requireCapability("db:query")],
72265
72303
  summary: "Injected service endpoints",
72266
- description: "The Postgres, object-storage and agent-process endpoints this box injected into the agent, read back. A creator uses it to point `psql` or an S3 client at the same stack their agent is running against. The storage SECRET key is not in the answer; the access key is.",
72267
- responses: { 200: jsonResponse(boxServicesSchema, "The endpoints this box handed the agent.") }
72304
+ description: "The Postgres, object-storage and agent-process endpoints this box injected into the agent, read back. A creator uses it to point `psql` or an S3 client at the same stack their agent is running against. No secret is in the answer: the Postgres password is removed from the DSN, and the storage secret key is left out (the access key is in).",
72305
+ responses: {
72306
+ 200: jsonResponse(boxServicesSchema, "The endpoints this box handed the agent."),
72307
+ 403: errorResponse("The caller's role does not grant `db:query`. Only a browser-direct identity token carries a role; a signed call and the self-host operator token are not checked against one.")
72308
+ }
72268
72309
  }), (c) => c.json({
72269
- postgres: { url: opts.injection.services.postgresUrl },
72310
+ postgres: { url: servicesPostgresUrl },
72270
72311
  storage: {
72271
72312
  endpoint: opts.injection.services.s3Endpoint,
72272
72313
  bucket: opts.injection.services.s3Bucket,
@@ -73665,7 +73706,7 @@ var createAddCommand = defineGroup({
73665
73706
  }),
73666
73707
  workflow: defineVerb({
73667
73708
  name: "workflow",
73668
- description: "Add a durable workflow (dev-only; never touches the control plane).",
73709
+ description: "Add a durable workflow to the current workspace (offline scaffold).",
73669
73710
  args: {
73670
73711
  name: {
73671
73712
  type: "positional",
@@ -76803,8 +76844,15 @@ var REDIS_IMAGE$1 = "redis:7-alpine";
76803
76844
  *
76804
76845
  * Same image and pin as `stackbone dev`'s compose, so the emulator and the
76805
76846
  * deliverable run the identical backend.
76847
+ *
76848
+ * The community fork, not `minio/minio`. MinIO closed anonymous pulls of its own
76849
+ * image on Docker Hub (2026-09-12, PLA-1423) and then on quay.io (401 on every
76850
+ * tag by 2026-09-25, PLA-1794). A first-run `stackbone dev`, and the operator
76851
+ * running a packaged folder, pull with no credentials. `pgsty/minio` publishes
76852
+ * amd64 + arm64, keeps the upstream entrypoint and ships `mc` and `sh`, which the
76853
+ * healthcheck and the bucket-init container call.
76806
76854
  */
76807
- var MINIO_IMAGE$1 = "quay.io/minio/minio:RELEASE.2025-03-12T18-04-18Z";
76855
+ var MINIO_IMAGE = "pgsty/minio:RELEASE.2026-08-04T00-00-00Z";
76808
76856
  /** Access key the bundled MinIO is created with. The secret is generated per package. */
76809
76857
  var MINIO_ACCESS_KEY = "stackbone";
76810
76858
  /** Bucket the init container creates, and the agent's `STACKBONE_S3_BUCKET`. */
@@ -76902,7 +76950,7 @@ var renderInfraServices = () => ` postgres:
76902
76950
  minio:
76903
76951
  # S3-compatible object storage: the agent's own files (\`client.storage\`), the
76904
76952
  # originals managed RAG ingests, and what Studio's file browser lists.
76905
- image: ${MINIO_IMAGE$1}
76953
+ image: ${MINIO_IMAGE}
76906
76954
  command: ['server', '/data']
76907
76955
  environment:
76908
76956
  MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
@@ -76945,7 +76993,7 @@ var renderInfraServices = () => ` postgres:
76945
76993
  # alphabet contains \`-\`, so about one packaged folder in 64 minted one. One
76946
76994
  # of them took \`next\` red on 2026-08-31 (run 33430510314) on a
76947
76995
  # comments-only commit.
76948
- image: ${MINIO_IMAGE$1}
76996
+ image: ${MINIO_IMAGE}
76949
76997
  restart: 'no'
76950
76998
  depends_on:
76951
76999
  minio:
@@ -80114,11 +80162,23 @@ var EVAL_RUN_SCREEN_PATH = "/studio/eval-runs";
80114
80162
  /**
80115
80163
  * Which Studio a given control plane belongs to.
80116
80164
  *
80117
- * One ternary, in one place: `stackbone dev` prints it in its banner and
80165
+ * One resolver, in one place: `stackbone dev` prints it in its banner and
80118
80166
  * `stackbone eval` prints it beside a run's result, and two copies would drift
80119
80167
  * the first time either host changed.
80168
+ *
80169
+ * `STACKBONE_WEB_URL` (the web origin, no `/app`) wins when set, even against a
80170
+ * cloud API: `scripts/worktree-env.sh` exports it next to `WEB_PORT`, because a
80171
+ * worktree with a port offset serves its web on `4200 + offset*10` and the
80172
+ * literal 4200 would open another checkout's web. The `STACKBONE_` prefix keeps
80173
+ * it apart from an `APP_URL` a creator's workspace `.env` defines for its own
80174
+ * app. Unset, a local control plane maps to `localhost:4200`, anything else to
80175
+ * the cloud Studio.
80120
80176
  */
80121
- var resolveStudioBaseUrl = (apiUrl) => apiUrl.includes("localhost") ? "http://localhost:4200/app" : "https://app.stackbone.ai/app";
80177
+ var resolveStudioBaseUrl = (apiUrl, env = {}) => {
80178
+ const webUrl = env["STACKBONE_WEB_URL"]?.trim().replace(/\/+$/, "");
80179
+ if (webUrl) return `${webUrl}/app`;
80180
+ return apiUrl.includes("localhost") ? "http://localhost:4200/app" : "https://app.stackbone.ai/app";
80181
+ };
80122
80182
  /**
80123
80183
  * The link to one eval run, with NO `?stackbone-dev=` pin.
80124
80184
  *
@@ -80173,7 +80233,6 @@ var readStackboneConfig = async (cwd) => readJsonFile(stackboneConfigPath(cwd),
80173
80233
  //#region src/dev/compose-template.ts
80174
80234
  var POSTGRES_IMAGE = "pgvector/pgvector:pg17";
80175
80235
  var REDIS_IMAGE = "redis:7-alpine";
80176
- var MINIO_IMAGE = "quay.io/minio/minio:RELEASE.2025-03-12T18-04-18Z";
80177
80236
  var PGVECTOR_INIT_SQL = `-- Generated by stackbone dev. Runs once on Postgres first boot via
80178
80237
  -- /docker-entrypoint-initdb.d. The queue extension is gone (BullMQ over Redis
80179
80238
  -- is the only job engine); only pgvector is enabled here.
@@ -81827,7 +81886,13 @@ var getAgentByOwnerAndSlug = async (ctx, ownerOrgSlug, templateSlug) => {
81827
81886
  init_src$19();
81828
81887
  var GRANT_PATH = "/api/dev-tunnel/grants";
81829
81888
  var DEFAULT_STATUS_URL = "https://status.stackbone.ai";
81830
- var REVOKE_TIMEOUT_MS = 3e3;
81889
+ var SIDE_CALL_TIMEOUT_MS = 3e3;
81890
+ var REVOCATIONS_PATH = "/api/dev-tunnel/revocations";
81891
+ var cliHeaders = (cliVersion) => ({
81892
+ Accept: "application/json",
81893
+ "User-Agent": `stackbone-cli/${cliVersion}`
81894
+ });
81895
+ var cliGrantResponseSchema = devTunnelGrantResponseSchema.extend({ ticket: devTunnelGrantResponseSchema.shape.ticket.optional() });
81831
81896
  /** Relay status page from the canonical envelope. */
81832
81897
  var readRelayStatusUrl = (body) => {
81833
81898
  if (typeof body !== "object" || body === null) return null;
@@ -81849,17 +81914,17 @@ var requestDevTunnelGrant = async (input) => {
81849
81914
  const url = composeUrl(input.ctx.apiUrl, GRANT_PATH);
81850
81915
  const body = {
81851
81916
  agentSlug: input.agentSlug,
81852
- ...input.ttlSeconds !== void 0 ? { ttlSeconds: input.ttlSeconds } : {}
81917
+ ...input.ttlSeconds !== void 0 ? { ttlSeconds: input.ttlSeconds } : {},
81918
+ ...input.installationId !== void 0 ? { installationId: input.installationId } : {}
81853
81919
  };
81854
81920
  let response;
81855
81921
  try {
81856
81922
  response = await fetchImpl(url, {
81857
81923
  method: "POST",
81858
81924
  headers: {
81925
+ ...cliHeaders(input.ctx.cliVersion),
81859
81926
  "Content-Type": "application/json",
81860
- Accept: "application/json",
81861
- Authorization: `Bearer ${session.session.token}`,
81862
- "User-Agent": `stackbone-cli/${input.ctx.cliVersion}`
81927
+ Authorization: `Bearer ${session.session.token}`
81863
81928
  },
81864
81929
  body: JSON.stringify(body)
81865
81930
  });
@@ -81904,7 +81969,7 @@ var requestDevTunnelGrant = async (input) => {
81904
81969
  message: `Unexpected ${response.status} response from ${GRANT_PATH}`,
81905
81970
  cause: parsedBody
81906
81971
  });
81907
- return parseResponse$1(devTunnelGrantResponseSchema, parsedBody, GRANT_PATH);
81972
+ return parseResponse$1(cliGrantResponseSchema, parsedBody, GRANT_PATH);
81908
81973
  };
81909
81974
  var revokeDevTunnelGrant = async (input) => {
81910
81975
  const session = await input.ctx.tokenStore.activeSession(input.ctx.apiUrl).catch(() => null);
@@ -81919,11 +81984,10 @@ var revokeDevTunnelGrant = async (input) => {
81919
81984
  response = await fetchImpl(url, {
81920
81985
  method: "DELETE",
81921
81986
  headers: {
81922
- Accept: "application/json",
81923
- Authorization: `Bearer ${session.session.token}`,
81924
- "User-Agent": `stackbone-cli/${input.ctx.cliVersion}`
81987
+ ...cliHeaders(input.ctx.cliVersion),
81988
+ Authorization: `Bearer ${session.session.token}`
81925
81989
  },
81926
- signal: AbortSignal.timeout(REVOKE_TIMEOUT_MS)
81990
+ signal: AbortSignal.timeout(SIDE_CALL_TIMEOUT_MS)
81927
81991
  });
81928
81992
  } catch (err) {
81929
81993
  input.ctx.logger.warn({
@@ -81940,9 +82004,29 @@ var revokeDevTunnelGrant = async (input) => {
81940
82004
  subdomain: input.subdomain
81941
82005
  }, "Unexpected response while revoking dev-tunnel grant (slot will free on TTL)");
81942
82006
  };
82007
+ var fetchDevTunnelRevocationList = async (input) => {
82008
+ const fetchImpl = input.fetchImpl ?? fetch;
82009
+ const url = composeUrl(input.ctx.apiUrl, REVOCATIONS_PATH);
82010
+ try {
82011
+ const response = await fetchImpl(url, {
82012
+ method: "GET",
82013
+ headers: cliHeaders(input.ctx.cliVersion),
82014
+ signal: AbortSignal.timeout(SIDE_CALL_TIMEOUT_MS)
82015
+ });
82016
+ if (!response.ok) {
82017
+ input.ctx.logger.debug({ status: response.status }, "dev-tunnel revocation list request was not answered 200");
82018
+ return null;
82019
+ }
82020
+ const parsed = devTunnelRevocationListResponseSchema.safeParse(await response.json());
82021
+ return parsed.success ? parsed.data.revocationList : null;
82022
+ } catch (err) {
82023
+ input.ctx.logger.debug({ err }, "Could not fetch the dev-tunnel revocation list");
82024
+ return null;
82025
+ }
82026
+ };
81943
82027
  //#endregion
81944
82028
  //#region src/dev/frpc-bin.ts
81945
- var execp = promisify(exec);
82029
+ var execFilep = promisify(execFile);
81946
82030
  var PINNED_FRPC_VERSION = "0.68.1";
81947
82031
  var assetForPlatform = (platform, arch) => {
81948
82032
  const version = PINNED_FRPC_VERSION;
@@ -81991,9 +82075,27 @@ var streamToFile = async (body, path) => {
81991
82075
  });
81992
82076
  };
81993
82077
  var archiveStem = (asset) => asset.name.replace(/\.(tar\.gz|zip)$/, "");
82078
+ var psQuote = (value) => `'${value.replaceAll("'", "''")}'`;
82079
+ var extractCommand = (archive, archivePath, destDir) => archive === "tgz" ? {
82080
+ file: "tar",
82081
+ args: [
82082
+ "-xzf",
82083
+ archivePath,
82084
+ "-C",
82085
+ destDir
82086
+ ]
82087
+ } : {
82088
+ file: "powershell",
82089
+ args: [
82090
+ "-NoProfile",
82091
+ "-NonInteractive",
82092
+ "-Command",
82093
+ `Expand-Archive -LiteralPath ${psQuote(archivePath)} -DestinationPath ${psQuote(destDir)} -Force`
82094
+ ]
82095
+ };
81994
82096
  var extractTarball = async (archivePath, asset, destBinary) => {
81995
- if (asset.archive === "tgz") await execp(`tar -xzf "${archivePath}" -C "${cacheDir()}"`);
81996
- else await execp(`unzip -o -q "${archivePath}" -d "${cacheDir()}"`);
82097
+ const { file, args } = extractCommand(asset.archive, archivePath, cacheDir());
82098
+ await execFilep(file, args);
81997
82099
  await rename(join(cacheDir(), archiveStem(asset), process.platform === "win32" ? "frpc.exe" : "frpc"), destBinary);
81998
82100
  };
81999
82101
  var resolveFrpcBin = async (input) => {
@@ -82016,24 +82118,27 @@ var resolveFrpcBin = async (input) => {
82016
82118
  const archivePath = `${cached}.${asset.archive === "tgz" ? "tar.gz" : "zip"}`;
82017
82119
  const s = spinner();
82018
82120
  s.start(`Downloading frpc ${version} (${asset.name})`);
82121
+ let stage = "download";
82019
82122
  try {
82020
82123
  const res = await fetch(url, { redirect: "follow" });
82021
82124
  if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} ${res.statusText} fetching ${url}`);
82022
82125
  await streamToFile(res.body, archivePath);
82126
+ stage = "extract";
82023
82127
  await extractTarball(archivePath, asset, cached);
82024
82128
  if (process.platform !== "win32") await chmod(cached, 493);
82025
82129
  s.stop(`Downloaded frpc ${version}`);
82026
82130
  return cached;
82027
82131
  } catch (err) {
82028
- s.error("Failed to download frpc");
82132
+ s.error(stage === "download" ? "Failed to download frpc" : "Failed to extract frpc");
82029
82133
  await Promise.all([unlink(cached).catch(() => void 0), rm(join(cacheDir(), archiveStem(asset)), {
82030
82134
  recursive: true,
82031
82135
  force: true
82032
82136
  }).catch(() => void 0)]);
82137
+ const manualInstall = `download frp ${version} from https://github.com/fatedier/frp/releases, and put its frpc binary at ${cached} (or point STACKBONE_FRPC_BIN at it) before re-running.`;
82033
82138
  throw new StackboneCliError({
82034
82139
  code: "generic",
82035
- message: "Could not auto-fetch frpc",
82036
- suggestion: "Check your network, or install frpc manually from https://github.com/fatedier/frp/releases and point STACKBONE_FRPC_BIN at the binary before re-running.",
82140
+ message: stage === "download" ? `Could not download frpc ${version}` : `Downloaded frpc ${version} but could not extract it`,
82141
+ suggestion: stage === "download" ? `Check your network, or ${manualInstall}` : `Extract ${asset.name} by hand: ${manualInstall}`,
82037
82142
  cause: err
82038
82143
  });
82039
82144
  } finally {
@@ -82109,10 +82214,85 @@ var clearLastTunnelGrant = async (projectRoot, logger) => {
82109
82214
  }
82110
82215
  };
82111
82216
  //#endregion
82217
+ //#region src/dev/tunnel-drop.ts
82218
+ init_src$19();
82219
+ var BASE64URL_SEGMENT = /^[A-Za-z0-9_-]+$/;
82220
+ var decodeJwtPayload = (token) => {
82221
+ const segments = token.split(".");
82222
+ const payload = segments[1];
82223
+ if (segments.length !== 3 || payload === void 0 || !BASE64URL_SEGMENT.test(payload)) return null;
82224
+ try {
82225
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
82226
+ return typeof claims === "object" && claims !== null && !Array.isArray(claims) ? claims : null;
82227
+ } catch {
82228
+ return null;
82229
+ }
82230
+ };
82231
+ var MAX_EPOCH_SECONDS = 1e10;
82232
+ var readTicketClaims = (ticket) => {
82233
+ const claims = decodeJwtPayload(ticket);
82234
+ if (!claims) return null;
82235
+ const { jti, exp } = claims;
82236
+ if (typeof jti !== "string" || jti.length === 0) return null;
82237
+ if (typeof exp !== "number" || !Number.isFinite(exp) || exp <= 0 || exp >= MAX_EPOCH_SECONDS) return null;
82238
+ return {
82239
+ jti,
82240
+ exp
82241
+ };
82242
+ };
82243
+ var readRevokedIds = (revocationList) => {
82244
+ const claims = decodeJwtPayload(revocationList);
82245
+ return claims && Array.isArray(claims["revoked"]) ? claims["revoked"] : null;
82246
+ };
82247
+ var classifyTunnelDrop = async (input) => {
82248
+ const claims = input.ticket === void 0 ? null : readTicketClaims(input.ticket);
82249
+ if (!claims) return "other";
82250
+ if (input.nowMs / 1e3 > claims.exp) return "cap";
82251
+ const list = await input.fetchRevocationList();
82252
+ return (list === null ? null : readRevokedIds(list))?.some((jti) => jti === claims.jti) ? "revoked" : "other";
82253
+ };
82254
+ var CAP_HOURS = DEV_TUNNEL_GRANT_HARD_CAP_SECONDS / 3600;
82255
+ var RESTART_HINT = "`stackbone dev` is still running, but its public URL no longer works. Restart `stackbone dev` to get a new URL.";
82256
+ var describeExit = (code, signal) => code !== null ? `code ${code}` : `signal ${signal ?? "unknown"}`;
82257
+ var describeCause = (input) => {
82258
+ if (input.cause === "cap") return [`The dev tunnel closed: its ticket reached the ${CAP_HOURS}-hour limit.`];
82259
+ if (input.cause === "revoked") return ["The dev tunnel closed: the control plane revoked its grant."];
82260
+ const tail = input.lastOutput.trim();
82261
+ return [`The dev tunnel closed: frpc exited with ${describeExit(input.code, input.signal)}.`, ...tail.length > 0 ? ["last frpc output:", tail] : []];
82262
+ };
82263
+ var reportTunnelDrop = async (input, code, signal) => {
82264
+ const cause = await classifyTunnelDrop({
82265
+ ticket: input.ticket,
82266
+ nowMs: Date.now(),
82267
+ fetchRevocationList: () => fetchDevTunnelRevocationList({ ctx: input.ctx })
82268
+ }).catch(() => "other");
82269
+ try {
82270
+ const lines = describeCause({
82271
+ cause,
82272
+ code,
82273
+ signal,
82274
+ lastOutput: input.lastOutput()
82275
+ });
82276
+ input.ctx.logger.warn({
82277
+ cause,
82278
+ code,
82279
+ signal
82280
+ }, [...lines, RESTART_HINT].join("\n"));
82281
+ } catch {}
82282
+ };
82283
+ var watchTunnelDrop = (input) => {
82284
+ input.child.once("close", (code, signal) => {
82285
+ if (input.isStopping()) return;
82286
+ reportTunnelDrop(input, code, signal);
82287
+ });
82288
+ };
82289
+ //#endregion
82112
82290
  //#region src/dev/tunnel.ts
82291
+ init_src$19();
82113
82292
  var STARTUP_TIMEOUT_MS = 3e4;
82114
82293
  var STOP_GRACE_MS = 3e3;
82115
82294
  var TAIL_BYTES = 4 * 1024;
82295
+ var FRPC_HEARTBEAT_INTERVAL_SECONDS = 30;
82116
82296
  var TUNNEL_HOST_SUFFIX = "tun.stackbone.ai";
82117
82297
  var TUNNEL_OPEN_DEFAULT_ATTEMPTS = 3;
82118
82298
  var TUNNEL_OPEN_DEFAULT_RETRY_DELAY_MS = 2500;
@@ -82125,13 +82305,21 @@ var frpcNotInstalledError = (cause) => new StackboneCliError({
82125
82305
  suggestion: "Install frpc from https://github.com/fatedier/frp/releases, or set STACKBONE_FRPC_BIN to point at an existing binary. The tunnel is mandatory — there is no opt-out flag.",
82126
82306
  cause
82127
82307
  });
82308
+ var frpcBlockedError = (binPath, cause) => new StackboneCliError({
82309
+ code: "permission",
82310
+ message: `The OS refused to run frpc (${binPath})`,
82311
+ suggestion: process.platform === "win32" ? "Windows is blocking the binary. Unblock it (right-click > Properties > Unblock), check Smart App Control and Defender exclusions, or run the CLI from WSL2. See https://docs.stackbone.ai/docs/cli/guides/windows" : `Make it executable with \`chmod +x ${binPath}\`, or point STACKBONE_FRPC_BIN at a binary you can run.`,
82312
+ cause
82313
+ });
82128
82314
  var buildFrpcConfig = (grant, targetUrl) => {
82129
82315
  const port = targetUrl.port ? Number.parseInt(targetUrl.port, 10) : targetUrl.protocol === "https:" ? 443 : 80;
82316
+ const ticketLines = grant.ticket === void 0 ? [] : [`transport.heartbeatInterval = ${FRPC_HEARTBEAT_INTERVAL_SECONDS}`, `metadatas.ticket = "${grant.ticket}"`];
82130
82317
  return [
82131
82318
  `serverAddr = "${grant.relayHost}"`,
82132
82319
  `serverPort = ${grant.relayPort}`,
82133
82320
  `auth.method = "token"`,
82134
82321
  `auth.token = "${grant.frpcToken}"`,
82322
+ ...ticketLines,
82135
82323
  "",
82136
82324
  "[[proxies]]",
82137
82325
  `name = "stackbone-dev-${grant.subdomain}"`,
@@ -82182,7 +82370,7 @@ var spawnFrpc = async (input) => {
82182
82370
  child.once("exit", () => process.off("exit", killOnParentExit));
82183
82371
  const tail = createTail();
82184
82372
  const successRegex = /start proxy success/i;
82185
- const expectedPublicUrl = `https://${input.expectedSubdomain}.${TUNNEL_HOST_SUFFIX}`;
82373
+ const expectedPublicUrl = buildCanonicalTunnelUrl(input.expectedSubdomain, TUNNEL_HOST_SUFFIX);
82186
82374
  const publicUrl = await new Promise((resolve, reject) => {
82187
82375
  const timer = setTimeout(() => {
82188
82376
  child.kill("SIGINT");
@@ -82209,6 +82397,10 @@ var spawnFrpc = async (input) => {
82209
82397
  reject(frpcNotInstalledError(err));
82210
82398
  return;
82211
82399
  }
82400
+ if (err.code === "EACCES" || err.code === "EPERM") {
82401
+ reject(frpcBlockedError(input.binPath, err));
82402
+ return;
82403
+ }
82212
82404
  reject(new StackboneCliError({
82213
82405
  code: "generic",
82214
82406
  message: "frpc failed to start",
@@ -82235,7 +82427,8 @@ var spawnFrpc = async (input) => {
82235
82427
  });
82236
82428
  return {
82237
82429
  child,
82238
- publicUrl
82430
+ publicUrl,
82431
+ lastOutput: () => tail.snapshot()
82239
82432
  };
82240
82433
  };
82241
82434
  var stopChild = async (child) => {
@@ -82268,7 +82461,8 @@ var stopChild = async (child) => {
82268
82461
  * `auth_required`, `workspace_tunnel_cap_reached`, or
82269
82462
  * `tunnel_relay_unhealthy` to the thrown StackboneCliError before
82270
82463
  * propagation, and `spawnFrpc` adds the captured output tail when frpc
82271
- * dies before opening the tunnel.
82464
+ * dies before opening the tunnel. A frpc exit after the tunnel is up never
82465
+ * throws: `watchTunnelDrop` logs its cause and the session keeps running.
82272
82466
  */
82273
82467
  var openDevTunnel = async (input) => {
82274
82468
  const targetUrl = new URL(input.targetUrl);
@@ -82296,19 +82490,29 @@ var openDevTunnel = async (input) => {
82296
82490
  try {
82297
82491
  const grant = await requestDevTunnelGrant({
82298
82492
  ctx: input.ctx,
82299
- agentSlug
82493
+ agentSlug,
82494
+ ...input.installationId !== void 0 ? { installationId: input.installationId } : {}
82300
82495
  });
82301
82496
  await writeLastTunnelGrant(input.projectRoot, grant.subdomain, input.ctx.logger);
82302
82497
  try {
82303
- const { child, publicUrl } = await spawnFrpc({
82498
+ const { child, publicUrl, lastOutput } = await spawnFrpc({
82304
82499
  binPath,
82305
82500
  config: buildFrpcConfig(grant, targetUrl),
82306
82501
  logger: input.ctx.logger,
82307
82502
  expectedSubdomain: grant.subdomain
82308
82503
  });
82504
+ let stopping = false;
82505
+ watchTunnelDrop({
82506
+ child,
82507
+ ctx: input.ctx,
82508
+ ticket: grant.ticket,
82509
+ lastOutput,
82510
+ isStopping: () => stopping
82511
+ });
82309
82512
  return {
82310
82513
  publicUrl,
82311
82514
  stop: async () => {
82515
+ stopping = true;
82312
82516
  try {
82313
82517
  await revokeDevTunnelGrant({
82314
82518
  ctx: input.ctx,
@@ -82979,7 +83183,8 @@ var startDevSession = async (input) => {
82979
83183
  tunnel = await openDevTunnel({
82980
83184
  targetUrl: emulator.url,
82981
83185
  ctx: input.ctx,
82982
- projectRoot: input.projectRoot
83186
+ projectRoot: input.projectRoot,
83187
+ ...input.installationId !== void 0 ? { installationId: input.installationId } : {}
82983
83188
  });
82984
83189
  onStage({
82985
83190
  stage: "tunnel",
@@ -83109,10 +83314,6 @@ init_src$19();
83109
83314
  var upsertLocalDevInstallation = async (ctx, body) => {
83110
83315
  return parseResponse$1(localDevInstallationResponseSchema, await ctx.http.post("/api/v1/installations/local-dev", body), "POST /api/v1/installations/local-dev");
83111
83316
  };
83112
- var patchLocalDevInstallation = async (ctx, id, body) => {
83113
- const path = `/api/v1/installations/local-dev/${encodeURIComponent(id)}`;
83114
- return parseResponse$1(localDevInstallationResponseSchema, await ctx.http.patch(path, body), `PATCH ${path}`);
83115
- };
83116
83317
  var heartbeatLocalDevInstallation = async (ctx, id) => {
83117
83318
  const path = `/api/v1/installations/local-dev/${encodeURIComponent(id)}/heartbeat`;
83118
83319
  await ctx.http.post(path, {});
@@ -83435,6 +83636,62 @@ var printContract = (deps = {}) => {
83435
83636
  writeStdout(`${JSON.stringify(payload)}\n`);
83436
83637
  };
83437
83638
  //#endregion
83639
+ //#region src/commands/dev/resolve-local-dev-installation.ts
83640
+ /**
83641
+ * - No stored id: register (upsert) and persist the id to project.json.
83642
+ * - A stored id: heartbeat it once. `not_found` means the row is gone (GC'd,
83643
+ * dev DB wiped) or is not the caller's, so register again, once. Any other
83644
+ * failure says nothing about the row: warn and keep the stored id.
83645
+ * - A failed registration warns and resolves no id instead of throwing.
83646
+ */
83647
+ var resolveLocalDevInstallation = async (ctx, config, agent) => {
83648
+ const linkedOrgSlug = async () => config.organizationSlug ?? (await getActiveOrganization(ctx)).slug;
83649
+ const register = async () => {
83650
+ let upserted;
83651
+ try {
83652
+ upserted = await upsertLocalDevInstallation(ctx, {
83653
+ creatorOrganizationSlug: await linkedOrgSlug(),
83654
+ agentTemplateSlug: agent.slug
83655
+ });
83656
+ } catch (err) {
83657
+ ctx.logger.warn({ err: err.message }, "failed to register the local-dev installation, the tunnel opens unbound (continuing)");
83658
+ return {
83659
+ id: void 0,
83660
+ orgSlug: config.organizationSlug
83661
+ };
83662
+ }
83663
+ try {
83664
+ await ctx.projectConfig.write({
83665
+ ...config,
83666
+ localDevInstallationId: upserted.id,
83667
+ organizationSlug: upserted.organizationSlug,
83668
+ agentSlug: upserted.agentSlug
83669
+ });
83670
+ } catch (err) {
83671
+ ctx.logger.warn({ err: err.message }, "registered the local-dev installation but could not persist its id to project.json (continuing)");
83672
+ }
83673
+ return {
83674
+ id: upserted.id,
83675
+ orgSlug: upserted.organizationSlug
83676
+ };
83677
+ };
83678
+ const storedId = config.localDevInstallationId;
83679
+ if (!storedId) return register();
83680
+ try {
83681
+ await heartbeatLocalDevInstallation(ctx, storedId);
83682
+ } catch (err) {
83683
+ if (err instanceof StackboneCliError && err.code === "not_found") {
83684
+ ctx.logger.warn("local-dev row not found, re-registering");
83685
+ return register();
83686
+ }
83687
+ ctx.logger.warn({ err: err.message }, "local-dev pre-flight heartbeat failed, keeping the stored id (continuing)");
83688
+ }
83689
+ return {
83690
+ id: storedId,
83691
+ orgSlug: await linkedOrgSlug()
83692
+ };
83693
+ };
83694
+ //#endregion
83438
83695
  //#region src/commands/dev/dev.command.ts
83439
83696
  init_src$19();
83440
83697
  var STAGE_LABELS = {
@@ -83546,6 +83803,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
83546
83803
  suggestion: "Run `stackbone link --force --agent <slug> --installation <installationId> --url <url> --secret <secret> --tag <tag>` to re-link to a different agent (see `stackbone link --help`)"
83547
83804
  });
83548
83805
  await assertSdkCompatible(innerCtx, { mode: "warn" });
83806
+ const { id: localDevId, orgSlug: installationOrgSlug } = await resolveLocalDevInstallation(innerCtx, config, agent);
83549
83807
  const renderStage = innerCtx.flags.json ? null : createStageRenderer();
83550
83808
  const renderReload = innerCtx.flags.json ? null : createReloadRenderer();
83551
83809
  let republishPointer = null;
@@ -83559,6 +83817,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
83559
83817
  },
83560
83818
  emulatorPort: flags.port,
83561
83819
  tunnel: true,
83820
+ ...localDevId ? { installationId: localDevId } : {},
83562
83821
  listen: flags.listen,
83563
83822
  verbose: flags.verbose,
83564
83823
  ...flags.autoMigrate !== void 0 ? { autoMigrateOverride: flags.autoMigrate } : {},
@@ -83570,48 +83829,6 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
83570
83829
  }
83571
83830
  });
83572
83831
  declaredPieces = session.declaredPieces;
83573
- const registerLocalDev = async () => {
83574
- const upserted = await upsertLocalDevInstallation(innerCtx, {
83575
- creatorOrganizationSlug: config.organizationSlug ?? (await getActiveOrganization(innerCtx)).slug,
83576
- agentTemplateSlug: agent.slug
83577
- });
83578
- await innerCtx.projectConfig.write({
83579
- ...config,
83580
- localDevInstallationId: upserted.id,
83581
- organizationSlug: upserted.organizationSlug,
83582
- agentSlug: upserted.agentSlug
83583
- });
83584
- return {
83585
- id: upserted.id,
83586
- orgSlug: upserted.organizationSlug
83587
- };
83588
- };
83589
- let localDevId = config.localDevInstallationId;
83590
- let installationOrgSlug = config.organizationSlug;
83591
- if (!localDevId) {
83592
- const registered = await registerLocalDev();
83593
- localDevId = registered.id;
83594
- installationOrgSlug = registered.orgSlug;
83595
- } else if (!installationOrgSlug) installationOrgSlug = (await getActiveOrganization(innerCtx)).slug;
83596
- const publicUrl = session.publicUrl;
83597
- if (publicUrl) {
83598
- const patchTunnel = (id) => patchLocalDevInstallation(innerCtx, id, { localTunnelUrl: publicUrl });
83599
- const patched = await patchTunnel(localDevId).catch(async (err) => {
83600
- if (err instanceof StackboneCliError && err.code === "not_found") {
83601
- innerCtx.logger.warn("local-dev row not found, re-registering");
83602
- const registered = await registerLocalDev();
83603
- localDevId = registered.id;
83604
- installationOrgSlug = registered.orgSlug;
83605
- return patchTunnel(localDevId).catch((retryErr) => {
83606
- innerCtx.logger.warn({ err: retryErr.message }, "failed to PATCH local-dev tunnel URL after re-register (continuing)");
83607
- return null;
83608
- });
83609
- }
83610
- innerCtx.logger.warn({ err: err.message }, "failed to PATCH local-dev tunnel URL (continuing)");
83611
- return null;
83612
- });
83613
- if (patched) installationOrgSlug = patched.organizationSlug;
83614
- }
83615
83832
  if (localDevId) {
83616
83833
  const installId = localDevId;
83617
83834
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -83627,7 +83844,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
83627
83844
  }, innerCtx.logger);
83628
83845
  await republishPointer();
83629
83846
  }
83630
- const studioBaseUrl = resolveStudioBaseUrl(innerCtx.apiUrl);
83847
+ const studioBaseUrl = resolveStudioBaseUrl(innerCtx.apiUrl, innerCtx.env);
83631
83848
  const linkTo = (screenPath) => studioDeeplink({
83632
83849
  studioBaseUrl,
83633
83850
  orgSlug: installationOrgSlug,
@@ -83691,15 +83908,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
83691
83908
  const sigintHandler = () => {
83692
83909
  if (heartbeatTimer) clearInterval(heartbeatTimer);
83693
83910
  shutdownAndExit(async () => {
83694
- const patch = localDevId ? patchLocalDevInstallation(innerCtx, localDevId, { localTunnelUrl: null }).catch((err) => {
83695
- innerCtx.logger.debug({ err: err.message }, "failed to null local-dev tunnel URL on exit (safety net will catch up)");
83696
- return null;
83697
- }) : Promise.resolve(null);
83698
- await Promise.allSettled([
83699
- patch,
83700
- session.stop(),
83701
- clearDevSession(projectRoot, innerCtx.logger)
83702
- ]);
83911
+ await Promise.allSettled([session.stop(), clearDevSession(projectRoot, innerCtx.logger)]);
83703
83912
  goodbye();
83704
83913
  }, { onForced: () => {
83705
83914
  if (!innerCtx.flags.json) log.warn("Shutdown did not finish in time — leaving anyway.");
@@ -84370,7 +84579,7 @@ var runEval = async (ctx, args, deps = {}) => {
84370
84579
  };
84371
84580
  const launched = await launch(callCtx, basePath, suite.id, body);
84372
84581
  const url = evalRunUrl({
84373
- studioBaseUrl: resolveStudioBaseUrl(ctx.apiUrl),
84582
+ studioBaseUrl: resolveStudioBaseUrl(ctx.apiUrl, ctx.env),
84374
84583
  orgSlug,
84375
84584
  installationId,
84376
84585
  evalRunId: launched.id
@@ -86375,16 +86584,29 @@ var parseLimit = (raw) => {
86375
86584
  * a relative duration (`15m`, `2h`, `30s`, `1d`) measured back from `now`, or an
86376
86585
  * absolute ISO timestamp. Returns `undefined` when the flag is absent. A value
86377
86586
  * that is neither a known duration nor a parseable date is a CLI-side error.
86587
+ *
86588
+ * A bare number is rejected before `Date.parse`, which would read it as a date
86589
+ * (`5` is April 2001, `2026` is 2026-01-01) and leave the window filtering
86590
+ * nothing. It is a duration with the unit forgotten.
86378
86591
  */
86379
86592
  var parseTimeBound = (flag, raw, now) => {
86380
86593
  if (raw === void 0) return void 0;
86381
- const duration = /^(\d+)(s|m|h|d)$/.exec(raw.trim());
86594
+ const trimmed = raw.trim();
86595
+ const duration = /^(\d+)(s|m|h|d)$/.exec(trimmed);
86382
86596
  if (duration) return now - Number(duration[1]) * ({
86383
86597
  s: 1e3,
86384
86598
  m: 6e4,
86385
86599
  h: 36e5,
86386
86600
  d: 864e5
86387
86601
  }[duration[2] ?? ""] ?? 0);
86602
+ if (/^[+-]?(\d+\.?\d*|\.\d+)$/.test(trimmed)) {
86603
+ const example = /^\d+$/.test(trimmed) ? `${trimmed}m` : "15m";
86604
+ throw new StackboneCliError({
86605
+ code: "generic",
86606
+ message: `Invalid ${flag} '${raw}'. A duration needs a unit: s, m, h or d.`,
86607
+ suggestion: `Pass e.g. \`${flag} ${example}\`, or an ISO timestamp such as \`${flag} 2026-05-22T00:00:00Z\`.`
86608
+ });
86609
+ }
86388
86610
  const ms = Date.parse(raw);
86389
86611
  if (Number.isNaN(ms)) throw new StackboneCliError({
86390
86612
  code: "generic",
@@ -88351,7 +88573,7 @@ var buildRootCommand = (ctx) => defineCommand({
88351
88573
  meta: {
88352
88574
  name: "stackbone",
88353
88575
  version: STACKBONE_CLI_BUILD_VERSION,
88354
- description: "Marketplace + hosting for AI agents — see `stackbone docs`."
88576
+ description: "Build, run and deploy Stackbone agents and workflows. See `stackbone docs`."
88355
88577
  },
88356
88578
  args: {
88357
88579
  json: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackbone/cli",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "bin": {
Binary file
Binary file