@forgezero/agent 0.1.32 → 0.1.34

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/README.md CHANGED
@@ -17,14 +17,17 @@ The one public package installs both commands. Keeping bootstrap and the daemon
17
17
  in one version prevents a newly installed `fz` from provisioning a different
18
18
  agent protocol.
19
19
 
20
- The operator command launches a platform, runs custody ceremonies, inspects
21
- status, wraps processes, and installs the daemon:
20
+ The operator command starts browser-first platform genesis, inspects status,
21
+ wraps processes, and installs the daemon. Genesis opens the one-use founder
22
+ invitation, waits for the new passkey account to approve this CLI through device
23
+ authorization, then continues custody in the browser. Passkey PRF output and
24
+ both recovery phrases never enter the terminal:
22
25
 
23
26
  ```bash
24
27
  fz keys
25
28
  fz status
26
- fz genesis --mode 2-of-3
27
- fz unlock
29
+ fz genesis --mode 2-of-3 --api https://api.example --app https://console.example
30
+ fz unlock --phrase-file /secure/offline-phrase.txt # run once per participating custodian
28
31
  ```
29
32
 
30
33
  Install the service explicitly:
@@ -136,6 +139,44 @@ are generated adapters. They contain no independent architecture or progress.
136
139
  by hand. Tools and skills remain optional execution aids; accepted decisions and
137
140
  status live in the repository and therefore survive switching AI agents.
138
141
 
142
+ ## Browser-authorized project control
143
+
144
+ Sign in from any checkout with the public CLI. The terminal displays a device
145
+ code and the browser approves it in the selected tenant realm:
146
+
147
+ ```bash
148
+ fz login --api https://api.forgezero.net --realm acme
149
+ fz whoami
150
+ fz ui routes --json
151
+ ```
152
+
153
+ The short session is stored by API origin and realm in an owner-only local file.
154
+ It is never committed to `.fz/config.json`. A protected mutation opens a browser
155
+ page for passkey or TOTP proof and then replays the exact request once; the CLI
156
+ has no privileged server mode.
157
+
158
+ Any ordinary website operation is available to developers and AI agents through
159
+ the same API and access matrix:
160
+
161
+ ```bash
162
+ fz ui routes pipeline
163
+ fz ui get /workspace/pipelines --query projectKey=my-project
164
+ fz api post /workspace/pipelines/status \
165
+ --data '{"pipelineKey":"pl_…","enabled":false}'
166
+ generate-request | fz api post /workspace/action --data -
167
+ ```
168
+
169
+ Only realm-relative paths are accepted, so the saved session cannot be forwarded
170
+ to another origin. Inline JSON, stdin and JSON files are supported. Use
171
+ `fz logout` to revoke the remote session and remove the local copy.
172
+
173
+ `fz ui routes --json` reads the action catalog from the API's enforced access
174
+ matrix. It lists only the current account's current realm, stage and grants, so
175
+ developers and AI agents can discover the available control surface without a
176
+ copied CLI command list or a visit to the website. Execute any listed action
177
+ with `fz ui <method> <path>`; sensitive actions open the same fresh-proof flow
178
+ as the website and replay the exact request once.
179
+
139
180
  ## Deployment definitions
140
181
 
141
182
  A repository may commit `.fz/deploy.json` with its own profiles, prerequisite
@@ -155,6 +196,20 @@ fz deploy check
155
196
  fz deploy sync
