c8ctl-plugin-nano 1.39.0 → 1.39.1

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 +158 -58
  2. package/package.json +8 -8
package/c8ctl-plugin.js CHANGED
@@ -2458,18 +2458,12 @@ function resolveBrokerRestConfig(env = process.env, opts = {}) {
2458
2458
  // exposes no usable restAddress. The token same-origin gate lives in
2459
2459
  // resolveBrokerRestConfig (re-run against the profile base), never duplicated.
2460
2460
  function resolveAutoRestConfig(camunda, env = process.env) {
2461
- const cfg = readConfig() || {};
2462
- const hasExplicitBase = Boolean(env.NANO_REST_URL || env.NANO_BASE_URL || cfg.nanoUrl);
2463
- if (!hasExplicitBase && camunda && typeof camunda.getConfig === 'function') {
2464
- let profileBase = '';
2465
- try {
2466
- profileBase = normalizeRestBase(camunda.getConfig()?.restAddress);
2467
- } catch {
2468
- // ignore — degrade to the resolveBrokerRestConfig (localhost) default below
2469
- }
2470
- if (profileBase) return resolveBrokerRestConfig(env, { baseUrl: profileBase });
2471
- }
2472
- return resolveBrokerRestConfig(env);
2461
+ // Derive the base from the single canonical resolver (explicit override
2462
+ // profile restAddress localhost), then run it through resolveBrokerRestConfig
2463
+ // so the token same-origin gate stays single-sourced. resolveWorkerEngineBase
2464
+ // always returns a base, so opts.baseUrl always pins it here.
2465
+ const baseUrl = resolveWorkerEngineBase(camunda, env);
2466
+ return resolveBrokerRestConfig(env, { baseUrl });
2473
2467
  }
2474
2468
 
2475
2469
  // ---------------------------------------------------------------------------
@@ -2647,28 +2641,117 @@ function normalizeRestBase(addr) {
2647
2641
  return String(addr || '').replace(/\/+$/, '').replace(/\/v2$/i, '');
2648
2642
  }
2649
2643
 
2644
+ // The ONE place that answers "what engine base does this worker use?" — the
2645
+ // single source of truth for the worker's engine-base precedence chain:
2646
+ //
2647
+ // explicit override (NANO_REST_URL → NANO_BASE_URL → persisted cfg.nanoUrl)
2648
+ // → active c8ctl profile restAddress (the client that activates jobs)
2649
+ // → localhost default (DEFAULT_NANO_URL).
2650
+ //
2651
+ // Every worker engine reference derives from this (derivation over duplication):
2652
+ // the `--auto` job-type read (resolveAutoRestConfig, injecting it into the
2653
+ // resolveBrokerRestConfig token gate), the linked-prompt fetch
2654
+ // (resolveLinkedPromptSource), the `supervisor status` engine column
2655
+ // (workerEngine), and the agentic visibility channel base (resolveAgenticConfig).
2656
+ // Before this existed the chain was hand-copied at each site and the agentic copy
2657
+ // silently lost the profile fallback, so a profile-only remote worker degraded to
2658
+ // its own localhost and never enrolled (jwulf/c8ctl-plugin-nano#107, sibling of
2659
+ // #93/#99). Sharing this resolver fixes that by construction and removes the
2660
+ // drift surface. The token same-origin gate stays in resolveBrokerRestConfig,
2661
+ // never duplicated here.
2662
+ function resolveWorkerEngineBase(camunda, env = process.env) {
2663
+ // readConfig() swallows parse/IO errors and returns {} — never throws.
2664
+ const cfg = readConfig() || {};
2665
+ const explicit = env.NANO_REST_URL || env.NANO_BASE_URL || cfg.nanoUrl;
2666
+ if (explicit) return normalizeRestBase(explicit);
2667
+ if (camunda && typeof camunda.getConfig === 'function') {
2668
+ try {
2669
+ const profileBase = normalizeRestBase(camunda.getConfig()?.restAddress);
2670
+ if (profileBase) return profileBase;
2671
+ } catch {
2672
+ // degrade to the localhost default below rather than throw
2673
+ }
2674
+ }
2675
+ return DEFAULT_NANO_URL;
2676
+ }
2677
+
2678
+ // The engine authority reported in the supervisor activity marker's ENGINE
2679
+ // column (#99): the base the worker actually POLLS JOBS from. The SDK job worker
2680
+ // (`camunda.createJobWorker`) activates jobs against the client's OWN profile
2681
+ // restAddress — which is NOT affected by the explicit NANO_REST_URL / NANO_BASE_URL
2682
+ // / cfg.nanoUrl overrides that resolveWorkerEngineBase prefers for auxiliary REST
2683
+ // reads (linked prompts, `--auto` reads, agentic channel). Reporting an override
2684
+ // there would make the ENGINE column claim an engine the worker is not polling,
2685
+ // violating supervisorEngineCell's "engine this worker polls jobs from" contract.
2686
+ // So prefer the profile restAddress (the polling engine) and fall back to the
2687
+ // canonical resolver only when the client exposes no usable base.
2688
+ function resolveWorkerPollEngineBase(camunda, env = process.env) {
2689
+ if (camunda && typeof camunda.getConfig === 'function') {
2690
+ try {
2691
+ const profileBase = normalizeRestBase(camunda.getConfig()?.restAddress);
2692
+ if (profileBase) return profileBase;
2693
+ } catch {
2694
+ // degrade to the canonical resolver below rather than throw
2695
+ }
2696
+ }
2697
+ return resolveWorkerEngineBase(camunda, env);
2698
+ }
2699
+
2700
+ // The base URL for a linked-prompt fetch. A linked-resource `resourceKey` is
2701
+ // BROKER-LOCAL: it lives on the engine the SDK client activated this job against
2702
+ // — the polling engine (its OWN profile restAddress). So the prompt fetch must
2703
+ // target that same broker, NOT the NANO_BASE_URL / cfg.nanoUrl auxiliary-read
2704
+ // overrides that resolveWorkerEngineBase prefers. Those overrides steer reads
2705
+ // that are NOT broker-local; honoring them here makes the base drift from the
2706
+ // activation broker so a broker-local resourceKey 404s ("prompt resource N fetch
2707
+ // failed") even while job activation still succeeds. Precedence:
2708
+ //
2709
+ // explicit NANO_REST_URL (same-broker escape hatch, e.g. a caching proxy in
2710
+ // front of the polling engine)
2711
+ // → polling engine base (resolveWorkerPollEngineBase → profile restAddress)
2712
+ // → localhost default.
2713
+ //
2714
+ // NANO_REST_URL is retained as an explicit escape hatch, but NANO_BASE_URL /
2715
+ // cfg.nanoUrl are deliberately NOT — steering the prompt fetch to a different
2716
+ // engine than the one that issued the broker-local resourceKey is the very bug
2717
+ // this resolves.
2718
+ function resolveLinkedPromptBase(camunda, env = process.env) {
2719
+ const explicit = env.NANO_REST_URL;
2720
+ if (explicit) return normalizeRestBase(explicit);
2721
+ return resolveWorkerPollEngineBase(camunda, env);
2722
+ }
2723
+
2650
2724
  // Derive the linked-resource fetch base URL + auth headers from the SAME SDK
2651
2725
  // client that activated the job. A linked-resource `resourceKey` is broker-local,
2652
- // so prompt content must be fetched from the broker this worker is connected to
2653
- // never a localhost default (the cause of "prompt resource N fetch failed").
2654
- // An explicit NANO_REST_URL / NANO_REST_TOKEN override still wins as an operator
2655
- // escape hatch. Both getConfig()/getAuthHeaders() are guarded so an older or
2656
- // atypical client runtime degrades to the override/legacy path rather than throw.
2726
+ // so prompt content must be fetched from the broker this worker POLLS jobs from
2727
+ // (the polling engine its profile restAddress). The base comes from
2728
+ // resolveLinkedPromptBase (explicit NANO_REST_URL escape hatch polling engine
2729
+ // base localhost); it deliberately does NOT follow the NANO_BASE_URL /
2730
+ // cfg.nanoUrl auxiliary-read overrides, since those would point the fetch at an
2731
+ // engine that never issued this broker-local resourceKey (the cause of "prompt
2732
+ // resource N fetch failed"). getAuthHeaders() is guarded so an older or atypical
2733
+ // client runtime degrades to the legacy path rather than throw.
2734
+ //
2735
+ // The base URL is invariant for a worker's lifetime, so callers on the per-job
2736
+ // hot path pass the once-at-startup `resolveLinkedPromptBase` result via
2737
+ // `baseUrl` to skip the synchronous config.json read (existsSync + readFileSync)
2738
+ // that this resolver would otherwise repeat on every job; only the auth headers
2739
+ // (which the client may rotate) are resolved per call. When `baseUrl` is omitted
2740
+ // it falls back to computing the base itself, so standalone callers and tests
2741
+ // keep the single-argument behaviour.
2657
2742
  //
2658
2743
  // TODO: once c8ctl bumps @camunda8/orchestration-cluster-api to v10 (10.0.0-alpha
2659
2744
  // exposes the typed camunda.getResourceContentBinary({resourceKey}) → Blob), drop
2660
2745
  // this raw /content/binary fetch and call that method directly. The pinned ^9.1.0
2661
2746
  // SDK only exposes the deprecated getResourceContent, which 406s for generic
2662
2747
  // (Markdown) prompt resources — see camunda/orchestration-cluster-api-js.
2663
- async function resolveLinkedPromptSource(camunda, env = process.env) {
2664
- let baseUrl = env.NANO_REST_URL || '';
2665
- if (!baseUrl && camunda && typeof camunda.getConfig === 'function') {
2666
- try {
2667
- baseUrl = normalizeRestBase(camunda.getConfig().restAddress);
2668
- } catch {
2669
- // ignore fall back to the legacy resolveBrokerRestConfig base below
2670
- }
2671
- }
2748
+ async function resolveLinkedPromptSource(camunda, env = process.env, { baseUrl: preResolvedBase } = {}) {
2749
+ // The fetch base follows resolveLinkedPromptBase (explicit NANO_REST_URL escape
2750
+ // hatch polling engine base localhost) so it tracks the broker the job was
2751
+ // activated against — the resourceKey is broker-local. A caller-supplied
2752
+ // pre-resolved base (already normalized at worker startup) is reused verbatim to
2753
+ // avoid a per-job config.json read.
2754
+ const baseUrl = preResolvedBase ?? resolveLinkedPromptBase(camunda, env);
2672
2755
  let authHeaders;
2673
2756
  if (env.NANO_REST_TOKEN) {
2674
2757
  authHeaders = { Authorization: `Bearer ${env.NANO_REST_TOKEN}` };
@@ -4083,13 +4166,19 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult,
4083
4166
  * secret is ignored (LOCAL mode).
4084
4167
  * - OFF: NANO_AGENTIC=off (or 0/false/no), or persisted `agentic:false`.
4085
4168
  *
4086
- * Env wins over persisted config; the base URL falls back to the configured nano
4087
- * URL (the app's own port) and ultimately DEFAULT_NANO_URL, so it is never empty.
4169
+ * Env wins over persisted config; when no explicit agentic target is set the base
4170
+ * URL defers to the shared resolveWorkerEngineBase (explicit engine override
4171
+ * active c8ctl profile restAddress → localhost default), so a profile-only remote
4172
+ * worker discovers the agentic channel of the engine its jobs actually run on
4173
+ * rather than its own loopback (jwulf/c8ctl-plugin-nano#107). It is never empty.
4088
4174
  * Returns `null` only when disabled (the off-switch).
4089
4175
  *
4176
+ * @param {object} [camunda] the SDK client that activates jobs — its
4177
+ * getConfig().restAddress supplies the profile engine base when no explicit
4178
+ * agentic/engine override is set. Threaded from the `work` call site.
4090
4179
  * @returns {{ url: string, token: string, credential: string, bufferCapacity: number, secure: boolean, explicitUrl: boolean } | null}
4091
4180
  */
4092
- function resolveAgenticConfig() {
4181
+ function resolveAgenticConfig(camunda) {
4093
4182
  const cfg = readConfig();
4094
4183
  // Explicit off-switch (env wins). Lets an operator fully opt out of visibility.
4095
4184
  const offSetting = process.env.NANO_AGENTIC
@@ -4098,14 +4187,14 @@ function resolveAgenticConfig() {
4098
4187
 
4099
4188
  // An explicit agentic target (env NANO_AGENTIC_URL or persisted `agenticUrl`)
4100
4189
  // is used verbatim and short-circuits hub auto-discovery (#75). When neither is
4101
- // set the URL below is the ENGINE base (nanoUrl → NANO_BASE_URL → default), off
4102
- // which `resolveAgenticTarget()` discovers the embedded app's own /agentic port.
4190
+ // set the base defers to the shared worker-engine resolver (explicit engine
4191
+ // override profile restAddress localhost default) NOT a re-inlined
4192
+ // nanoUrl → NANO_BASE_URL → default chain — so agentic discovery targets the
4193
+ // same engine the worker's jobs run on (jwulf/c8ctl-plugin-nano#107).
4103
4194
  const explicitUrl = !!(process.env.NANO_AGENTIC_URL || cfg.agenticUrl);
4104
4195
  const url = process.env.NANO_AGENTIC_URL
4105
4196
  || cfg.agenticUrl
4106
- || cfg.nanoUrl
4107
- || process.env.NANO_BASE_URL
4108
- || DEFAULT_NANO_URL;
4197
+ || resolveWorkerEngineBase(camunda);
4109
4198
  // SECURE-mode shared secret. Named NANO_AGENTIC_SECRET to match the server's env
4110
4199
  // var EXACTLY (Tab A → Slot A): set the same name + value on the server and every
4111
4200
  // worker box. The worker presents it as its identity token; the hub verifies it
@@ -4349,11 +4438,11 @@ async function discoverAgenticHubs(engineBaseUrl, {
4349
4438
  * no projects API / not a nano engine, or discovery error/timeout). The
4350
4439
  * worker continues doing real work with no channel.
4351
4440
  *
4352
- * @param {{ fetchImpl?: Function, wsProbe?: Function, timeoutMs?: number }} [opts]
4441
+ * @param {{ camunda?: object, fetchImpl?: Function, wsProbe?: Function, timeoutMs?: number }} [opts]
4353
4442
  * @returns {Promise<{ status: string, config?: object, message?: string, candidates?: Array }>}
4354
4443
  */
4355
- async function resolveAgenticTarget(opts = {}) {
4356
- const base = resolveAgenticConfig();
4444
+ async function resolveAgenticTarget({ camunda, ...opts } = {}) {
4445
+ const base = resolveAgenticConfig(camunda);
4357
4446
  if (!base) return { status: 'off' };
4358
4447
  // Explicit target wins verbatim and skips discovery entirely.
4359
4448
  if (base.explicitUrl) return { status: 'connect', config: base };
@@ -4774,24 +4863,30 @@ async function workAgent(req, flags) {
4774
4863
  installParentDeathWatchdog({ parentPid: Number.isInteger(daemonPid) ? daemonPid : undefined });
4775
4864
  }
4776
4865
  const activeJobs = new Map(); // jobKey -> { type, since (ms epoch) }
4777
- // Which engine this worker polls jobs from + the live agentic-visibility
4778
- // channel status, both surfaced to `supervisor status` via the activity
4779
- // marker (#99). `agenticState` starts 'starting' and is updated once the
4780
- // channel target is resolved and again on each connect/disconnect below.
4781
- // The engine must name the ACTUAL polling authority: jobs are activated by
4782
- // `camunda.createJobWorker()` against the active c8ctl profile engine
4783
- // (`camunda.getConfig().restAddress`), whereas `restConfig` can honor the
4784
- // auxiliary NANO_REST_URL/NANO_BASE_URL/nanoUrl overrides (for `--auto`
4785
- // reads). Derive from the profile engine, using restConfig only as a
4786
- // fallback, so the column can't advertise an override host jobs aren't
4787
- // polled from.
4788
- const workerEngine = (() => {
4789
- try {
4790
- const profileBase = normalizeRestBase(camunda?.getConfig?.()?.restAddress);
4791
- if (profileBase) return profileBase;
4792
- } catch { /* degrade to the auxiliary REST config below */ }
4793
- return restConfig?.baseUrl || null;
4794
- })();
4866
+ // Which engine this worker polls jobs from, surfaced to `supervisor status` via
4867
+ // the activity marker (#99). Derived from resolveWorkerPollEngineBase the SDK
4868
+ // client's OWN profile restAddress (the base createJobWorker actually activates
4869
+ // against), NOT the NANO_* / cfg.nanoUrl override that resolveWorkerEngineBase
4870
+ // prefers for auxiliary REST reads. That keeps the ENGINE column honest: it
4871
+ // reports the engine the worker truly polls, never an override the job worker
4872
+ // ignores. Falls back to the canonical resolver only when the client exposes no
4873
+ // usable profile base.
4874
+ const workerEngine = resolveWorkerPollEngineBase(camunda);
4875
+ // Base URL for the per-job linked-prompt fetch (issue #63). This follows
4876
+ // resolveLinkedPromptBase — the polling engine (profile restAddress the SDK
4877
+ // activates jobs against), since the linked resourceKey is BROKER-LOCAL and must
4878
+ // be fetched from the broker that issued it — with NANO_REST_URL as the only
4879
+ // explicit escape hatch. It deliberately does NOT follow the NANO_BASE_URL /
4880
+ // cfg.nanoUrl auxiliary-read overrides resolveWorkerEngineBase prefers: pointing
4881
+ // the prompt fetch at a different engine than the activation broker would 404 a
4882
+ // broker-local resourceKey while job activation still succeeds. Computed ONCE at
4883
+ // startup so the per-job hot path skips a config.json read. It coincides with
4884
+ // `workerEngine` (the polling engine) unless NANO_REST_URL is set.
4885
+ const linkedPromptBase = resolveLinkedPromptBase(camunda);
4886
+ // The live agentic-visibility channel status, also surfaced to `supervisor
4887
+ // status` via the activity marker (#99). `agenticState` starts 'starting' and
4888
+ // is updated once the channel target is resolved and again on each
4889
+ // connect/disconnect below.
4795
4890
  let agenticState = { status: 'starting' };
4796
4891
  const writeActivity = () => {
4797
4892
  if (!activityFile) return;
@@ -4843,7 +4938,7 @@ async function workAgent(req, flags) {
4843
4938
  // worker joins with the well-known LOCAL token and no credential; SECURE mode
4844
4939
  // (NANO_AGENTIC_SECRET) sends a real per-peer shared secret as the identity;
4845
4940
  // NANO_AGENTIC=off disables it (see resolveAgenticConfig).
4846
- const agenticTarget = await resolveAgenticTarget({ logger });
4941
+ const agenticTarget = await resolveAgenticTarget({ camunda, logger });
4847
4942
  let agenticCfg = null;
4848
4943
  // buildAgenticUrl can throw on a malformed/unsupported explicit NANO_AGENTIC_URL.
4849
4944
  // This is only the display URL for the activity marker, so compute it
@@ -5012,8 +5107,10 @@ async function workAgent(req, flags) {
5012
5107
  try {
5013
5108
  // Fetch the prompt from the broker the SDK client is connected to,
5014
5109
  // deriving base URL + auth from that client (not restConfig, whose base
5015
- // defaults to localhost) — the resourceKey is broker-local.
5016
- const promptSource = await resolveLinkedPromptSource(camunda);
5110
+ // defaults to localhost) — the resourceKey is broker-local. The base is
5111
+ // invariant, so reuse the once-at-startup linkedPromptBase and let the
5112
+ // resolver only compute per-job auth headers (no per-job config.json read).
5113
+ const promptSource = await resolveLinkedPromptSource(camunda, process.env, { baseUrl: linkedPromptBase });
5017
5114
  const linked = await resolveLinkedPrompt(job.customHeaders ?? {}, {
5018
5115
  baseUrl: promptSource.baseUrl || restConfig.baseUrl,
5019
5116
  authHeaders: promptSource.authHeaders,
@@ -8852,6 +8949,9 @@ export {
8852
8949
  pickLinkedResource,
8853
8950
  resolveBrokerRestConfig,
8854
8951
  resolveAutoRestConfig,
8952
+ resolveWorkerEngineBase,
8953
+ resolveWorkerPollEngineBase,
8954
+ resolveLinkedPromptBase,
8855
8955
  resourceContentUrl,
8856
8956
  fetchLinkedResourceContent,
8857
8957
  resolveLinkedPrompt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.39.0",
3
+ "version": "1.39.1",
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.39.0",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.39.0",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.39.0",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.39.0",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.39.0",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.39.0",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.39.0"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.39.1",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.39.1",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.39.1",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.39.1",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.39.1",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.39.1",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.39.1"
67
67
  }
68
68
  }