c8ctl-plugin-nano 1.35.0 → 1.35.2

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.
Files changed (2) hide show
  1. package/c8ctl-plugin.js +137 -6
  2. package/package.json +8 -8
package/c8ctl-plugin.js CHANGED
@@ -2549,14 +2549,18 @@ function resourceContentUrl(baseUrl, resourceKey) {
2549
2549
  // is surfaced as a ProvisionError so the caller fails the job with a clear
2550
2550
  // provisioning message (never runs an agent with a silently-empty prompt).
2551
2551
  async function fetchLinkedResourceContent(resourceKey, opts = {}) {
2552
- const { baseUrl, token, fetchImpl = fetch, timeoutMs = 15_000 } = opts;
2552
+ const { baseUrl, token, authHeaders, fetchImpl = fetch, timeoutMs = 15_000 } = opts;
2553
2553
  const url = resourceContentUrl(baseUrl, resourceKey);
2554
2554
  const controller = new AbortController();
2555
2555
  const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
2556
2556
  let res;
2557
2557
  try {
2558
2558
  const headers = { Accept: 'application/octet-stream' };
2559
- if (token) headers.Authorization = `Bearer ${token}`;
2559
+ // A ready-made auth-headers map (from the activating SDK client's
2560
+ // getAuthHeaders(), covering OAuth/basic/none) wins over a bare token. An
2561
+ // empty map means unauthenticated — deliberately no Authorization header.
2562
+ if (authHeaders && typeof authHeaders === 'object') Object.assign(headers, authHeaders);
2563
+ else if (token) headers.Authorization = `Bearer ${token}`;
2560
2564
  res = await fetchImpl(url, { method: 'GET', headers, signal: controller.signal });
2561
2565
  } catch (err) {
2562
2566
  throw new ProvisionError(`prompt resource ${resourceKey} fetch failed: ${err && err.message ? err.message : String(err)}`);
@@ -2581,14 +2585,57 @@ async function fetchLinkedResourceContent(resourceKey, opts = {}) {
2581
2585
  // prompt resource cannot be fetched — a provisioning failure, not a silent
2582
2586
  // empty-prompt run.
2583
2587
  async function resolveLinkedPrompt(customHeaders, opts = {}) {
2584
- const { linkName = DEFAULT_PROMPT_LINK_NAME, baseUrl, token, fetchImpl, timeoutMs } = opts;
2588
+ const { linkName = DEFAULT_PROMPT_LINK_NAME, baseUrl, token, authHeaders, fetchImpl, timeoutMs } = opts;
2585
2589
  const entry = pickLinkedResource(parseLinkedResources(customHeaders), linkName);
2586
2590
  if (!entry) return null;
2587
2591
  const resourceKey = entry.resourceKey;
2588
- const basePrompt = await fetchLinkedResourceContent(resourceKey, { baseUrl, token, fetchImpl, timeoutMs });
2592
+ const basePrompt = await fetchLinkedResourceContent(resourceKey, { baseUrl, token, authHeaders, fetchImpl, timeoutMs });
2589
2593
  return { basePrompt, resourceKey, resourceType: entry.resourceType ?? null, linkName: String(linkName) };
2590
2594
  }
2591
2595
 
2596
+ // Normalize a client-configured REST address for use as a linked-resource fetch
2597
+ // base. The SDK's restAddress may or may not already include the `/v2` API
2598
+ // prefix (CAMUNDA_REST_ADDRESS accepts either); resourceContentUrl re-adds it,
2599
+ // so strip a trailing `/v2` (and any trailing slashes) to avoid a double `/v2`.
2600
+ function normalizeRestBase(addr) {
2601
+ return String(addr || '').replace(/\/+$/, '').replace(/\/v2$/i, '');
2602
+ }
2603
+
2604
+ // Derive the linked-resource fetch base URL + auth headers from the SAME SDK
2605
+ // client that activated the job. A linked-resource `resourceKey` is broker-local,
2606
+ // so prompt content must be fetched from the broker this worker is connected to
2607
+ // — never a localhost default (the cause of "prompt resource N fetch failed").
2608
+ // An explicit NANO_REST_URL / NANO_REST_TOKEN override still wins as an operator
2609
+ // escape hatch. Both getConfig()/getAuthHeaders() are guarded so an older or
2610
+ // atypical client runtime degrades to the override/legacy path rather than throw.
2611
+ //
2612
+ // TODO: once c8ctl bumps @camunda8/orchestration-cluster-api to v10 (10.0.0-alpha
2613
+ // exposes the typed camunda.getResourceContentBinary({resourceKey}) → Blob), drop
2614
+ // this raw /content/binary fetch and call that method directly. The pinned ^9.1.0
2615
+ // SDK only exposes the deprecated getResourceContent, which 406s for generic
2616
+ // (Markdown) prompt resources — see camunda/orchestration-cluster-api-js.
2617
+ async function resolveLinkedPromptSource(camunda, env = process.env) {
2618
+ let baseUrl = env.NANO_REST_URL || '';
2619
+ if (!baseUrl && camunda && typeof camunda.getConfig === 'function') {
2620
+ try {
2621
+ baseUrl = normalizeRestBase(camunda.getConfig().restAddress);
2622
+ } catch {
2623
+ // ignore — fall back to the legacy resolveBrokerRestConfig base below
2624
+ }
2625
+ }
2626
+ let authHeaders;
2627
+ if (env.NANO_REST_TOKEN) {
2628
+ authHeaders = { Authorization: `Bearer ${env.NANO_REST_TOKEN}` };
2629
+ } else if (camunda && typeof camunda.getAuthHeaders === 'function') {
2630
+ try {
2631
+ authHeaders = await camunda.getAuthHeaders();
2632
+ } catch {
2633
+ authHeaders = undefined;
2634
+ }
2635
+ }
2636
+ return { baseUrl, authHeaders };
2637
+ }
2638
+
2592
2639
  // Secrets are referenced by NAME, never by value, in the model. A resolver maps
2593
2640
  // a name → value at run time. The wrapper injects them into the child ENV, so
2594
2641
  // values never appear in argv or `docker inspect`.
@@ -4357,6 +4404,16 @@ async function workAgent(req, flags) {
4357
4404
  // path via NANO_SUPERVISOR_ACTIVITY_FILE; a standalone `nano work` has no such
4358
4405
  // env var and writes nothing (this is entirely advisory).
4359
4406
  const activityFile = process.env.NANO_SUPERVISOR_ACTIVITY_FILE || null;
4407
+ // Supervised workers (spawned by the daemon, hence NANO_SUPERVISOR_ACTIVITY_FILE)
4408
+ // arm a parent-death watchdog: if the daemon dies *ungracefully* (SIGKILL /
4409
+ // crash / its host force-killed) it can't run its child-reaping shutdown, and
4410
+ // this worker would otherwise idle forever as an orphan reparented to init.
4411
+ // The watchdog makes the child self-exit the moment it is reparented. A
4412
+ // standalone `nano work` (no activity file) keeps classic nohup semantics.
4413
+ if (activityFile) {
4414
+ const daemonPid = Number.parseInt(process.env.NANO_SUPERVISOR_DAEMON_PID ?? '', 10);
4415
+ installParentDeathWatchdog({ parentPid: Number.isInteger(daemonPid) ? daemonPid : undefined });
4416
+ }
4360
4417
  const activeJobs = new Map(); // jobKey -> { type, since (ms epoch) }
4361
4418
  const writeActivity = () => {
4362
4419
  if (!activityFile) return;
@@ -4533,8 +4590,13 @@ async function workAgent(req, flags) {
4533
4590
  let promptResourceKey = null;
4534
4591
  let basePromptOverride;
4535
4592
  try {
4593
+ // Fetch the prompt from the broker the SDK client is connected to,
4594
+ // deriving base URL + auth from that client (not restConfig, whose base
4595
+ // defaults to localhost) — the resourceKey is broker-local.
4596
+ const promptSource = await resolveLinkedPromptSource(camunda);
4536
4597
  const linked = await resolveLinkedPrompt(job.customHeaders ?? {}, {
4537
- baseUrl: restConfig.baseUrl,
4598
+ baseUrl: promptSource.baseUrl || restConfig.baseUrl,
4599
+ authHeaders: promptSource.authHeaders,
4538
4600
  token: restConfig.token,
4539
4601
  });
4540
4602
  if (linked) {
@@ -5497,6 +5559,63 @@ function waitForChildExit(child, timeoutMs) {
5497
5559
  });
5498
5560
  }
5499
5561
 
5562
+ /**
5563
+ * Self-terminate a supervised worker if its parent daemon dies *ungracefully*.
5564
+ *
5565
+ * The daemon reaps its `nano work` children on a clean stop/SIGTERM/SIGINT (see
5566
+ * `shutdown`). But a `SIGKILL`, a crash, or the daemon's host process being
5567
+ * force-killed cannot run that path, and the children — spawned attached, with
5568
+ * no controlling TTY — would otherwise survive forever as orphans reparented to
5569
+ * init (ppid 1). That is exactly how a test/agent run that force-kills its
5570
+ * supervisor leaks a whole idle worker fleet.
5571
+ *
5572
+ * The watchdog closes that gap from the child's side. The parent to watch is the
5573
+ * daemon pid the spawn recorded (`parentPid`, forwarded via
5574
+ * NANO_SUPERVISOR_DAEMON_PID) rather than a late `process.ppid` sample: a worker
5575
+ * whose ~async startup (an 8k-line dynamic import) is outrun by the daemon's
5576
+ * death is *already* reparented to init by the time it arms, so a bare
5577
+ * `process.ppid` read would miss the very race that leaks. Given the known pid,
5578
+ * the worker self-reaps whenever it is reparented away from it OR that pid is
5579
+ * gone — and does so *immediately* if it was orphaned during its own startup.
5580
+ *
5581
+ * The poll timer is unref'd so it never keeps an otherwise-idle worker alive;
5582
+ * the real work loop (or, in tests, an explicit keep-alive) holds the event loop
5583
+ * open while the worker is meant to run.
5584
+ *
5585
+ * Unix-only: reparent-to-init is a POSIX semantic; Windows job objects are the
5586
+ * equivalent lifecycle tie and are out of scope here (matching the daemon's
5587
+ * other `win32` guards). Returns a canceller so callers/tests can stop it.
5588
+ */
5589
+ function installParentDeathWatchdog({ intervalMs = 2000, parentPid, onOrphan, readPpid } = {}) {
5590
+ if (osPlatform() === 'win32') return () => {};
5591
+ // `readPpid` is an injectable seam (defaults to the live value) so the pid-1
5592
+ // container case — which a normal test process can't reproduce, since its own
5593
+ // ppid is never 1 — is unit-testable.
5594
+ const ppid = typeof readPpid === 'function' ? readPpid : () => process.ppid;
5595
+ // Prefer the daemon pid the spawn recorded (`explicit`); fall back to the
5596
+ // current parent. An explicit pid is authoritative even if it is 1 — the
5597
+ // daemon may legitimately run as PID 1 (a container entrypoint) — so we must
5598
+ // NOT treat that as "orphaned". A *fallback* ppid of 1, by contrast, means we
5599
+ // were already reparented to init with no daemon left to watch.
5600
+ const explicit = Number.isInteger(parentPid) && parentPid > 0;
5601
+ const watched = explicit ? parentPid : ppid();
5602
+ const orphaned = typeof onOrphan === 'function' ? onOrphan : () => process.exit(0);
5603
+ const isOrphan = () => {
5604
+ // Reparented away from the daemon (typically to init, pid 1) → orphaned.
5605
+ if (ppid() !== watched) return true;
5606
+ // Defensive: the daemon pid is gone even though ppid still names it (a
5607
+ // zombie/racey read). ESRCH ⇒ gone; EPERM ⇒ alive but not ours to signal.
5608
+ try { process.kill(watched, 0); return false; } catch (err) { return err.code !== 'EPERM'; }
5609
+ };
5610
+ // Orphaned already — self-reap now rather than idle forever. Either we fell
5611
+ // back to ppid and it is already init (no known parent), or the recorded
5612
+ // daemon is verifiably gone/reparented (e.g. it died before we armed).
5613
+ if ((!explicit && watched === 1) || isOrphan()) { orphaned(); return () => {}; }
5614
+ const timer = setInterval(() => { if (isOrphan()) { clearInterval(timer); orphaned(); } }, intervalMs);
5615
+ if (typeof timer.unref === 'function') timer.unref();
5616
+ return () => { try { clearInterval(timer); } catch { /* ignore */ } };
5617
+ }
5618
+
5500
5619
  // --- Daemon ----------------------------------------------------------------
5501
5620
 
5502
5621
  /**
@@ -5570,8 +5689,12 @@ async function runSupervisorDaemon() {
5570
5689
  // supervisor id, so the same profile launched twice is distinct end-to-end.
5571
5690
  // NANO_SUPERVISOR_ACTIVITY_FILE tells the child where to report per-job
5572
5691
  // activity for `supervisor status` (idle vs the job key it is servicing).
5692
+ // NANO_SUPERVISOR_DAEMON_PID hands the child our pid so its parent-death
5693
+ // watchdog can self-reap if we die ungracefully (SIGKILL/crash) and can't
5694
+ // run the child-draining shutdown — even if we die mid-startup, before the
5695
+ // child reparents to init.
5573
5696
  const child = spawn(exec, [entry, 'nano', 'work', w.profile, '--name', w.id, ...w.args], {
5574
- env: { ...process.env, NANO_SUPERVISOR_ACTIVITY_FILE: activityFile },
5697
+ env: { ...process.env, NANO_SUPERVISOR_ACTIVITY_FILE: activityFile, NANO_SUPERVISOR_DAEMON_PID: String(process.pid) },
5575
5698
  stdio: ['ignore', fd, fd],
5576
5699
  });
5577
5700
  if (typeof fd === 'number') { try { closeSync(fd); } catch { /* dup'd into child */ } }
@@ -5837,6 +5960,11 @@ async function runSupervisorDaemon() {
5837
5960
 
5838
5961
  process.once('SIGTERM', () => shutdown('SIGTERM'));
5839
5962
  process.once('SIGINT', () => shutdown('SIGINT'));
5963
+ // SIGHUP (controlling terminal/session gone) must also drain children rather
5964
+ // than let Node's default terminate the daemon and orphan the fleet. SIGKILL
5965
+ // and hard crashes still can't run this — the per-worker parent-death
5966
+ // watchdog is the backstop for those.
5967
+ process.once('SIGHUP', () => shutdown('SIGHUP'));
5840
5968
  dlog(`supervisor daemon up (pid ${process.pid}) — control ${socketPath}`);
5841
5969
  persist();
5842
5970
 
@@ -7865,6 +7993,8 @@ export {
7865
7993
  resourceContentUrl,
7866
7994
  fetchLinkedResourceContent,
7867
7995
  resolveLinkedPrompt,
7996
+ resolveLinkedPromptSource,
7997
+ normalizeRestBase,
7868
7998
  coerceBool,
7869
7999
  coerceInt,
7870
8000
  deepMerge,
@@ -7944,6 +8074,7 @@ export {
7944
8074
  supervisorJobCell,
7945
8075
  supervisorWorkerActivityFile,
7946
8076
  WORK_FORWARD_FLAGS,
8077
+ installParentDeathWatchdog,
7947
8078
  runSupervisorDaemon,
7948
8079
  startSupervisorDaemon,
7949
8080
  supervisorRequest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.35.0",
3
+ "version": "1.35.2",
4
4
  "type": "module",
5
5
  "description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
6
6
  "main": "c8ctl-plugin.js",
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "node-pty": "^1.0.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.35.0",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.35.0",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.35.0",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.35.0",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.35.0",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.35.0",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.35.0"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.35.2",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.35.2",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.35.2",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.35.2",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.35.2",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.35.2",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.35.2"
67
67
  }
68
68
  }