c8ctl-plugin-nano 1.33.0 → 1.33.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 +121 -16
  2. package/package.json +8 -8
package/c8ctl-plugin.js CHANGED
@@ -161,6 +161,17 @@ function getLogger() {
161
161
  warn: console.warn,
162
162
  error: console.error,
163
163
  debug: () => {},
164
+ // Primary command output, written to stdout as-is (mirrors the c8ctl host
165
+ // logger's `output()`). Used for preformatted, non-structured content such
166
+ // as the supervisor status table, whose newlines must survive verbatim.
167
+ // Uses `process.stdout.write` (not `console.log`) so the content is emitted
168
+ // literally — `console.log` applies `util.format` (mangling stray `%`
169
+ // sequences) and would append its own newline; here we add exactly one
170
+ // trailing newline when the text lacks one.
171
+ output: (msg) => {
172
+ const s = typeof msg === 'string' ? msg : String(msg);
173
+ process.stdout.write(s.endsWith('\n') ? s : s + '\n');
174
+ },
164
175
  };
165
176
  }
166
177
 
@@ -1244,7 +1255,7 @@ function logsCluster(req) {
1244
1255
  const proc = spawn('tail', tailArgs, { stdio: ['ignore', 'inherit', 'inherit'] });
1245
1256
  proc.on('error', (err) => {
1246
1257
  logger.error(`Failed to read logs: ${err.message}`);
1247
- logger.info(`Log files:\n ${files.join('\n ')}`);
1258
+ logger.output(`Log files:\n ${files.join('\n ')}`);
1248
1259
  });
1249
1260
  }
1250
1261
 
@@ -2853,9 +2864,55 @@ function ghUserIdentity() {
2853
2864
  }
2854
2865
  }
2855
2866
 
2867
+ // Reject a commit-author email that can't be attributed to a real account and
2868
+ // can't receive mail — a `*@nano.local` (or other non-routable) placeholder
2869
+ // injected by the launch environment. Such an address produces UNVERIFIED
2870
+ // commits that look like a person but map to no GitHub user, so the harness must
2871
+ // never stamp it onto a commit; it falls through to the next identity source
2872
+ // instead. An EMPTY email is NOT a placeholder — it is an absent field handled
2873
+ // by ordinary per-field fallthrough, so it does not invalidate its source.
2874
+ // Matching is trim + case-insensitive.
2875
+ function isPlaceholderEmail(email) {
2876
+ const e = String(email || '').trim().toLowerCase();
2877
+ if (!e) return false; // absent — handled by per-field fallthrough, not a placeholder
2878
+ const at = e.lastIndexOf('@');
2879
+ if (at < 0) return true; // no domain at all — not a routable address
2880
+ const local = e.slice(0, at);
2881
+ const domain = e.slice(at + 1);
2882
+ // Malformed addresses missing a local part (`@example.com`) or a domain
2883
+ // (`user@`) can't be routed or attributed either — reject them too.
2884
+ if (!local || !domain) return true;
2885
+ // Non-routable mDNS/host-local TLDs and the loopback host: unattributable and
2886
+ // undeliverable, so never a legitimate commit author.
2887
+ return domain === 'localhost'
2888
+ || domain.endsWith('.local')
2889
+ || domain.endsWith('.internal');
2890
+ }
2891
+
2892
+ // Coerce one identity source into a usable { name, email }. When the source's
2893
+ // email is a non-routable placeholder we discard the WHOLE candidate (both
2894
+ // fields) rather than just the email — otherwise a placeholder-derived name
2895
+ // (e.g. `trial-merge`) would be stitched onto a borrowed email from a lower
2896
+ // source, forging a Frankenstein author. An empty email is preserved as-is so
2897
+ // ordinary per-field fill still works (e.g. git supplies a name, gh the email).
2898
+ // Fields are trimmed so a whitespace-only/space-padded name or email behaves
2899
+ // like "absent" (empty) rather than a truthy value that would block per-field
2900
+ // fallthrough and get stamped as an invalid commit identity — this matches
2901
+ // isPlaceholderEmail, which already normalizes via trim().
2902
+ function sanitizeIdentity(id) {
2903
+ const name = String((id && id.name) || '').trim();
2904
+ const email = String((id && id.email) || '').trim();
2905
+ if (isPlaceholderEmail(email)) return { name: '', email: '' };
2906
+ return { name, email };
2907
+ }
2908
+
2856
2909
  // Resolve the committer identity the harness stamps onto the cloned workspace.
