@yemi33/minions 0.1.1984 → 0.1.1986

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.
package/docs/README.md CHANGED
@@ -15,6 +15,7 @@ Architecture, design proposals, and lifecycle references for people working on t
15
15
 
16
16
  - [command-center.md](command-center.md) — Command Center (CC) chat panel: persistent Sonnet sessions, `--resume` semantics, system-prompt invalidation, and per-tab session storage.
17
17
  - [completion-reports.md](completion-reports.md) — Canonical schema for the per-spawn completion JSON: trust nonce, `failure_class` enum, `noop` semantics, `retryable` / `needs_rerun` shape, and the artifacts array.
18
+ - [constellation-bridge.md](constellation-bridge.md) — Read-only cross-repo bridge: `engine.constellationBridge.enabled` flag, marker-file contract, and the `minions bridge` subcommand for local debugging.
18
19
  - [copilot-cli-schema.md](copilot-cli-schema.md) — Behavior and schema reference for the GitHub Copilot CLI adapter (capability flags, stdin vs `-p`, model discovery, effort levels).
19
20
  - [design-state-storage.md](design-state-storage.md) — Design proposal evaluating five database options for replacing Minions' file-based JSON state; recommends `node:sqlite` as the medium-term target.
20
21
  - [kb-sweep.md](kb-sweep.md) — Knowledge-base consolidation sweep (hash dedup → LLM batch dedup/reclassify → per-entry compress) and the detached runner that keeps it alive across `minions restart`.
@@ -39,6 +40,7 @@ Operational runbooks for engine operators and fleet maintainers.
39
40
  - [human-vs-automated.md](human-vs-automated.md) — Quick reference table of which features humans start, run, decide, and recover, and the two human approval gates.
40
41
  - [kb-sweep.md](kb-sweep.md) — Knowledge-base sweep runbook: how `engine/kb-sweep.js` consolidates `notes/inbox/` into `knowledge/` and survives `minions restart`.
41
42
  - [onboarding.md](onboarding.md) — First-30-minutes walkthrough for a new operator: install, init, dispatch a first work item, watch it land.
43
+ - [security.md](security.md) — Threat model: single-user/loopback deployment assumptions, dashboard Origin gate, data-flow trust boundaries, secret handling, and known residual risks (CSRF sweep, prompt injection, log-redactor audit).
42
44
 
43
45
  ---
44
46
 
