@corenel/harness 0.17.0 → 0.19.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,18 @@
1
1
  import { asEcPublicJwk, jwkThumbprint, verifySignature, randomToken, serverProofPayload, clientProofPayload, parseEnrollmentCode, bytesToB64url, codeProof, codeHash, } from './deviceKeys.js';
2
2
  import { capabilityMissingMessage } from './hostCapability.js';
3
- import { SIDECAR_PROTOCOL, HOST_METHODS, errMessage, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, DEVICES_FEATURE, AGENT_ACCESS_FEATURE, AGENT_RUNS_FEATURE, PEER_LINKS_FEATURE, CHILD_EVENTS_FEATURE, PEER_DAEMON_FEATURE, TEAM_MEMBERS_FEATURE, SPEECH_FEATURE, encodePcm16, isClerkOrgId, } from '@corenel/protocol';
3
+ import { SIDECAR_PROTOCOL, HOST_METHODS, errMessage, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, DEVICES_FEATURE, AGENT_ACCESS_FEATURE, AGENT_RUNS_FEATURE, PEER_LINKS_FEATURE, CHILD_EVENTS_FEATURE, PEER_DAEMON_FEATURE, TEAM_MEMBERS_FEATURE, SPEECH_FEATURE, encodePcm16, isClerkOrgId, WORKFLOW_RUNNER_FEATURE, readWorkflowOkReply, readWorkflowResultsListReply, readWorkflowResultsTakeReply, } from '@corenel/protocol';
4
+ const NO_WORKFLOWS = 'this daemon does not run workflows';
5
+ const WRONG_REPLY = 'the daemon answered with the wrong reply';
6
+ /** A reply that failed its check (protocol workflowWire.ts readers). */
7
+ const MALFORMED_REPLY = 'the daemon sent a reply this app cannot read';
8
+ /** A workflow request the daemon has not answered by now never will be on
9
+ * this connection (a lost reply): it settles not ok, so the caller (the
10
+ * copy-home pass, say) is freed rather than held until the connection drops
11
+ * (M8). A package put carries up to 8 MiB, so it waits longer, sized for a
12
+ * slow link. */
13
+ export const WORKFLOW_REQUEST_TIMEOUT_MS = 60000;
14
+ export const WORKFLOW_PUT_TIMEOUT_MS = 120000;
15
+ const NO_ANSWER = 'the sidecar did not answer in time';
4
16
  /** Decode a base64 chunk to bytes (atob is available in the browser and Node 18+). */
5
17
  function b64ToBytes(b64) {
6
18
  const bin = atob(b64);
@@ -236,6 +248,20 @@ export class SidecarHost {
236
248
  writable: true,
237
249
  value: new Map()
238
250
  });