156
197
  ```
157
198
 
199
+ After sign-in, create and operate the durable pipeline from the same checkout.
200
+ The project defaults to `.fz/config.json`; compute/profile attachments remain
201
+ control-plane state rather than fields in the deploy file:
202
+
203
+ ```bash
204
+ fz deploy connect --provider github --repository acme/web \
205
+ --clone-url https://github.com/acme/web.git --auth public \
206
+ --target compute-eu@api --target compute-us@api
207
+ fz deploy list
208
+ fz deploy runs --pipeline <pipeline-key>
209
+ fz deploy release --pipeline <pipeline-key> \
210
+ --revision <full-40-character-commit> --wait
211
+ ```
212
+
158
213
  `init` refuses to invent a generic release or health check: both generated steps
159
214
  exit non-zero until the project replaces them. `check` validates the published
160
215
  v2 schema and active software coordinates, then prints a formatting-independent
@@ -209,7 +264,7 @@ only claims assigned to its enrolled hostname, then passes
209
264
  the fixed claim over a local Unix socket to a narrowly privileged root helper.
210
265
  The helper can materialize the audited QEMU profile; it cannot clone a project,
211
266
  read Vault data, accept arbitrary commands, or retain tenant credentials. Root
212
- SSH is an operator-only platform-genesis/recovery path, not the normal tenant
267
+ SSH is an operator-only platform recovery path, not the normal tenant
213
268
  dispatch mechanism.
214
269
 
215
270
  New guests also call this same public `fz agent install --apply --enrol` path
@@ -1,6 +1,7 @@
1
1
  import type { NodeKeyPair } from '@forgezero/runtime/identity';
2
2
  import { type AgentRelease } from './agent-update';
3
3
  import { type AgentUpdateReceipt, type AgentUpdateResponse } from './agent-update-helper';
4
+ import type { AgentOperationTelemetry } from './telemetry-runtime';
4
5
  export interface AgentObservation {
5
6
  version: string;
6
7
  os: {
@@ -42,6 +43,7 @@ export interface AgentHeartbeatOptions {
42
43
  setTimer?: (callback: () => void, ms: number) => unknown;
43
44
  clearTimer?: (handle: unknown) => void;
44
45
  onEvent?: (event: string, detail?: unknown) => void;
46
+ telemetry?: AgentOperationTelemetry;
45
47
  }
46
48
  export declare function observeAgentHost(version?: string, mode?: AgentObservation['mode'], osRelease?: string, architecture?: NodeJS.Architecture): AgentObservation;
47
49
  /** One PQ-authenticated observation and optional supervised update decision. */
@@ -229,6 +229,7 @@ var AGENT_UPDATE_JOURNAL = "/var/lib/forgezero/agent-update.json";
229
229
  var AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update-receipt.json";
230
230
  var MAX_REQUEST_BYTES = 8 * 1024;
231
231
  var COMPUTE_HELPER_UNITS = [
232
+ "forgezero-agent-egress.service",
232
233
  "forgezero-deploy-runner.service",
233
234
  "forgezero-lifecycle-helper.service",
234
235
  "forgezero-software-helper.service"
@@ -743,10 +744,14 @@ async function postSignedNode(options, path, body) {
743
744
  }
744
745
 
745
746
  // src/version.ts
746
- var VERSION3 = "0.1.32";
747
+ var VERSION3 = "0.1.34";
747
748
 
748
749
  // src/agent-heartbeat.ts
749
750
  var unquote = (value) => value.replace(/^['"]|['"]$/g, "");
751
+ var remoteOutcome = (cause) => cause instanceof SignedNodeHttpError && cause.status < 500 ? "refused" : cause instanceof SignedNodeHttpError ? "retryable" : "failed";
752
+
753
+ class AgentUpdateRefusedError extends Error {
754
+ }
750
755
  function observeAgentHost(version = VERSION3, mode = "enrolled", osRelease = readFileSync3("/etc/os-release", "utf8"), architecture = process.arch) {
751
756
  const values = Object.fromEntries(osRelease.split(`