@@ -0,0 +1,94 @@
1
+ # Constellation bridge
2
+
3
+ Minions ships a small read-only surface that the [Constellation](https://office.visualstudio.com/DefaultCollection/ISS/_git/constellation) dashboard polls to project Minions engine state (agents, dispatch queue, PR pipeline) into its HUD. This page documents the Minions-side of that contract.
4
+
5
+ > **The bridge polling logic itself lives in the Constellation repo** (`packages/agent/src/bridges/`). Minions only owns the on/off flag, the marker-file contract, and the `minions bridge` subcommand for local debugging.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ minions bridge status # Show enabled flag + last-seen Constellation agent
11
+ minions bridge health # Probe http://127.0.0.1:7331/api/status and print the projection
12
+ minions bridge enable # Set engine.constellationBridge.enabled = true
13
+ minions bridge disable # Set engine.constellationBridge.enabled = false
14
+ ```
15
+
16
+ ## Config flag
17
+
18
+ ```json
19
+ {
20
+ "engine": {
21
+ "constellationBridge": {
22
+ "enabled": false
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ Default: `false`. The flag is backfilled into existing configs on `minions init` (including the implicit init that runs on every `minions update`).
29
+
30
+ **Strict semantics.** Only the literal boolean `true` enables the bridge. Any missing, malformed, or non-boolean value (e.g. the string `"true"`, the number `1`, `null`) is treated as **disabled**. The Constellation-side reader MUST mirror this check — no truthy coercion — so a typo'd `"enabled": "false"` does not silently turn the bridge on.
31
+
32
+ Toggle without editing JSON by hand:
33
+
34
+ ```bash
35
+ minions bridge enable
36
+ minions bridge disable
37
+ ```
38
+
39
+ Both subcommands write `~/.minions/config.json` atomically via `mutateJsonFileLocked`, so concurrent edits from the engine/dashboard cannot tear the file.
40
+
41
+ ## Marker-file contract
42
+
43
+ The Constellation agent's bridge writes a marker file on every successful poll. Minions reads it with `minions bridge status` so a local operator can verify the bridge is alive and Constellation is talking to it.
44
+
45
+ - **Path:** `~/.minions/engine/constellation-bridge.json` (exposed as `CONSTELLATION_BRIDGE_MARKER_PATH` in `engine/shared.js`).
46
+ - **Owner:** the Constellation agent. The Minions engine **never** writes this file — it is a one-way breadcrumb from Constellation → Minions.
47
+
48
+ ### Schema (`schemaVersion: 1`)
49
+
50
+ ```json
51
+ {
52
+ "schemaVersion": 1,
53
+ "lastSeenAt": "2026-05-19T04:41:23.123Z",
54
+ "agentVersion": "0.2.3",
55
+ "source": "constellation-agent"
56
+ }
57
+ ```
58
+
59
+ | Field | Required | Notes |
60
+ | --------------- | -------- | ------------------------------------------------------------------------ |
61
+ | `schemaVersion` | yes | Must equal `1`. Any other value causes Minions to ignore the marker entirely (treated as no-marker, same as a missing file). New fields are added behind a deliberate version bump. |
62
+ | `lastSeenAt` | yes | ISO-8601 UTC timestamp of the last successful poll. |
63
+ | `agentVersion` | no | Constellation agent semver string, surfaced in `bridge status`. |
64
+ | `source` | no | Free-form identifier, expected `"constellation-agent"` today. |
65
+
66
+ Writers MUST use an atomic-replace pattern (`write to tmp + rename`) so a partial write never leaves Minions reading a half-baked JSON blob.
67
+
68
+ ## `minions bridge health`
69
+
70
+ `bridge health` performs a synchronous probe of the Minions dashboard's `GET /api/status` endpoint and prints a **curated subset** — the same fields the Constellation bridge would project into its own data model. This is intentionally small: full `/api/status` is large, unstable, and may expose unrelated local state.
71
+
72
+ Sample output:
73
+
74
+ ```text
75
+ bridge: dashboard reachable on http://127.0.0.1:7331
76
+ projection (same fields the Constellation bridge would read):
77
+ engineState: running
78
+ enginePid: 1234
79
+ minionsVersion: 0.1.1984
80
+ agentCount: 5
81
+ activeAgentCount: 2
82
+ dispatchPending: 1
83
+ dispatchActive: 2
84
+ dispatchCompleted: 14
85
+ projectCount: 3
86
+ ```
87
+
88
+ If the dashboard is not listening, `bridge health` prints `dashboard not running on :7331 — start it with \`minions dash\`` and exits 1. Use this exit code to gate scripted health checks.
89
+
90
+ ## Cross-repo coordination
91
+
92
+ The Constellation-side PR ([P-wi1-bridge-readonly](https://office.visualstudio.com/DefaultCollection/ISS/_git/constellation)) lands independently of this Minions PR. The Minions side merges first with the default `enabled: false`, then the Constellation side lights up bridge polling. Operators flip the flag to `true` only after both sides are deployed.
93
+
94
+ The Constellation agent's bridge reads `~/.minions/config.json` directly (no Minions HTTP API call) so config edits propagate without waiting for the engine to restart.
@@ -0,0 +1,177 @@
1
+ # Minions Security Model
2
+
3
+ This document records the threat model for Minions today. It is intentionally
4
+ narrow: it describes what the engine and dashboard are designed to protect
5
+ against, what they are **not** designed to protect against, and the residual
6
+ risks an operator should know about. It is the source of truth for "is X a
7
+ vulnerability or a documented assumption?" questions.
8
+
9
+ If you are implementing a change that touches authentication, the dashboard
10
+ HTTP surface, secret handling, or the agent prompt boundary, read this first
11
+ and update it in the same PR if the model changes.
12
+
13
+ ## 1. Deployment model
14
+
15
+ Minions is designed as a **single-user, localhost-only, single-tenant**
16
+ developer tool:
17
+
18
+ - One human operator runs `minions start` on a workstation (or a remote DevBox
19
+ they treat as their own workstation). Agents dispatched by the engine run as
20
+ the same OS user as the engine and dashboard.
21
+ - The dashboard binds `127.0.0.1` only (see
22
+ [`dashboard.js`](../dashboard.js) — `server.listen(PORT, '127.0.0.1', ...)`)
23
+ and is **not** intended to be reachable from any other host.
24
+ - Configuration, runtime state, secrets, project worktrees, and agent output
25
+ all live on the same machine under `MINIONS_DIR` and the operator's git
26
+ worktrees.
27
+
28
+ **Multi-tenant deployment is explicitly out of scope.** Minions is not a
29
+ hosted service. The engine, dashboard, agents, MCP helpers, and any tools the
30
+ operator invokes from the same shell session form one trust domain. Anything
31
+ that could allow a second human to share that trust domain — exposing the
32
+ dashboard port, mirroring `MINIONS_DIR` to another user, running the engine as
33
+ a service account read by multiple operators — is unsupported and not
34
+ defended against here.
35
+
36
+ ## 2. Dashboard threat model
37
+
38
+ The dashboard (`dashboard.js`, port 7331) is the only HTTP surface in the
39
+ system. Its threat model:
40
+
41
+ ### In scope (intentional, not vulnerabilities)
42
+
43
+ - **Loopback bind.** The dashboard binds `127.0.0.1` only; no LAN, container,
44
+ or VPN client can reach it. Operators who tunnel the port elsewhere (SSH
45
+ port forward, `ngrok`, etc.) opt out of this assumption and inherit
46
+ responsibility for whatever auth gate they place in front.
47
+ - **Same-user process access.** Any process running as the same OS user as
48
+ the engine (other agent runtimes, MCP helpers, `curl` from a terminal,
49
+ `minions` CLI subcommands) can call `/api/*`. This is intentional — it is
50
+ how `minions dispatch`, the Copilot/Claude runtimes, and operator scripts
51
+ drive the engine. We do not attempt to authenticate same-user callers.
52
+ - **No authentication gate.** There is no login, no session cookie, no
53
+ per-user ACL. The single-user assumption above is the entire authn story.
54
+ Adding authn would not increase security in the single-user model; it would
55
+ only break local CLI/MCP tooling.
56
+
57
+ ### Residual risks defended today
58
+
59
+ - **Cross-origin browser requests / CSRF / DNS rebinding.** A browser tab the
60
+ operator visits could in principle issue requests to `http://127.0.0.1:7331`.
61
+ The dashboard defends against this with:
62
+ - An **Origin gate** on mutating methods (`POST`/`PUT`/`PATCH`/`DELETE`)
63
+ and CORS preflights — see `dashboard.js` ~3677–3730 and
64
+ `shared.isAllowedOrigin` / `shared.buildSecurityHeaders` in
65
+ [`engine/shared.js`](../engine/shared.js). Requests whose `Origin` (or
66
+ `Referer`, if `Origin` is absent) is not in the local allowlist are
67
+ rejected with HTTP 403. Callers without an `Origin` header at all
68
+ (Node `http.request`, `curl` without `-H Origin`, CLI tooling) are
69
+ allowed through to preserve local automation.
70
+ - Baseline **security headers** (CSP, `X-Content-Type-Options`,
71
+ `Referrer-Policy`, clickjacking protections) applied to every response
72
+ via `shared.buildSecurityHeaders()`.
73
+
74
+ ### Residual risks tracked elsewhere
75
+
76
+ - **CSRF hardening sweep.** A broader hardening pass — deny-by-default CORS,
77
+ `Sec-Fetch-Site: same-origin` enforcement on mutating endpoints, and an
78
+ optional bearer-token gate as a secondary defense — is **deferred to a
79
+ separate plan** (`D-f8-csrf` in
80
+ `prd/security-fix-plan-from-weekly-review-2026-05-18.json`, open question
81
+ `Q-csrf-followup`). This document does not gate that work; if and when the
82
+ CSRF follow-up plan ships, update §2 to reflect the new posture.
83
+
84
+ ### Recommended hardening (if F8 ever moves from docs to code)
85
+
86
+ If we revisit this assumption — e.g. the dashboard ever serves more than one
87
+ operator, or we want defense-in-depth beyond Origin checks — the recommended
88
+ shape is:
89
+
90
+ 1. Reject mutating requests whose `Origin` / `Sec-Fetch-Site` is not
91
+ `same-origin`, instead of the current allowlist + missing-header pass-through.
92
+ 2. Switch CORS to deny-by-default and explicitly opt specific endpoints in.
93
+ 3. Add an optional bearer token (operator-supplied via env) as a secondary
94
+ gate; require it on mutating endpoints when set.
95
+ 4. Document the resulting break in CLI/MCP tooling and provide a token
96
+ injection path for it.
97
+
98
+ ## 3. Data flow trust boundaries
99
+
100
+ Minions reads from several sources with very different trust levels. The
101
+ engine and dashboard treat them differently on purpose:
102
+
103
+ | Source | Trust | Examples | Handling |
104
+ |---|---|---|---|
105
+ | **Operator config** | Trusted | `config.json`, `projects/`, `notes.md`, `notes/inbox/*` authored by the human, `pinned.md` | Read as-is. The operator is assumed to control these files. |
106
+ | **Agent output** | Semi-trusted | Completion reports, fenced `completion` blocks, learnings notes, PR comments authored by the shared `gh` identity | Schema-validated; completion JSON is gated by the per-spawn nonce (`MINIONS_COMPLETION_NONCE`, see [`completion-reports.md`](completion-reports.md) → "Trust boundary"). Reports without a valid nonce are rejected with `failure_class: 'completion-nonce-mismatch'`. |
107
+ | **External APIs** | Untrusted | GitHub REST/GraphQL responses, Azure DevOps REST responses, GitHub/ADO PR comment bodies, CI/run logs | Validated and shape-checked before persistence. Strings sourced from these responses are never passed as raw arguments to shells or `git`; see F2 (gh shell-injection fix) and F7 (`git log` execFile conversion) in the same security plan. |
108
+ | **Agent-controlled paths** | Untrusted | Paths supplied to dashboard endpoints by agents (e.g. `/api/agent-output`) | Normalized through `shared.sanitizePath` to constrain to the expected root; see F4 in the same plan. |
109
+
110
+ The agent-output trust boundary deserves emphasis: completion reports are the
111
+ single most powerful signal an agent can emit (they advance work-item status,
112
+ mark PRs reviewed, trigger merges). The nonce gate exists specifically so a
113
+ report written by an unrelated process — or by a stale dispatch from a
114
+ previous tick — cannot be silently consumed. Anything in the report body
115
+ itself remains agent-controlled and is treated as such (no `eval`, no shell
116
+ interpolation, schema-validated fields only).
117
+
118
+ ## 4. Secret management
119
+
120
+ - **PATs and API tokens live in environment variables only.** GitHub tokens
121
+ (`GH_TOKEN`, `COPILOT_GITHUB_TOKEN`), Azure DevOps PATs, and any
122
+ runtime-specific credentials are read from the engine's process
123
+ environment. They are not persisted to `config.json`, work-item state,
124
+ PR metadata, or any other on-disk JSON Minions owns.
125
+ - **Tokens are never intentionally logged.** Engine code that shells out to
126
+ `gh` or the ADO CLI threads the token via per-call `GH_TOKEN=...`
127
+ environment injection (see `engine/gh-token.js`), so the value never
128
+ appears on a command line or in `live-output.log`.
129
+ - **The log redactor is best-effort, not authoritative.** A best-effort
130
+ redactor scrubs token-shaped strings from logs and agent output, but its
131
+ coverage has **not** been formally audited (deferred as `D-f9`, open
132
+ question `Q-f9-log-redactor`). Treat redaction as a defense-in-depth nicety,
133
+ not a guarantee — do not rely on it to keep a leaked token out of an
134
+ uploaded log bundle. If a token may have appeared in output, rotate it.
135
+
136
+ ## 5. Known limitations
137
+
138
+ These are accepted limitations of the current model. They are documented
139
+ rather than fixed because (a) they are out of scope for the single-user
140
+ threat model, (b) they are tracked under other items, or (c) a fix would
141
+ break operator workflows we want to preserve.
142
+
143
+ - **No authentication gate on the dashboard.** Intentional — see §2. The
144
+ single-user UX (and `minions` CLI, MCP integrations, and operator scripts
145
+ that POST to `/api/*` without juggling a token) depends on this. Revisit
146
+ only if the deployment model in §1 changes.
147
+ - **Prompt-injection surface from PR comments and inbox notes.** Agent
148
+ prompts splice in human-authored content (pinned notes, `notes/inbox/*`,
149
+ PR comment bodies, `pendingHumanFeedback`) without a fenced delimiter
150
+ separating "instructions" from "data." A malicious PR comment author
151
+ could attempt to steer an agent that reads the comment thread. Mitigation
152
+ (F5 — delimited untrusted content blocks) is **blocked on an open
153
+ question** (`Q-f5-delimiter`) about which delimiter token to standardize
154
+ on. Until F5 lands, operators should treat external PR comment threads
155
+ as a low-but-nonzero injection surface.
156
+ - **Temp-file predictability.** Per-dispatch temp paths can be predictable
157
+ in some shells, opening a narrow TOCTOU window for a same-user process to
158
+ race the engine. Tracked as **F6** in this same security plan
159
+ (`P-f6-tmp-toctou`); the fix moves dispatch temp dirs to per-spawn unique
160
+ paths with restrictive permissions.
161
+ - **Log redactor coverage is unaudited.** See §4 and `D-f9` /
162
+ `Q-f9-log-redactor`. Until the audit lands, treat any log bundle that
163
+ might contain agent output, CI logs, or `live-output.log` excerpts as
164
+ potentially containing tokens, and rotate accordingly.
165
+ - **CSRF hardening sweep is deferred.** See §2. Origin gate + security
166
+ headers are in place today; the broader sweep (deny-by-default CORS,
167
+ `Sec-Fetch-Site` enforcement, optional bearer token) is `D-f8-csrf` /
168
+ `Q-csrf-followup`.
169
+
170
+ ---
171
+
172
+ **Updating this doc:** If you change the dashboard's bind address, add or
173
+ remove an authn/authz mechanism, change how completion reports are trusted,
174
+ change how secrets are read, or land any of F5 / F6 / F9 / the CSRF
175
+ follow-up, update the relevant section here in the same PR. Keep the
176
+ "in-scope vs residual vs deferred" split — it is the part reviewers come
177
+ back to.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * engine/ado-git-auth.js — W-mpcuc8i80003a7b3
3
+ *
4
+ * Inject a per-invocation Authorization: Bearer <token> http.extraHeader into
5
+ * git ops that touch an ADO origin. Solves the headless-dispatch failure mode
6
+ * where Git Credential Manager falls back to a TTY prompt (because the engine
7
+ * has no terminal) and emits:
8
+ *
9
+ * fatal: could not read Username for 'https://office.visualstudio.com'
10
+ *
11
+ * The token is sourced via the existing `engine/ado-token.js#acquireAdoTokenSync`
12
+ * helper (az CLI first, azureauth fallback). We cache it for 30 minutes and
13
+ * back off acquisition retries for 10 minutes on failure so we don't hammer
14
+ * az / azureauth on every git op during an outage.
15
+ *
16
+ * Public API:
17
+ * - getAdoGitExtraArgs(project, opts?) → string[]
18
+ * Sync. Returns `['-c', 'http.<scope>.extraHeader=Authorization: Bearer <token>']`
19
+ * for ADO projects, `[]` for everything else (GitHub/local/null). Plumbed
20
+ * into shared.shellSafeGit via `opts.gitExtraArgs`.
21
+ * - invalidateAdoTokenCache()
22
+ * Drops the cached token so the next getAdoGitExtraArgs() re-acquires.
23
+ * Used by runAdoGit on auth failure to handle mid-dispatch token expiry.
24
+ * - isAdoAuthFailure(err) → boolean
25
+ * Pure. Matches credential/auth-specific phrases against err.message,
26
+ * err.stdout, err.stderr. Deliberately NARROW — does not match bare
27
+ * "fatal: unable to access" since that also fires for DNS/TLS/proxy.
28
+ * - runAdoGit(project, gitArgs, opts) → Promise<string>
29
+ * Async wrapper around shellSafeGit. On ADO auth failure, invalidates
30
+ * cache, re-acquires, retries ONCE in the same dispatch. Redacts the
31
+ * bearer token from rethrown error messages so the token never lands in
32
+ * logs / inbox alerts / completion reports.
33
+ *
34
+ * Argv-vs-env tradeoff: putting `-c http.extraHeader=Authorization: Bearer X`
35
+ * in argv is visible via process listing. The simpler alternative
36
+ * `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=... GIT_CONFIG_VALUE_0=...` hides the
37
+ * token from `ps`. We chose argv to match the documented manual-verification
38
+ * recipe (matches the task spec) and explicitly redact on error rethrow.
39
+ * Migrate to env-form if argv exposure becomes a real concern.
40
+ */
41
+
42
+ const shared = require('./shared');
43
+ const adoToken = require('./ado-token');
44
+ const { log } = shared;
45
+
46
+ const TOKEN_TTL_MS = 30 * 60 * 1000; // 30 min — matches gh-token cadence
47
+ const ACQUIRE_BACKOFF_MS = 10 * 60 * 1000; // 10 min — same backoff as gh-token
48
+ const ACQUIRE_TIMEOUT_MS = 30000; // 30s — generous for slow IWA/broker auth
49
+
50
+ let _cached = null; // { token, expiresAt }
51
+ let _backoffUntil = 0; // epoch ms — no acquire attempts before this
52
+
53
+ // Credential/auth patterns. Narrow on purpose: do NOT include bare
54
+ // "fatal: unable to access" — that also fires for DNS, TLS, and proxy errors
55
+ // which are correctly classified elsewhere (NETWORK_ERROR, retryable).
56
+ const ADO_AUTH_PATTERNS = [
57
+ /could not read Username/i,
58
+ /could not read Password/i,
59
+ /Authentication failed/i,
60
+ /terminal prompts disabled/i,
61
+ /TF40081\d/i, // Azure DevOps auth error family
62
+ /HTTP\s*4(?:01|03)\b/i, // explicit 401/403 in error text
63
+ ];
64
+
65
+ function isAdoProject(project) {
66
+ return !!(project && project.repoHost === 'ado');
67
+ }
68
+
69
+ // Scope the header to the ADO host so a misconfigured remote pointing at
70
+ // another host (e.g. github.com on a misclassified project) doesn't leak the
71
+ // bearer token. Falls back to the unscoped `http.extraHeader` key when we
72
+ // can't compute a host (still safe — git only sends extraHeader on HTTP/S
73
+ // transfers, and the engine only invokes ado-git-auth for ADO projects).
74
+ function _resolveScopeUrl(project) {
75
+ try {
76
+ if (!project || !project.adoOrg) return null;
77
+ const base = shared.getAdoOrgBase(project);
78
+ if (typeof base !== 'string' || !base.startsWith('http')) return null;
79
+ const m = base.match(/^(https?:\/\/[^/]+)/);
80
+ return m ? `${m[1]}/` : null;
81
+ } catch (_e) {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function _buildHeaderArgs(token, scopeUrl) {
87
+ const key = scopeUrl ? `http.${scopeUrl}.extraHeader` : 'http.extraHeader';
88
+ return ['-c', `${key}=Authorization: Bearer ${token}`];
89
+ }
90
+
91
+ function _acquireToken(opts = {}) {
92
+ const exec = opts.acquireAdoTokenSync || adoToken.acquireAdoTokenSync;
93
+ const result = exec({ timeout: opts.timeout || ACQUIRE_TIMEOUT_MS });
94
+ return result && result.token ? String(result.token) : null;
95
+ }
96
+
97
+ function getAdoGitExtraArgs(project, opts = {}) {
98
+ if (!isAdoProject(project)) return [];
99
+ const now = Date.now();
100
+ if (_cached && _cached.expiresAt > now) {
101
+ return _buildHeaderArgs(_cached.token, _resolveScopeUrl(project));
102
+ }
103
+ if (now < _backoffUntil) return [];
104
+ try {
105
+ const token = _acquireToken(opts);
106
+ if (!token) {
107
+ _backoffUntil = now + ACQUIRE_BACKOFF_MS;
108
+ log('warn', `ado-git-auth: acquireAdoTokenSync returned empty token; backing off ${ACQUIRE_BACKOFF_MS / 1000}s`);
109
+ return [];
110
+ }
111
+ _cached = { token, expiresAt: now + TOKEN_TTL_MS };
112
+ return _buildHeaderArgs(token, _resolveScopeUrl(project));
113
+ } catch (e) {
114
+ _backoffUntil = now + ACQUIRE_BACKOFF_MS;
115
+ const firstLine = String(e && e.message || e).split('\n')[0];
116
+ log('warn', `ado-git-auth: token acquire failed (${firstLine}); backing off ${ACQUIRE_BACKOFF_MS / 1000}s`);
117
+ return [];
118
+ }
119
+ }
120
+
121
+ function invalidateAdoTokenCache() {
122
+ _cached = null;
123
+ }
124
+
125
+ function isAdoAuthFailure(err) {
126
+ if (!err || typeof err !== 'object') return false;
127
+ const parts = [
128
+ err.message,
129
+ typeof err.stdout === 'string' ? err.stdout : '',
130
+ typeof err.stderr === 'string' ? err.stderr : '',
131
+ ].filter(Boolean).join('\n');
132
+ if (!parts) return false;
133
+ return ADO_AUTH_PATTERNS.some((p) => p.test(parts));
134
+ }
135
+
136
+ function _redactBearer(s) {
137
+ if (typeof s !== 'string') return s;
138
+ return s.replace(/Authorization:\s*Bearer\s+[A-Za-z0-9._\-]+/gi, 'Authorization: Bearer [REDACTED]');
139
+ }
140
+
141
+ function _redactErrorInPlace(err) {
142
+ if (!err || typeof err !== 'object') return err;
143
+ if (err.message) err.message = _redactBearer(err.message);
144
+ if (typeof err.stdout === 'string') err.stdout = _redactBearer(err.stdout);
145
+ if (typeof err.stderr === 'string') err.stderr = _redactBearer(err.stderr);
146
+ return err;
147
+ }
148
+
149
+ async function runAdoGit(project, gitArgs, opts = {}) {
150
+ const { acquireAdoTokenSync, _runGit, ...callerOpts } = opts;
151
+ const runGit = _runGit || shared.shellSafeGit;
152
+ const baseExtra = Array.isArray(callerOpts.gitExtraArgs) ? callerOpts.gitExtraArgs : [];
153
+ const adoOpts = { acquireAdoTokenSync };
154
+
155
+ const firstExtra = baseExtra.concat(getAdoGitExtraArgs(project, adoOpts));
156
+ const firstOpts = firstExtra.length ? { ...callerOpts, gitExtraArgs: firstExtra } : callerOpts;
157
+ try {
158
+ return await runGit(gitArgs, firstOpts);
159
+ } catch (err) {
160
+ if (!isAdoProject(project) || !isAdoAuthFailure(err)) {
161
+ throw _redactErrorInPlace(err);
162
+ }
163
+ // Token may have expired mid-dispatch — refresh and retry exactly once.
164
+ invalidateAdoTokenCache();
165
+ _backoffUntil = 0; // give the retry a chance to actually hit the token source
166
+ const refreshedExtra = getAdoGitExtraArgs(project, adoOpts);
167
+ if (!refreshedExtra.length) {
168
+ throw _redactErrorInPlace(err);
169
+ }
170
+ const retryExtra = baseExtra.concat(refreshedExtra);
171
+ try {
172
+ return await runGit(gitArgs, { ...callerOpts, gitExtraArgs: retryExtra });
173
+ } catch (err2) {
174
+ throw _redactErrorInPlace(err2);
175
+ }
176
+ }
177
+ }
178
+
179
+ // ── Test seams ──────────────────────────────────────────────────────────────
180
+
181
+ function _setTokenForTest(token) {
182
+ if (token == null) { _cached = null; _backoffUntil = 0; return; }
183
+ _cached = { token: String(token), expiresAt: Date.now() + TOKEN_TTL_MS };
184
+ _backoffUntil = 0;
185
+ }
186
+
187
+ function _clearTokenCache() {
188
+ _cached = null;
189
+ _backoffUntil = 0;
190
+ }
191
+
192
+ function _isBackedOff() { return Date.now() < _backoffUntil; }
193
+
194
+ module.exports = {
195
+ getAdoGitExtraArgs,
196
+ invalidateAdoTokenCache,
197
+ isAdoAuthFailure,
198
+ runAdoGit,
199
+ isAdoProject,
200
+ _setTokenForTest,
201
+ _clearTokenCache,
202
+ _isBackedOff,
203
+ TOKEN_TTL_MS,
204
+ ACQUIRE_BACKOFF_MS,
205
+ ADO_AUTH_PATTERNS,
206
+ };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * engine/bridge.js — Constellation-bridge config + marker accessors.
3
+ *
4
+ * Pure helpers for the `minions bridge ...` subcommand and any future
5
+ * code that wants to inspect or mutate the read-only Constellation bridge
6
+ * surface. The bridge polling logic itself lives in the Constellation
7
+ * repo (P-wi1-bridge-readonly) — this file owns ONLY the on/off flag, the
8
+ * marker-file contract, and atomic config writes for the toggle.
9
+ *
10
+ * Strict semantics: only the literal boolean `true` enables the bridge.
11
+ * Mirror this check on the Constellation reader to avoid truthy coercion
12
+ * silently flipping bridge state on a typo'd `"enabled": "false"`.
13
+ */
14
+
15
+ const path = require('path');
16
+ const shared = require('./shared');
17
+
18
+ const {
19
+ MINIONS_DIR,
20
+ CONSTELLATION_BRIDGE_MARKER_PATH,
21
+ CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION,
22
+ safeJson,
23
+ mutateJsonFileLocked,
24
+ } = shared;
25
+
26
+ const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
27
+
28
+ /**
29
+ * Strict check: `true` ⇔ bridge enabled. Any other shape (missing field,
30
+ * non-object, string "true", etc.) returns false.
31
+ */
32
+ function isBridgeEnabled(config) {
33
+ return config?.engine?.constellationBridge?.enabled === true;
34
+ }
35
+
36
+ /**
37
+ * Read the cross-repo marker written by the Constellation agent. Returns
38
+ * `null` when the file is missing, unreadable, or schema-mismatched.
39
+ *
40
+ * Marker shape (see ENGINE_DEFAULTS.constellationBridge docstring):
41
+ * { schemaVersion: 1, lastSeenAt: ISO8601,
42
+ * agentVersion?: string, source?: 'constellation-agent' }
43
+ */
44
+ function readBridgeMarker(markerPath = CONSTELLATION_BRIDGE_MARKER_PATH) {
45
+ const raw = safeJson(markerPath);
46
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
47
+ if (raw.schemaVersion !== CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION) return null;
48
+ if (typeof raw.lastSeenAt !== 'string') return null;
49
+ return {
50
+ schemaVersion: raw.schemaVersion,
51
+ lastSeenAt: raw.lastSeenAt,
52
+ agentVersion: typeof raw.agentVersion === 'string' ? raw.agentVersion : null,
53
+ source: typeof raw.source === 'string' ? raw.source : null,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Flip `config.engine.constellationBridge.enabled` atomically via
59
+ * mutateJsonFileLocked. Returns `{ previous: bool, current: bool }`.
60
+ * `configPath` override exists for unit tests.
61
+ */
62
+ function setBridgeEnabled(enabled, configPath = CONFIG_PATH) {
63
+ const next = enabled === true;
64
+ let previous = false;
65
+ mutateJsonFileLocked(configPath, (cfg) => {
66
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return cfg;
67
+ cfg.engine = cfg.engine || {};
68
+ cfg.engine.constellationBridge = cfg.engine.constellationBridge || {};
69
+ previous = cfg.engine.constellationBridge.enabled === true;
70
+ cfg.engine.constellationBridge.enabled = next;
71
+ return cfg;
72
+ });
73
+ return { previous, current: next };
74
+ }
75
+
76
+ /**
77
+ * Human-readable relative age string (e.g. "12s ago", "3m ago", "2h ago").
78
+ * Caps at "1d+ ago" — anything older than the bridge polling cadence is
79
+ * already actionable as "stale".
80
+ */
81
+ function formatRelativeAge(isoTimestamp, nowMs = Date.now()) {
82
+ const t = Date.parse(isoTimestamp);
83
+ if (!Number.isFinite(t)) return '(unknown)';
84
+ const deltaSec = Math.max(0, Math.round((nowMs - t) / 1000));
85
+ if (deltaSec < 60) return `${deltaSec}s ago`;
86
+ if (deltaSec < 3600) return `${Math.round(deltaSec / 60)}m ago`;
87
+ if (deltaSec < 86400) return `${Math.round(deltaSec / 3600)}h ago`;
88
+ return '1d+ ago';
89
+ }
90
+
91
+ /**
92
+ * Compose the small stable projection the Constellation bridge consumes
93
+ * from `/api/status`. Kept narrow on purpose: full /api/status is large,
94
+ * unstable, and may surface unrelated local state. New fields go behind
95
+ * a deliberate schema version bump.
96
+ */
97
+ function projectStatusForBridge(statusJson) {
98
+ if (!statusJson || typeof statusJson !== 'object') return null;
99
+ const dispatch = statusJson.dispatch || {};
100
+ const queueCount = (arr) => (Array.isArray(arr) ? arr.length : 0);
101
+ return {
102
+ engineState: statusJson.control?.state ?? null,
103
+ enginePid: statusJson.control?.pid ?? null,
104
+ minionsVersion: statusJson.version ?? null,
105
+ agentCount: Array.isArray(statusJson.agents) ? statusJson.agents.length : null,
106
+ activeAgentCount: Array.isArray(statusJson.agents)
107
+ ? statusJson.agents.filter(a => a && a.status && a.status !== 'idle').length
108
+ : null,
109
+ dispatchPending: queueCount(dispatch.pending),
110
+ dispatchActive: queueCount(dispatch.active),
111
+ dispatchCompleted: queueCount(dispatch.completed),
112
+ projectCount: Array.isArray(statusJson.projects) ? statusJson.projects.length : null,
113
+ };
114
+ }
115
+
116
+ module.exports = {
117
+ isBridgeEnabled,
118
+ readBridgeMarker,
119
+ setBridgeEnabled,
120
+ formatRelativeAge,
121
+ projectStatusForBridge,
122
+ CONSTELLATION_BRIDGE_MARKER_PATH,
123
+ CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION,
124
+ };