2857
- // Per-field precedence: explicit GIT_AUTHOR_* env → the operator's global git
2910
+ // Source precedence: explicit GIT_AUTHOR_* env → the operator's global git
2858
2911
  // config → the gh-authenticated GitHub user → the `nano-agent` fallback.
2912
+ // Precedence is per-field only for ABSENT fields (an empty name/email falls
2913
+ // through to the next source); a source whose email is a non-routable
2914
+ // placeholder is discarded WHOLE by sanitizeIdentity (name included), so in that
2915
+ // case its name does not participate in per-field fill (see sanitizeIdentity).
2859
2916
  // Preferring the operator's real identity means autonomous commits are authored
2860
2917
  // by the human running the fleet (who has signed any CLA) rather than an
2861
2918
  // anonymous bot that hasn't; the agent's own authorship is recorded as a PR
@@ -2864,20 +2921,22 @@ function ghUserIdentity() {
2864
2921
  // when a higher-precedence source didn't already supply the field — so explicit
2865
2922
  // GIT_AUTHOR_* env fully short-circuits them (no `git config`/`gh` spawns, hence
2866
2923
  // no added latency or failure modes when the override is present).
2867
- // `gitIdentity`/`ghIdentity` are injectable for testing.
2924
+ // Every candidate source is passed through sanitizeIdentity, so a non-routable
2925
+ // `*@nano.local` placeholder from ANY source (env, git-global) is discarded and
2926
+ // falls through to the gh identity / marked bot fallback — never stamped onto a
2927
+ // commit. `gitIdentity`/`ghIdentity` are injectable for testing.
2868
2928
  function resolveCommitterIdentity({ gitIdentity = hostGitIdentity, ghIdentity = ghUserIdentity } = {}) {
2869
- const envName = process.env.GIT_AUTHOR_NAME || '';
2870
- const envEmail = process.env.GIT_AUTHOR_EMAIL || '';
2929
+ const env = sanitizeIdentity({ name: process.env.GIT_AUTHOR_NAME || '', email: process.env.GIT_AUTHOR_EMAIL || '' });
2871
2930
  let g = null;
2872
- const gitOnce = () => (g ??= (gitIdentity() || { name: '', email: '' }));
2931
+ const gitOnce = () => (g ??= sanitizeIdentity(gitIdentity() || {}));
2873
2932
  let gh = null;
2874
- const ghOnce = () => (gh ??= (ghIdentity() || { name: '', email: '' }));
2933
+ const ghOnce = () => (gh ??= sanitizeIdentity(ghIdentity() || {}));
2875
2934
 
2876
- const name = envName || gitOnce().name || ghOnce().name || 'nano-agent';
2877
- const email = envEmail || gitOnce().email || ghOnce().email || 'nano-agent@users.noreply.github.com';
2935
+ const name = env.name || gitOnce().name || ghOnce().name || 'nano-agent';
2936
+ const email = env.email || gitOnce().email || ghOnce().email || 'nano-agent@users.noreply.github.com';
2878
2937
 
2879
2938
  const source =
2880
- (envName || envEmail) ? 'env'
2939
+ (env.name || env.email) ? 'env'
2881
2940
  : (g && (g.name || g.email)) ? 'git-global'
2882
2941
  : (gh && (gh.name || gh.email)) ? 'gh'
2883
2942
  : 'fallback';
@@ -2983,6 +3042,20 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
2983
3042
  const committer = resolveCommitterIdentity();
2984
3043
  runGit(['config', 'user.name', committer.name], { cwd: workspaceDir, env: gitEnv });
2985
3044
  runGit(['config', 'user.email', committer.email], { cwd: workspaceDir, env: gitEnv });
3045
+ // Config alone is not enough: git honours GIT_AUTHOR_*/GIT_COMMITTER_* OVER
3046
+ // user.name/user.email config, so a placeholder GIT_AUTHOR_EMAIL inherited from
3047
+ // the launch environment (e.g. `trial-merge@nano.local`) would still be stamped
3048
+ // onto commits even though we just wrote a clean identity into config. Pin all
3049
+ // four env vars to the resolved (already placeholder-sanitized) identity so
3050
+ // EVERY commit — finalizeGit's own rebase commits (which run with gitEnv) and
3051
+ // the harness's commits (extraEnv below carries these into harnessEnv) — uses
3052
+ // it deterministically, and a non-routable `*@nano.local` author can never be
3053
+ // written. When GIT_AUTHOR_* already held a real identity, resolveCommitterIdentity
3054
+ // returned it verbatim, so this is a no-op in that case.
3055
+ gitEnv.GIT_AUTHOR_NAME = committer.name;
3056
+ gitEnv.GIT_AUTHOR_EMAIL = committer.email;
3057
+ gitEnv.GIT_COMMITTER_NAME = committer.name;
3058
+ gitEnv.GIT_COMMITTER_EMAIL = committer.email;
2986
3059
 
2987
3060
  // Determine the working branch. With branch.create we make a real branch.
2988
3061
  // Otherwise we're on whatever the clone checked out: a branch only if
@@ -3003,7 +3076,7 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
3003
3076
  // `git rev-parse HEAD` on an unborn branch (freshly cloned empty repo) exits
3004
3077
  // non-zero and echoes the literal "HEAD" on stdout — treat that as "no base
3005
3078
  // commit" (empty startSha) rather than a bogus revision.
3006
- return { workspaceDir, gitEnv, startSha: sha.status === 0 ? (sha.stdout || '').trim() : '', workingBranch, detached: !workingBranch, ref: target || '', remote: redactToken(repo.url, token) };
3079
+ return { workspaceDir, gitEnv, committer, startSha: sha.status === 0 ? (sha.stdout || '').trim() : '', workingBranch, detached: !workingBranch, ref: target || '', remote: redactToken(repo.url, token) };
3007
3080
  }
3008
3081
 
3009
3082
  // Look up a PR for this branch (2a does NOT open it — the harness does, driven
@@ -4239,6 +4312,15 @@ async function workAgent(req, flags) {
4239
4312
  AGENT_REPO_URL: provisioned.remote,
4240
4313
  AGENT_REPO_BRANCH: provisioned.workingBranch || '',
4241
4314
  AGENT_REPO_REF: provisioned.ref || '',
4315
+ // Pin the harness's commit identity to the resolved (placeholder-
4316
+ // sanitized) committer so the agent's own `git commit` can't be
4317
+ // hijacked by a placeholder GIT_AUTHOR_* inherited from process.env
4318
+ // (git honours these over user.name/user.email config). Layered via
4319
+ // extraEnv so they override any inherited placeholder in harnessEnv.
4320
+ GIT_AUTHOR_NAME: provisioned.committer.name,
4321
+ GIT_AUTHOR_EMAIL: provisioned.committer.email,
4322
+ GIT_COMMITTER_NAME: provisioned.committer.name,
4323
+ GIT_COMMITTER_EMAIL: provisioned.committer.email,
4242
4324
  };
4243
4325
  } catch (err) {
4244
4326
  if (runDir) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(runDir); }
