@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/bin/minions.js +3 -1
- package/dashboard/js/qa.js +53 -0
- package/dashboard/js/refresh.js +4 -2
- package/dashboard/js/render-managed.js +43 -9
- package/dashboard/js/render-other.js +41 -11
- package/dashboard/layout.html +1 -0
- package/dashboard/pages/qa.html +23 -0
- package/dashboard-build.js +2 -2
- package/dashboard.js +135 -24
- package/docs/.nojekyll +0 -0
- package/docs/README.md +2 -0
- package/docs/constellation-bridge.md +94 -0
- package/docs/security.md +177 -0
- package/engine/ado-git-auth.js +206 -0
- package/engine/bridge.js +124 -0
- package/engine/cc-worker-pool.js +48 -1
- package/engine/cleanup.js +72 -23
- package/engine/cli.js +169 -12
- package/engine/dispatch.js +26 -11
- package/engine/github.js +79 -26
- package/engine/issues.js +14 -3
- package/engine/lifecycle.js +55 -14
- package/engine/llm.js +16 -9
- package/engine/meeting.js +16 -5
- package/engine/queries.js +123 -52
- package/engine/recovery.js +6 -0
- package/engine/shared.js +281 -9
- package/engine/spawn-agent.js +13 -5
- package/engine/timeout.js +4 -2
- package/engine.js +242 -52
- package/package.json +1 -1
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.
|
package/docs/security.md
ADDED
|
@@ -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
|
+
};
|
package/engine/bridge.js
ADDED
|
@@ -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
|
+
};
|