752
757
  `).flatMap((line) => {
@@ -794,17 +799,25 @@ async function heartbeatAgentOnce(options) {
794
799
  try {
795
800
  await options.prepareUpdate?.(release);
796
801
  prepared = true;
797
- const applied = await (options.applyUpdate ?? ((next, current, attemptId) => requestAgentUpdate({
798
- op: "apply",
799
- target: options.updateTarget ?? "compute",
800
- release: next,
801
- currentVersion: current,
802
- attemptId
803
- })))(release, observation.version, desired.attemptId);
804
- if (!applied.ok)
805
- throw new Error(`agent update refused: ${applied.error.message}`);
806
- if (applied.attemptId !== desired.attemptId) {
807
- throw new Error("agent update helper returned the wrong rollout attempt");
802
+ const apply = async () => {
803
+ const applied = await (options.applyUpdate ?? ((next, current, attemptId) => requestAgentUpdate({
804
+ op: "apply",
805
+ target: options.updateTarget ?? "compute",
806
+ release: next,
807
+ currentVersion: current,
808
+ attemptId
809
+ })))(release, observation.version, desired.attemptId);
810
+ if (!applied.ok)
811
+ throw new AgentUpdateRefusedError(`agent update refused: ${applied.error.message}`);
812
+ if (applied.attemptId !== desired.attemptId) {
813
+ throw new Error("agent update helper returned the wrong rollout attempt");
814
+ }
815
+ return applied;
816
+ };
817
+ if (options.telemetry) {
818
+ await options.telemetry.observe("agent.update", apply, () => "success", (cause) => cause instanceof AgentUpdateRefusedError ? "refused" : "failed");
819
+ } else {
820
+ await apply();
808
821
  }
809
822
  options.onEvent?.("update-staged", { from: observation.version, to: release.version });
810
823
  } catch (cause) {
@@ -826,7 +839,8 @@ function startAgentHeartbeat(options) {
826
839
  if (stopped || active)
827
840
  return;
828
841
  let nextSeconds = 30;
829
- active = heartbeatAgentOnce(options).then((response) => {
842
+ const heartbeat = () => heartbeatAgentOnce(options);
843
+ active = (options.telemetry ? options.telemetry.observe("agent.heartbeat", heartbeat, () => "success", remoteOutcome) : heartbeat()).then((response) => {
830
844
  nextSeconds = Math.max(5, Math.min(response.intervalSeconds, 300));
831
845
  }).catch((cause) => options.onEvent?.("heartbeat-failed", cause)).finally(() => {
832
846
  active = null;
@@ -229,6 +229,7 @@ var AGENT_UPDATE_JOURNAL = "/var/lib/forgezero/agent-update.json";
229
229
  var AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update-receipt.json";
230
230
  var MAX_REQUEST_BYTES = 8 * 1024;
231
231
  var COMPUTE_HELPER_UNITS = [
232
+ "forgezero-agent-egress.service",
232
233
  "forgezero-deploy-runner.service",
233
234
  "forgezero-lifecycle-helper.service",
234
235
  "forgezero-software-helper.service"
@@ -1,6 +1,7 @@
1
1
  import type { NodeKeyPair } from '@forgezero/runtime/identity';
2
2
  import type { AttestationSource } from './socket';
3
3
  import { type SignedNodeHttpOptions } from './signed-node-http';
4
+ import type { AgentOperationTelemetry } from './telemetry-runtime';
4
5
  export interface NodeAttestationOptions extends SignedNodeHttpOptions {
5
6
  keys: NodeKeyPair;
6
7
  source: AttestationSource;
@@ -10,6 +11,7 @@ export interface NodeAttestationOptions extends SignedNodeHttpOptions {
10
11
  setTimer?: (callback: () => void, ms: number) => unknown;
11
12
  clearTimer?: (handle: unknown) => void;
12
13
  onEvent?: (event: string, detail?: unknown) => void;
14
+ telemetry?: AgentOperationTelemetry;
13
15
  }
14
16
  /** One challenge, one PSP report, one signed presentation. */
15
17
  export declare function attestNodeOnce(options: NodeAttestationOptions): Promise<{
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Bounded, node-local HTTP concurrency calibration.
3
+ *
4
+ * This deliberately benchmarks a real application probe rather than CPU. A
5
+ * database-bound API can have idle CPU while every request is waiting on I/O.
6
+ * The caller chooses a representative, idempotent GET endpoint; ForgeZero's
7
+ * cluster rehearsal uses its typed Arango read query.
8
+ */
9
+ export interface CapacityStage {
10
+ concurrency: number;
11
+ requests: number;
12
+ succeeded: number;
13
+ failed: number;
14
+ overloaded: number;
15
+ throughputPerSecond: number;
16
+ p95Ms: number;
17
+ }
18
+ export interface CapacityCalibration {
19
+ endpoint: string;
20
+ recommendedConcurrency: number;
21
+ stopReason: 'maximum-tested' | 'latency' | 'errors' | 'throughput-regression';
22
+ stages: CapacityStage[];
23
+ }
24
+ export interface CapacityCalibrationOptions {
25
+ endpoint: string;
26
+ maxConcurrency?: number;
27
+ requestsPerWorker?: number;
28
+ maxP95Ms?: number;
29
+ maxErrorRate?: number;
30
+ headroomRatio?: number;
31
+ requestTimeoutMs?: number;
32
+ }
33
+ export interface ValidatedCapacityCalibrationOptions {
34
+ endpoint: URL;
35
+ maxConcurrency: number;
36
+ requestsPerWorker: number;
37
+ maxP95Ms: number;
38
+ maxErrorRate: number;
39
+ headroomRatio: number;
40
+ requestTimeoutMs: number;
41
+ }
42
+ /** Only a service on this node may be stressed by an install-time probe. */
43
+ export declare function localCalibrationEndpoint(value: string): URL;
44
+ /** Parse every bound without sending a request; deploy definitions use this fail-closed gate. */
45
+ export declare function validateCapacityCalibrationOptions(options: CapacityCalibrationOptions): ValidatedCapacityCalibrationOptions;
46
+ /**
47
+ * Increase load geometrically and stop at the first unsafe stage. The returned
48
+ * limit is the last safe stage with explicit headroom; it is evidence, not a
49
+ * promise that a different route or future release has the same capacity.
50
+ */
51
+ export declare function calibrateHttpConcurrency(options: CapacityCalibrationOptions, fetcher?: typeof fetch): Promise<CapacityCalibration>;
@@ -0,0 +1,119 @@
1
+ // src/capacity-calibration.ts
2
+ var percentile95 = (values) => {
3
+ if (values.length === 0)
4
+ return Number.POSITIVE_INFINITY;
5
+ const sorted = values.toSorted((left, right) => left - right);
6
+ return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)];
7
+ };
8
+ function localCalibrationEndpoint(value) {
9
+ const endpoint = new URL(value);
10
+ if (endpoint.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname) || !endpoint.port || endpoint.username || endpoint.password || endpoint.hash) {
11
+ throw new Error("Capacity calibration requires an explicit loopback HTTP endpoint and port.");
12
+ }
13
+ return endpoint;
14
+ }
15
+ function validateCapacityCalibrationOptions(options) {
16
+ const endpoint = localCalibrationEndpoint(options.endpoint);
17
+ const maxConcurrency = options.maxConcurrency ?? 256;
18
+ const requestsPerWorker = options.requestsPerWorker ?? 8;
19
+ const maxP95Ms = options.maxP95Ms ?? 250;
20
+ const maxErrorRate = options.maxErrorRate ?? 0.01;
21
+ const headroomRatio = options.headroomRatio ?? 0.8;
22
+ const requestTimeoutMs = options.requestTimeoutMs ?? 5000;
23
+ if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4096 || !Number.isSafeInteger(requestsPerWorker) || requestsPerWorker < 2 || requestsPerWorker > 100 || !Number.isFinite(maxP95Ms) || maxP95Ms < 1 || !Number.isFinite(maxErrorRate) || maxErrorRate < 0 || maxErrorRate > 0.2 || !Number.isFinite(headroomRatio) || headroomRatio < 0.25 || headroomRatio > 0.95 || !Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 100 || requestTimeoutMs > 30000) {
24
+ throw new Error("Capacity calibration bounds are invalid.");
25
+ }
26
+ return {
27
+ endpoint,
28
+ maxConcurrency,
29
+ requestsPerWorker,
30
+ maxP95Ms,
31
+ maxErrorRate,
32
+ headroomRatio,
33
+ requestTimeoutMs
34
+ };
35
+ }
36
+ async function stage(endpoint, concurrency, requestsPerWorker, timeoutMs, fetcher) {
37
+ const latencies = [];
38
+ let succeeded = 0;
39
+ let failed = 0;
40
+ let overloaded = 0;
41
+ const started = performance.now();
42
+ await Promise.all(Array.from({ length: concurrency }, async () => {
43
+ for (let request = 0;request < requestsPerWorker; request += 1) {
44
+ const requestStarted = performance.now();
45
+ try {
46
+ const response = await fetcher(endpoint, {
47
+ method: "GET",
48
+ headers: { accept: "application/json", "user-agent": "forgezero-capacity-calibration/1" },
49
+ signal: AbortSignal.timeout(timeoutMs),
50
+ redirect: "error"
51
+ });
52
+ await response.body?.cancel();
53
+ if (response.ok)
54
+ succeeded += 1;
55
+ else {
56
+ failed += 1;
57
+ if (response.status === 503)
58
+ overloaded += 1;
59
+ }
60
+ } catch {
61
+ failed += 1;
62
+ } finally {
63
+ latencies.push(performance.now() - requestStarted);
64
+ }
65
+ }
66
+ }));
67
+ const elapsedSeconds = Math.max((performance.now() - started) / 1000, 0.001);
68
+ return {
69
+ concurrency,
70
+ requests: concurrency * requestsPerWorker,
71
+ succeeded,
72
+ failed,
73
+ overloaded,
74
+ throughputPerSecond: Number(((succeeded + failed) / elapsedSeconds).toFixed(2)),
75
+ p95Ms: Number(percentile95(latencies).toFixed(2))
76
+ };
77
+ }
78
+ async function calibrateHttpConcurrency(options, fetcher = fetch) {
79
+ const {
80
+ endpoint,
81
+ maxConcurrency,
82
+ requestsPerWorker,
83
+ maxP95Ms,
84
+ maxErrorRate,
85
+ headroomRatio,
86
+ requestTimeoutMs: timeoutMs
87
+ } = validateCapacityCalibrationOptions(options);
88
+ const stages = [];
89
+ let lastSafe = 1;
90
+ let stopReason = "maximum-tested";
91
+ for (let concurrency = 1;; concurrency = Math.min(maxConcurrency, concurrency * 2)) {
92
+ const measured = await stage(endpoint, concurrency, requestsPerWorker, timeoutMs, fetcher);
93
+ stages.push(measured);
94
+ const errorRate = measured.failed / measured.requests;
95
+ const previous = stages.at(-2);
96
+ const throughputRegressed = Boolean(previous && concurrency > 1 && measured.throughputPerSecond < previous.throughputPerSecond * 0.9);
97
+ if (measured.overloaded > 0 || errorRate > maxErrorRate)
98
+ stopReason = "errors";
99
+ else if (measured.p95Ms > maxP95Ms)
100
+ stopReason = "latency";
101
+ else if (throughputRegressed)
102
+ stopReason = "throughput-regression";
103
+ else
104
+ lastSafe = concurrency;
105
+ if (stopReason !== "maximum-tested" || concurrency === maxConcurrency)
106
+ break;
107
+ }
108
+ return {
109
+ endpoint: endpoint.toString(),
110
+ recommendedConcurrency: Math.max(1, Math.floor(lastSafe * headroomRatio)),
111
+ stopReason,
112
+ stages
113
+ };
114
+ }
115
+ export {
116
+ validateCapacityCalibrationOptions,
117
+ localCalibrationEndpoint,
118
+ calibrateHttpConcurrency
119
+ };
@@ -16,6 +16,8 @@ import { type Capabilities, type ProvisionPlan } from '../provision';
16
16
  export type { ProvisionPlan };
17
17
  /** Parse NAME=value pairs used only for non-secret unit coordinates and credential paths. */
18
18
  export declare function parseAssignments(value: string | undefined): Record<string, string> | undefined;
19
+ /** Parse an explicit provisioning-owned TCP allowlist; repository data never reaches this input. */
20
+ export declare function parseTcpPorts(value: string | undefined): readonly number[] | undefined;
19
21
  /**
20
22
  * Answer the capability checks by running their commands.
21
23
  *
@@ -50,6 +52,9 @@ export interface InstallOptions {
50
52
  deploymentEnvironment?: Record<string, string>;
51
53
  deploymentCredentials?: Record<string, string>;
52
54
  pullDeployments?: boolean;
55
+ enforceEgress?: boolean;
56
+ runnerLoopbackPorts?: readonly number[];
57
+ runnerPublicTcpPorts?: readonly number[];
53
58
  pullMigrations?: boolean;
54
59
  lifecycleProfilePath?: string;
55
60
  lifecycleHelperSocketPath?: string;
@@ -71,6 +76,7 @@ export interface InstallOptions {
71
76
  enrolStatePath?: string;
72
77
  nodeLabel?: string;
73
78
  nodeHostname?: string;
79
+ telemetryEndpoint?: string;
74
80
  }
75
81
  export declare function planInstall(options: InstallOptions): ProvisionPlan;
76
82
  /** Execute the same ordered plan the control plane executes over SSH. */
@@ -1,19 +1,5 @@
1
1
  import { type SealedShare } from '@forgezero/runtime/custody-share';
2
- import type { UsableIdentity } from './custody';
3
- /**
4
- * The genesis ceremony, driven from a terminal.
5
- *
6
- * Every value that matters is produced HERE and only sealed material crosses
7
- * the wire:
8
- *
9
- * phrase generated locally, printed once, never transmitted or stored
10
- * ssh key derived locally from an agent signature, never transmitted
11
- *
12
- * The API receives the derived 32-byte keys because that is what seals a share,
13
- * and it holds them for exactly the length of one request. Nothing here gives
14
- * the CLI a privileged path — it calls the same endpoints the browser does, and
15
- * a bypass that existed for bootstrapping would exist for an attacker too.
16
- */
2
+ /** Transport shared by the post-genesis vault unlock routine. */
17
3
  export interface Transport {
18
4
  (path: string, init?: {
19
5
  method?: string;
@@ -23,32 +9,6 @@ export interface Transport {
23
9
  body: unknown;
24
10
  }>;
25
11
  }
26
- export interface GenesisResult {
27
- ceremonyKey: string;
28
- phrase: string[];
29
- fingerprint: string;
30
- activated: boolean;
31
- }
32
- /**
33
- * Run a single-custodian genesis.
34
- *
35
- * Deliberately limited to the operator running the command. A multi-custodian
36
- * genesis needs each person at their own terminal with their own agent, which
37
- * is a coordination problem rather than a code one — and pretending to do it
38
- * from one shell would mean one machine briefly holding every share.
39
- */
40
- export declare function runGenesis(args: {
41
- api: Transport;
42
- modeId: string;
43
- userKey: string;
44
- email: string;
45
- identity: UsableIdentity;
46
- /** The `plt_` token setup.sh wrote. Required — see below. */
47
- token: string;
48
- displayName?: string;
49
- socketPath?: string;
50
- onStep?: (message: string) => void;
51
- }): Promise<GenesisResult>;
52
12
  /**
53
13
  * Reconstruct the seed and unlock.
54
14
  *
@@ -59,9 +19,9 @@ export declare function runGenesis(args: {
59
19
  export declare function runUnlock(args: {
60
20
  api: Transport;
61
21
  userKey: string;
62
- identity?: UsableIdentity;
63
- phrase?: string[];
64
- socketPath?: string;
22
+ phrase: string[];
23
+ ceremonyKey: string;
24
+ fingerprint: string;
65
25
  /**
66
26
  * This custodian's sealed envelope, read from the ceremony.
67
27
  *
@@ -69,11 +29,10 @@ export declare function runUnlock(args: {
69
29
  * unlocking. Opening it is a local step now, so no factor travels.
70
30
  */
71
31
  sealed?: SealedShare;
72
- /** The salt the phrase wrapping key was derived over. */
73
- phraseSalt?: string;
74
32
  onStep?: (message: string) => void;
75
33
  }): Promise<{
76
34
  fingerprint: string;
35
+ collected?: number;
36
+ required?: number;
37
+ unlocked?: boolean;
77
38
  }>;
78
- /** Resolve `--key` to a usable identity, with an actionable error if it cannot. */
79
- export declare function resolveIdentity(selector: string | undefined, socketPath?: string): Promise<UsableIdentity>;
@@ -10,3 +10,8 @@ import { type AgentIdentity } from '@forgezero/runtime/ssh-agent';
10
10
  */
11
11
  export declare function requestHeaders(apiBase: string, cookie: string | null): Record<string, string>;
12
12
  export declare function useCustodyIdentity(identity: AgentIdentity, socketPath?: string): void;
13
+ export declare function safeApiPath(value: string): string;
14
+ export declare function targetCoordinate(value: string): {
15
+ computeReference: string;
16
+ profile: string;
17
+ };
@@ -0,0 +1,22 @@
1
+ /** A short-lived browser-authorized session used only by the public CLI. */
2
+ export interface CliSession {
3
+ api: string;
4
+ /** Browser application origin that approved this session and fresh actions. */
5
+ app?: string;
6
+ realm: string;
7
+ cookie: string;
8
+ createdAtTs: number;
9
+ lastUsedAtTs: number;
10
+ user?: {
11
+ key?: string;
12
+ email?: string;
13
+ displayName?: string;
14
+ };
15
+ }
16
+ export declare function canonicalApi(value: string): string;
17
+ export declare const sessionId: (api: string, realm: string) => string;
18
+ export declare function defaultSessionPath(env?: NodeJS.ProcessEnv): string;
19
+ export declare function activeSession(path?: string): CliSession | null;
20
+ export declare function sessionFor(api: string, realm: string, path?: string): CliSession | null;
21
+ export declare function saveSession(session: CliSession, path?: string): void;
22
+ export declare function removeSession(api: string, realm: string, path?: string): void;
@@ -1,5 +1,6 @@
1
1
  import type { Pipeline, PipelineStep } from './pipeline';
2
2
  import { type DeploymentChannel, type SoftwareRequirement } from './software';
3
+ import { type CapacityCalibrationOptions } from './capacity-calibration';
3
4
  /**
4
5
  * Version two separates a repository's deploy recipe from the computes that use
5
6
  * it. A target chooses one named profile in the control plane; compute names,
@@ -9,6 +10,8 @@ export declare const PIPELINE_VERSION: 2;
9
10
  export declare const DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
10
11
  export interface PipelineProfile {
11
12
  software: readonly SoftwareRequirement[];
13
+ /** Explicit opt-in, provider-neutral loopback GET probe run after target health. */
14
+ capacityCalibration?: CapacityCalibrationOptions;
12
15
  }
13
16
  export interface DeployStep extends PipelineStep {
14
17
  phase: 'build' | 'release' | 'migrate' | 'health';