@stackbone/cli 0.5.3 → 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 +36 -0
- package/README.md +3 -3
- package/main.js +369 -153
- package/package.json +1 -1
- package/stackbone-cli-0.5.4.tgz +0 -0
- package/stackbone-cli-0.5.3.tgz +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,42 @@ 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
|
+
|
|
10
46
|
## [0.5.3] - 2026-09-25
|
|
11
47
|
|
|
12
48
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @stackbone/cli
|
|
2
2
|
|
|
3
|
-
The CLI for [Stackbone](https://stackbone.ai)
|
|
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
|
|
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
|
-
-
|
|
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 {
|
|
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 \`
|
|
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) ?? "
|
|
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
|
|
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:
|
|
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.
|
|
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
|
|
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":"<name>","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
|
-
*
|
|
72141
|
-
*
|
|
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,
|
|
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:
|
|
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.
|
|
72267
|
-
responses: {
|
|
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:
|
|
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
|
|
73709
|
+
description: "Add a durable workflow to the current workspace (offline scaffold).",
|
|
73669
73710
|
args: {
|
|
73670
73711
|
name: {
|
|
73671
73712
|
type: "positional",
|
|
@@ -80121,11 +80162,23 @@ var EVAL_RUN_SCREEN_PATH = "/studio/eval-runs";
|
|
|
80121
80162
|
/**
|
|
80122
80163
|
* Which Studio a given control plane belongs to.
|
|
80123
80164
|
*
|
|
80124
|
-
* One
|
|
80165
|
+
* One resolver, in one place: `stackbone dev` prints it in its banner and
|
|
80125
80166
|
* `stackbone eval` prints it beside a run's result, and two copies would drift
|
|
80126
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.
|
|
80127
80176
|
*/
|
|
80128
|
-
var resolveStudioBaseUrl = (apiUrl
|
|
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
|
+
};
|
|
80129
80182
|
/**
|
|
80130
80183
|
* The link to one eval run, with NO `?stackbone-dev=` pin.
|
|
80131
80184
|
*
|
|
@@ -81833,7 +81886,13 @@ var getAgentByOwnerAndSlug = async (ctx, ownerOrgSlug, templateSlug) => {
|
|
|
81833
81886
|
init_src$19();
|
|
81834
81887
|
var GRANT_PATH = "/api/dev-tunnel/grants";
|
|
81835
81888
|
var DEFAULT_STATUS_URL = "https://status.stackbone.ai";
|
|
81836
|
-
var
|
|
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() });
|
|
81837
81896
|
/** Relay status page from the canonical envelope. */
|
|
81838
81897
|
var readRelayStatusUrl = (body) => {
|
|
81839
81898
|
if (typeof body !== "object" || body === null) return null;
|
|
@@ -81855,17 +81914,17 @@ var requestDevTunnelGrant = async (input) => {
|
|
|
81855
81914
|
const url = composeUrl(input.ctx.apiUrl, GRANT_PATH);
|
|
81856
81915
|
const body = {
|
|
81857
81916
|
agentSlug: input.agentSlug,
|
|
81858
|
-
...input.ttlSeconds !== void 0 ? { ttlSeconds: input.ttlSeconds } : {}
|
|
81917
|
+
...input.ttlSeconds !== void 0 ? { ttlSeconds: input.ttlSeconds } : {},
|
|
81918
|
+
...input.installationId !== void 0 ? { installationId: input.installationId } : {}
|
|
81859
81919
|
};
|
|
81860
81920
|
let response;
|
|
81861
81921
|
try {
|
|
81862
81922
|
response = await fetchImpl(url, {
|
|
81863
81923
|
method: "POST",
|
|
81864
81924
|
headers: {
|
|
81925
|
+
...cliHeaders(input.ctx.cliVersion),
|
|
81865
81926
|
"Content-Type": "application/json",
|
|
81866
|
-
|
|
81867
|
-
Authorization: `Bearer ${session.session.token}`,
|
|
81868
|
-
"User-Agent": `stackbone-cli/${input.ctx.cliVersion}`
|
|
81927
|
+
Authorization: `Bearer ${session.session.token}`
|
|
81869
81928
|
},
|
|
81870
81929
|
body: JSON.stringify(body)
|
|
81871
81930
|
});
|
|
@@ -81910,7 +81969,7 @@ var requestDevTunnelGrant = async (input) => {
|
|
|
81910
81969
|
message: `Unexpected ${response.status} response from ${GRANT_PATH}`,
|
|
81911
81970
|
cause: parsedBody
|
|
81912
81971
|
});
|
|
81913
|
-
return parseResponse$1(
|
|
81972
|
+
return parseResponse$1(cliGrantResponseSchema, parsedBody, GRANT_PATH);
|
|
81914
81973
|
};
|
|
81915
81974
|
var revokeDevTunnelGrant = async (input) => {
|
|
81916
81975
|
const session = await input.ctx.tokenStore.activeSession(input.ctx.apiUrl).catch(() => null);
|
|
@@ -81925,11 +81984,10 @@ var revokeDevTunnelGrant = async (input) => {
|
|
|
81925
81984
|
response = await fetchImpl(url, {
|
|
81926
81985
|
method: "DELETE",
|
|
81927
81986
|
headers: {
|
|
81928
|
-
|
|
81929
|
-
Authorization: `Bearer ${session.session.token}
|
|
81930
|
-
"User-Agent": `stackbone-cli/${input.ctx.cliVersion}`
|
|
81987
|
+
...cliHeaders(input.ctx.cliVersion),
|
|
81988
|
+
Authorization: `Bearer ${session.session.token}`
|
|
81931
81989
|
},
|
|
81932
|
-
signal: AbortSignal.timeout(
|
|
81990
|
+
signal: AbortSignal.timeout(SIDE_CALL_TIMEOUT_MS)
|
|
81933
81991
|
});
|
|
81934
81992
|
} catch (err) {
|
|
81935
81993
|
input.ctx.logger.warn({
|
|
@@ -81946,9 +82004,29 @@ var revokeDevTunnelGrant = async (input) => {
|
|
|
81946
82004
|
subdomain: input.subdomain
|
|
81947
82005
|
}, "Unexpected response while revoking dev-tunnel grant (slot will free on TTL)");
|
|
81948
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
|
+
};
|
|
81949
82027
|
//#endregion
|
|
81950
82028
|
//#region src/dev/frpc-bin.ts
|
|
81951
|
-
var
|
|
82029
|
+
var execFilep = promisify(execFile);
|
|
81952
82030
|
var PINNED_FRPC_VERSION = "0.68.1";
|
|
81953
82031
|
var assetForPlatform = (platform, arch) => {
|
|
81954
82032
|
const version = PINNED_FRPC_VERSION;
|
|
@@ -81997,9 +82075,27 @@ var streamToFile = async (body, path) => {
|
|
|
81997
82075
|
});
|
|
81998
82076
|
};
|
|
81999
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
|
+
};
|
|
82000
82096
|
var extractTarball = async (archivePath, asset, destBinary) => {
|
|
82001
|
-
|
|
82002
|
-
|
|
82097
|
+
const { file, args } = extractCommand(asset.archive, archivePath, cacheDir());
|
|
82098
|
+
await execFilep(file, args);
|
|
82003
82099
|
await rename(join(cacheDir(), archiveStem(asset), process.platform === "win32" ? "frpc.exe" : "frpc"), destBinary);
|
|
82004
82100
|
};
|
|
82005
82101
|
var resolveFrpcBin = async (input) => {
|
|
@@ -82022,24 +82118,27 @@ var resolveFrpcBin = async (input) => {
|
|
|
82022
82118
|
const archivePath = `${cached}.${asset.archive === "tgz" ? "tar.gz" : "zip"}`;
|
|
82023
82119
|
const s = spinner();
|
|
82024
82120
|
s.start(`Downloading frpc ${version} (${asset.name})`);
|
|
82121
|
+
let stage = "download";
|
|
82025
82122
|
try {
|
|
82026
82123
|
const res = await fetch(url, { redirect: "follow" });
|
|
82027
82124
|
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} ${res.statusText} fetching ${url}`);
|
|
82028
82125
|
await streamToFile(res.body, archivePath);
|
|
82126
|
+
stage = "extract";
|
|
82029
82127
|
await extractTarball(archivePath, asset, cached);
|
|
82030
82128
|
if (process.platform !== "win32") await chmod(cached, 493);
|
|
82031
82129
|
s.stop(`Downloaded frpc ${version}`);
|
|
82032
82130
|
return cached;
|
|
82033
82131
|
} catch (err) {
|
|
82034
|
-
s.error("Failed to download frpc");
|
|
82132
|
+
s.error(stage === "download" ? "Failed to download frpc" : "Failed to extract frpc");
|
|
82035
82133
|
await Promise.all([unlink(cached).catch(() => void 0), rm(join(cacheDir(), archiveStem(asset)), {
|
|
82036
82134
|
recursive: true,
|
|
82037
82135
|
force: true
|
|
82038
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.`;
|
|
82039
82138
|
throw new StackboneCliError({
|
|
82040
82139
|
code: "generic",
|
|
82041
|
-
message: "Could not
|
|
82042
|
-
suggestion: "Check your network, or
|
|
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}`,
|
|
82043
82142
|
cause: err
|
|
82044
82143
|
});
|
|
82045
82144
|
} finally {
|
|
@@ -82115,10 +82214,85 @@ var clearLastTunnelGrant = async (projectRoot, logger) => {
|
|
|
82115
82214
|
}
|
|
82116
82215
|
};
|
|
82117
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
|
|
82118
82290
|
//#region src/dev/tunnel.ts
|
|
82291
|
+
init_src$19();
|
|
82119
82292
|
var STARTUP_TIMEOUT_MS = 3e4;
|
|
82120
82293
|
var STOP_GRACE_MS = 3e3;
|
|
82121
82294
|
var TAIL_BYTES = 4 * 1024;
|
|
82295
|
+
var FRPC_HEARTBEAT_INTERVAL_SECONDS = 30;
|
|
82122
82296
|
var TUNNEL_HOST_SUFFIX = "tun.stackbone.ai";
|
|
82123
82297
|
var TUNNEL_OPEN_DEFAULT_ATTEMPTS = 3;
|
|
82124
82298
|
var TUNNEL_OPEN_DEFAULT_RETRY_DELAY_MS = 2500;
|
|
@@ -82131,13 +82305,21 @@ var frpcNotInstalledError = (cause) => new StackboneCliError({
|
|
|
82131
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.",
|
|
82132
82306
|
cause
|
|
82133
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
|
+
});
|
|
82134
82314
|
var buildFrpcConfig = (grant, targetUrl) => {
|
|
82135
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}"`];
|
|
82136
82317
|
return [
|
|
82137
82318
|
`serverAddr = "${grant.relayHost}"`,
|
|
82138
82319
|
`serverPort = ${grant.relayPort}`,
|
|
82139
82320
|
`auth.method = "token"`,
|
|
82140
82321
|
`auth.token = "${grant.frpcToken}"`,
|
|
82322
|
+
...ticketLines,
|
|
82141
82323
|
"",
|
|
82142
82324
|
"[[proxies]]",
|
|
82143
82325
|
`name = "stackbone-dev-${grant.subdomain}"`,
|
|
@@ -82188,7 +82370,7 @@ var spawnFrpc = async (input) => {
|
|
|
82188
82370
|
child.once("exit", () => process.off("exit", killOnParentExit));
|
|
82189
82371
|
const tail = createTail();
|
|
82190
82372
|
const successRegex = /start proxy success/i;
|
|
82191
|
-
const expectedPublicUrl =
|
|
82373
|
+
const expectedPublicUrl = buildCanonicalTunnelUrl(input.expectedSubdomain, TUNNEL_HOST_SUFFIX);
|
|
82192
82374
|
const publicUrl = await new Promise((resolve, reject) => {
|
|
82193
82375
|
const timer = setTimeout(() => {
|
|
82194
82376
|
child.kill("SIGINT");
|
|
@@ -82215,6 +82397,10 @@ var spawnFrpc = async (input) => {
|
|
|
82215
82397
|
reject(frpcNotInstalledError(err));
|
|
82216
82398
|
return;
|
|
82217
82399
|
}
|
|
82400
|
+
if (err.code === "EACCES" || err.code === "EPERM") {
|
|
82401
|
+
reject(frpcBlockedError(input.binPath, err));
|
|
82402
|
+
return;
|
|
82403
|
+
}
|
|
82218
82404
|
reject(new StackboneCliError({
|
|
82219
82405
|
code: "generic",
|
|
82220
82406
|
message: "frpc failed to start",
|
|
@@ -82241,7 +82427,8 @@ var spawnFrpc = async (input) => {
|
|
|
82241
82427
|
});
|
|
82242
82428
|
return {
|
|
82243
82429
|
child,
|
|
82244
|
-
publicUrl
|
|
82430
|
+
publicUrl,
|
|
82431
|
+
lastOutput: () => tail.snapshot()
|
|
82245
82432
|
};
|
|
82246
82433
|
};
|
|
82247
82434
|
var stopChild = async (child) => {
|
|
@@ -82274,7 +82461,8 @@ var stopChild = async (child) => {
|
|
|
82274
82461
|
* `auth_required`, `workspace_tunnel_cap_reached`, or
|
|
82275
82462
|
* `tunnel_relay_unhealthy` to the thrown StackboneCliError before
|
|
82276
82463
|
* propagation, and `spawnFrpc` adds the captured output tail when frpc
|
|
82277
|
-
* 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.
|
|
82278
82466
|
*/
|
|
82279
82467
|
var openDevTunnel = async (input) => {
|
|
82280
82468
|
const targetUrl = new URL(input.targetUrl);
|
|
@@ -82302,19 +82490,29 @@ var openDevTunnel = async (input) => {
|
|
|
82302
82490
|
try {
|
|
82303
82491
|
const grant = await requestDevTunnelGrant({
|
|
82304
82492
|
ctx: input.ctx,
|
|
82305
|
-
agentSlug
|
|
82493
|
+
agentSlug,
|
|
82494
|
+
...input.installationId !== void 0 ? { installationId: input.installationId } : {}
|
|
82306
82495
|
});
|
|
82307
82496
|
await writeLastTunnelGrant(input.projectRoot, grant.subdomain, input.ctx.logger);
|
|
82308
82497
|
try {
|
|
82309
|
-
const { child, publicUrl } = await spawnFrpc({
|
|
82498
|
+
const { child, publicUrl, lastOutput } = await spawnFrpc({
|
|
82310
82499
|
binPath,
|
|
82311
82500
|
config: buildFrpcConfig(grant, targetUrl),
|
|
82312
82501
|
logger: input.ctx.logger,
|
|
82313
82502
|
expectedSubdomain: grant.subdomain
|
|
82314
82503
|
});
|
|
82504
|
+
let stopping = false;
|
|
82505
|
+
watchTunnelDrop({
|
|
82506
|
+
child,
|
|
82507
|
+
ctx: input.ctx,
|
|
82508
|
+
ticket: grant.ticket,
|
|
82509
|
+
lastOutput,
|
|
82510
|
+
isStopping: () => stopping
|
|
82511
|
+
});
|
|
82315
82512
|
return {
|
|
82316
82513
|
publicUrl,
|
|
82317
82514
|
stop: async () => {
|
|
82515
|
+
stopping = true;
|
|
82318
82516
|
try {
|
|
82319
82517
|
await revokeDevTunnelGrant({
|
|
82320
82518
|
ctx: input.ctx,
|
|
@@ -82985,7 +83183,8 @@ var startDevSession = async (input) => {
|
|
|
82985
83183
|
tunnel = await openDevTunnel({
|
|
82986
83184
|
targetUrl: emulator.url,
|
|
82987
83185
|
ctx: input.ctx,
|
|
82988
|
-
projectRoot: input.projectRoot
|
|
83186
|
+
projectRoot: input.projectRoot,
|
|
83187
|
+
...input.installationId !== void 0 ? { installationId: input.installationId } : {}
|
|
82989
83188
|
});
|
|
82990
83189
|
onStage({
|
|
82991
83190
|
stage: "tunnel",
|
|
@@ -83115,10 +83314,6 @@ init_src$19();
|
|
|
83115
83314
|
var upsertLocalDevInstallation = async (ctx, body) => {
|
|
83116
83315
|
return parseResponse$1(localDevInstallationResponseSchema, await ctx.http.post("/api/v1/installations/local-dev", body), "POST /api/v1/installations/local-dev");
|
|
83117
83316
|
};
|
|
83118
|
-
var patchLocalDevInstallation = async (ctx, id, body) => {
|
|
83119
|
-
const path = `/api/v1/installations/local-dev/${encodeURIComponent(id)}`;
|
|
83120
|
-
return parseResponse$1(localDevInstallationResponseSchema, await ctx.http.patch(path, body), `PATCH ${path}`);
|
|
83121
|
-
};
|
|
83122
83317
|
var heartbeatLocalDevInstallation = async (ctx, id) => {
|
|
83123
83318
|
const path = `/api/v1/installations/local-dev/${encodeURIComponent(id)}/heartbeat`;
|
|
83124
83319
|
await ctx.http.post(path, {});
|
|
@@ -83441,6 +83636,62 @@ var printContract = (deps = {}) => {
|
|
|
83441
83636
|
writeStdout(`${JSON.stringify(payload)}\n`);
|
|
83442
83637
|
};
|
|
83443
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
|
|
83444
83695
|
//#region src/commands/dev/dev.command.ts
|
|
83445
83696
|
init_src$19();
|
|
83446
83697
|
var STAGE_LABELS = {
|
|
@@ -83552,6 +83803,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
|
|
|
83552
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`)"
|
|
83553
83804
|
});
|
|
83554
83805
|
await assertSdkCompatible(innerCtx, { mode: "warn" });
|
|
83806
|
+
const { id: localDevId, orgSlug: installationOrgSlug } = await resolveLocalDevInstallation(innerCtx, config, agent);
|
|
83555
83807
|
const renderStage = innerCtx.flags.json ? null : createStageRenderer();
|
|
83556
83808
|
const renderReload = innerCtx.flags.json ? null : createReloadRenderer();
|
|
83557
83809
|
let republishPointer = null;
|
|
@@ -83565,6 +83817,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
|
|
|
83565
83817
|
},
|
|
83566
83818
|
emulatorPort: flags.port,
|
|
83567
83819
|
tunnel: true,
|
|
83820
|
+
...localDevId ? { installationId: localDevId } : {},
|
|
83568
83821
|
listen: flags.listen,
|
|
83569
83822
|
verbose: flags.verbose,
|
|
83570
83823
|
...flags.autoMigrate !== void 0 ? { autoMigrateOverride: flags.autoMigrate } : {},
|
|
@@ -83576,48 +83829,6 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
|
|
|
83576
83829
|
}
|
|
83577
83830
|
});
|
|
83578
83831
|
declaredPieces = session.declaredPieces;
|
|
83579
|
-
const registerLocalDev = async () => {
|
|
83580
|
-
const upserted = await upsertLocalDevInstallation(innerCtx, {
|
|
83581
|
-
creatorOrganizationSlug: config.organizationSlug ?? (await getActiveOrganization(innerCtx)).slug,
|
|
83582
|
-
agentTemplateSlug: agent.slug
|
|
83583
|
-
});
|
|
83584
|
-
await innerCtx.projectConfig.write({
|
|
83585
|
-
...config,
|
|
83586
|
-
localDevInstallationId: upserted.id,
|
|
83587
|
-
organizationSlug: upserted.organizationSlug,
|
|
83588
|
-
agentSlug: upserted.agentSlug
|
|
83589
|
-
});
|
|
83590
|
-
return {
|
|
83591
|
-
id: upserted.id,
|
|
83592
|
-
orgSlug: upserted.organizationSlug
|
|
83593
|
-
};
|
|
83594
|
-
};
|
|
83595
|
-
let localDevId = config.localDevInstallationId;
|
|
83596
|
-
let installationOrgSlug = config.organizationSlug;
|
|
83597
|
-
if (!localDevId) {
|
|
83598
|
-
const registered = await registerLocalDev();
|
|
83599
|
-
localDevId = registered.id;
|
|
83600
|
-
installationOrgSlug = registered.orgSlug;
|
|
83601
|
-
} else if (!installationOrgSlug) installationOrgSlug = (await getActiveOrganization(innerCtx)).slug;
|
|
83602
|
-
const publicUrl = session.publicUrl;
|
|
83603
|
-
if (publicUrl) {
|
|
83604
|
-
const patchTunnel = (id) => patchLocalDevInstallation(innerCtx, id, { localTunnelUrl: publicUrl });
|
|
83605
|
-
const patched = await patchTunnel(localDevId).catch(async (err) => {
|
|
83606
|
-
if (err instanceof StackboneCliError && err.code === "not_found") {
|
|
83607
|
-
innerCtx.logger.warn("local-dev row not found, re-registering");
|
|
83608
|
-
const registered = await registerLocalDev();
|
|
83609
|
-
localDevId = registered.id;
|
|
83610
|
-
installationOrgSlug = registered.orgSlug;
|
|
83611
|
-
return patchTunnel(localDevId).catch((retryErr) => {
|
|
83612
|
-
innerCtx.logger.warn({ err: retryErr.message }, "failed to PATCH local-dev tunnel URL after re-register (continuing)");
|
|
83613
|
-
return null;
|
|
83614
|
-
});
|
|
83615
|
-
}
|
|
83616
|
-
innerCtx.logger.warn({ err: err.message }, "failed to PATCH local-dev tunnel URL (continuing)");
|
|
83617
|
-
return null;
|
|
83618
|
-
});
|
|
83619
|
-
if (patched) installationOrgSlug = patched.organizationSlug;
|
|
83620
|
-
}
|
|
83621
83832
|
if (localDevId) {
|
|
83622
83833
|
const installId = localDevId;
|
|
83623
83834
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -83633,7 +83844,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
|
|
|
83633
83844
|
}, innerCtx.logger);
|
|
83634
83845
|
await republishPointer();
|
|
83635
83846
|
}
|
|
83636
|
-
const studioBaseUrl = resolveStudioBaseUrl(innerCtx.apiUrl);
|
|
83847
|
+
const studioBaseUrl = resolveStudioBaseUrl(innerCtx.apiUrl, innerCtx.env);
|
|
83637
83848
|
const linkTo = (screenPath) => studioDeeplink({
|
|
83638
83849
|
studioBaseUrl,
|
|
83639
83850
|
orgSlug: installationOrgSlug,
|
|
@@ -83697,15 +83908,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
|
|
|
83697
83908
|
const sigintHandler = () => {
|
|
83698
83909
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
83699
83910
|
shutdownAndExit(async () => {
|
|
83700
|
-
|
|
83701
|
-
innerCtx.logger.debug({ err: err.message }, "failed to null local-dev tunnel URL on exit (safety net will catch up)");
|
|
83702
|
-
return null;
|
|
83703
|
-
}) : Promise.resolve(null);
|
|
83704
|
-
await Promise.allSettled([
|
|
83705
|
-
patch,
|
|
83706
|
-
session.stop(),
|
|
83707
|
-
clearDevSession(projectRoot, innerCtx.logger)
|
|
83708
|
-
]);
|
|
83911
|
+
await Promise.allSettled([session.stop(), clearDevSession(projectRoot, innerCtx.logger)]);
|
|
83709
83912
|
goodbye();
|
|
83710
83913
|
}, { onForced: () => {
|
|
83711
83914
|
if (!innerCtx.flags.json) log.warn("Shutdown did not finish in time — leaving anyway.");
|
|
@@ -84376,7 +84579,7 @@ var runEval = async (ctx, args, deps = {}) => {
|
|
|
84376
84579
|
};
|
|
84377
84580
|
const launched = await launch(callCtx, basePath, suite.id, body);
|
|
84378
84581
|
const url = evalRunUrl({
|
|
84379
|
-
studioBaseUrl: resolveStudioBaseUrl(ctx.apiUrl),
|
|
84582
|
+
studioBaseUrl: resolveStudioBaseUrl(ctx.apiUrl, ctx.env),
|
|
84380
84583
|
orgSlug,
|
|
84381
84584
|
installationId,
|
|
84382
84585
|
evalRunId: launched.id
|
|
@@ -86381,16 +86584,29 @@ var parseLimit = (raw) => {
|
|
|
86381
86584
|
* a relative duration (`15m`, `2h`, `30s`, `1d`) measured back from `now`, or an
|
|
86382
86585
|
* absolute ISO timestamp. Returns `undefined` when the flag is absent. A value
|
|
86383
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.
|
|
86384
86591
|
*/
|
|
86385
86592
|
var parseTimeBound = (flag, raw, now) => {
|
|
86386
86593
|
if (raw === void 0) return void 0;
|
|
86387
|
-
const
|
|
86594
|
+
const trimmed = raw.trim();
|
|
86595
|
+
const duration = /^(\d+)(s|m|h|d)$/.exec(trimmed);
|
|
86388
86596
|
if (duration) return now - Number(duration[1]) * ({
|
|
86389
86597
|
s: 1e3,
|
|
86390
86598
|
m: 6e4,
|
|
86391
86599
|
h: 36e5,
|
|
86392
86600
|
d: 864e5
|
|
86393
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
|
+
}
|
|
86394
86610
|
const ms = Date.parse(raw);
|
|
86395
86611
|
if (Number.isNaN(ms)) throw new StackboneCliError({
|
|
86396
86612
|
code: "generic",
|
|
@@ -88357,7 +88573,7 @@ var buildRootCommand = (ctx) => defineCommand({
|
|
|
88357
88573
|
meta: {
|
|
88358
88574
|
name: "stackbone",
|
|
88359
88575
|
version: STACKBONE_CLI_BUILD_VERSION,
|
|
88360
|
-
description: "
|
|
88576
|
+
description: "Build, run and deploy Stackbone agents and workflows. See `stackbone docs`."
|
|
88361
88577
|
},
|
|
88362
88578
|
args: {
|
|
88363
88579
|
json: {
|
package/package.json
CHANGED
|
Binary file
|
package/stackbone-cli-0.5.3.tgz
DELETED
|
Binary file
|