@@ -5023,6 +5105,27 @@ function formatSupervisorStatus(status) {
5023
5105
  return lines.join('\n');
5024
5106
  }
5025
5107
 
5108
+ /**
5109
+ * Print a preformatted supervisor status table as primary command output.
5110
+ *
5111
+ * Preformatted, multi-line text MUST go through the logger's `output()`
5112
+ * channel, never `info()`. In `--output json` mode the c8ctl host logger wraps
5113
+ * an `info()` message in a JSON envelope (`{"status":"info","message":"…"}`),
5114
+ * which escapes every newline to a literal `\n` and collapses the aligned table
5115
+ * onto a single line — the exact breakage this guards against. `output()` writes
5116
+ * the content to stdout verbatim in every output mode (like `raw` command
5117
+ * output), so the table renders correctly regardless of mode. Falls back to
5118
+ * `info()` for a logger without `output()`, and to `console.log` if `logger`
5119
+ * is null/undefined or lacks `info()` (defensive; both the c8ctl host logger
5120
+ * and this plugin's fallback logger provide `output()`).
5121
+ */
5122
+ function printSupervisorStatus(logger, status) {
5123
+ const text = formatSupervisorStatus(status);
5124
+ if (logger && typeof logger.output === 'function') logger.output(text);
5125
+ else if (logger && typeof logger.info === 'function') logger.info(text);
5126
+ else console.log(text);
5127
+ }
5128
+
5026
5129
  function readSupervisorState() {
5027
5130
  const file = getSupervisorStateFile();
5028
5131
  if (!existsSync(file)) return null;
@@ -5656,7 +5759,7 @@ async function supervisorStatusCmd() {
5656
5759
  const res = await supervisorRequest({ op: 'status' }, { socketPath: getSupervisorSocketPath(), timeoutMs: 500, responseTimeoutMs: SUPERVISOR_PROBE_RESPONSE_TIMEOUT_MS });
5657
5760
  if (res && res.ok) {
5658
5761
  try { writeSupervisorState(stateFromStatus(res, getSupervisorSocketPath())); } catch { /* best effort */ }
5659
- logger.info(formatSupervisorStatus(res));
5762
+ printSupervisorStatus(logger, res);
5660
5763
  return;
5661
5764
  }
5662
5765
  } catch { /* no live daemon on the socket — genuinely down */ }
@@ -5672,13 +5775,13 @@ async function supervisorStatusCmd() {
5672
5775
  }
5673
5776
  try {
5674
5777
  const res = await supervisorRequest({ op: 'status' });
5675
- if (res.ok) { logger.info(formatSupervisorStatus(res)); return; }
5778
+ if (res.ok) { printSupervisorStatus(logger, res); return; }
5676
5779
  } catch { /* fall back to state file below */ }
5677
5780
  // Socket unreachable but pid alive — render from the last persisted state.
5678
- logger.info(formatSupervisorStatus({
5781
+ printSupervisorStatus(logger, {
5679
5782
  daemon: { pid: running.pid, startedAt: running.startedAt, socket: running.socket },
5680
5783
  workers: (running.workers || []).map((w) => summarizeSupervisorWorker(w)),
5681
- }));
5784
+ });
5682
5785
  }
