@byok-sdk/client 0.2.0 → 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.
@@ -1,6 +1,6 @@
1
1
  import type { RuntimeId } from '@byok-sdk/protocol';
2
2
  import type { PermissionPolicy } from '@byok-sdk/protocol';
3
- import type { RuntimeAdapter, GitWorkspaceConfig } from '../types';
3
+ import type { RuntimeAdapter, GitWorkspaceConfig, McpToolsetConfig } from '../types';
4
4
  import type { BackoffOptions, LivenessOptions } from './ws-transport';
5
5
  import { type OperationalHealthSnapshot } from './operational-health';
6
6
  import { type DaemonEventListener, type DaemonTaskInfo, type Unsubscribe } from './observer';
@@ -10,6 +10,7 @@ import { type DeviceRecord } from './store';
10
10
  import { type LocalTaskJournal } from './journal/journal';
11
11
  import { type JournalOpenFaultSeam } from './journal/sqlite-support';
12
12
  import { LocalStoragePressureEngine, type LocalStoragePolicyInput } from './journal/storage-policy';
13
+ import { type ResultDocumentExtractor } from './task-runner';
13
14
  import type { ProgressBatcherOptions } from './progress-batcher';
14
15
  /**
15
16
  * Optional white-label product display info — purely opaque passthrough
@@ -104,6 +105,12 @@ export interface DaemonConfig {
104
105
  * unchanged from M1/M2.
105
106
  */
106
107
  runtimeAllowlist?: string[];
108
+ /**
109
+ * Separate-process Pi BYOK credential boundary. Required only for a
110
+ * `dispatchSelection` in the BYOK lane; subscription runtimes and legacy
111
+ * Pi tasks do not invoke it.
112
+ */
113
+ piByokLauncher?: import('../adapters/pi/pi-adapter').PiByokLauncherConfig;
107
114
  /**
108
115
  * M5 batch-3 (workstream 1): explicit auto-select priority order for
109
116
  * `TaskRunner.pickAdapter`'s no-explicit-runtime branch (`task-runner.ts`)
@@ -175,6 +182,13 @@ export interface DaemonConfig {
175
182
  runtimeEnvironment?: Record<string, {
176
183
  allow?: string[];
177
184
  }>;
185
+ /**
186
+ * Device-local registry behind wire-level `requiredToolsets` ids. Only
187
+ * logical ids cross the SaaS wire; MCP executable definitions stay here.
188
+ * The first slice supports stdio servers (`command` + `args`) only and
189
+ * deliberately has no task-provided env/header/secret surface.
190
+ */
191
+ mcpToolsets?: Record<string, McpToolsetConfig>;
178
192
  /**
179
193
  * M5: explicit escape hatch for `url.ts`'s `assertServerUrlAllowed` — see
180
194
  * that function's own doc comment for the full allow/deny rule. Default
@@ -209,6 +223,48 @@ export interface DaemonConfig {
209
223
  * explicitly instead to opt out of enforcement altogether.
210
224
  */
211
225
  maxTaskOutputBytes?: number;
226
+ /**
227
+ * additive-minor (`task.complete.document`): the seam through which this
228
+ * product turns a finished task's final output text into the STRUCTURED
229
+ * terminal result the wire carries as `task.complete.document`, and the
230
+ * server projects into `TaskResult.document`.
231
+ *
232
+ * `extract(finalOutput, task)` is called exactly once per task, at the
233
+ * moment `task.complete` is built, with the same text that becomes
234
+ * `summary` (the concatenated `progress` events for that task) plus the
235
+ * task's `taskId`/`sessionRef`. Return `undefined` for "no structured
236
+ * result this time". Everything about the document's SHAPE is the
237
+ * product's business — the SDK never inspects, validates, or transforms
238
+ * it; extraction logic (prompting for JSON, parsing a fenced block,
239
+ * validating against the product's own schema) is product glue and belongs
240
+ * in this callback, not in the SDK.
241
+ *
242
+ * The SDK enforces exactly two wire rules, via the protocol's own
243
+ * `checkResultDocument`: the value must be JSON-serializable, and at most
244
+ * `RESULT_DOCUMENT_MAX_BYTES` (1 MiB) as canonical JSON. Stay under ~512
245
+ * KiB in practice (docs/protocol.md); a bigger result belongs in an
246
+ * artifact, not here.
247
+ *
248
+ * FAIL-CLOSED, never silent: if the extractor throws, returns a promise
249
+ * (the seam is synchronous and the runtime enforces it — an unawaited
250
+ * promise would be encoded as an empty document), produces something
251
+ * unsendable, or produces a document while the connected server never
252
+ * advertised the `result-document` capability (an old server would strip
253
+ * the field on arrival without a word), the task is reported as
254
+ * `task.fail` with `retryable: false` and a reason prefixed
255
+ * `result document undeliverable` — see
256
+ * `RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX` (`task-runner.ts`).
257
+ * Completing a task while quietly discarding the structured result it
258
+ * exists to produce is not an option this SDK offers.
259
+ *
260
+ * Omitted entirely by default, in which case the completion path is
261
+ * unchanged in every respect — no extractor call, no capability check, and
262
+ * a `task.complete` payload byte-identical to the one sent before this
263
+ * field existed.
264
+ */
265
+ resultDocument?: {
266
+ readonly extract: ResultDocumentExtractor;
267
+ };
212
268
  /**
213
269
  * M5 batch-3 (workstream 2): deadline bound on the graceful-shutdown
214
270
  * sequence's own wait for `TaskRunner.shutdownActiveTasks` to finish
@@ -221,6 +277,85 @@ export interface DaemonConfig {
221
277
  * existed.
222
278
  */