251
+ Object.defineProperty(this, "workflowsSeq", {
252
+ enumerable: true,
253
+ configurable: true,
254
+ writable: true,
255
+ value: 0
256
+ });
257
+ /** One pending map for the five workflow-* requests; each method checks it
258
+ * got its own reply type, then checks and rebuilds the reply itself. */
259
+ Object.defineProperty(this, "workflowsPending", {
260
+ enumerable: true,
261
+ configurable: true,
262
+ writable: true,
263
+ value: new Map()
264
+ });
239
265
  Object.defineProperty(this, "accessSeq", {
240
266
  enumerable: true,
241
267
  configurable: true,
@@ -798,7 +824,9 @@ export class SidecarHost {
798
824
  const proven = this.verifiedSidecarJkt !== null && this.provenSidecarId !== null && m.sidecarId === this.provenSidecarId;
799
825
  const jkt = proven ? this.opts.deviceKey?.jkt : undefined;
800
826
  const orgId = isClerkOrgId(m.orgId) ? m.orgId : undefined;
801
- assertion = jkt ? (await this.opts.onIdentityChallenge?.({ nonce: m.nonce, sidecarId: m.sidecarId, jkt, ...(orgId ? { orgId } : {}) })) ?? null : null;
827
+ // The daemon's trusted issuer, for explaining a refusal only; bounded.
828
+ const issuer = typeof m.issuer === 'string' && m.issuer.length <= 512 ? m.issuer : undefined;
829
+ assertion = jkt ? (await this.opts.onIdentityChallenge?.({ nonce: m.nonce, sidecarId: m.sidecarId, jkt, ...(orgId ? { orgId } : {}), ...(issuer ? { issuer } : {}) })) ?? null : null;
802
830
  }
803
831
  catch {
804
832
  assertion = null;
@@ -867,6 +895,9 @@ export class SidecarHost {
867
895
  for (const p of this.crewDeployPending.values())
868
896
  p.reject(new Error(reason));
869
897
  this.crewDeployPending.clear();
898
+ for (const p of this.workflowsPending.values())
899
+ p.reject(new Error(reason));
900
+ this.workflowsPending.clear();
870
901
  for (const p of this.accessPending.values())
871
902
  p.reject(new Error(reason));
872
903
  this.accessPending.clear();
@@ -1502,6 +1533,18 @@ export class SidecarHost {
1502
1533
  p.resolve(m.reply);
1503
1534
  return;
1504
1535
  }
1536
+ case 'workflow-package-result':
1537
+ case 'workflow-results-list-result':
1538
+ case 'workflow-results-take-result':
1539
+ case 'workflow-results-ack-result':
1540
+ case 'workflow-stop-result': {
1541
+ const p = this.workflowsPending.get(m.id);
1542
+ if (!p)
1543
+ return;
1544
+ this.workflowsPending.delete(m.id);
1545
+ p.resolve(m);
1546
+ return;
1547
+ }
1505
1548
  case 'member-result': {
1506
1549
  const p = this.memberReplies.get(m.id);
1507
1550
  if (!p)
@@ -1515,6 +1558,7 @@ export class SidecarHost {
1515
1558
  ...(m.adminLabel !== undefined ? { adminLabel: m.adminLabel } : {}),
1516
1559
  ...(m.adminId !== undefined ? { adminId: m.adminId } : {}),
1517
1560
  ...(m.admin !== undefined ? { admin: m.admin } : {}),
1561
+ ...(m.noAdmin === true ? { noAdmin: true } : {}),
1518
1562
  ...(m.members ? { members: m.members } : {}),
1519
1563
  }
1520
1564
  : { ok: false, code: m.code, error: m.error });
@@ -2375,12 +2419,101 @@ export class SidecarHost {
2375
2419
  }
2376
2420
  /** The machine's deploy list, statuses and audit. */
2377
2421
  crewDeployments() { return this.crewDeployRequest((id) => ({ t: 'crew-deployments', id })); }
2378
- crewDeploy(ref, reason) { return this.crewDeployRequest((id) => ({ t: 'crew-deploy', id, ref, ...(reason ? { reason } : {}) })); }
2422
+ /** Deploy an agent or a workflow. `workspaceId`: where a deployed
2423
+ * WORKFLOW's results go (the daemon refuses it on an agent). */
2424
+ crewDeploy(ref, reason, workspaceId) {
2425
+ return this.crewDeployRequest((id) => ({ t: 'crew-deploy', id, ref, ...(reason ? { reason } : {}), ...(workspaceId ? { workspaceId } : {}) }));
2426
+ }
2379
2427
  crewUndeploy(ref, reason) { return this.crewDeployRequest((id) => ({ t: 'crew-undeploy', id, ref, ...(reason ? { reason } : {}) })); }
2380
2428
  crewPause(ref, reason) { return this.crewDeployRequest((id) => ({ t: 'crew-pause', id, ref, reason })); }
2381
2429
  crewResume(ref, reason) { return this.crewDeployRequest((id) => ({ t: 'crew-resume', id, ref, ...(reason ? { reason } : {}) })); }
2382
2430
  /** Stop a run AND record who stopped it and why (the plain `cancelRun` records nothing). */
2383
2431
  crewStop(ref, runId, reason) { return this.crewDeployRequest((id) => ({ t: 'crew-stop', id, ref, runId, reason })); }
2432
+ /** True when the connected daemon runs deployed workflows (package intake,
2433
+ * the results outbox, and `workflow-stop`). Feature-detected from the welcome. */
2434
+ supportsWorkflows() { return this.features.includes(WORKFLOW_RUNNER_FEATURE); }
2435
+ /** One workflow-* request. A refusal resolves; only a dropped connection
2436
+ * rejects. Callers check the feature first, so nothing is sent to a daemon
2437
+ * without it. */
2438
+ /** One workflow-* request: its reply, or null when the daemon did not
2439
+ * answer within `timeoutMs` (the request is then forgotten, so a late reply
2440
+ * is dropped). Only a dropped connection rejects. */
2441
+ workflowsRequest(build, timeoutMs = WORKFLOW_REQUEST_TIMEOUT_MS) {
2442
+ const id = ++this.workflowsSeq;
2443
+ return new Promise((resolve, reject) => {
2444
+ const timer = setTimeout(() => {
2445
+ if (!this.workflowsPending.delete(id))
2446
+ return;
2447
+ resolve(null);
2448
+ }, timeoutMs);
2449
+ this.workflowsPending.set(id, {
2450
+ resolve: (m) => { clearTimeout(timer); resolve(m); },
2451
+ reject: (e) => { clearTimeout(timer); reject(e); },
2452
+ });
2453
+ this.ch.send(build(id));
2454
+ });
2455
+ }
2456
+ /** Send one packaged workflow (`.pdpkg`, base64) to the daemon. */
2457
+ async workflowPackagePut(req) {
2458
+ if (!this.supportsWorkflows())
2459
+ return { ok: false, error: NO_WORKFLOWS };
2460
+ const m = await this.workflowsRequest((id) => ({
2461
+ t: 'workflow-package-put', id, packageId: req.packageId, workspaceId: req.workspaceId, sha256: req.sha256, pkg: req.pkg,
2462
+ }), WORKFLOW_PUT_TIMEOUT_MS);
2463
+ if (!m)
2464
+ return { ok: false, error: NO_ANSWER };
2465
+ if (m.t !== 'workflow-package-result')
2466
+ return { ok: false, error: WRONG_REPLY };
2467
+ return readWorkflowOkReply(m.reply) ?? { ok: false, error: MALFORMED_REPLY };
2468
+ }
2469
+ /** The results waiting for `workspaceId` in this connection's outbox. Every
2470
+ * item is checked to be one of that workspace's own results. */
2471
+ async workflowResultsList(workspaceId) {
2472
+ if (!this.supportsWorkflows())
2473
+ return { ok: false, error: NO_WORKFLOWS };
2474
+ const m = await this.workflowsRequest((id) => ({ t: 'workflow-results-list', id, workspaceId }));
2475
+ if (!m)
2476
+ return { ok: false, error: NO_ANSWER };
2477
+ if (m.t !== 'workflow-results-list-result')
2478
+ return { ok: false, error: WRONG_REPLY };
2479
+ return readWorkflowResultsListReply(m.reply, workspaceId) ?? { ok: false, error: MALFORMED_REPLY };
2480
+ }
2481
+ /** One waiting result's text. Nothing is deleted until `workflowResultsAck`. */
2482
+ async workflowResultsTake(path) {
2483
+ if (!this.supportsWorkflows())
2484
+ return { ok: false, error: NO_WORKFLOWS };
2485
+ const m = await this.workflowsRequest((id) => ({ t: 'workflow-results-take', id, path }));
2486
+ if (!m)
2487
+ return { ok: false, error: NO_ANSWER };
2488
+ if (m.t !== 'workflow-results-take-result')
2489
+ return { ok: false, error: WRONG_REPLY };
2490
+ return readWorkflowResultsTakeReply(m.reply) ?? { ok: false, error: MALFORMED_REPLY };
2491
+ }
2492
+ /** The result at `path` is safely home: the daemon deletes it. */
2493
+ async workflowResultsAck(path) {
2494
+ if (!this.supportsWorkflows())
2495
+ return { ok: false, error: NO_WORKFLOWS };
2496
+ const m = await this.workflowsRequest((id) => ({ t: 'workflow-results-ack', id, path }));
2497
+ if (!m)
2498
+ return { ok: false, error: NO_ANSWER };
2499
+ if (m.t !== 'workflow-results-ack-result')
2500
+ return { ok: false, error: WRONG_REPLY };
2501
+ return readWorkflowOkReply(m.reply) ?? { ok: false, error: MALFORMED_REPLY };
2502
+ }
2503
+ /** Stop a running deployed workflow: every live run of `ref`, or only
2504
+ * `runId`. The daemon records who stopped it and why. */
2505
+ async workflowStop(ref, reason, workspaceId, runId) {
2506
+ if (!this.supportsWorkflows())
2507
+ return { ok: false, error: NO_WORKFLOWS };
2508
+ const m = await this.workflowsRequest((id) => ({
2509
+ t: 'workflow-stop', id, ref, reason, ...(workspaceId ? { workspaceId } : {}), ...(runId ? { runId } : {}),
2510
+ }));
2511
+ if (!m)
2512
+ return { ok: false, error: NO_ANSWER };
2513
+ if (m.t !== 'workflow-stop-result')
2514
+ return { ok: false, error: WRONG_REPLY };
2515
+ return readWorkflowOkReply(m.reply) ?? { ok: false, error: MALFORMED_REPLY };
2516
+ }
2384
2517
  /** True when the connected daemon keeps per-agent access (team mode, Part B). */
2385
2518
  supportsAgentAccess() { return this.features.includes(AGENT_ACCESS_FEATURE); }
2386
2519
  /** One access-* request. `ok: false` is the daemon's considered answer and