@gajae-code/coding-agent 0.12.21 → 0.13.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/CHANGELOG.md +67 -2
- package/dist/types/cli/args.d.ts +5 -2
- package/dist/types/cli/root-flags.d.ts +246 -0
- package/dist/types/cli.d.ts +11 -19
- package/dist/types/commands/daemon.d.ts +0 -30
- package/dist/types/commands/launch.d.ts +11 -19
- package/dist/types/commands/quick-lane.d.ts +32 -0
- package/dist/types/commands/sdk.d.ts +21 -15
- package/dist/types/config/file-lock-gc.d.ts +2 -0
- package/dist/types/config/model-equivalence.d.ts +20 -0
- package/dist/types/config/model-profile-activation.d.ts +62 -17
- package/dist/types/config/model-profiles.d.ts +10 -0
- package/dist/types/config/model-registry.d.ts +68 -1
- package/dist/types/config/model-resolver.d.ts +15 -4
- package/dist/types/config/models-config-schema.d.ts +7 -2
- package/dist/types/config/provider-selection-policy.d.ts +62 -0
- package/dist/types/config/settings-schema.d.ts +36 -78
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/edit/renderer.d.ts +5 -2
- package/dist/types/edit/streaming.d.ts +13 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
- package/dist/types/extensibility/extensions/compact-handler.d.ts +2 -1
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +19 -5
- package/dist/types/internal-urls/local-protocol.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +0 -2
- package/dist/types/modes/components/provider-order-context.d.ts +45 -0
- package/dist/types/modes/components/provider-order-editor.d.ts +24 -0
- package/dist/types/modes/components/settings-defs.d.ts +5 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/quick-lane/classify.d.ts +40 -0
- package/dist/types/runtime/stt-settings-setup.d.ts +18 -0
- package/dist/types/sdk/broker/broker.d.ts +56 -2
- package/dist/types/sdk/broker/operation-receipt-ledger.d.ts +25 -0
- package/dist/types/sdk/broker/session-index.d.ts +83 -2
- package/dist/types/sdk/broker/state-version.d.ts +1 -1
- package/dist/types/sdk/bus/notification-service.d.ts +6 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +8 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +26 -0
- package/dist/types/sdk/cli/rows.d.ts +98 -0
- package/dist/types/sdk/cli/session-cli.d.ts +35 -3
- package/dist/types/sdk/elevation/allowlist.d.ts +20 -0
- package/dist/types/sdk/elevation/audit.d.ts +93 -0
- package/dist/types/sdk/elevation/capability.d.ts +3 -0
- package/dist/types/sdk/elevation/digest.d.ts +72 -0
- package/dist/types/sdk/elevation/dispatch-receipt.d.ts +85 -0
- package/dist/types/sdk/elevation/grant-ledger.d.ts +163 -0
- package/dist/types/sdk/elevation/owner.d.ts +48 -0
- package/dist/types/sdk/guides/cache.d.ts +98 -0
- package/dist/types/sdk/guides/catalog.d.ts +156 -0
- package/dist/types/sdk/guides/cli.d.ts +39 -0
- package/dist/types/sdk/guides/manifest.d.ts +66 -0
- package/dist/types/sdk/guides/verify.d.ts +66 -0
- package/dist/types/sdk/host/control/dispatch.d.ts +8 -0
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +3 -2
- package/dist/types/sdk/host/query/cursor.d.ts +10 -0
- package/dist/types/sdk/host/query/handlers.d.ts +21 -0
- package/dist/types/sdk/host/session-runtime.d.ts +9 -0
- package/dist/types/sdk/mcp/server.d.ts +2 -2
- package/dist/types/sdk/prompt-status.d.ts +11 -0
- package/dist/types/sdk/session.d.ts +4 -0
- package/dist/types/sdk/transport/index.d.ts +1 -0
- package/dist/types/sdk/transport/relay.d.ts +1 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +20 -1
- package/dist/types/session/agent-session.d.ts +32 -18
- package/dist/types/session/blob-store.d.ts +13 -7
- package/dist/types/session/fallback-chain-controller.d.ts +15 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +25 -1
- package/dist/types/session/internal/session-memory-sidecar.d.ts +5 -5
- package/dist/types/session/session-manager.d.ts +139 -10
- package/dist/types/session/session-storage.d.ts +48 -0
- package/dist/types/setup/provider-onboarding.d.ts +7 -1
- package/dist/types/slash-commands/builtin-registry.d.ts +14 -0
- package/dist/types/stt/setup.d.ts +2 -0
- package/dist/types/task/executor.d.ts +10 -2
- package/dist/types/tools/browser/runtime-diagnostics.d.ts +65 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
- package/dist/types/tools/browser/tab-worker.d.ts +2 -0
- package/dist/types/tools/browser.d.ts +2 -0
- package/dist/types/tools/image-gen.d.ts +14 -18
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/resource-gc.d.ts +2 -0
- package/dist/types/utils/pasted-image-path.d.ts +6 -0
- package/package.json +9 -9
- package/scripts/generate-sdk-operation-inventory.ts +8 -0
- package/scripts/run-test-manifest.ts +61 -21
- package/scripts/verify-gjc-sdk-canonicalization.ts +9 -4
- package/src/cli/args.ts +62 -9
- package/src/cli/completion-cli.ts +1 -0
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/root-flags.ts +100 -0
- package/src/cli/setup-cli.ts +6 -2
- package/src/cli.ts +42 -65
- package/src/commands/acp.ts +1 -1
- package/src/commands/daemon.ts +2 -31
- package/src/commands/gc.ts +1 -1
- package/src/commands/launch.ts +8 -129
- package/src/commands/quick-lane.ts +51 -0
- package/src/commands/sdk.ts +200 -26
- package/src/commands/setup.ts +4 -2
- package/src/config/file-lock-gc.ts +4 -1
- package/src/config/model-equivalence.ts +76 -7
- package/src/config/model-profile-activation.ts +1115 -265
- package/src/config/model-profiles.ts +134 -2
- package/src/config/model-registry.ts +352 -50
- package/src/config/model-resolver.ts +289 -38
- package/src/config/models-config-schema.ts +11 -4
- package/src/config/provider-selection-policy.ts +146 -0
- package/src/config/settings-schema.ts +50 -77
- package/src/config/settings.ts +40 -0
- package/src/coordinator-mcp/server.ts +32 -2
- package/src/edit/renderer.ts +175 -223
- package/src/edit/streaming.ts +108 -1
- package/src/export/html/index.ts +57 -7
- package/src/extensibility/custom-tools/types.ts +2 -0
- package/src/extensibility/extensions/compact-handler.ts +3 -2
- package/src/extensibility/extensions/runner.ts +6 -0
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/gjc-runtime/gc-runtime.ts +379 -20
- package/src/gjc-runtime/launch-worktree.ts +5 -1
- package/src/gjc-runtime/team-runtime.ts +59 -2
- package/src/gjc-runtime/tmux-owner-isolation.ts +19 -0
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +16 -1
- package/src/internal-urls/docs-index.generated.ts +17 -13
- package/src/internal-urls/local-protocol.ts +1 -0
- package/src/main.ts +40 -3
- package/src/memories/index.ts +4 -2
- package/src/modes/acp/acp-agent.ts +235 -91
- package/src/modes/components/model-selector.ts +206 -42
- package/src/modes/components/provider-order-context.ts +197 -0
- package/src/modes/components/provider-order-editor.ts +441 -0
- package/src/modes/components/settings-defs.ts +21 -1
- package/src/modes/components/settings-selector.ts +45 -2
- package/src/modes/components/tool-execution.ts +47 -15
- package/src/modes/controllers/command-controller.ts +6 -3
- package/src/modes/controllers/extension-ui-controller.ts +2 -0
- package/src/modes/controllers/input-controller.ts +73 -2
- package/src/modes/controllers/selector-controller.ts +260 -153
- package/src/modes/prompt-suggestion-controller.ts +1 -1
- package/src/modes/runtime-init.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +18 -0
- package/src/prompts/tools/browser.md +1 -0
- package/src/quick-lane/classify.ts +257 -0
- package/src/rlm/index.ts +1 -1
- package/src/runtime/stt-settings-setup.ts +57 -0
- package/src/sdk/acp/adapter.ts +2 -1
- package/src/sdk/broker/broker.ts +780 -12
- package/src/sdk/broker/lifecycle.ts +70 -29
- package/src/sdk/broker/operation-receipt-ledger.ts +144 -0
- package/src/sdk/broker/session-index.ts +463 -48
- package/src/sdk/broker/state-version.ts +13 -9
- package/src/sdk/broker/transport.ts +14 -1
- package/src/sdk/bus/index.ts +34 -13
- package/src/sdk/bus/kind-aware-reconciliation.ts +21 -2
- package/src/sdk/bus/notification-service.ts +47 -1
- package/src/sdk/bus/prompt-reconciliation.ts +22 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
- package/src/sdk/bus/telegram-daemon.ts +54 -3
- package/src/sdk/cli/rows.ts +247 -0
- package/src/sdk/cli/session-cli.ts +930 -79
- package/src/sdk/elevation/allowlist.ts +54 -0
- package/src/sdk/elevation/audit.ts +208 -0
- package/src/sdk/elevation/capability.ts +41 -0
- package/src/sdk/elevation/digest.ts +129 -0
- package/src/sdk/elevation/dispatch-receipt.ts +184 -0
- package/src/sdk/elevation/grant-ledger.ts +1142 -0
- package/src/sdk/elevation/owner.ts +109 -0
- package/src/sdk/guides/cache.ts +363 -0
- package/src/sdk/guides/catalog.ts +554 -0
- package/src/sdk/guides/cli.ts +235 -0
- package/src/sdk/guides/manifest.ts +152 -0
- package/src/sdk/guides/verify.ts +178 -0
- package/src/sdk/host/control/dispatch.ts +49 -1
- package/src/sdk/host/control/index.ts +1 -0
- package/src/sdk/host/control/operations.ts +8 -1
- package/src/sdk/host/query/cursor.ts +47 -3
- package/src/sdk/host/query/handlers.ts +155 -2
- package/src/sdk/host/sdk-surface-parity.test.ts +1 -0
- package/src/sdk/host/session-runtime.test.ts +391 -1
- package/src/sdk/host/session-runtime.ts +308 -73
- package/src/sdk/host/surface-policy.ts +3 -0
- package/src/sdk/host/websocket-transport.ts +7 -1
- package/src/sdk/mcp/server.ts +88 -15
- package/src/sdk/prompt-status.ts +8 -0
- package/src/sdk/protocol/operation-inventory.generated.json +62 -0
- package/src/sdk/protocol/operation-registry.ts +3 -2
- package/src/sdk/session.ts +87 -39
- package/src/sdk/transport/index.ts +1 -0
- package/src/sdk/transport/relay.ts +3 -0
- package/src/sdk/transport/serve-cli.ts +111 -24
- package/src/secrets/obfuscator.ts +92 -12
- package/src/session/agent-session.ts +322 -93
- package/src/session/blob-store.ts +60 -8
- package/src/session/fallback-chain-controller.ts +35 -0
- package/src/session/internal/managed-session-scope.ts +25 -3
- package/src/session/internal/managed-session-storage.ts +574 -73
- package/src/session/internal/session-memory-sidecar.ts +19 -29
- package/src/session/session-manager.ts +2166 -743
- package/src/session/session-storage.ts +715 -48
- package/src/setup/provider-onboarding.ts +19 -3
- package/src/setup/provider-presets.json +3 -1
- package/src/slash-commands/builtin-registry.ts +85 -3
- package/src/stt/downloader.ts +2 -3
- package/src/stt/setup.ts +29 -4
- package/src/task/executor.ts +22 -18
- package/src/task/index.ts +24 -1
- package/src/tools/archive-reader.ts +11 -2
- package/src/tools/browser/runtime-diagnostics.ts +249 -0
- package/src/tools/browser/tab-protocol.ts +4 -0
- package/src/tools/browser/tab-supervisor.ts +5 -0
- package/src/tools/browser/tab-worker.ts +36 -1
- package/src/tools/browser.ts +7 -0
- package/src/tools/image-gen.ts +110 -166
- package/src/tools/index.ts +2 -0
- package/src/tools/read.ts +1 -1
- package/src/tools/resource-gc.ts +6 -1
- package/src/tools/tool-catalog.generated.ts +5 -1
- package/src/utils/pasted-image-path.ts +93 -0
- package/src/web/search/index.ts +3 -3
package/src/sdk/broker/broker.ts
CHANGED
|
@@ -6,6 +6,25 @@ import path from "node:path";
|
|
|
6
6
|
import type { NativeDirectoryTreeSnapshot } from "@gajae-code/natives";
|
|
7
7
|
import { logger } from "@gajae-code/utils";
|
|
8
8
|
import type { ModelProfileErrorDetails } from "../../config/model-profile-contract";
|
|
9
|
+
import { SdkClient, SdkClientError } from "../client";
|
|
10
|
+
import { elevationAuthorityPath, signElevationCapability } from "../elevation/capability";
|
|
11
|
+
import { type ElevationOperation, elevationRequestDigest, isElevatableOperation } from "../elevation/digest";
|
|
12
|
+
import {
|
|
13
|
+
type ElevationAnswerAuthority,
|
|
14
|
+
type ElevationClaimIdentity,
|
|
15
|
+
type ElevationDispatchOutcome,
|
|
16
|
+
isElevationRequestId,
|
|
17
|
+
} from "../elevation/dispatch-receipt";
|
|
18
|
+
import {
|
|
19
|
+
type ElevationAnswerValue,
|
|
20
|
+
type ElevationGrantRecord,
|
|
21
|
+
ElevationLedger,
|
|
22
|
+
type ElevationRequester,
|
|
23
|
+
type ElevationResult,
|
|
24
|
+
type ElevationSessionIdentity,
|
|
25
|
+
} from "../elevation/grant-ledger";
|
|
26
|
+
import { type BrokerOwnerPrincipal, readBrokerOwnerPrincipal } from "../elevation/owner";
|
|
27
|
+
import { findOperation } from "../protocol/operation-registry";
|
|
9
28
|
import {
|
|
10
29
|
type DirectoryMigrationPolicy,
|
|
11
30
|
listManagedSessionCandidates,
|
|
@@ -28,14 +47,14 @@ import {
|
|
|
28
47
|
} from "./discovery";
|
|
29
48
|
import { deriveIdempotencyIdentity } from "./identity";
|
|
30
49
|
import { canonicalDeleteLocatorPath, executeLifecycle, isCanonicalSessionId } from "./lifecycle";
|
|
31
|
-
|
|
32
50
|
import {
|
|
33
51
|
type LifecycleDurableEffectsReceipt,
|
|
34
52
|
LifecycleLedger,
|
|
35
53
|
type LifecycleStartupFailureReceipt,
|
|
36
54
|
type LifecycleState,
|
|
37
55
|
} from "./lifecycle-ledger";
|
|
38
|
-
import {
|
|
56
|
+
import { OperationReceiptLedger, operationReceiptDigest, operationReceiptKey } from "./operation-receipt-ledger";
|
|
57
|
+
import { type IndexedSession, SessionIndex, type SessionList } from "./session-index";
|
|
39
58
|
import { BrokerTransport } from "./transport";
|
|
40
59
|
|
|
41
60
|
export interface BrokerSettings {
|
|
@@ -71,6 +90,7 @@ export type BrokerErrorCode =
|
|
|
71
90
|
| "not_found"
|
|
72
91
|
| "live_session"
|
|
73
92
|
| "cleanup_pending"
|
|
93
|
+
| "ambiguous_session"
|
|
74
94
|
| (string & {});
|
|
75
95
|
|
|
76
96
|
export type BrokerCleanupIdentity = {
|
|
@@ -170,6 +190,35 @@ export type BrokerResponse =
|
|
|
170
190
|
};
|
|
171
191
|
const error = (code: BrokerErrorCode, message: string): BrokerResponse => ({ ok: false, error: { code, message } });
|
|
172
192
|
|
|
193
|
+
function elevationResultToBroker<T>(result: ElevationResult<T>): BrokerResponse {
|
|
194
|
+
if (result.ok) return { ok: true, result: result.value };
|
|
195
|
+
return error(result.error.code as BrokerErrorCode, result.error.message);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function elevationAnswerToBroker(result: ElevationResult<ElevationAnswerValue>): BrokerResponse {
|
|
199
|
+
if (!result.ok) return elevationResultToBroker(result);
|
|
200
|
+
const { outcome, grant } = result.value;
|
|
201
|
+
if (outcome === "granted")
|
|
202
|
+
return { ok: true, result: { elevationRequestId: grant.elevationRequestId, state: grant.state, outcome, grant } };
|
|
203
|
+
return error(outcome as BrokerErrorCode, `elevation ${outcome}: ${grant.elevationRequestId}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Result of resolving the session behind an elevation grant/request. An
|
|
208
|
+
* ambiguous session (C6) can never bind an exact endpoint identity.
|
|
209
|
+
*/
|
|
210
|
+
type ElevationSessionResolution =
|
|
211
|
+
| { kind: "identity"; identity: ElevationSessionIdentity }
|
|
212
|
+
| { kind: "ambiguous" }
|
|
213
|
+
| { kind: "unavailable" };
|
|
214
|
+
function sameElevationSessionIdentity(left: ElevationSessionIdentity, right: ElevationSessionIdentity): boolean {
|
|
215
|
+
return (
|
|
216
|
+
left.sessionId === right.sessionId &&
|
|
217
|
+
path.resolve(left.endpointStateRoot) === path.resolve(right.endpointStateRoot) &&
|
|
218
|
+
left.endpointGeneration === right.endpointGeneration &&
|
|
219
|
+
left.endpointIncarnation === right.endpointIncarnation
|
|
220
|
+
);
|
|
221
|
+
}
|
|
173
222
|
function isCleanupPending(response: BrokerResponse): boolean {
|
|
174
223
|
return !response.ok && response.error.code === "cleanup_pending" && response.error.cleanup !== undefined;
|
|
175
224
|
}
|
|
@@ -190,6 +239,28 @@ function lifecycleResponseState(response: BrokerResponse): LifecycleState {
|
|
|
190
239
|
|
|
191
240
|
type InputNormalization = { input: Record<string, unknown> } | BrokerResponse;
|
|
192
241
|
|
|
242
|
+
type SessionListCursor = {
|
|
243
|
+
sessions: IndexedSession[];
|
|
244
|
+
indexSeq: number;
|
|
245
|
+
warnings: string[];
|
|
246
|
+
limit: number;
|
|
247
|
+
offset: number;
|
|
248
|
+
expiresAt: number;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const SESSION_LIST_DEFAULT_LIMIT = 100;
|
|
252
|
+
const SESSION_LIST_MAX_LIMIT = 100;
|
|
253
|
+
const SESSION_LIST_CURSOR_TTL_MS = 15 * 60 * 1_000;
|
|
254
|
+
const SESSION_LIST_MAX_CURSORS = 32;
|
|
255
|
+
|
|
256
|
+
function sessionListLimit(input: Record<string, unknown>): number | BrokerResponse {
|
|
257
|
+
const limit = input.limit;
|
|
258
|
+
if (limit === undefined) return SESSION_LIST_DEFAULT_LIMIT;
|
|
259
|
+
if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 1 || limit > SESSION_LIST_MAX_LIMIT)
|
|
260
|
+
return error("invalid_input", `limit must be a safe integer from 1 to ${SESSION_LIST_MAX_LIMIT}`);
|
|
261
|
+
return limit;
|
|
262
|
+
}
|
|
263
|
+
|
|
193
264
|
function isBrokerResponse(value: unknown): value is BrokerResponse {
|
|
194
265
|
return typeof value === "object" && value !== null && "ok" in value && typeof value.ok === "boolean";
|
|
195
266
|
}
|
|
@@ -234,6 +305,10 @@ function normalizeBrokerInput(operation: string, input: Record<string, unknown>)
|
|
|
234
305
|
const resolved = input.resolveSessionId;
|
|
235
306
|
if (resolved !== undefined && (typeof resolved !== "string" || !isCanonicalSessionId(resolved)))
|
|
236
307
|
return error("invalid_input", "resolveSessionId must be a canonical safe identifier");
|
|
308
|
+
if (input.cursor !== undefined && (typeof input.cursor !== "string" || input.cursor.length === 0))
|
|
309
|
+
return error("invalid_input", "cursor must be a non-empty opaque string");
|
|
310
|
+
const limit = sessionListLimit(input);
|
|
311
|
+
if (isBrokerResponse(limit)) return limit;
|
|
237
312
|
return { input: normalized };
|
|
238
313
|
}
|
|
239
314
|
if (
|
|
@@ -389,6 +464,7 @@ function lifecycleTarget(operation: string, input: Record<string, unknown>): unk
|
|
|
389
464
|
}
|
|
390
465
|
|
|
391
466
|
const BROKER_LOCK_RECORD = "owner.json";
|
|
467
|
+
const BROKER_INDEX_COMPACT_INTERVAL_MS = 6 * 60 * 60 * 1_000;
|
|
392
468
|
const BROKER_LOCK_STARTUP_WAIT_MS = 1_000;
|
|
393
469
|
const BROKER_LOCK_RETRY_MS = 10;
|
|
394
470
|
|
|
@@ -696,14 +772,32 @@ const terminalPersistenceHooksForTest = new WeakMap<Broker, () => void>();
|
|
|
696
772
|
const ambiguityGraceOverridesForTest = new WeakMap<Broker, number>();
|
|
697
773
|
const publicationObservationOverridesForTest = new WeakMap<Broker, BrokerPublicationObservation>();
|
|
698
774
|
const lockArtifactGraceOverridesForTest = new WeakMap<Broker, number>();
|
|
775
|
+
function elevationLedgerEnabledForBroker(): boolean {
|
|
776
|
+
const raw = (process.env.GJC_SDK_ELEVATION_ENABLED ?? "").trim().toLowerCase();
|
|
777
|
+
return raw === "1" || raw === "true";
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Recursive reserved-key rejection: the claim selector never travels inside approved input. */
|
|
781
|
+
function containsReservedKey(value: unknown, key: string, seen = new Set<object>()): boolean {
|
|
782
|
+
if (!value || typeof value !== "object" || seen.has(value)) return false;
|
|
783
|
+
seen.add(value);
|
|
784
|
+
if (Array.isArray(value)) return value.some(item => containsReservedKey(item, key, seen));
|
|
785
|
+
return (
|
|
786
|
+
Object.hasOwn(value, key) ||
|
|
787
|
+
Object.values(value as Record<string, unknown>).some(nested => containsReservedKey(nested, key, seen))
|
|
788
|
+
);
|
|
789
|
+
}
|
|
699
790
|
|
|
700
791
|
export class Broker {
|
|
701
792
|
readonly settings: ResolvedBrokerSettings;
|
|
702
793
|
readonly index: SessionIndex;
|
|
703
794
|
readonly ledger: LifecycleLedger;
|
|
795
|
+
readonly elevation: ElevationLedger;
|
|
796
|
+
readonly operationReceipts: OperationReceiptLedger;
|
|
704
797
|
discovery: BrokerDiscovery | null = null;
|
|
705
798
|
#lock: string;
|
|
706
799
|
#owner = randomBytes(12).toString("hex");
|
|
800
|
+
#sessionListCursors = new Map<string, SessionListCursor>();
|
|
707
801
|
#chains = new Map<string, Promise<void>>();
|
|
708
802
|
#admitted = new Set<Promise<void>>();
|
|
709
803
|
#startupAdmissions = new StartupAdmissionQueue(sdkHostStartupConcurrency());
|
|
@@ -718,6 +812,12 @@ export class Broker {
|
|
|
718
812
|
#completion!: Promise<void>;
|
|
719
813
|
#resolveCompletion!: () => void;
|
|
720
814
|
#rejectCompletion!: (error: unknown) => void;
|
|
815
|
+
/** Broker tenure counter: increments per start so a restart is a new epoch. */
|
|
816
|
+
#epoch = 0;
|
|
817
|
+
#incarnation: string | null = null;
|
|
818
|
+
#elevationEnforced: boolean;
|
|
819
|
+
#compactTimer: NodeJS.Timeout | null = null;
|
|
820
|
+
#operatorTimer: NodeJS.Timeout | null = null;
|
|
721
821
|
constructor(settings: BrokerSettings) {
|
|
722
822
|
this.settings = {
|
|
723
823
|
agentDir: settings.agentDir,
|
|
@@ -728,7 +828,10 @@ export class Broker {
|
|
|
728
828
|
};
|
|
729
829
|
this.index = new SessionIndex(settings.agentDir);
|
|
730
830
|
this.ledger = new LifecycleLedger(settings.agentDir);
|
|
831
|
+
this.elevation = new ElevationLedger(settings.agentDir);
|
|
832
|
+
this.operationReceipts = new OperationReceiptLedger(settings.agentDir);
|
|
731
833
|
this.#lock = path.join(settings.agentDir, "sdk", "broker.lock");
|
|
834
|
+
this.#elevationEnforced = elevationLedgerEnabledForBroker();
|
|
732
835
|
const completion = Promise.withResolvers<void>();
|
|
733
836
|
this.#completion = completion.promise;
|
|
734
837
|
this.#resolveCompletion = completion.resolve;
|
|
@@ -881,10 +984,15 @@ export class Broker {
|
|
|
881
984
|
this.#startupAdmissions = new StartupAdmissionQueue(sdkHostStartupConcurrency());
|
|
882
985
|
}
|
|
883
986
|
this.#stopping = false;
|
|
987
|
+
this.#epoch += 1;
|
|
884
988
|
this.#publicationState = "healthy-owned";
|
|
885
989
|
this.#lossAt = null;
|
|
886
990
|
this.#ambiguousAt = null;
|
|
887
|
-
await Promise.all([
|
|
991
|
+
await Promise.all([
|
|
992
|
+
this.ledger.assertSupportedStateVersions(),
|
|
993
|
+
this.elevation.assertSupportedStateVersions(),
|
|
994
|
+
readBrokerDiscovery(this.settings.agentDir),
|
|
995
|
+
]);
|
|
888
996
|
await fs.mkdir(path.dirname(this.#lock), { recursive: true, mode: 0o700 });
|
|
889
997
|
for (;;) {
|
|
890
998
|
try {
|
|
@@ -931,9 +1039,20 @@ export class Broker {
|
|
|
931
1039
|
try {
|
|
932
1040
|
await this.index.open();
|
|
933
1041
|
await this.ledger.open();
|
|
1042
|
+
await this.elevation.open();
|
|
1043
|
+
await this.operationReceipts.open();
|
|
1044
|
+
if (this.#elevationEnforced)
|
|
1045
|
+
await fs.mkdir(path.join(this.settings.agentDir, "sdk", "elevation", "operator"), {
|
|
1046
|
+
recursive: true,
|
|
1047
|
+
mode: 0o700,
|
|
1048
|
+
});
|
|
1049
|
+
// Broker-scheduled compaction (C3): apply the retention policy once at
|
|
1050
|
+
// startup and again every six hours, independently of log rotation.
|
|
1051
|
+
await this.#compactSessionIndex();
|
|
934
1052
|
const now = Date.now();
|
|
935
1053
|
const incarnation = brokerProcessIncarnation(process.pid);
|
|
936
1054
|
if (!incarnation) throw new Error("Broker process incarnation is unavailable.");
|
|
1055
|
+
this.#incarnation = incarnation;
|
|
937
1056
|
const token = newBrokerToken();
|
|
938
1057
|
this.#transport = new BrokerTransport(this, token, this.settings.port);
|
|
939
1058
|
const port = await this.#transport.start();
|
|
@@ -958,6 +1077,8 @@ export class Broker {
|
|
|
958
1077
|
Math.min(BROKER_PUBLICATION_CADENCE_MS, Math.floor(this.settings.heartbeatTtlMs / 3)),
|
|
959
1078
|
);
|
|
960
1079
|
this.#heartbeatTimer = setInterval(() => void this.#watchPublication(), cadenceMs);
|
|
1080
|
+
this.#compactTimer = setInterval(() => void this.#compactSessionIndex(), BROKER_INDEX_COMPACT_INTERVAL_MS);
|
|
1081
|
+
if (this.#elevationEnforced) this.#operatorTimer = setInterval(() => void this.#pollOperatorDirectives(), 250);
|
|
961
1082
|
return this.discovery;
|
|
962
1083
|
} catch (error) {
|
|
963
1084
|
await this.#transport?.stop();
|
|
@@ -1024,6 +1145,7 @@ export class Broker {
|
|
|
1024
1145
|
this.#lossAt = null;
|
|
1025
1146
|
this.#ambiguousAt = null;
|
|
1026
1147
|
if (writeHeartbeat) await this.#writeHeartbeat();
|
|
1148
|
+
await this.#checkpointSessionHeartbeats();
|
|
1027
1149
|
return;
|
|
1028
1150
|
}
|
|
1029
1151
|
this.#fence(observation === "ambiguous" ? "observation-ambiguous" : "suspect-unpublished");
|
|
@@ -1050,6 +1172,33 @@ export class Broker {
|
|
|
1050
1172
|
if (this.#publicationState !== "healthy-owned") return;
|
|
1051
1173
|
await this.#writeHeartbeat();
|
|
1052
1174
|
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Production coalesced heartbeat checkpoint seam (C2): delegates to the session
|
|
1178
|
+
* index writer, which appends at most one `host_heartbeat` per session per
|
|
1179
|
+
* {@link SESSION_HEARTBEAT_INTERVAL_MS} for hosts that are alive and, when recorded,
|
|
1180
|
+
* still carry the same OS process incarnation. Returns the number of checkpoints
|
|
1181
|
+
* written. Used by the periodic publication watch, by session reads so a fresh
|
|
1182
|
+
* registration is observed as live, and by tests.
|
|
1183
|
+
*/
|
|
1184
|
+
async heartbeatSessions(now = Date.now()): Promise<number> {
|
|
1185
|
+
return await this.index.checkpointLiveHeartbeats(now);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async #checkpointSessionHeartbeats(): Promise<void> {
|
|
1189
|
+
try {
|
|
1190
|
+
await this.heartbeatSessions();
|
|
1191
|
+
} catch {
|
|
1192
|
+
// Best-effort observation; a failed checkpoint never fences the broker.
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
async #compactSessionIndex(): Promise<void> {
|
|
1196
|
+
try {
|
|
1197
|
+
await this.index.compact();
|
|
1198
|
+
} catch {
|
|
1199
|
+
// Compaction is best-effort maintenance; the next scheduled pass retries.
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1053
1202
|
async #complete(mode: BrokerStopMode): Promise<void> {
|
|
1054
1203
|
if (this.#completionTask) return this.#completionTask;
|
|
1055
1204
|
this.#stopping = true;
|
|
@@ -1063,6 +1212,10 @@ export class Broker {
|
|
|
1063
1212
|
if (mode === "lost-root") this.#startupAdmissions.close();
|
|
1064
1213
|
if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
|
|
1065
1214
|
this.#heartbeatTimer = null;
|
|
1215
|
+
if (this.#compactTimer) clearInterval(this.#compactTimer);
|
|
1216
|
+
this.#compactTimer = null;
|
|
1217
|
+
if (this.#operatorTimer) clearInterval(this.#operatorTimer);
|
|
1218
|
+
this.#operatorTimer = null;
|
|
1066
1219
|
this.#completionTask = (async () => {
|
|
1067
1220
|
await this.#transport?.stop();
|
|
1068
1221
|
this.#transport = null;
|
|
@@ -1142,8 +1295,12 @@ export class Broker {
|
|
|
1142
1295
|
const authority = expectedEndpointAuthority(input);
|
|
1143
1296
|
if ("ok" in authority) return authority;
|
|
1144
1297
|
await this.index.refresh();
|
|
1298
|
+
await this.heartbeatSessions();
|
|
1145
1299
|
const record = this.index.listSessions().sessions.find(session => session.sessionId === sessionId);
|
|
1146
1300
|
if (!record) return error("resource_gone", "session is not indexed");
|
|
1301
|
+
// C6: a cross-repo duplicate cannot resolve an endpoint credential; require
|
|
1302
|
+
// disambiguation before any credential is minted.
|
|
1303
|
+
if (record.ambiguous) return error("ambiguous_session", "session id maps to more than one state root");
|
|
1147
1304
|
if (!record.live || !matchesEndpointAuthority(record, authority))
|
|
1148
1305
|
return error("endpoint_stale", "session endpoint is stale");
|
|
1149
1306
|
return this.#readEndpoint(record, authority);
|
|
@@ -1175,13 +1332,74 @@ export class Broker {
|
|
|
1175
1332
|
throw e;
|
|
1176
1333
|
}
|
|
1177
1334
|
}
|
|
1178
|
-
|
|
1335
|
+
#storeSessionListCursor(cursor: SessionListCursor, replacingToken?: string): string | BrokerResponse {
|
|
1336
|
+
const now = Date.now();
|
|
1337
|
+
for (const [token, stored] of this.#sessionListCursors) {
|
|
1338
|
+
if (stored.expiresAt <= now) this.#sessionListCursors.delete(token);
|
|
1339
|
+
}
|
|
1340
|
+
const replacing = replacingToken !== undefined && this.#sessionListCursors.has(replacingToken);
|
|
1341
|
+
if (!replacing && this.#sessionListCursors.size >= SESSION_LIST_MAX_CURSORS)
|
|
1342
|
+
return error("invalid_input", "session.list cursor capacity is exhausted");
|
|
1343
|
+
const token = randomBytes(24).toString("base64url");
|
|
1344
|
+
if (replacingToken !== undefined) this.#sessionListCursors.delete(replacingToken);
|
|
1345
|
+
this.#sessionListCursors.set(token, cursor);
|
|
1346
|
+
return token;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
#sessionListPage(input: Record<string, unknown>, result: SessionList): BrokerResponse {
|
|
1350
|
+
const requestedLimit = input.limit === undefined ? undefined : sessionListLimit(input);
|
|
1351
|
+
if (isBrokerResponse(requestedLimit)) return requestedLimit;
|
|
1352
|
+
const cursor = input.cursor;
|
|
1353
|
+
const stored = typeof cursor === "string" ? this.#sessionListCursors.get(cursor) : undefined;
|
|
1354
|
+
if (cursor !== undefined && (!stored || stored.expiresAt <= Date.now())) {
|
|
1355
|
+
if (typeof cursor === "string") this.#sessionListCursors.delete(cursor);
|
|
1356
|
+
return error("invalid_input", "cursor is expired or invalid");
|
|
1357
|
+
}
|
|
1358
|
+
if (stored && requestedLimit !== undefined && stored.limit !== requestedLimit)
|
|
1359
|
+
return error("invalid_input", "limit must match the cursor page shape");
|
|
1360
|
+
const limit = stored?.limit ?? requestedLimit ?? SESSION_LIST_DEFAULT_LIMIT;
|
|
1361
|
+
const snapshot = stored ?? {
|
|
1362
|
+
sessions: [...result.sessions],
|
|
1363
|
+
indexSeq: result.indexSeq,
|
|
1364
|
+
warnings: [...result.warnings],
|
|
1365
|
+
limit,
|
|
1366
|
+
offset: 0,
|
|
1367
|
+
expiresAt: Date.now() + SESSION_LIST_CURSOR_TTL_MS,
|
|
1368
|
+
};
|
|
1369
|
+
const sessions = snapshot.sessions.slice(snapshot.offset, snapshot.offset + snapshot.limit);
|
|
1370
|
+
const offset = snapshot.offset + sessions.length;
|
|
1371
|
+
if (offset >= snapshot.sessions.length && typeof cursor === "string") this.#sessionListCursors.delete(cursor);
|
|
1372
|
+
const continuationCursor =
|
|
1373
|
+
offset >= snapshot.sessions.length
|
|
1374
|
+
? undefined
|
|
1375
|
+
: this.#storeSessionListCursor(
|
|
1376
|
+
{ ...snapshot, offset, expiresAt: Date.now() + SESSION_LIST_CURSOR_TTL_MS },
|
|
1377
|
+
typeof cursor === "string" ? cursor : undefined,
|
|
1378
|
+
);
|
|
1379
|
+
if (isBrokerResponse(continuationCursor)) return continuationCursor;
|
|
1380
|
+
return {
|
|
1381
|
+
ok: true,
|
|
1382
|
+
result: {
|
|
1383
|
+
indexSeq: snapshot.indexSeq,
|
|
1384
|
+
sessions,
|
|
1385
|
+
warnings: snapshot.warnings,
|
|
1386
|
+
...(continuationCursor ? { continuationCursor } : {}),
|
|
1387
|
+
},
|
|
1388
|
+
indexSeq: snapshot.indexSeq,
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
handleRequest(
|
|
1392
|
+
operation: string,
|
|
1393
|
+
input: Record<string, unknown>,
|
|
1394
|
+
idempotencyKey?: string,
|
|
1395
|
+
elevationRequestId?: string,
|
|
1396
|
+
): Promise<BrokerResponse> {
|
|
1179
1397
|
if (this.#stopping || (this.#publication !== null && this.#publicationState !== "healthy-owned"))
|
|
1180
1398
|
return Promise.resolve(error("unavailable", "broker publication is unavailable"));
|
|
1181
1399
|
let release!: () => void;
|
|
1182
1400
|
const admission = new Promise<void>(resolve => (release = resolve));
|
|
1183
1401
|
this.#admitted.add(admission);
|
|
1184
|
-
return this.#handleRequest(operation, input, idempotencyKey).finally(() => {
|
|
1402
|
+
return this.#handleRequest(operation, input, idempotencyKey, elevationRequestId).finally(() => {
|
|
1185
1403
|
release();
|
|
1186
1404
|
this.#admitted.delete(admission);
|
|
1187
1405
|
});
|
|
@@ -1190,14 +1408,24 @@ export class Broker {
|
|
|
1190
1408
|
operation: string,
|
|
1191
1409
|
input: Record<string, unknown>,
|
|
1192
1410
|
idempotencyKey?: string,
|
|
1411
|
+
elevationRequestId?: string,
|
|
1193
1412
|
): Promise<BrokerResponse> {
|
|
1194
1413
|
if (this.#stopping) return error("broker_restarting", "broker is stopping");
|
|
1195
1414
|
const normalization = normalizeBrokerInput(operation, input);
|
|
1196
1415
|
if (isBrokerResponse(normalization)) return normalization;
|
|
1416
|
+
const approvedInput = input;
|
|
1197
1417
|
input = normalization.input;
|
|
1198
1418
|
if (operation === "session.list") {
|
|
1199
|
-
|
|
1200
|
-
|
|
1419
|
+
if (input.cursor === undefined) {
|
|
1420
|
+
await this.index.refresh();
|
|
1421
|
+
// Self-healing heartbeat checkpoint (C2): a host registered since the
|
|
1422
|
+
// broker's last pass is observed as live on this read, so a fresh
|
|
1423
|
+
// registration never reads as unknown (while a restart still does).
|
|
1424
|
+
await this.heartbeatSessions();
|
|
1425
|
+
}
|
|
1426
|
+
const page = this.#sessionListPage(input, this.index.listSessions());
|
|
1427
|
+
if (!page.ok) return page;
|
|
1428
|
+
const pageResult = page.result as Record<string, unknown>;
|
|
1201
1429
|
const resolveSessionId = typeof input.resolveSessionId === "string" ? input.resolveSessionId : undefined;
|
|
1202
1430
|
const cwd = typeof input.cwd === "string" ? input.cwd : undefined;
|
|
1203
1431
|
if (resolveSessionId && cwd) {
|
|
@@ -1210,21 +1438,69 @@ export class Broker {
|
|
|
1210
1438
|
: [];
|
|
1211
1439
|
const match = matches.length === 1 ? matches[0] : undefined;
|
|
1212
1440
|
return {
|
|
1213
|
-
|
|
1441
|
+
...page,
|
|
1214
1442
|
result: {
|
|
1215
|
-
...
|
|
1443
|
+
...pageResult,
|
|
1216
1444
|
savedSession:
|
|
1217
1445
|
match && match.sessionId === resolveSessionId
|
|
1218
1446
|
? { id: match.sessionId, path: match.path }
|
|
1219
1447
|
: undefined,
|
|
1220
1448
|
},
|
|
1221
|
-
indexSeq: result.indexSeq,
|
|
1222
1449
|
};
|
|
1223
1450
|
}
|
|
1224
|
-
return
|
|
1451
|
+
return page;
|
|
1225
1452
|
}
|
|
1226
1453
|
if (operation === "session.get_endpoint") return this.#endpoint(input);
|
|
1454
|
+
if (operation === "elevation.issue") return this.#elevationIssueRequest(input);
|
|
1455
|
+
if (operation === "elevation.status") return this.#elevationStatusRequest(input);
|
|
1456
|
+
if (operation === "session.control") return this.#sessionControlRequest(input);
|
|
1227
1457
|
if (!idempotencyKey) return error("invalid_input", "idempotencyKey is required for lifecycle operations");
|
|
1458
|
+
// Elevation gate (F1.3): allowlisted raw operations execute only behind a
|
|
1459
|
+
// broker-owned single-use grant. Validation is fail-closed and happens
|
|
1460
|
+
// before any lifecycle ledger write; the claim itself is consumed right
|
|
1461
|
+
// before dispatch and the truthful outcome is recorded after it.
|
|
1462
|
+
const elevationGateKind = this.#elevationGateFor(operation);
|
|
1463
|
+
let elevationRequestIdForDispatch: string | undefined;
|
|
1464
|
+
if (elevationGateKind !== undefined) {
|
|
1465
|
+
if (elevationRequestId === undefined)
|
|
1466
|
+
return error("elevation_required", "an elevation grant claim is required for this operation");
|
|
1467
|
+
if (!isElevationRequestId(elevationRequestId))
|
|
1468
|
+
return error("invalid_input", "elevationRequestId must be an RFC4122 lowercase UUID v4");
|
|
1469
|
+
const resolved = await this.elevation.get(elevationRequestId);
|
|
1470
|
+
if (!resolved.ok) {
|
|
1471
|
+
if (resolved.error.code === "not_found")
|
|
1472
|
+
return error(
|
|
1473
|
+
"elevation_required",
|
|
1474
|
+
"elevation grant was not found; a grant is required for this operation",
|
|
1475
|
+
);
|
|
1476
|
+
return elevationResultToBroker(resolved);
|
|
1477
|
+
}
|
|
1478
|
+
const grant = resolved.value.grant;
|
|
1479
|
+
if (grant.operation.kind !== elevationGateKind || grant.operation.sdkId !== operation)
|
|
1480
|
+
return error("elevation_digest_mismatch", "elevation grant was issued for a different operation");
|
|
1481
|
+
const digest = elevationRequestDigest({ kind: elevationGateKind, sdkId: operation, input: approvedInput });
|
|
1482
|
+
if (!digest.ok || digest.digest !== grant.requestDigest)
|
|
1483
|
+
return error("elevation_digest_mismatch", "elevation grant digest does not match the dispatched input");
|
|
1484
|
+
// Bind the grant to the actual lifecycle target before any claim: the
|
|
1485
|
+
// approved input must resolve to the exact session identity the grant
|
|
1486
|
+
// was issued against (C10). session.delete may target a retained
|
|
1487
|
+
// stopped session; everything else requires a live endpoint.
|
|
1488
|
+
const targetSessionId = typeof input.sessionId === "string" ? input.sessionId : undefined;
|
|
1489
|
+
if (targetSessionId !== undefined) {
|
|
1490
|
+
const target = await this.#elevationSessionResolution(targetSessionId, {
|
|
1491
|
+
allowStopped: operation === "session.delete",
|
|
1492
|
+
});
|
|
1493
|
+
if (target.kind === "ambiguous")
|
|
1494
|
+
return error("ambiguous_session", "session id maps to more than one state root");
|
|
1495
|
+
if (target.kind !== "identity")
|
|
1496
|
+
return error("target_unavailable", "session endpoint is unreachable or its identity is not provable");
|
|
1497
|
+
if (grant.sessionIdentity.sessionId !== target.identity.sessionId)
|
|
1498
|
+
return error("elevation_digest_mismatch", "elevation grant was issued for a different session target");
|
|
1499
|
+
if (!sameElevationSessionIdentity(grant.sessionIdentity, target.identity))
|
|
1500
|
+
return error("endpoint_stale", "session endpoint identity no longer matches the elevation grant");
|
|
1501
|
+
}
|
|
1502
|
+
elevationRequestIdForDispatch = elevationRequestId;
|
|
1503
|
+
}
|
|
1228
1504
|
const target = createHash("sha256")
|
|
1229
1505
|
.update(canonicalJson(lifecycleTarget(operation, input)))
|
|
1230
1506
|
.digest("hex");
|
|
@@ -1328,8 +1604,46 @@ export class Broker {
|
|
|
1328
1604
|
return response;
|
|
1329
1605
|
}
|
|
1330
1606
|
if (begun.kind === "in_progress") return error("broker_restarting", "lifecycle operation is in progress");
|
|
1331
|
-
|
|
1607
|
+
// Single-use grant claim (F1.3): consume the grant before any dispatch.
|
|
1608
|
+
// A claimed grant that crashes before outcome recording is replayed
|
|
1609
|
+
// truthfully as consumed/uncertain; retry requires a new grant.
|
|
1610
|
+
if (elevationRequestIdForDispatch !== undefined) {
|
|
1611
|
+
const claimed = await this.elevationClaim({ elevationRequestId: elevationRequestIdForDispatch });
|
|
1612
|
+
if (!claimed.ok) return claimed;
|
|
1613
|
+
}
|
|
1614
|
+
let outcome: Awaited<ReturnType<typeof executeLifecycle>>;
|
|
1615
|
+
try {
|
|
1616
|
+
outcome = await executeLifecycle(this, operation, input, identity);
|
|
1617
|
+
} catch (cause) {
|
|
1618
|
+
if (elevationRequestIdForDispatch !== undefined)
|
|
1619
|
+
await this.elevationDispatch({
|
|
1620
|
+
elevationRequestId: elevationRequestIdForDispatch,
|
|
1621
|
+
outcome: {
|
|
1622
|
+
status: "failed",
|
|
1623
|
+
code: "unavailable",
|
|
1624
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
1625
|
+
dispatchedAt: Date.now(),
|
|
1626
|
+
},
|
|
1627
|
+
});
|
|
1628
|
+
throw cause;
|
|
1629
|
+
}
|
|
1332
1630
|
const response = outcome.response;
|
|
1631
|
+
// Record the truthful dispatch outcome for the claimed grant.
|
|
1632
|
+
if (elevationRequestIdForDispatch !== undefined) {
|
|
1633
|
+
const dispatchedAt = Date.now();
|
|
1634
|
+
const recorded = await this.elevationDispatch({
|
|
1635
|
+
elevationRequestId: elevationRequestIdForDispatch,
|
|
1636
|
+
outcome: response.ok
|
|
1637
|
+
? { status: "ok", dispatchedAt }
|
|
1638
|
+
: {
|
|
1639
|
+
status: "failed",
|
|
1640
|
+
code: response.error.code,
|
|
1641
|
+
message: response.error.message,
|
|
1642
|
+
dispatchedAt,
|
|
1643
|
+
},
|
|
1644
|
+
});
|
|
1645
|
+
if (!recorded.ok) return recorded;
|
|
1646
|
+
}
|
|
1333
1647
|
await this.ledger.transition(identity, lifecycleResponseState(response), {
|
|
1334
1648
|
...(pendingCleanupSessionId(response) ? { intendedSessionId: pendingCleanupSessionId(response) } : {}),
|
|
1335
1649
|
resultSessionId:
|
|
@@ -1369,6 +1683,460 @@ export class Broker {
|
|
|
1369
1683
|
if (this.#chains.get(target) === current) this.#chains.delete(target);
|
|
1370
1684
|
}
|
|
1371
1685
|
}
|
|
1686
|
+
|
|
1687
|
+
#elevationGateFor(operation: string): "global" | undefined {
|
|
1688
|
+
return this.#elevationEnforced && isElevatableOperation("global", operation) ? "global" : undefined;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
async #elevationIssueRequest(input: Record<string, unknown>): Promise<BrokerResponse> {
|
|
1692
|
+
const sessionId = input.sessionId;
|
|
1693
|
+
if (typeof sessionId !== "string" || !isCanonicalSessionId(sessionId))
|
|
1694
|
+
return error("invalid_input", "sessionId must be a canonical safe identifier");
|
|
1695
|
+
const rawOperation = input.operation;
|
|
1696
|
+
if (typeof rawOperation !== "object" || rawOperation === null || Array.isArray(rawOperation))
|
|
1697
|
+
return error("invalid_input", "operation must be an object with kind and sdkId");
|
|
1698
|
+
const { kind, sdkId } = rawOperation as { kind?: unknown; sdkId?: unknown };
|
|
1699
|
+
if (
|
|
1700
|
+
typeof kind !== "string" ||
|
|
1701
|
+
(kind !== "control" && kind !== "query" && kind !== "global") ||
|
|
1702
|
+
typeof sdkId !== "string" ||
|
|
1703
|
+
!findOperation(kind, sdkId)
|
|
1704
|
+
)
|
|
1705
|
+
return error("invalid_request", `unknown elevation operation: ${String(kind)}:${String(sdkId)}`);
|
|
1706
|
+
if (!isElevatableOperation(kind, sdkId))
|
|
1707
|
+
return error("elevation_not_allowlisted", `${sdkId} does not require elevation`);
|
|
1708
|
+
const approved = input.input;
|
|
1709
|
+
if (typeof approved !== "object" || approved === null || Array.isArray(approved))
|
|
1710
|
+
return error("invalid_input", "input must be the approved operation arguments");
|
|
1711
|
+
if (containsReservedKey(approved, "elevationRequestId"))
|
|
1712
|
+
return error(
|
|
1713
|
+
"invalid_input",
|
|
1714
|
+
"elevationRequestId is reserved and cannot appear inside the approved operation input",
|
|
1715
|
+
);
|
|
1716
|
+
if (input.idempotencyKey !== undefined)
|
|
1717
|
+
return error(
|
|
1718
|
+
"invalid_input",
|
|
1719
|
+
"idempotencyKey is broker-frame metadata and cannot appear inside elevation.issue input",
|
|
1720
|
+
);
|
|
1721
|
+
const elevationRequestId = input.elevationRequestId;
|
|
1722
|
+
if (elevationRequestId !== undefined && !isElevationRequestId(elevationRequestId))
|
|
1723
|
+
return error("invalid_input", "elevationRequestId must be an RFC4122 lowercase UUID v4");
|
|
1724
|
+
const expiresInMs = input.expiresInMs;
|
|
1725
|
+
if (
|
|
1726
|
+
expiresInMs !== undefined &&
|
|
1727
|
+
(typeof expiresInMs !== "number" || !Number.isSafeInteger(expiresInMs) || expiresInMs <= 0)
|
|
1728
|
+
)
|
|
1729
|
+
return error("invalid_input", "expiresInMs must be a positive safe integer");
|
|
1730
|
+
let requester: ElevationRequester;
|
|
1731
|
+
if (input.requester === undefined) {
|
|
1732
|
+
requester = { source: "broker_connection", connectionId: `broker:${this.#owner}` };
|
|
1733
|
+
} else {
|
|
1734
|
+
const candidate = input.requester as { source?: unknown; connectionId?: unknown };
|
|
1735
|
+
if (
|
|
1736
|
+
candidate?.source !== "broker_connection" ||
|
|
1737
|
+
typeof candidate.connectionId !== "string" ||
|
|
1738
|
+
candidate.connectionId.length === 0
|
|
1739
|
+
)
|
|
1740
|
+
return error("invalid_input", "requester must be a broker_connection audit identity");
|
|
1741
|
+
requester = { source: "broker_connection", connectionId: candidate.connectionId };
|
|
1742
|
+
}
|
|
1743
|
+
return await this.elevationIssue({
|
|
1744
|
+
sessionId,
|
|
1745
|
+
operation: { kind, sdkId },
|
|
1746
|
+
input: approved as Record<string, unknown>,
|
|
1747
|
+
requester,
|
|
1748
|
+
...(elevationRequestId === undefined ? {} : { elevationRequestId }),
|
|
1749
|
+
...(expiresInMs === undefined ? {} : { expiresInMs }),
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
async #elevationStatusRequest(input: Record<string, unknown>): Promise<BrokerResponse> {
|
|
1754
|
+
const elevationRequestId = input.elevationRequestId;
|
|
1755
|
+
if (typeof elevationRequestId !== "string" || !isElevationRequestId(elevationRequestId))
|
|
1756
|
+
return error("invalid_input", "elevationRequestId must be an RFC4122 lowercase UUID v4");
|
|
1757
|
+
return await this.elevationResolve(elevationRequestId);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
#elevationClaimIdentity(): ElevationClaimIdentity {
|
|
1761
|
+
const incarnation = this.#incarnation;
|
|
1762
|
+
if (incarnation === null) throw new Error("Broker process incarnation is unavailable.");
|
|
1763
|
+
return { ownerId: this.#owner, epoch: this.#epoch, pid: process.pid, incarnation };
|
|
1764
|
+
}
|
|
1765
|
+
async #elevationPrincipal(): Promise<BrokerOwnerPrincipal> {
|
|
1766
|
+
const incarnation = this.#incarnation;
|
|
1767
|
+
if (incarnation === null) throw new Error("Broker process incarnation is unavailable.");
|
|
1768
|
+
return readBrokerOwnerPrincipal(this.settings.agentDir, process.pid, incarnation);
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* Resolves the exact elevation session identity (C6/C10). A cross-repo
|
|
1772
|
+
* duplicate (`ambiguous`) is rejected before any endpoint credential or
|
|
1773
|
+
* elevation grant can bind; an unreachable or unprovable session is
|
|
1774
|
+
* `unavailable`. The heartbeat checkpoint pass runs first so a fresh
|
|
1775
|
+
* registration is observable as live.
|
|
1776
|
+
*
|
|
1777
|
+
* `allowStopped` is the session.delete-only exemption: a retained stopped
|
|
1778
|
+
* session keeps its exact endpoint identity tuple (generation/pid/mtime)
|
|
1779
|
+
* in the index row, so a delete grant can still be bound and dispatched
|
|
1780
|
+
* against it. Everything else fails closed unless the endpoint is live.
|
|
1781
|
+
*/
|
|
1782
|
+
async #elevationSessionResolution(
|
|
1783
|
+
sessionId: string,
|
|
1784
|
+
options?: { allowStopped?: boolean },
|
|
1785
|
+
): Promise<ElevationSessionResolution> {
|
|
1786
|
+
if (!isCanonicalSessionId(sessionId)) return { kind: "unavailable" };
|
|
1787
|
+
await this.index.refresh();
|
|
1788
|
+
await this.heartbeatSessions();
|
|
1789
|
+
const record = this.index.listSessions().sessions.find(session => session.sessionId === sessionId);
|
|
1790
|
+
if (!record) return { kind: "unavailable" };
|
|
1791
|
+
if (record.ambiguous) return { kind: "ambiguous" };
|
|
1792
|
+
if (!record.live && options?.allowStopped !== true) return { kind: "unavailable" };
|
|
1793
|
+
const incarnation = endpointIncarnation(record, sessionId);
|
|
1794
|
+
if (incarnation === undefined) return { kind: "unavailable" };
|
|
1795
|
+
return {
|
|
1796
|
+
kind: "identity",
|
|
1797
|
+
identity: {
|
|
1798
|
+
sessionId: record.sessionId,
|
|
1799
|
+
endpointStateRoot: record.locator.stateRoot,
|
|
1800
|
+
endpointGeneration: record.endpointGeneration,
|
|
1801
|
+
endpointIncarnation: incarnation,
|
|
1802
|
+
},
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
async #pollOperatorDirectives(): Promise<void> {
|
|
1807
|
+
const directory = path.join(this.settings.agentDir, "sdk", "elevation", "operator");
|
|
1808
|
+
let names: string[];
|
|
1809
|
+
try {
|
|
1810
|
+
names = await fs.readdir(directory);
|
|
1811
|
+
} catch {
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
for (const name of names) {
|
|
1815
|
+
if (!name.endsWith(".json")) continue;
|
|
1816
|
+
const file = path.join(directory, name);
|
|
1817
|
+
try {
|
|
1818
|
+
const stat = await fs.stat(file);
|
|
1819
|
+
if ((stat.mode & 0o077) !== 0) throw new Error("operator directive is not private");
|
|
1820
|
+
const raw: unknown = JSON.parse(await fs.readFile(file, "utf8"));
|
|
1821
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("malformed operator directive");
|
|
1822
|
+
const directive = raw as Record<string, unknown>;
|
|
1823
|
+
if (
|
|
1824
|
+
typeof directive.elevationRequestId !== "string" ||
|
|
1825
|
+
(directive.answer !== "approve" && directive.answer !== "deny") ||
|
|
1826
|
+
typeof directive.presentedDigest !== "string"
|
|
1827
|
+
)
|
|
1828
|
+
throw new Error("malformed operator directive");
|
|
1829
|
+
const response = await this.elevationAnswer({
|
|
1830
|
+
elevationRequestId: directive.elevationRequestId,
|
|
1831
|
+
answer: directive.answer,
|
|
1832
|
+
presentedDigest: directive.presentedDigest,
|
|
1833
|
+
answerer: { source: "local_operator", attestedBy: "gjc-sdk-session-cli" },
|
|
1834
|
+
});
|
|
1835
|
+
if (!response.ok) throw new Error(response.error.message);
|
|
1836
|
+
await fs.rm(file, { force: true });
|
|
1837
|
+
} catch {
|
|
1838
|
+
await fs.rename(file, `${file}.rejected`).catch(() => undefined);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
async #sessionControlRequest(input: Record<string, unknown>): Promise<BrokerResponse> {
|
|
1844
|
+
const sessionId = typeof input.sessionId === "string" ? input.sessionId : undefined;
|
|
1845
|
+
const operation = typeof input.operation === "string" ? input.operation : undefined;
|
|
1846
|
+
const rawInput = input.input;
|
|
1847
|
+
const controlInput =
|
|
1848
|
+
rawInput && typeof rawInput === "object" && !Array.isArray(rawInput)
|
|
1849
|
+
? (rawInput as Record<string, unknown>)
|
|
1850
|
+
: undefined;
|
|
1851
|
+
if (!sessionId || !operation || !controlInput || !findOperation("control", operation))
|
|
1852
|
+
return error(
|
|
1853
|
+
"invalid_input",
|
|
1854
|
+
"session.control requires a valid sessionId, control operation, and object input",
|
|
1855
|
+
);
|
|
1856
|
+
const elevationRequestId = typeof input.elevationRequestId === "string" ? input.elevationRequestId : undefined;
|
|
1857
|
+
const elevatable = this.#elevationEnforced && isElevatableOperation("control", operation);
|
|
1858
|
+
// Elevation preflight (F1.3) runs before any clientRef reservation so a
|
|
1859
|
+
// no-grant attempt can never poison a later granted retry with a stale
|
|
1860
|
+
// pending receipt.
|
|
1861
|
+
let grant: ElevationGrantRecord | undefined;
|
|
1862
|
+
if (elevatable) {
|
|
1863
|
+
if (!elevationRequestId) return error("elevation_required", "an elevation grant is required for this control");
|
|
1864
|
+
const resolved = await this.elevation.get(elevationRequestId);
|
|
1865
|
+
if (!resolved.ok) return elevationResultToBroker(resolved);
|
|
1866
|
+
grant = resolved.value.grant;
|
|
1867
|
+
const digest = elevationRequestDigest({ kind: "control", sdkId: operation, input: controlInput });
|
|
1868
|
+
if (
|
|
1869
|
+
grant.operation.kind !== "control" ||
|
|
1870
|
+
grant.operation.sdkId !== operation ||
|
|
1871
|
+
!digest.ok ||
|
|
1872
|
+
digest.digest !== grant.requestDigest
|
|
1873
|
+
)
|
|
1874
|
+
return error("elevation_digest_mismatch", "elevation grant does not match the requested control");
|
|
1875
|
+
}
|
|
1876
|
+
await this.index.refresh();
|
|
1877
|
+
const matches = this.index.listSessions().sessions.filter(session => session.sessionId === sessionId);
|
|
1878
|
+
if (matches.some(session => session.ambiguous) || matches.length > 1)
|
|
1879
|
+
return error("ambiguous_session", "session id maps to more than one state root");
|
|
1880
|
+
const record = matches[0];
|
|
1881
|
+
if (!record?.live) return error("target_unavailable", "session endpoint is unavailable");
|
|
1882
|
+
const incarnation = endpointIncarnation(record, sessionId);
|
|
1883
|
+
if (!incarnation) return error("endpoint_stale", "session endpoint incarnation is unavailable");
|
|
1884
|
+
// Bind the grant to the actual approved target session identity before
|
|
1885
|
+
// claim/capability minting: a grant issued for a different session can
|
|
1886
|
+
// never authorize this control, even with byte-identical input.
|
|
1887
|
+
if (grant !== undefined) {
|
|
1888
|
+
if (grant.sessionIdentity.sessionId !== record.sessionId)
|
|
1889
|
+
return error("elevation_digest_mismatch", "elevation grant was issued for a different session target");
|
|
1890
|
+
const targetIdentity: ElevationSessionIdentity = {
|
|
1891
|
+
sessionId: record.sessionId,
|
|
1892
|
+
endpointStateRoot: record.locator.stateRoot,
|
|
1893
|
+
endpointGeneration: record.endpointGeneration,
|
|
1894
|
+
endpointIncarnation: incarnation,
|
|
1895
|
+
};
|
|
1896
|
+
if (!sameElevationSessionIdentity(grant.sessionIdentity, targetIdentity))
|
|
1897
|
+
return error("endpoint_stale", "session endpoint identity no longer matches the elevation grant");
|
|
1898
|
+
}
|
|
1899
|
+
const clientRef =
|
|
1900
|
+
typeof controlInput.clientRef === "string" && controlInput.clientRef.length > 0
|
|
1901
|
+
? controlInput.clientRef
|
|
1902
|
+
: undefined;
|
|
1903
|
+
// clientRef idempotency is target-session scoped: identical refs and
|
|
1904
|
+
// input on different sessions never collide, conflict, or replay.
|
|
1905
|
+
const receiptKey = clientRef === undefined ? undefined : operationReceiptKey(sessionId, clientRef);
|
|
1906
|
+
const receiptDigest =
|
|
1907
|
+
clientRef === undefined ? undefined : operationReceiptDigest(operation, controlInput, sessionId);
|
|
1908
|
+
if (receiptKey !== undefined && receiptDigest !== undefined) {
|
|
1909
|
+
const reservation = await this.operationReceipts.reserve(receiptKey, receiptDigest);
|
|
1910
|
+
if (reservation.status === "replay") return reservation.response;
|
|
1911
|
+
if (reservation.status === "in_progress")
|
|
1912
|
+
return error("operation_in_progress", "operation receipt is pending");
|
|
1913
|
+
if (reservation.status === "conflict")
|
|
1914
|
+
return error("idempotency_conflict", "clientRef was reused with different operation input");
|
|
1915
|
+
}
|
|
1916
|
+
const settle = async (response: BrokerResponse): Promise<BrokerResponse> => {
|
|
1917
|
+
if (receiptKey !== undefined && receiptDigest !== undefined)
|
|
1918
|
+
await this.operationReceipts.complete(receiptKey, receiptDigest, response);
|
|
1919
|
+
return response;
|
|
1920
|
+
};
|
|
1921
|
+
const endpointResponse = await this.#endpoint({
|
|
1922
|
+
sessionId,
|
|
1923
|
+
endpointGeneration: record.endpointGeneration,
|
|
1924
|
+
endpointIncarnation: incarnation,
|
|
1925
|
+
});
|
|
1926
|
+
if (!endpointResponse.ok) return settle(endpointResponse);
|
|
1927
|
+
const endpoint = endpointResponse.result as { url?: unknown; token?: unknown };
|
|
1928
|
+
if (typeof endpoint.url !== "string" || typeof endpoint.token !== "string")
|
|
1929
|
+
return settle(error("unavailable", "session endpoint is malformed"));
|
|
1930
|
+
let elevationCapability: string | undefined;
|
|
1931
|
+
if (elevatable && elevationRequestId) {
|
|
1932
|
+
let authorityToken: string | undefined;
|
|
1933
|
+
try {
|
|
1934
|
+
const authority = JSON.parse(
|
|
1935
|
+
await fs.readFile(elevationAuthorityPath(record.locator.stateRoot, sessionId), "utf8"),
|
|
1936
|
+
) as { token?: unknown };
|
|
1937
|
+
authorityToken = typeof authority.token === "string" ? authority.token : undefined;
|
|
1938
|
+
} catch {
|
|
1939
|
+
return settle(error("unavailable", "session elevation authority is unavailable"));
|
|
1940
|
+
}
|
|
1941
|
+
if (!authorityToken) return settle(error("unavailable", "session elevation authority is malformed"));
|
|
1942
|
+
const claimed = await this.elevationClaim({ elevationRequestId });
|
|
1943
|
+
if (!claimed.ok) return settle(claimed);
|
|
1944
|
+
elevationCapability = signElevationCapability(authorityToken, elevationRequestId, operation, controlInput);
|
|
1945
|
+
}
|
|
1946
|
+
let client: SdkClient | undefined;
|
|
1947
|
+
try {
|
|
1948
|
+
client = await SdkClient.connect(endpoint.url, endpoint.token);
|
|
1949
|
+
const response = await client.control(operation, controlInput, {
|
|
1950
|
+
confirm: input.confirm === true,
|
|
1951
|
+
...(elevationCapability === undefined ? {} : { elevationRequestId: elevationCapability }),
|
|
1952
|
+
});
|
|
1953
|
+
const ok = (response as { ok?: unknown }).ok === true;
|
|
1954
|
+
if (elevatable && elevationRequestId) {
|
|
1955
|
+
await this.elevationDispatch({
|
|
1956
|
+
elevationRequestId,
|
|
1957
|
+
outcome: ok
|
|
1958
|
+
? { status: "ok", dispatchedAt: Date.now() }
|
|
1959
|
+
: {
|
|
1960
|
+
status: "failed",
|
|
1961
|
+
code: String((response as { error?: { code?: unknown } }).error?.code ?? "control_failed"),
|
|
1962
|
+
message: String(
|
|
1963
|
+
(response as { error?: { message?: unknown } }).error?.message ?? "control failed",
|
|
1964
|
+
),
|
|
1965
|
+
dispatchedAt: Date.now(),
|
|
1966
|
+
},
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
const brokerResponse = ok
|
|
1970
|
+
? { ok: true as const, result: (response as { result?: unknown }).result }
|
|
1971
|
+
: error(
|
|
1972
|
+
String((response as { error?: { code?: unknown } }).error?.code ?? "unavailable"),
|
|
1973
|
+
String((response as { error?: { message?: unknown } }).error?.message ?? "control failed"),
|
|
1974
|
+
);
|
|
1975
|
+
return settle(brokerResponse);
|
|
1976
|
+
} catch (cause) {
|
|
1977
|
+
if (elevatable && elevationRequestId)
|
|
1978
|
+
await this.elevationDispatch({
|
|
1979
|
+
elevationRequestId,
|
|
1980
|
+
outcome: {
|
|
1981
|
+
status: "failed",
|
|
1982
|
+
code: "unavailable",
|
|
1983
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
1984
|
+
dispatchedAt: Date.now(),
|
|
1985
|
+
},
|
|
1986
|
+
});
|
|
1987
|
+
return settle(
|
|
1988
|
+
cause instanceof SdkClientError
|
|
1989
|
+
? error(cause.code, cause.message)
|
|
1990
|
+
: error("unavailable", cause instanceof Error ? cause.message : String(cause)),
|
|
1991
|
+
);
|
|
1992
|
+
} finally {
|
|
1993
|
+
await client?.close();
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
/**
|
|
1997
|
+
* Elevation issuance (internal). Binds the exact endpoint identity
|
|
1998
|
+
* (endpointStateRoot/generation/incarnation) from the live index record;
|
|
1999
|
+
* the request digest is broker-computed from the full `{kind,sdkId,input}`
|
|
2000
|
+
* triple with the top-level `elevationRequestId` excluded.
|
|
2001
|
+
*/
|
|
2002
|
+
async elevationIssue(params: {
|
|
2003
|
+
sessionId: string;
|
|
2004
|
+
operation: ElevationOperation;
|
|
2005
|
+
input: Record<string, unknown>;
|
|
2006
|
+
requester: ElevationRequester;
|
|
2007
|
+
elevationRequestId?: string;
|
|
2008
|
+
expiresInMs?: number;
|
|
2009
|
+
}): Promise<BrokerResponse> {
|
|
2010
|
+
if (!isCanonicalSessionId(params.sessionId))
|
|
2011
|
+
return error("invalid_input", "sessionId must be a canonical safe identifier");
|
|
2012
|
+
// Retained stopped sessions may only back an elevation request for the
|
|
2013
|
+
// exact global lifecycle operation that needs them (session.delete); all
|
|
2014
|
+
// other operations still require a live endpoint (fail closed otherwise).
|
|
2015
|
+
const allowStopped = params.operation.kind === "global" && params.operation.sdkId === "session.delete";
|
|
2016
|
+
const resolution = await this.#elevationSessionResolution(params.sessionId, { allowStopped });
|
|
2017
|
+
if (resolution.kind === "ambiguous")
|
|
2018
|
+
return error("ambiguous_session", "session id maps to more than one state root");
|
|
2019
|
+
if (resolution.kind === "unavailable")
|
|
2020
|
+
return error("target_unavailable", "session endpoint is unreachable or its identity is not provable");
|
|
2021
|
+
// Bind the grant to the actual approved target session: the approved
|
|
2022
|
+
// input for a global lifecycle operation must name the elevation session
|
|
2023
|
+
// itself, never a different session (C10).
|
|
2024
|
+
if (params.operation.kind === "global") {
|
|
2025
|
+
const approvedTarget = typeof params.input.sessionId === "string" ? params.input.sessionId : undefined;
|
|
2026
|
+
if (approvedTarget !== undefined && approvedTarget !== params.sessionId)
|
|
2027
|
+
return error("invalid_input", "approved input targets a different session than the elevation session");
|
|
2028
|
+
}
|
|
2029
|
+
const { identity: sessionIdentity } = resolution;
|
|
2030
|
+
let principal: BrokerOwnerPrincipal;
|
|
2031
|
+
try {
|
|
2032
|
+
principal = await this.#elevationPrincipal();
|
|
2033
|
+
} catch {
|
|
2034
|
+
return error("unavailable", "broker owner principal is unavailable");
|
|
2035
|
+
}
|
|
2036
|
+
const result = await this.elevation.issue({ ...params, sessionIdentity, principal });
|
|
2037
|
+
return elevationResultToBroker(result);
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/**
|
|
2041
|
+
* Elevation answer (internal, operator-only). There is no public
|
|
2042
|
+
* `elevation.answer` operation: this entry point is reserved for the local
|
|
2043
|
+
* operator approval surface and the separately authorized gate-answer
|
|
2044
|
+
* source, and the ledger enforces the distinct-answer-source boundary.
|
|
2045
|
+
*/
|
|
2046
|
+
async elevationAnswer(params: {
|
|
2047
|
+
elevationRequestId: string;
|
|
2048
|
+
answer: "approve" | "deny";
|
|
2049
|
+
presentedDigest: string;
|
|
2050
|
+
answerer: ElevationAnswerAuthority;
|
|
2051
|
+
}): Promise<BrokerResponse> {
|
|
2052
|
+
const resolved = await this.elevation.get(params.elevationRequestId);
|
|
2053
|
+
if (!resolved.ok) return elevationResultToBroker(resolved);
|
|
2054
|
+
const sessionId = resolved.value.grant.sessionIdentity.sessionId;
|
|
2055
|
+
const grantOperation = resolved.value.grant.operation;
|
|
2056
|
+
const resolution = await this.#elevationSessionResolution(sessionId, {
|
|
2057
|
+
allowStopped: grantOperation.kind === "global" && grantOperation.sdkId === "session.delete",
|
|
2058
|
+
});
|
|
2059
|
+
if (resolution.kind === "ambiguous")
|
|
2060
|
+
return error("ambiguous_session", "session id maps to more than one state root");
|
|
2061
|
+
const currentSessionIdentity = resolution.kind === "identity" ? resolution.identity : undefined;
|
|
2062
|
+
let principal: BrokerOwnerPrincipal;
|
|
2063
|
+
try {
|
|
2064
|
+
principal = await this.#elevationPrincipal();
|
|
2065
|
+
} catch {
|
|
2066
|
+
return error("unavailable", "broker owner principal is unavailable");
|
|
2067
|
+
}
|
|
2068
|
+
const result = await this.elevation.answer({ ...params, principal, currentSessionIdentity });
|
|
2069
|
+
return elevationAnswerToBroker(result);
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/** Single-use grant claim (internal): consumes the grant before dispatch. */
|
|
2073
|
+
async elevationClaim(params: { elevationRequestId: string }): Promise<BrokerResponse> {
|
|
2074
|
+
const resolved = await this.elevation.get(params.elevationRequestId);
|
|
2075
|
+
if (!resolved.ok) return elevationResultToBroker(resolved);
|
|
2076
|
+
const sessionId = resolved.value.grant.sessionIdentity.sessionId;
|
|
2077
|
+
const grantOperation = resolved.value.grant.operation;
|
|
2078
|
+
const resolution = await this.#elevationSessionResolution(sessionId, {
|
|
2079
|
+
allowStopped: grantOperation.kind === "global" && grantOperation.sdkId === "session.delete",
|
|
2080
|
+
});
|
|
2081
|
+
if (resolution.kind === "ambiguous")
|
|
2082
|
+
return error("ambiguous_session", "session id maps to more than one state root");
|
|
2083
|
+
const currentSessionIdentity = resolution.kind === "identity" ? resolution.identity : undefined;
|
|
2084
|
+
let claimIdentity: ElevationClaimIdentity;
|
|
2085
|
+
try {
|
|
2086
|
+
claimIdentity = this.#elevationClaimIdentity();
|
|
2087
|
+
} catch {
|
|
2088
|
+
return error("unavailable", "broker process incarnation is unavailable");
|
|
2089
|
+
}
|
|
2090
|
+
const result = await this.elevation.claim({
|
|
2091
|
+
elevationRequestId: params.elevationRequestId,
|
|
2092
|
+
claimIdentity,
|
|
2093
|
+
currentSessionIdentity,
|
|
2094
|
+
});
|
|
2095
|
+
return elevationResultToBroker(result);
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
/** Records the dispatch outcome for a claimed grant (internal). */
|
|
2099
|
+
async elevationDispatch(params: {
|
|
2100
|
+
elevationRequestId: string;
|
|
2101
|
+
outcome: ElevationDispatchOutcome;
|
|
2102
|
+
}): Promise<BrokerResponse> {
|
|
2103
|
+
let dispatchIdentity: ElevationClaimIdentity;
|
|
2104
|
+
try {
|
|
2105
|
+
dispatchIdentity = this.#elevationClaimIdentity();
|
|
2106
|
+
} catch {
|
|
2107
|
+
return error("unavailable", "broker process incarnation is unavailable");
|
|
2108
|
+
}
|
|
2109
|
+
const result = await this.elevation.dispatch({
|
|
2110
|
+
elevationRequestId: params.elevationRequestId,
|
|
2111
|
+
dispatchIdentity,
|
|
2112
|
+
outcome: params.outcome,
|
|
2113
|
+
});
|
|
2114
|
+
return elevationResultToBroker(result);
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
/** Truthful elevation status read (internal; reconciles expiry and crash state). */
|
|
2118
|
+
async elevationResolve(elevationRequestId: string): Promise<BrokerResponse> {
|
|
2119
|
+
let claimIdentity: ElevationClaimIdentity;
|
|
2120
|
+
try {
|
|
2121
|
+
claimIdentity = this.#elevationClaimIdentity();
|
|
2122
|
+
} catch {
|
|
2123
|
+
return error("unavailable", "broker process incarnation is unavailable");
|
|
2124
|
+
}
|
|
2125
|
+
const result = await this.elevation.resolve(elevationRequestId, claimIdentity);
|
|
2126
|
+
return elevationResultToBroker(result);
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
/** Truthful elevation listing (internal). */
|
|
2130
|
+
async elevationList(): Promise<BrokerResponse> {
|
|
2131
|
+
let claimIdentity: ElevationClaimIdentity;
|
|
2132
|
+
try {
|
|
2133
|
+
claimIdentity = this.#elevationClaimIdentity();
|
|
2134
|
+
} catch {
|
|
2135
|
+
return error("unavailable", "broker process incarnation is unavailable");
|
|
2136
|
+
}
|
|
2137
|
+
const result = await this.elevation.list(claimIdentity);
|
|
2138
|
+
return elevationResultToBroker(result);
|
|
2139
|
+
}
|
|
1372
2140
|
}
|
|
1373
2141
|
|
|
1374
2142
|
/** Test-only hook for simulating a process crash after terminal persistence verification. */
|