@byok-sdk/client 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -6
- package/dist/adapters/claude/claude-adapter.d.ts +3 -0
- package/dist/adapters/claude/resolve-bin.d.ts +2 -2
- package/dist/adapters/codex/codex-adapter.d.ts +5 -3
- package/dist/adapters/index.d.ts +1 -1
- package/dist/adapters/index.js +293 -100
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/pi/events.d.ts +10 -37
- package/dist/adapters/pi/permission-mapping.d.ts +4 -20
- package/dist/adapters/pi/pi-adapter.d.ts +22 -0
- package/dist/adapters/pi/resolve-bin.d.ts +18 -14
- package/dist/adapters/pi/rpc-client.d.ts +1 -4
- package/dist/adapters/provider-credential-environment.d.ts +18 -0
- package/dist/bin/byok-agent.js +1839 -838
- package/dist/bin/byok-agent.js.map +1 -1
- package/dist/bin/byok-approval-mcp.js +2 -2
- package/dist/bin/byok-approval-mcp.js.map +1 -1
- package/dist/daemon/assertion-client.d.ts +68 -0
- package/dist/daemon/capabilities-client.d.ts +48 -0
- package/dist/daemon/control-protocol.d.ts +81 -4
- package/dist/daemon/create-daemon.d.ts +169 -1
- package/dist/daemon/daemon-owner.d.ts +35 -0
- package/dist/daemon/device-assertion-signer.d.ts +41 -0
- package/dist/daemon/device-keys.d.ts +15 -13
- package/dist/daemon/observer.d.ts +68 -3
- package/dist/daemon/presence-publisher.d.ts +69 -0
- package/dist/daemon/skill-pack-installer.d.ts +116 -0
- package/dist/daemon/task-runner.d.ts +129 -3
- package/dist/index.d.ts +22 -3
- package/dist/index.js +1872 -284
- package/dist/index.js.map +1 -1
- package/dist/lifecycle/create-service-lifecycle.d.ts +2 -2
- package/dist/types.d.ts +29 -0
- package/package.json +6 -5
|
@@ -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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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>;
|