@bnbagent/deploy-provider-bnb 0.3.0
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/package.json +27 -0
- package/src/api-types.ts +147 -0
- package/src/artifact.ts +121 -0
- package/src/client.ts +258 -0
- package/src/extras.ts +150 -0
- package/src/image.ts +222 -0
- package/src/index.ts +591 -0
- package/src/init.ts +55 -0
- package/src/session.ts +94 -0
package/src/extras.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bnb-ONLY CLI extras (deliberately NOT on the CloudProvider contract — they
|
|
3
|
+
* would leak platform concepts into core), mirroring how `cognito` is the aws
|
|
4
|
+
* extra:
|
|
5
|
+
*
|
|
6
|
+
* clients — the A2A/MCP buyer token plane (client-integration.md §5b):
|
|
7
|
+
* mint / list / revoke per-agent OAuth2 invoke clients.
|
|
8
|
+
* token — platform API tokens (bnbk_…) for CI/automation: mint / list /
|
|
9
|
+
* revoke; consumed via BNBAGENT_API_TOKEN. Account-level: needs no
|
|
10
|
+
* project spec (spec is undefined outside a project).
|
|
11
|
+
*
|
|
12
|
+
* Output contract: with --json the platform's response object is printed to
|
|
13
|
+
* stdout VERBATIM (machine channel — orchestrators must not scrape human
|
|
14
|
+
* text); human summaries go to the logger (stderr) otherwise.
|
|
15
|
+
*
|
|
16
|
+
* ★ Minted secrets (client_secret / bnbk_ token) are shown ONCE by the
|
|
17
|
+
* platform and printed ONCE here on the user's explicit request — never
|
|
18
|
+
* stored, never logged elsewhere.
|
|
19
|
+
*/
|
|
20
|
+
import type { Context, DeploymentSpec } from "@bnbagent/deploy-core";
|
|
21
|
+
import { authedRequest } from "./client.ts";
|
|
22
|
+
import type { ApiTokenCreated, ApiTokenList, InvokeClientCreated, InvokeClientList, TrialStatus } from "./api-types.ts";
|
|
23
|
+
|
|
24
|
+
const wantsJson = (ctx: Context): boolean => Boolean((ctx.flags as Record<string, unknown>).json);
|
|
25
|
+
const emit = (data: unknown): void => {
|
|
26
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `bnbagent-deploy trial [--json]` — the structured trial-clock read
|
|
31
|
+
* (GET /v1/account/trial: case/started_at/expires_at/remaining_seconds).
|
|
32
|
+
* Account-level: works in any directory. Orchestrators gate deploy offers on it.
|
|
33
|
+
*/
|
|
34
|
+
export async function trialCmd(_spec: DeploymentSpec | undefined, ctx: Context): Promise<void> {
|
|
35
|
+
const trial: TrialStatus = await authedRequest(ctx, "GET", "/v1/account/trial");
|
|
36
|
+
if (wantsJson(ctx)) {
|
|
37
|
+
emit(trial);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const detail =
|
|
41
|
+
trial.case === "active"
|
|
42
|
+
? ` — expires ${trial.expires_at} (${Math.max(0, Math.round((trial.remaining_seconds ?? 0) / 3600))}h left)`
|
|
43
|
+
: "";
|
|
44
|
+
ctx.logger.info(`Trial: ${trial.case}${detail}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `bnbagent-deploy clients [--new [--label x] | --revoke <id>] [--json]` (default: list). */
|
|
48
|
+
export async function clientsCmd(spec: DeploymentSpec | undefined, ctx: Context): Promise<void> {
|
|
49
|
+
if (!spec) throw new Error("clients needs the agent's spec (run inside a project with agent-deploy.toml).");
|
|
50
|
+
const flags = ctx.flags as Record<string, unknown>;
|
|
51
|
+
const json = wantsJson(ctx);
|
|
52
|
+
const slug = encodeURIComponent(spec.name);
|
|
53
|
+
|
|
54
|
+
if (flags.new) {
|
|
55
|
+
const label = typeof flags.label === "string" && flags.label ? { label: flags.label } : {};
|
|
56
|
+
const created: InvokeClientCreated = await authedRequest(
|
|
57
|
+
ctx,
|
|
58
|
+
"POST",
|
|
59
|
+
`/v1/agents/${slug}/invoke-clients`,
|
|
60
|
+
{ json: label },
|
|
61
|
+
);
|
|
62
|
+
if (json) {
|
|
63
|
+
emit(created); // verbatim platform response — the ONE structured chance to capture the secret
|
|
64
|
+
} else {
|
|
65
|
+
ctx.logger.success(`Invoke client minted for '${spec.name}'. Hand these to the buyer:`);
|
|
66
|
+
process.stdout.write(
|
|
67
|
+
[
|
|
68
|
+
`client_id: ${created.client_id}`,
|
|
69
|
+
`client_secret: ${created.client_secret}`,
|
|
70
|
+
`token_url: ${created.token_url}`,
|
|
71
|
+
`scope: ${created.scope}`,
|
|
72
|
+
"",
|
|
73
|
+
].join("\n"),
|
|
74
|
+
);
|
|
75
|
+
ctx.logger.info(
|
|
76
|
+
"Buyer flow: POST token_url (grant_type=client_credentials) → Bearer token → call the A2A/MCP endpoint.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
ctx.logger.warn("The client_secret is shown ONCE — store it now; it cannot be retrieved again.");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (typeof flags.revoke === "string" && flags.revoke) {
|
|
84
|
+
await authedRequest(ctx, "DELETE", `/v1/invoke-clients/${encodeURIComponent(flags.revoke)}`);
|
|
85
|
+
if (json) emit({ revoked: true, id: flags.revoke });
|
|
86
|
+
else ctx.logger.success(`Revoked invoke client ${flags.revoke}.`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const list: InvokeClientList = await authedRequest(ctx, "GET", `/v1/agents/${slug}/invoke-clients`);
|
|
91
|
+
if (json) {
|
|
92
|
+
emit(list);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!list.clients.length) {
|
|
96
|
+
ctx.logger.info(`No invoke clients for '${spec.name}' yet — mint one: bnbagent-deploy clients --new`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const c of list.clients) {
|
|
100
|
+
const state = c.revokedAt ? "revoked" : "active";
|
|
101
|
+
ctx.logger.info(`${c.clientId} ${state} ${c.label ?? ""} (id ${c.id}, created ${c.createdAt})`);
|
|
102
|
+
}
|
|
103
|
+
ctx.logger.info("Revoke one: bnbagent-deploy clients --revoke <id>");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `bnbagent-deploy token [--new | --revoke <id>] [--json]` (default: list).
|
|
108
|
+
* Account-level — usable in ANY directory (`--provider bnb` + BNBAGENT_API_URL
|
|
109
|
+
* outside a project); the spec, when present, only supplies [bnb] apiUrl.
|
|
110
|
+
*/
|
|
111
|
+
export async function tokenCmd(_spec: DeploymentSpec | undefined, ctx: Context): Promise<void> {
|
|
112
|
+
const flags = ctx.flags as Record<string, unknown>;
|
|
113
|
+
const json = wantsJson(ctx);
|
|
114
|
+
|
|
115
|
+
if (flags.new) {
|
|
116
|
+
const created: ApiTokenCreated = await authedRequest(ctx, "POST", "/v1/tokens", { json: {} });
|
|
117
|
+
if (json) {
|
|
118
|
+
emit(created); // verbatim — the ONE structured chance to capture the token
|
|
119
|
+
} else {
|
|
120
|
+
ctx.logger.success("Platform API token minted (tenant scope). For CI/automation:");
|
|
121
|
+
process.stdout.write(`${created.token}\n`);
|
|
122
|
+
ctx.logger.info("Use it via the BNBAGENT_API_TOKEN env var (replaces the interactive session).");
|
|
123
|
+
}
|
|
124
|
+
ctx.logger.warn("The token is shown ONCE — store it now (e.g. a CI secret).");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof flags.revoke === "string" && flags.revoke) {
|
|
129
|
+
await authedRequest(ctx, "DELETE", `/v1/tokens/${encodeURIComponent(flags.revoke)}`);
|
|
130
|
+
if (json) emit({ revoked: true, id: flags.revoke });
|
|
131
|
+
else ctx.logger.success(`Revoked API token ${flags.revoke}.`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const list: ApiTokenList = await authedRequest(ctx, "GET", "/v1/tokens");
|
|
136
|
+
if (json) {
|
|
137
|
+
emit(list);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!list.tokens.length) {
|
|
141
|
+
ctx.logger.info("No API tokens yet — mint one: bnbagent-deploy token --new");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
for (const t of list.tokens) {
|
|
145
|
+
ctx.logger.info(
|
|
146
|
+
`${t.prefix ?? "bnbk_…"} ${t.scope ?? "tenant"} expires ${t.expires_at ?? "never"} (id ${t.id})`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
ctx.logger.info("Revoke one: bnbagent-deploy token --revoke <id>");
|
|
150
|
+
}
|
package/src/image.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ship a CONTAINER-IMAGE artifact to the trial platform (client-integration.md
|
|
3
|
+
* §3b): the platform brokers a private repo + a short-lived push credential;
|
|
4
|
+
* WE drive the local Docker CLI — build (single manifest, the platform's
|
|
5
|
+
* advertised arch), stdin-only login, push with bounded retries (a re-login
|
|
6
|
+
* between attempts refreshes the short-lived token), then complete the
|
|
7
|
+
* artifact against the resolved content digest.
|
|
8
|
+
*
|
|
9
|
+
* Docker mechanics (probe, cross-build preflight, transient-retry build,
|
|
10
|
+
* constrained-network knobs) are the shared core helpers (`docker.ts`).
|
|
11
|
+
* ★ The registry password is passed ONLY via stdin — never argv, never logged.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
15
|
+
import type { Context, DeploymentSpec } from "@bnbagent/deploy-core";
|
|
16
|
+
import {
|
|
17
|
+
assertCrossBuildCapable,
|
|
18
|
+
constrainedNetworkArgs,
|
|
19
|
+
dockerBuildWithRetry,
|
|
20
|
+
ensureDocker,
|
|
21
|
+
runDocker,
|
|
22
|
+
} from "@bnbagent/deploy-core";
|
|
23
|
+
import { authedRequest } from "./client.ts";
|
|
24
|
+
import type { ImageArtifactCreated } from "./api-types.ts";
|
|
25
|
+
|
|
26
|
+
/** Platform/AgentCore hard cap — fail before a doomed multi-minute push (also checked server-side). */
|
|
27
|
+
const IMAGE_LIMIT_BYTES = 2 * 1024 * 1024 * 1024;
|
|
28
|
+
const PUSH_MAX_ATTEMPTS = 3;
|
|
29
|
+
|
|
30
|
+
export class TrialImageError extends Error {
|
|
31
|
+
constructor(message: string) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "TrialImageError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ShippedImage {
|
|
38
|
+
artifactId: string;
|
|
39
|
+
/** The pushed content digest — the deploy idempotency key's content half. */
|
|
40
|
+
digest: string;
|
|
41
|
+
/** Full pushed reference (`<repository>@sha256:…`) for record.imageUri. */
|
|
42
|
+
imageUri: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Build/retag, push to the brokered repo, and return the verified artifact id + digest. */
|
|
46
|
+
export async function shipImageArtifact(spec: DeploymentSpec, ctx: Context): Promise<ShippedImage> {
|
|
47
|
+
await ensureDockerWrapped();
|
|
48
|
+
|
|
49
|
+
// Resolve all local inputs BEFORE minting the artifact — its push credential
|
|
50
|
+
// is short-lived, so don't burn its window on local misconfiguration.
|
|
51
|
+
let dockerfile: string | undefined;
|
|
52
|
+
let contextPath: string | undefined;
|
|
53
|
+
if (spec.type === "container") {
|
|
54
|
+
contextPath = resolve(ctx.baseDir, spec.path ?? ".");
|
|
55
|
+
dockerfile = resolveDockerfile(spec, ctx, contextPath);
|
|
56
|
+
} else if (!spec.image) {
|
|
57
|
+
throw new TrialImageError('type = "image" but deploy.image is empty.');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
ctx.logger.step("Requesting a brokered image repository from the platform…");
|
|
61
|
+
const art: ImageArtifactCreated = await authedRequest(ctx, "POST", "/v1/artifacts/image", { json: {} });
|
|
62
|
+
if (spec.platform && spec.platform !== art.platform) {
|
|
63
|
+
throw new TrialImageError(
|
|
64
|
+
`The trial platform runs ${art.platform} images (got platform = "${spec.platform}"). ` +
|
|
65
|
+
`Remove the platform field or set it to ${art.platform}.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const tag = `${art.repository}:${art.artifactId.toLowerCase()}`;
|
|
69
|
+
|
|
70
|
+
if (spec.type === "container") {
|
|
71
|
+
// HARD preflight before the multi-minute build: an incapable host fails
|
|
72
|
+
// now, actionably — not late with 'exec format error'.
|
|
73
|
+
try {
|
|
74
|
+
await assertCrossBuildCapable(art.platform, ctx);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
throw new TrialImageError(
|
|
77
|
+
`${(e as Error).message}\n (The trial platform's runtime is ${art.platform}; an arm64 host — e.g. Apple Silicon — needs no emulation.)`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
ctx.logger.step(`Building ${tag} (${art.platform})…`);
|
|
81
|
+
// --provenance/--sbom off → a single manifest, not an OCI attestation index
|
|
82
|
+
// (the runtime rejects a multi-manifest index).
|
|
83
|
+
const buildArgs = [
|
|
84
|
+
"build", "--platform", art.platform, "--provenance=false", "--sbom=false",
|
|
85
|
+
...constrainedNetworkArgs(),
|
|
86
|
+
"-t", tag, "-f", dockerfile!, contextPath!,
|
|
87
|
+
];
|
|
88
|
+
try {
|
|
89
|
+
await dockerBuildWithRetry(buildArgs, ctx);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
throw new TrialImageError((e as Error).message);
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
await ensureLocalImage(spec.image!, ctx);
|
|
95
|
+
ctx.logger.info(`Using prebuilt image ${spec.image} — make sure it is ${art.platform}, single manifest.`);
|
|
96
|
+
await docker(["tag", spec.image!, tag], ctx);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const size = await imageSize(tag, ctx);
|
|
100
|
+
if (size > IMAGE_LIMIT_BYTES) {
|
|
101
|
+
throw new TrialImageError(
|
|
102
|
+
`The image is ${(size / 1024 / 1024 / 1024).toFixed(1)}GB — the trial platform caps images at 2GB.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await registryLogin(art, ctx);
|
|
107
|
+
ctx.logger.info(`Pushing ${tag}…`);
|
|
108
|
+
for (let attempt = 1; ; attempt++) {
|
|
109
|
+
try {
|
|
110
|
+
await docker(["push", tag], ctx);
|
|
111
|
+
break;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (attempt >= PUSH_MAX_ATTEMPTS) throw e;
|
|
114
|
+
ctx.logger.warn(
|
|
115
|
+
`Push failed (attempt ${attempt}/${PUSH_MAX_ATTEMPTS}) — re-logging in and retrying ` +
|
|
116
|
+
`(the brokered token is short-lived; transient registry/network errors are common on multi-layer pushes).`,
|
|
117
|
+
);
|
|
118
|
+
await Bun.sleep(attempt * 5000);
|
|
119
|
+
await registryLogin(art, ctx);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const digest = await repoDigest(tag, art.repository, ctx);
|
|
124
|
+
ctx.logger.info(`Pushed ${art.repository}@${digest.slice(0, 19)}… — completing the artifact…`);
|
|
125
|
+
await authedRequest(ctx, "POST", `/v1/artifacts/${art.artifactId}/complete`, { json: { digest } });
|
|
126
|
+
ctx.logger.debug(`[bnb] image artifact ${art.artifactId} verified (${digest.slice(0, 19)}…)`);
|
|
127
|
+
return { artifactId: art.artifactId, digest, imageUri: `${art.repository}@${digest}` };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** stdin-only login to the brokered registry (the password never touches argv/logs). */
|
|
131
|
+
async function registryLogin(art: ImageArtifactCreated, ctx: Context): Promise<void> {
|
|
132
|
+
ctx.logger.debug(`[bnb] docker login → ${art.registry}`);
|
|
133
|
+
await docker(["login", "--username", art.username, "--password-stdin", art.registry], ctx, {
|
|
134
|
+
stdin: art.password,
|
|
135
|
+
quiet: true,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The pushed digest for OUR repo — never blindly RepoDigests[0] (other repos may be listed). */
|
|
140
|
+
async function repoDigest(tag: string, repository: string, ctx: Context): Promise<string> {
|
|
141
|
+
const out = await docker(["inspect", "--format", "{{json .RepoDigests}}", tag], ctx, { quiet: true });
|
|
142
|
+
let digests: string[] = [];
|
|
143
|
+
try {
|
|
144
|
+
digests = JSON.parse(out.trim()) as string[];
|
|
145
|
+
} catch {
|
|
146
|
+
/* fall through to the error below */
|
|
147
|
+
}
|
|
148
|
+
const match = digests.find((d) => d.startsWith(`${repository}@`));
|
|
149
|
+
if (!match) {
|
|
150
|
+
throw new TrialImageError(
|
|
151
|
+
`Pushed, but could not resolve the content digest for ${repository} ` +
|
|
152
|
+
`(RepoDigests: ${digests.join(", ") || "none"}).`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return match.slice(repository.length + 1); // "sha256:…"
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function imageSize(tag: string, ctx: Context): Promise<number> {
|
|
159
|
+
const out = await docker(["inspect", "--format", "{{.Size}}", tag], ctx, { quiet: true });
|
|
160
|
+
return Number(out.trim()) || 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** For type = "image": the ref must resolve locally (pull it if it doesn't). */
|
|
164
|
+
async function ensureLocalImage(ref: string, ctx: Context): Promise<void> {
|
|
165
|
+
try {
|
|
166
|
+
await docker(["image", "inspect", ref], ctx, { quiet: true });
|
|
167
|
+
} catch {
|
|
168
|
+
ctx.logger.info(`Image ${ref} not found locally — pulling…`);
|
|
169
|
+
try {
|
|
170
|
+
await docker(["pull", ref], ctx);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
throw new TrialImageError(
|
|
173
|
+
`deploy.image "${ref}" is neither available locally nor pullable: ${(e as Error).message}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve which Dockerfile to use — the SAME rules as the aws provider, and
|
|
181
|
+
* the same red line: this tool never GENERATES a Dockerfile.
|
|
182
|
+
*/
|
|
183
|
+
function resolveDockerfile(spec: DeploymentSpec, ctx: Context, contextPath: string): string {
|
|
184
|
+
if (spec.dockerfile) {
|
|
185
|
+
const p = isAbsolute(spec.dockerfile) ? spec.dockerfile : resolve(ctx.baseDir, spec.dockerfile);
|
|
186
|
+
if (!existsSync(p)) {
|
|
187
|
+
throw new TrialImageError(`deploy.dockerfile not found: ${p}\n \`deploy\` only deploys — create it, then re-run.`);
|
|
188
|
+
}
|
|
189
|
+
return p;
|
|
190
|
+
}
|
|
191
|
+
const existing = join(contextPath, "Dockerfile");
|
|
192
|
+
if (existsSync(existing)) return existing;
|
|
193
|
+
throw new TrialImageError(
|
|
194
|
+
`type = "container" needs a Dockerfile, but none was found in ${contextPath}.\n` +
|
|
195
|
+
" • Add one (copy from the container-ts example to start), or set deploy.dockerfile, or\n" +
|
|
196
|
+
' • use type = "zip" (managed runtime, no Docker).\n' +
|
|
197
|
+
" The container contract matches AgentCore: HTTP :8080 /invocations · A2A :9000 · MCP :8000 /mcp.",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function ensureDockerWrapped(): Promise<void> {
|
|
202
|
+
try {
|
|
203
|
+
await ensureDocker(
|
|
204
|
+
' Options: switch to type = "zip" (managed runtime, no Docker), or start Docker and retry.',
|
|
205
|
+
);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
throw new TrialImageError((e as Error).message);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Provider-flavored wrapper: core runDocker throwing Error → TrialImageError. */
|
|
212
|
+
async function docker(
|
|
213
|
+
args: string[],
|
|
214
|
+
ctx: Context,
|
|
215
|
+
opts: { stdin?: string; quiet?: boolean } = {},
|
|
216
|
+
): Promise<string> {
|
|
217
|
+
try {
|
|
218
|
+
return await runDocker(args, { ...opts, ctx });
|
|
219
|
+
} catch (e) {
|
|
220
|
+
throw new TrialImageError((e as Error).message);
|
|
221
|
+
}
|
|
222
|
+
}
|