223
279
  shutdownGraceMs?: number;
280
+ /**
281
+ * Cadence for the `online` presence heartbeat (§12.3), when — and only when
282
+ * — the deployment's capability declaration contains `presence.hints` (see
283
+ * `capabilities-client.ts`; a deployment that declares nothing, or one this
284
+ * daemon could not read a declaration from, publishes nothing at all).
285
+ *
286
+ * Every field is optional and defaults to `presence-publisher.ts`'s own
287
+ * constants, which are chosen against the hosted defaults. `ttlMs` and
288
+ * `minimumIntervalMs` describe THE DEPLOYMENT's hint TTL and publication
289
+ * throttle as this operator understands them: the daemon never learns either
290
+ * from the wire, and uses them only to validate `intervalMs` sits strictly
291
+ * between them — a cadence outside that band is rejected synchronously here,
292
+ * the same way `maxTaskOutputBytes` is above, rather than degrading into a
293
+ * rate-limited or flickering hint nobody sees an error for.
294
+ */
295
+ presence?: PresenceConfig;
296
+ /**
297
+ * Plan `device-assertion-broker`: opt-in local assertion broker — lets a
298
+ * sibling process on this same machine (typically the host's own CLI,
299
+ * installed alongside this daemon) ask the daemon, over the already
300
+ * authenticated control socket, to mint a short-lived audience-scoped
301
+ * assertion signed with the paired device key. See
302
+ * {@link DeviceAssertionConfig}.
303
+ *
304
+ * OFF by default, and off is expressed two ways that mean the same thing: an
305
+ * absent section, or a present one with an empty `audiences` list. Both make
306
+ * `assertion.issue` answer `assertion_disabled` without looking at anything
307
+ * else. A new local authentication surface does not get to be on because
308
+ * someone left a config key behind.
309
+ */
310
+ deviceAssertion?: DeviceAssertionConfig;
311
+ }
312
+ /**
313
+ * Plan `device-assertion-broker`. Two fields, both about what this daemon will
314
+ * refuse.
315
+ *
316
+ * Every field is validated synchronously at construction, the same way
317
+ * `maxTaskOutputBytes` and the presence cadence are — a misconfigured
318
+ * authentication surface must fail when the daemon is built, not on the first
319
+ * call that needed it.
320
+ */
321
+ export interface DeviceAssertionConfig {
322
+ /**
323
+ * The EXACT audience strings this daemon will mint for. Matched with
324
+ * `Set.has` — exact string equality, never a prefix or suffix or subdomain
325
+ * rule.
326
+ *
327
+ * Prefix matching is the classic hole here: an allowlist entry of
328
+ * `salesko-api` under a `startsWith` rule also admits `salesko-api.evil.com`,
329
+ * and a suffix rule admits `evil-salesko-api`. There is no configuration
330
+ * that turns this into a pattern match, because there is no pattern-matching
331
+ * code to configure.
332
+ *
333
+ * An empty list means the feature is off (see
334
+ * `DaemonConfig.deviceAssertion`). Duplicate entries, empty entries, and
335
+ * entries over 256 UTF-8 bytes are construction errors — a duplicate is
336
+ * usually a copy-paste that hid a typo'd second entry, and silently
337
+ * de-duplicating it would hide it for good.
338
+ */
339
+ audiences: string[];
340
+ /**
341
+ * Assertion lifetime, ms. Default
342
+ * `DEVICE_ASSERTION_DEFAULT_TTL_MS` (120s), hard ceiling
343
+ * `DEVICE_ASSERTION_MAX_TTL_MS` (300s) — both from `@byok-sdk/core`, which
344
+ * enforces the same ceiling again at verification time, so a daemon patched
345
+ * to ignore this one still cannot get a longer-lived assertion accepted.
346
+ *
347
+ * Deliberately NOT caller-selectable over the control socket: a lifetime a
348
+ * caller can ask for is a lifetime every caller asks the maximum of.
349
+ */
350
+ ttlMs?: number;
351
+ }
352
+ export interface PresenceConfig {
353
+ /** Heartbeat cadence. Default 30s. */
354
+ intervalMs?: number;
355
+ /** The deployment's presence hint TTL. Default 90s (core §12.7.5 suggests 60-120s). */
356
+ ttlMs?: number;
357
+ /** The deployment's minimum interval between accepted publications. Default 5s. */
358
+ minimumIntervalMs?: number;
224
359
  }