5683
5786
 
5684
5787
  async function supervisorAddCmd(req, flags) {
@@ -5791,7 +5894,7 @@ function supervisorLogsCmd(req) {
5791
5894
  if (follow) logger.warn('`--follow` is not supported without `tail` on this platform; printing the current tail only.');
5792
5895
  try {
5793
5896
  const lines = readFileSync(file, 'utf-8').split('\n');
5794
- logger.info(lines.slice(-200).join('\n'));
5897
+ logger.output(lines.slice(-200).join('\n'));
5795
5898
  } catch (err) { logger.error(`Could not read ${file}: ${err.message}`); }
5796
5899
  });
5797
5900
  }
@@ -7506,6 +7609,7 @@ export {
7506
7609
  finalizeGit,
7507
7610
  reconcileAgentPr,
7508
7611
  resolveCommitterIdentity,
7612
+ isPlaceholderEmail,
7509
7613
  postAgentAttribution,
7510
7614
  reapAgentRunDirs,
7511
7615
  authUrl,
@@ -7552,6 +7656,7 @@ export {
7552
7656
  formatDuration,
7553
7657
  summarizeSupervisorWorker,
7554
7658
  formatSupervisorStatus,
7659
+ printSupervisorStatus,
7555
7660
  supervisorStatusSignature,
7556
7661
  supervisorJobCell,
7557
7662
  supervisorWorkerActivityFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.33.0",
3
+ "version": "1.33.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.33.0",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.33.0",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.33.0",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.33.0",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.33.0",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.33.0",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.33.0"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.33.2",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.33.2",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.33.2",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.33.2",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.33.2",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.33.2",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.33.2"
67
67
  }
68
68
  }