c8ctl-plugin-nano 1.26.1 → 1.26.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 (3) hide show
  1. package/README.md +11 -3
  2. package/c8ctl-plugin.js +173 -12
  3. package/package.json +8 -8
package/README.md CHANGED
@@ -346,13 +346,21 @@ the harness:
346
346
  as the checkout target) into a throwaway workspace under
347
347
  `<state>/agent-runs/run-*`;
348
348
  3. create `branch.create` (if set) off that target;
349
- 4. run the harness **in the workspace** (`cwd`), with `AGENT_WORKSPACE`,
349
+ 4. set a **committer identity** on the workspace, preferring the operator's own
350
+ (`GIT_AUTHOR_*` env → global `git config user.name/email` → the
351
+ `gh`-authenticated GitHub user), and only falling back to `nano-agent` when
352
+ none resolve — so autonomous commits are authored by the human running the
353
+ fleet (who has signed any CLA/DCO), not an anonymous bot;
354
+ 5. run the harness **in the workspace** (`cwd`), with `AGENT_WORKSPACE`,
350
355
  `AGENT_REPO_URL`, `AGENT_REPO_BRANCH`, `AGENT_REPO_REF` exported and the job
351
356
  envelope on stdin;
352
- 5. on success, enumerate new commits, `git push` the branch when `branch.push`
357
+ 6. on success, enumerate new commits, `git push` the branch when `branch.push`
353
358
  (default true), and — when `task.allowPr` — **reconcile the PR the agent