225
360
  export interface DaemonStatus {
226
361
  paired: boolean;
@@ -340,7 +475,40 @@ export interface DaemonOverrides {
340
475
  pressureEngine?: LocalStoragePressureEngine;
341
476
  };
342
477
  }
478
+ /**
479
+ * Plan `device-assertion-broker` (codex round-2 F3): the internal, NON-public
480
+ * test seam for observing assertion issuance.
481
+ *
482
+ * This is NOT reachable through `DaemonConfig`/`DaemonOverrides`, and is NOT
483
+ * re-exported from the package `index.ts` — a test imports it straight from
484
+ * this module. That isolation is the point. The earlier `DaemonOverrides.
485
+ * deviceAssertion.mint` seam replaced the SIGNER, which meant a production
486
+ * embedder (`DaemonOverrides` is public API) could inject a callback that
487
+ * received the whole `DeviceRecord` — private key included — and exfiltrate it
488
+ * or forge claims.
489
+ *
490
+ * `onIssued` is a strict OBSERVER, called only AFTER a real, successful sign,
491
+ * with non-secret metadata ONLY (`jti`, `audience`). It cannot see the private
492
+ * key, cannot alter the signature, the claims, or the audit event, and cannot
493
+ * be reached from any public type. A test counts these calls to prove a gate
494
+ * rejection never reached the signer (a rejection never calls `onIssued`).
495
+ */
496
+ export interface AssertionIssueProbe {
497
+ onIssued(meta: {
498
+ jti: string;
499
+ audience: string;
500
+ }): void;
501
+ }
343
502
  export declare function createDaemonWithAdapters(config: DaemonConfig, adapters: RuntimeAdapter[], overrides?: DaemonOverrides): Daemon;
503
+ /**
504
+ * codex round-2 F3: the real builder. Exported from THIS module but NOT from
505
+ * the package `index.ts`, so the optional `assertionProbe` (an
506
+ * {@link AssertionIssueProbe} post-sign observer) is reachable only by tests
507
+ * importing this module directly — never through any public type. The public
508
+ * `createDaemonWithAdapters` above forwards without it, so production has no
509
+ * observer and no signer-injection surface at all.
510
+ */
511
+ export declare function buildDaemonWithAdapters(config: DaemonConfig, adapters: RuntimeAdapter[], overrides?: DaemonOverrides, assertionProbe?: AssertionIssueProbe): Daemon;
344
512
  /**
345
513
  * Public white-label entry point (M0-M3): the "5-line launcher" — a product
346
514
  * only needs a `DaemonConfig`, no hand-built adapter list. The bundled
@@ -11,6 +11,41 @@ interface OwnerRecord {
11
11
  export interface DaemonOwnerLease {
12
12
  release(): Promise<void>;
13
13
  }
14
+ /**
15
+ * Where this store's lock lives. Keyed by the CANONICAL storeDir alone — not
16
+ * by product id or OS user — because the store is the resource being
17
+ * serialized: a symlink alias and its target, or two products pointed at one
18
+ * store directory, must contend for the same endpoint (see
19
+ * `acquireDaemonOwner`'s own note on resolving aliases before deriving this).
20
+ *
21
+ * POSIX prefers `<storeDir>/mutex.sock`, keeping the lock beside the store
22
+ * state it guards in a directory `ensureSecureDir` has already made 0700. A
23
+ * storeDir can be long enough that no address under it fits
24
+ * {@link UNIX_SOCKET_PATH_SOFT_LIMIT} — a storeDir alone can exceed the whole
25
+ * `sun_path` budget — so the second candidate drops the storeDir from the path
26
+ * entirely and carries it as a hash under {@link STORE_MUTEX_FALLBACK_ROOT},
27
+ * nested one level deep so that directory can be created 0700 BEFORE anything
28
+ * binds inside it (the nesting convention is `controlSocketPath`'s).
29
+ *
30
+ * `controlSocketPath` reaches for `os.tmpdir()` at this point; this must not,
31
+ * for two independent reasons proven by CI job 94334133652. Correctness:
32
+ * `os.tmpdir()` is environment-derived, and a lock address that differs
33
+ * between two contending processes admits two writers. Reachability: the
34
+ * caller may have pointed `TMPDIR` INSIDE the very tree that made the natural
35
+ * path too long — there, `os.tmpdir()` yields an address LONGER than the one
36
+ * being escaped (152 bytes vs 126 in that job), so the "fallback" cannot bind
37
+ * at all. A fixed short root is immune to both.
38
+ *
39
+ * win32 has no socket file to place; a named pipe lives in one flat
40
+ * machine-wide namespace, so the name carries the store hash to keep two
41
+ * stores from colliding, and no length branch is needed.
42
+ *
43
+ * @internal Exported for the regression guard only (never re-exported from
44
+ * `index.ts`): the crashed-holder recovery path can only be exercised by
45
+ * planting a stale socket file at the exact address this derives, and a test
46
+ * recomputing the derivation itself would prove nothing about this one.
47
+ */
48
+ export declare function storeMutexEndpoint(canonicalStoreDir: string, identity: string, platform?: NodeJS.Platform): string;
14
49
  export declare class DaemonOwnerActiveError extends Error {
15
50
  readonly role: OwnerRecord['role'] | 'unknown';
16
51
  constructor(role: OwnerRecord['role'] | 'unknown');
@@ -0,0 +1,41 @@
1
+ import { type DeviceAssertionClaims, type DeviceAssertionEnvelopeV1 } from '@byok-sdk/core';
2
+ import type { DeviceRecord } from './store';
3
+ /**
4
+ * Mints one device assertion (plan `device-assertion-broker`).
5
+ *
6
+ * This module exists so there is exactly ONE place in the client that touches
7
+ * the device private key for this envelope, and so "the key is never cached"
8
+ * is a property of a small readable file rather than a claim about a
9
+ * 2000-line one. Two rules it enforces structurally:
10
+ *
11
+ * 1. **No module-level key state.** There is no cache, no memo, no
12
+ * module-scope variable of any kind here. The `KeyObject` is created inside
13
+ * {@link mintDeviceAssertion} from the record the caller just read off disk
14
+ * and becomes unreachable when the function returns — the same
15
+ * read-the-store-every-time discipline `StoredDeviceProofSigner` documents,
16
+ * for the same reason: clearing `device.json` must remove local signing
17
+ * authority immediately, not at the next process restart.
18
+ * 2. **The caller supplies the record.** This function does not load the
19
+ * store, check revocation, or consult an allowlist. All of that is the
20
+ * daemon's fail-closed gate sequence (`create-daemon.ts`), and duplicating
21
+ * any of it here would create a second, quieter authority on whether an
22
+ * assertion may be minted at all.
23
+ */
24
+ export interface MintDeviceAssertionInput {
25
+ /** The record just read from disk — never a cached one. */
26
+ readonly record: DeviceRecord;
27
+ /** The paired server's normalized origin (`url.ts`'s `toHttpBase` → `origin`). */
28
+ readonly issuer: string;
29
+ readonly productId: string;
30
+ /** Already checked against the configured allowlist by the caller. */
31
+ readonly audience: string;
32
+ /** Already range-checked at daemon construction time. */
33
+ readonly ttlMs: number;
34
+ readonly now: Date;
35
+ }
36
+ export interface MintedDeviceAssertion {
37
+ readonly envelope: DeviceAssertionEnvelopeV1;
38
+ readonly claims: DeviceAssertionClaims;
39
+ readonly expiresAt: string;
40
+ }
41
+ export declare function mintDeviceAssertion(input: MintDeviceAssertionInput): MintedDeviceAssertion;
@@ -24,22 +24,24 @@ export declare function exportPrivateKeyPem(privateKey: KeyObject): string;
24
24
  export declare function importPrivateKeyPem(pem: string): KeyObject;
25
25
  /**
26
26
  * S1 (GAP-004): the domain-separation prefix this device signs along with a
27
- * challenge nonce, byte-identical to the server's own
28
- * `NONCE_SIGNING_DOMAIN` (`packages/server/src/auth.ts`). The device key is a
29
- * long-lived identity key that later planes will also sign structured
30
- * messages with; tagging the domain is what stops a signature made for one of
31
- * those from being replayable as a token-renewal credential.
27
+ * challenge nonce. The device key is a long-lived identity key that later
28
+ * planes will also sign structured messages with; tagging the domain is what
29
+ * stops a signature made for one of those from being replayable as a
30
+ * token-renewal credential.
32
31
  *
33
- * Not shared through a package: the two ends agree on a wire constant, and
34
- * inventing a dependency between server and client to hold one string literal
35
- * would couple them far harder than the literal does.
32
+ * The literal itself now lives in `@byok-sdk/core` (`src/pairing.ts`), which
33
+ * the daemon, the hosted surface, and the reference server all already depend
34
+ * on. It used to be three copies, each commented as byte-identical to the
35
+ * others — an agreement that holds only until someone edits one of them.
36
+ * Re-exported here so this module's public surface is unchanged.
36
37
  */
37
- export declare const NONCE_SIGNING_DOMAIN = "byok-nonce-v1\n";
38
+ export { NONCE_SIGNING_DOMAIN } from '@byok-sdk/core';
38
39
  /**
39
40
  * Sign a challenge nonce with the device private key: the signed message is
40
- * {@link NONCE_SIGNING_DOMAIN} followed by `nonce` (UTF-8), and the result is
41
- * the raw 64-byte Ed25519 signature, base64url-encoded (protocol §6.2). A
42
- * server on the domain-separated contract rejects the undomained form, so
43
- * there is no variant of this that omits the prefix.
41
+ * `NONCE_SIGNING_DOMAIN` followed by `nonce` (UTF-8) core's
42
+ * {@link nonceSigningBytes} produces those bytes and the result is the raw
43
+ * 64-byte Ed25519 signature, base64url-encoded (protocol §6.2). A server on the
44
+ * domain-separated contract rejects the undomained form, so there is no variant
45
+ * of this that omits the prefix.
44
46
  */
45
47
  export declare function signNonce(privateKey: KeyObject, nonce: string): string;
@@ -20,8 +20,8 @@ import type { ConnectionState } from './ws-transport';
20
20
  * - `ConnectionManagerOptions.onEnvelope`/`onStateChange` — likewise already
21
21
  * `create-daemon.ts`'s own closures (`(envelope) =>
22
22
  * runner?.handleEnvelope(envelope)`, `(state) => { connectionState = state;
23
- * }`). `onEnvelope` additionally exposes the raw INBOUND `task.offer` the
24
- * one event with no corresponding outbound envelope of its own — see
23
+ * }`). `onEnvelope` additionally exposes the raw INBOUND offer variants
24
+ * the one event class with no corresponding outbound envelope of its own — see
25
25
  * `handleInboundEnvelope`.