354
359
  opened** for the branch (`gh pr list --head <branch>`; `openedBy` reports the
355
- PR's actual author login, or `null` when none is found).
360
+ PR's actual author login, or `null` when none is found), then post a one-time
361
+ attribution comment recording that the change was agent-generated (marker-
362
+ guarded so convergence rounds don't repeat it; disable with
363
+ `NANO_AGENT_ATTRIBUTION=0`, rename the agent with `NANO_AGENT_NAME`).
356
364
 
357
365
  The token is delivered to git via `GIT_ASKPASS` (env), never on argv or in the
358
366
  remote URL, and is redacted from all logs/results. Credential helpers are
package/c8ctl-plugin.js CHANGED
@@ -2526,6 +2526,71 @@ function primeGhAuthToken() {
2526
2526
  return ghAuthTokenCache !== GH_AUTH_TOKEN_UNSET;
2527
2527
  }
2528
2528
 
2529
+ // Read the operator's own git identity from their GLOBAL config, using the real
2530
+ // host environment (process.env) rather than a job's sanitized gitEnv — so it
2531
+ // resolves even on the anonymous clone path, where gitEnv points
2532
+ // GIT_CONFIG_GLOBAL at /dev/null. Returns { name, email }, each possibly ''.
2533
+ function hostGitIdentity() {
2534
+ const read = (key) => {
2535
+ try {
2536
+ const r = spawnSync('git', ['config', '--global', '--get', key], { encoding: 'utf8', timeout: 5_000 });
2537
+ return r.status === 0 ? (r.stdout || '').trim() : '';
2538
+ } catch {
2539
+ return '';
2540
+ }
2541
+ };
2542
+ return { name: read('user.name'), email: read('user.email') };
2543
+ }
2544
+
2545
+ // Fall back to the gh-authenticated GitHub user for a committer identity. Uses
2546
+ // the account's public email, or the id+login noreply address when the email is
2547
+ // private/unset. Returns { name, email }, each possibly ''.
2548
+ function ghUserIdentity() {
2549
+ try {
2550
+ const r = spawnSync('gh', ['api', 'user', '--jq', '[.name // "", .login // "", .email // "", (.id // "" | tostring)] | @tsv'],
2551
+ { encoding: 'utf8', timeout: 10_000,
2552
+ env: { ...process.env, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1' } });
2553
+ if (r.status !== 0) return { name: '', email: '' };
2554
+ const [name = '', login = '', email = '', id = ''] = (r.stdout || '').trim().split('\t');
2555
+ const resolvedName = name || login || '';
2556
+ const resolvedEmail = email || (id && login ? `${id}+${login}@users.noreply.github.com` : '');
2557
+ return { name: resolvedName, email: resolvedEmail };
2558
+ } catch {
2559
+ return { name: '', email: '' };
2560
+ }
2561
+ }
2562
+
2563
+ // Resolve the committer identity the harness stamps onto the cloned workspace.
2564
+ // Per-field precedence: explicit GIT_AUTHOR_* env → the operator's global git
2565
+ // config → the gh-authenticated GitHub user → the `nano-agent` fallback.
2566
+ // Preferring the operator's real identity means autonomous commits are authored
2567
+ // by the human running the fleet (who has signed any CLA) rather than an
2568
+ // anonymous bot that hasn't; the agent's own authorship is recorded as a PR
2569
+ // comment (see postAgentAttribution) instead of forged onto the commit. Both the
2570
+ // git-config and gh lookups are lazy and performed at most once each, and only
2571
+ // when a higher-precedence source didn't already supply the field — so explicit
2572
+ // GIT_AUTHOR_* env fully short-circuits them (no `git config`/`gh` spawns, hence
2573
+ // no added latency or failure modes when the override is present).
2574
+ // `gitIdentity`/`ghIdentity` are injectable for testing.
2575
+ function resolveCommitterIdentity({ gitIdentity = hostGitIdentity, ghIdentity = ghUserIdentity } = {}) {
2576
+ const envName = process.env.GIT_AUTHOR_NAME || '';
2577
+ const envEmail = process.env.GIT_AUTHOR_EMAIL || '';
2578
+ let g = null;
2579
+ const gitOnce = () => (g ??= (gitIdentity() || { name: '', email: '' }));
2580
+ let gh = null;
2581
+ const ghOnce = () => (gh ??= (ghIdentity() || { name: '', email: '' }));
2582
+
2583
+ const name = envName || gitOnce().name || ghOnce().name || 'nano-agent';
2584
+ const email = envEmail || gitOnce().email || ghOnce().email || 'nano-agent@users.noreply.github.com';
2585
+
2586
+ const source =
2587
+ (envName || envEmail) ? 'env'
2588
+ : (g && (g.name || g.email)) ? 'git-global'
2589
+ : (gh && (gh.name || gh.email)) ? 'gh'
2590
+ : 'fallback';
2591
+ return { name, email, source };
2592
+ }
2593
+
2529
2594
  // Normalize a repository authRef into one of three intents. Trimming matters so
2530
2595
  // a present-but-blank authRef ('' or whitespace) is treated as a misconfiguration
2531
2596
  // rather than "absent": absence enables the default/gh fallback, but a blank
@@ -2616,9 +2681,15 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
2616
2681
  }
2617
2682
  }
2618
2683
 
2619
- // Give the harness a committer identity in case it commits (many do).
2620
- runGit(['config', 'user.name', process.env.GIT_AUTHOR_NAME || 'nano-agent'], { cwd: workspaceDir, env: gitEnv });
2621
- runGit(['config', 'user.email', process.env.GIT_AUTHOR_EMAIL || 'nano-agent@users.noreply.github.com'], { cwd: workspaceDir, env: gitEnv });
2684
+ // Give the harness a committer identity in case it commits (many do). Prefer
2685
+ // the operator's real identity (git global / gh user) over the `nano-agent`
2686
+ // fallback so autonomous commits are authored by the human running the fleet —
2687
+ // who has signed any CLA — instead of an anonymous bot. The agent's authorship
2688
+ // is instead recorded as a PR comment (postAgentAttribution). Set via repo-
2689
+ // level config, which overrides global, so the identity is deterministic.
2690
+ const committer = resolveCommitterIdentity();
2691
+ runGit(['config', 'user.name', committer.name], { cwd: workspaceDir, env: gitEnv });
2692
+ runGit(['config', 'user.email', committer.email], { cwd: workspaceDir, env: gitEnv });
2622
2693
 
2623
2694
  // Determine the working branch. With branch.create we make a real branch.
2624
2695
  // Otherwise we're on whatever the clone checked out: a branch only if
@@ -2648,15 +2719,7 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
2648
2719
  // gh returns whatever PR is open for the head branch, which may not be ours.
2649
2720
  function reconcileAgentPr({ workspaceDir, token, branch, provider }) {
2650
2721
  if (provider && provider !== 'github') return { openedBy: null, found: false, error: `PR reconcile unsupported for provider "${provider}"` };
2651
- const env = { ...process.env };
2652
- if (token) {
2653
- env.GH_TOKEN = token;
2654
- } else {
2655
- // No job token ⇒ honor the anonymous guarantee: never let gh fall back to an
2656
- // operator-provided token in the ambient env. Scrub every gh auth source so
2657
- // PR reconcile can only use credentials we were explicitly handed.
2658
- for (const k of ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN']) delete env[k];
2659
- }
2722
+ const env = ghAuthEnv(token, workspaceDir);
2660
2723
  try {
2661
2724
  const r = spawnSync('gh', ['pr', 'list', '--head', branch, '--state', 'all', '--json', 'number,url,state,isDraft,title,author', '--limit', '1'],
2662
2725
  { cwd: workspaceDir, env, encoding: 'utf8', timeout: 30_000 });
@@ -2671,6 +2734,96 @@ function reconcileAgentPr({ workspaceDir, token, branch, provider }) {
2671
2734
  }
2672
2735
  }
2673
2736
 
2737
+ // Build the gh environment for PR-side calls: inject the resolved job token, or
2738
+ // (anonymous path) scrub every ambient gh credential so we can only use what we
2739
+ // were explicitly handed. Scrubbing the token env vars alone is not enough — gh
2740
+ // will still authenticate from its on-disk config (hosts.yml / OS keychain), so
2741
+ // in the anonymous path we also point gh at a private, empty GH_CONFIG_DIR
2742
+ // (created inside the harness-reaped workspace) and disable interactive prompts,
2743
+ // guaranteeing a token-less job cannot act as the operator via stored creds.
2744
+ function ghAuthEnv(token, workspaceDir) {
2745
+ const env = { ...process.env };
2746
+ // These apply on every path: workers are non-interactive, so gh must fail
2747
+ // fast rather than block on an auth/update prompt — even a provided token can
2748
+ // be invalid/expired, in which case gh would otherwise try to prompt.
2749
+ env.GH_PROMPT_DISABLED = '1';
2750
+ env.GH_NO_UPDATE_NOTIFIER = '1';
2751
+ if (token) {
2752
+ env.GH_TOKEN = token;
2753
+ return env;
2754
+ }
2755
+ for (const k of ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN']) delete env[k];
2756
+ // Fail closed: always point gh at an isolated (empty) config dir so it can
2757
+ // never fall back to the operator's on-disk config/keychain. Set GH_CONFIG_DIR
2758
+ // unconditionally — even if mkdirSync fails, gh reading a missing/empty dir
2759
+ // errors out rather than silently authenticating as the operator, preserving
2760
+ // the "token-less job cannot act as the operator" guarantee.
2761
+ const dir = join(workspaceDir || tmpdir(), '.nano-gh-anon');
2762
+ env.GH_CONFIG_DIR = dir;
2763
+ try {
2764
+ mkdirSync(dir, { recursive: true });
2765
+ } catch {
2766
+ // Directory couldn't be created; GH_CONFIG_DIR still points at it so gh
2767
+ // fails closed rather than using ambient operator credentials.
2768
+ }
2769
+ return env;
2770
+ }
2771
+
2772
+ // The agent whose authorship we record on the PR. Because commits are now
2773
+ // authored under the operator's own identity (so they satisfy CLA/DCO), this
2774
+ // comment preserves the provenance that the change was machine-generated.
2775
+ const AGENT_ATTRIBUTION_NAME = process.env.NANO_AGENT_NAME || 'nano-agent';
2776
+ const ATTRIBUTION_MARKER = '<!-- nano-agent-attribution -->';
2777
+
2778
+ // Post a one-time attribution comment on the agent-opened PR, recording that the
2779
+ // change was produced by the autonomous agent even though the commits carry the
2780
+ // operator's identity. Idempotent via a hidden marker so convergence's repeated
2781
+ // rounds don't spam the thread. Gated off with NANO_AGENT_ATTRIBUTION=0. Best
2782
+ // effort: never throws; returns a small status object.
2783
+ function postAgentAttribution({ workspaceDir, token, number, agentName = AGENT_ATTRIBUTION_NAME }) {
2784
+ if (!coerceBool(process.env.NANO_AGENT_ATTRIBUTION, true)) return { posted: false, reason: 'disabled' };
2785
+ if (!number) return { posted: false, reason: 'no-pr' };
2786
+ const env = ghAuthEnv(token, workspaceDir);
2787
+ try {
2788
+ // Ask jq for a single boolean ("marker present?") rather than streaming every
2789
+ // comment body back through stdout. On PRs with many/large comments the full
2790
+ // dump can be slow and can exceed spawnSync's output buffer (maxBuffer),
2791
+ // surfacing as existing.error and wedging attribution forever; a lone
2792
+ // true/false keeps output tiny while preserving idempotency.
2793
+ const markerFilter = `any((.comments // [])[].body; contains(${JSON.stringify(ATTRIBUTION_MARKER)}))`;
2794
+ const existing = spawnSync('gh', ['pr', 'view', String(number), '--json', 'comments', '--jq', markerFilter],
2795
+ { cwd: workspaceDir, env, encoding: 'utf8', timeout: 30_000 });
2796
+ // Idempotency hinges on reliably reading the existing comments: if we cannot
2797
+ // verify whether the marker is already present (transient gh failure — rate
2798
+ // limit, auth glitch, timeout), do NOT post. Posting blind would let repeated
2799
+ // convergence rounds spam duplicate attribution comments. Bail out instead.
2800
+ if (existing.error) {
2801
+ return { posted: false, error: `gh not runnable: ${redactToken(existing.error.message, token).trim().slice(0, 200)}` };
2802
+ }
2803
+ if (existing.status !== 0) {
2804
+ return { posted: false, error: redactToken(existing.stderr || existing.stdout, token).trim().slice(0, 200) || `gh pr view failed (exit ${existing.status ?? 'null'}${existing.signal ? `, signal ${existing.signal}` : ''})` };
2805
+ }
2806
+ if ((existing.stdout || '').trim() === 'true') {
2807
+ return { posted: false, reason: 'exists' };
2808
+ }
2809
+ const body = `${ATTRIBUTION_MARKER}\n`
2810
+ + `🤖 The changes in this PR were produced by **${agentName}**, an autonomous agent. `
2811
+ + `Commits are authored under the operator's own git identity when one is resolvable (the human running the fleet), so they can satisfy CLA/DCO requirements; `
2812
+ + `this note records that the work was generated by the agent.`;
2813
+ const r = spawnSync('gh', ['pr', 'comment', String(number), '--body', body],
2814
+ { cwd: workspaceDir, env, encoding: 'utf8', timeout: 30_000 });
2815
+ if (r.error) {
2816
+ return { posted: false, error: `gh not runnable: ${redactToken(r.error.message, token).trim().slice(0, 200)}` };
2817
+ }
2818
+ if (r.status !== 0) {
2819
+ return { posted: false, error: redactToken(r.stderr || r.stdout, token).trim().slice(0, 200) || `gh pr comment failed (exit ${r.status ?? 'null'}${r.signal ? `, signal ${r.signal}` : ''})` };
2820
+ }
2821
+ return { posted: true };
2822
+ } catch (err) {
2823
+ return { posted: false, error: err.message };
2824
+ }
2825
+ }
2826
+
2674
2827
  // After the harness runs: enumerate new commits, push the branch (when
2675
2828
  // branch.push), and reconcile the agent-opened PR (when task.allowPr). A push
2676
2829
  // failure is reported (pushError) rather than thrown — the process model decides
@@ -2703,6 +2856,11 @@ function finalizeGit({ workspaceDir, gitEnv, startSha, workingBranch, envelope,
2703
2856
 
2704
2857
  if (workingBranch && envelope.task?.allowPr) {
2705
2858
  out.pr = reconcileAgentPr({ workspaceDir, token, branch: workingBranch, provider: envelope.repository?.provider || 'github' });
2859
+ // Record the agent's authorship on the PR (commits carry the operator's
2860
+ // identity now, so this preserves the machine-generated provenance).
2861
+ if (out.pr?.found && out.pr.number && (envelope.repository?.provider || 'github') === 'github') {
2862
+ out.attribution = postAgentAttribution({ workspaceDir, token, number: out.pr.number });
2863
+ }
2706
2864
  }
2707
2865
  return out;
2708
2866
  }
@@ -6320,11 +6478,14 @@ export {
6320
6478
  provisionRepo,
6321
6479
  finalizeGit,
6322
6480
  reconcileAgentPr,
6481
+ resolveCommitterIdentity,
6482
+ postAgentAttribution,
6323
6483
  reapAgentRunDirs,
6324
6484
  authUrl,
6325
6485
  githubCloneToken,
6326
6486
  ghAuthTokenFromCli,
6327
6487
  primeGhAuthToken,
6488
+ ghAuthEnv,
6328
6489
  redactToken,
6329
6490
  agentRunsRoot,
6330
6491
  ProvisionError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.26.1",
3
+ "version": "1.26.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",
@@ -47,12 +47,12 @@
47
47
  "semantic-release": "^25.0.3"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.26.1",
51
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.26.1",
52
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.26.1",
53
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.26.1",
54
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.26.1",
55
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.26.1",
56
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.26.1"
50
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.26.2",
51
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.26.2",
52
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.26.2",
53
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.26.2",
54
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.26.2",
55
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.26.2",
56
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.26.2"
57
57
  }
58
58
  }