26
26
  *
27
27
  * Neither seam required adding anything to `TaskRunnerDeps`/`TaskRunner`
@@ -188,6 +188,50 @@ export type DaemonEvent = {
188
188
  conflicted: number;
189
189
  };
190
190
  errorCategory?: string;
191
+ }
192
+ /**
193
+ * Plan `device-assertion-broker`: one `assertion.issue` control call
194
+ * resolved — either an assertion was minted (`issued`) or one of the six
195
+ * fail-closed gates refused (`denied`, with `reason` naming which one; see
196
+ * `ASSERTION_ISSUE_ERROR_CODES`, `control-protocol.ts`).
197
+ *
198
+ * ONE kind for both outcomes, so an operator reading the feed sees the
199
+ * issuance rate and the refusal rate on the same line shape rather than
200
+ * having to correlate two.
201
+ *
202
+ * The signature and the envelope bytes are NOT fields of this event and
203
+ * never can be. That is structural, not a redaction rule: `bin/audit-log.ts`
204
+ * can only drop what it is handed, and an event type that cannot carry a
205
+ * signature cannot leak one into a durable file no matter how the audit
206
+ * projection is later edited. What IS carried is the metadata an incident
207
+ * needs — which audience, which `jti` (so a suspect assertion presented to
208
+ * the host's cloud can be traced back to the exact local call that minted
209
+ * it), and when it expires.
210
+ *
211
+ * codex round-2 F4 — the union is split by `result` for the same structural
212
+ * reason: on the ISSUED path `audience` came from this daemon's own
213
+ * configured allowlist (operator-authored, safe verbatim), but on the DENIED
214
+ * path it is whatever the CALLER sent — free text that could be a PEM, a
215
+ * signature, or any secret shaped as an audience. So the denied variant has
216
+ * NO raw `audience` field at all; it carries only `audienceSize` (a byte
217
+ * count), computed at event-construction time (`noteDeviceAssertion`). The
218
+ * raw denied audience therefore never reaches the observer feed, `format.ts`,
219
+ * daemon stdout, or the audit file — there is no field to carry it, rather
220
+ * than a redactor that has to remember to strip it.
221
+ */
222
+ | {
223
+ kind: 'device-assertion';
224
+ ts: string;
225
+ result: 'issued';
226
+ audience: string;
227
+ jti: string;
228
+ expiresAt: string;
229
+ } | {
230
+ kind: 'device-assertion';
231
+ ts: string;
232
+ result: 'denied';
233
+ reason: string;
234
+ audienceSize?: number;
191
235
  };
192
236
  export type DaemonEventListener = (event: DaemonEvent) => void;
193
237
  export type Unsubscribe = () => void;
@@ -246,7 +290,7 @@ export declare class DaemonObserver {
246
290
  tasks(): DaemonTaskInfo[];
247
291
  /**
248
292
  * Feed a raw INBOUND (server -> daemon) envelope. Deliberately narrow: only
249
- * `task.offer` produces a local event here — every other inbound type
293
+ * either offer variant produces a local event here — every other inbound type
250
294
  * (`task.cancel`/`task.steer`/`task.approve`/`task.reject`) is a
251
295
  * best-effort notification whose OWN observable effect already surfaces
252
296
  * through the daemon's outbound envelopes (`task.cancelled`, `task.progress`
@@ -269,6 +313,27 @@ export declare class DaemonObserver {
269
313
  noteShutdownRequested(reason: string): void;
270
314
  /** M4 Phase 2: see the `shutdown-complete` `DaemonEvent` variant's own doc comment (finding F5(b): `undeliveredOutboxCount`). */
271
315
  noteShutdownComplete(reason: string, undeliveredOutboxCount?: number): void;
316
+ /**
317
+ * Plan `device-assertion-broker`: see the `device-assertion` `DaemonEvent`
318
+ * variant's own doc comment. The parameter type is what keeps the signature
319
+ * out — there is no field to pass one through.
320
+ *
321
+ * codex round-2 F4: the DENIED caller can pass its raw `audience` here, but
322
+ * it is converted to a byte SIZE the instant the event is constructed and the
323
+ * raw string is dropped — it is never placed on the emitted `DaemonEvent`, so
324
+ * it cannot reach a subscriber, `format.ts`, stdout, or the audit file. The
325
+ * ISSUED `audience` came from the allowlist and is kept verbatim.
326
+ */
327
+ noteDeviceAssertion(event: {
328
+ result: 'issued';
329
+ audience: string;
330
+ jti: string;
331
+ expiresAt: string;
332
+ } | {
333
+ result: 'denied';
334
+ reason: string;
335
+ audience?: string;
336
+ }): void;
272
337
  /** M4 Phase 3 hardening: see the `stale-approval-decision` `DaemonEvent` variant's own doc comment. */
273
338
  noteStaleApprovalDecision(taskId: string, decision: 'approve' | 'reject', reason?: string): void;
274
339
  noteGitWorkspace(event: {
@@ -0,0 +1,69 @@
1
+ import type { AuthManager } from './auth-manager';
2
+ /**
3
+ * Client-side defaults, chosen against the hosted defaults
4
+ * (`@byok-sdk/cloud`'s `DEFAULT_PRESENCE_TTL_MS` = 90s,
5
+ * `DEFAULT_PRESENCE_MINIMUM_INTERVAL_MS` = 5s) and core §12.7.5's suggested
6
+ * 60-120s presence TTL. Spelled out here rather than imported: the daemon must
7
+ * not depend on the hosted package, and these are this producer's own cadence
8
+ * expectations, not a mirror of any deployment's configuration.
9
+ *
10
+ * The invariant that matters is `minimumIntervalMs < intervalMs < ttlMs`,
11
+ * asserted in the constructor: beat faster than the store's throttle and every
12
+ * other beat is rejected as `hint_rate_limited`; beat slower than the TTL and
13
+ * the device flickers offline between beats.
14
+ */
15
+ export declare const DEFAULT_PRESENCE_HEARTBEAT_INTERVAL_MS = 30000;
16
+ export declare const DEFAULT_PRESENCE_TTL_MS = 90000;
17
+ export declare const DEFAULT_PRESENCE_MINIMUM_INTERVAL_MS = 5000;
18
+ /**
19
+ * The cadence invariant, as a function so the two places that need it — this
20
+ * module's constructor and `create-daemon.ts`'s synchronous config validation —
21
+ * share one authority instead of two spellings of the same band.
22
+ *
23
+ * @throws {Error} when the cadence is outside `minimumIntervalMs < intervalMs < ttlMs`.
24
+ */
25
+ export declare function assertPresenceHeartbeatCadence(cadence: {
26
+ intervalMs: number;
27
+ ttlMs: number;
28
+ minimumIntervalMs: number;
29
+ }): void;
30
+ export interface PresencePublisherOptions {
31
+ serverUrl: string;
32
+ auth: AuthManager;
33
+ /** Heartbeat cadence. Must sit strictly between {@link PresencePublisherOptions.minimumIntervalMs} and {@link PresencePublisherOptions.ttlMs}. */
34
+ intervalMs?: number;
35
+ /** The deployment's presence hint TTL, as this daemon understands it. Only used to validate the cadence. */
36
+ ttlMs?: number;
37
+ /** The deployment's minimum accepted interval between publications, as this daemon understands it. Only used to validate the cadence. */
38
+ minimumIntervalMs?: number;
39
+ /**
40
+ * Observable degradation sink — one line per publish failure and one for the
41
+ * permanent stop. The daemon passes its `console.warn` convention; tests read
42
+ * it directly instead of scraping stdout.
43
+ */
44
+ onDegraded?: (reason: string) => void;
45
+ }
46
+ /**
47
+ * Periodic `online` heartbeat. Construct once per `start()`, {@link stop} it in
48
+ * the shutdown sequence.
49
+ *
50
+ * The loop is a self-rescheduling `setTimeout` rather than `setInterval`: a slow
51
+ * publish must delay the next beat, not queue a second one behind it.
52
+ */
53
+ export declare class PresencePublisher {
54
+ private readonly opts;
55
+ private readonly url;
56
+ private readonly intervalMs;
57
+ private timer;
58
+ private running;
59
+ /** Set once a revoked device is observed. Terminal: `start()` will not restart this instance. */
60
+ private stoppedPermanently;
61
+ constructor(opts: PresencePublisherOptions);
62
+ /** Publishes immediately, then every `intervalMs`. Idempotent; a no-op after a permanent stop. */
63
+ start(): void;
64
+ /** Stops the cadence. Idempotent, and the only "offline" signal this producer emits — the hint's TTL does the rest. */
65
+ stop(): void;
66
+ private schedule;
67
+ private beat;
68
+ private stopPermanently;
69
+ }
@@ -0,0 +1,116 @@
1
+ import { type CapabilityDeclaration } from '@byok-sdk/core';
2
+ import type { AuthManager } from './auth-manager';
3
+ /**
4
+ * The hosted capability this pipeline gates on. A plain string, not an import
5
+ * from `@byok-sdk/cloud`: the daemon must not gain a dependency on the hosted
6
+ * implementation, and ADR-010 makes capability names deployment vocabulary that
7
+ * core validates the shape of but never the meaning of. Same rule the presence
8
+ * producer's `PRESENCE_HINTS_CAPABILITY` follows.
9
+ */
10
+ export declare const SKILL_PACKS_CAPABILITY = "skills.pack";
11
+ /** Directory under `dataDir` this module owns end to end. */
12
+ export declare const SKILL_PACKS_DIRNAME = "skill-packs";
13
+ /** Per-pack pointer at the installed content-addressed revision. */
14
+ export declare const SKILL_PACK_LOCK_FILENAME = "lock.json";
15
+ /** Append-only install/refusal record, beside the packs it describes. */
16
+ export declare const SKILL_PACK_AUDIT_FILENAME = "audit.jsonl";
17
+ export declare const SKILL_PACK_LOCK_SCHEMA = "byok-skill-pack-lock-v1";
18
+ /**
19
+ * Ceiling on a single HTTP response this pipeline will read into memory.
20
+ *
21
+ * Deliberately derived from the pack cap rather than chosen independently: the
22
+ * largest legitimate manifest list is bounded by what a pack may declare, and a
23
+ * response beyond that is refused before it is parsed rather than after. A
24
+ * transfer cap that is not evaluated is the exact failure this plan set out not
25
+ * to repeat.
26
+ */
27
+ export declare const SKILL_PACK_RESPONSE_MAX_BYTES: number;
28
+ export declare const SKILL_PACK_INSTALL_ERROR_CODES: readonly ['capability_unavailable', 'transport_failed', 'response_invalid', 'response_too_large', 'manifest_invalid', 'content_rejected', 'store_unsafe'];
29
+ export type SkillPackInstallErrorCode = (typeof SKILL_PACK_INSTALL_ERROR_CODES)[number];
30
+ /**
31
+ * Every way an install can be refused, as one type with a `code`.
32
+ *
33
+ * Code-based branching rather than a class per failure — the same idiom
34
+ * `@byok-sdk/core` uses — because a caller only ever needs two decisions from
35
+ * this: "was the channel unavailable" (retry later, or the deployment simply
36
+ * does not offer it) versus "was the content refused" (a publication problem
37
+ * nobody on this device can fix by retrying).
38
+ */
39
+ export declare class SkillPackInstallError extends Error {
40
+ readonly code: SkillPackInstallErrorCode;
41
+ readonly packName: string | undefined;
42
+ constructor(code: SkillPackInstallErrorCode, message: string, options?: {
43
+ cause?: unknown;
44
+ packName?: string;
45
+ });
46
+ }
47
+ /** What a device recorded about one installed pack. Mirrors `lock.json` on disk. */
48
+ export interface SkillPackLock {
49
+ readonly schema: typeof SKILL_PACK_LOCK_SCHEMA;
50
+ readonly name: string;
51
+ readonly version: string;
52
+ readonly description: string;
53
+ readonly content_hash: string;
54
+ /** The deployment the pack came from, normalized to its http(s) origin. Never a token, never a path. */
55
+ readonly source: string;
56
+ readonly installed_at: string;
57
+ readonly files: readonly {
58
+ readonly path: string;
59
+ readonly sha256: string;
60
+ readonly bytes: number;
61
+ }[];
62
+ }
63
+ export interface InstalledSkillPack {
64
+ readonly name: string;
65
+ readonly lock: SkillPackLock;
66
+ /** Absolute path of the content-addressed revision `lock.json` points at. */
67
+ readonly directory: string;
68
+ }
69
+ export interface InstallSkillPacksOptions {
70
+ /** The SDK data directory. A daemon passes its `storeDir`; the pack tree lives beside its other private state. */
71
+ readonly dataDir: string;
72
+ readonly serverUrl: string;
73
+ readonly auth: AuthManager;
74
+ /** The declaration read from `GET /byok/capabilities`. Never re-derived here, and never assumed. */
75
+ readonly declaration: CapabilityDeclaration;
76
+ readonly signal?: AbortSignal;
77
+ }
78
+ export interface SkillPackInstallResult {
79
+ readonly installed: readonly InstalledSkillPack[];
80
+ /** Packs already present at the same content hash. Re-installing is a no-op, not a rewrite. */
81
+ readonly unchanged: readonly string[];
82
+ }
83
+ /** The store root this module owns. */
84
+ export declare function skillPacksRoot(dataDir: string): string;
85
+ /**
86
+ * Fetches, verifies and installs every pack this deployment offers.
87
+ *
88
+ * @throws {SkillPackInstallError} `capability_unavailable` before any request
89
+ * is issued when the declaration does not name `skills.pack`.
90
+ */
91
+ export declare function installSkillPacks(options: InstallSkillPacksOptions): Promise<SkillPackInstallResult>;
92
+ /**
93
+ * Every pack this device has a valid lock for, sorted by name.
94
+ *
95
+ * Reads only the locks — never the pack bytes — so a caller listing what is
96
+ * installed pays nothing for packs it is not about to project.
97
+ */
98
+ export declare function listInstalledSkillPacks(dataDir: string): Promise<readonly InstalledSkillPack[]>;
99
+ export interface ProjectedSkillPack {
100
+ readonly name: string;
101
+ readonly contentHash: string;
102
+ readonly targetDir: string;
103
+ readonly files: readonly string[];
104
+ }
105
+ /**
106
+ * Copies an installed pack's files into a host-chosen directory.
107
+ *
108
+ * Copy, not symlink, and re-verified on the way out: the store is on the same
109
+ * machine as whatever else runs there, so the bytes are hashed again and
110
+ * compared against the lock before they are handed to a runtime. An install
111
+ * that was verified last week is not evidence about the file on disk today.
112
+ *
113
+ * @throws {SkillPackInstallError} `store_unsafe` for a missing pack, a symlink
114
+ * anywhere in the pack, or a file whose bytes no longer match the lock.
115
+ */
116
+ export declare function projectSkillPack(dataDir: string, name: string, targetDir: string): Promise<ProjectedSkillPack>;