@nanobpm/nano-workforce 0.152.0 → 0.154.0
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/.github/workflows/ci.yml +6 -5
- package/CHANGELOG.md +12 -0
- package/README.md +47 -15
- package/install.sh +680 -17
- package/package.json +1 -1
- package/test/install-smoke.sh +346 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -92,10 +92,11 @@ jobs:
|
|
|
92
92
|
# blocker, and a self-proving canary asserts the oracle's red path genuinely fires so the gate can
|
|
93
93
|
# never rot into a vacuous green while the corpus is parked. Hermetic (in-process wasm engine, no
|
|
94
94
|
# sockets), so it runs on every PR/push as its own job.
|
|
95
|
-
# install.sh onboarding script (
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
95
|
+
# install.sh onboarding script (issues #576 + #583): POSIX-sh cleanliness + a
|
|
96
|
+
# hermetic smoke test asserting the emitted command sequence (phase 1) and the
|
|
97
|
+
# phase-2 console call flow (dry-run + a stubbed-console live run). The script
|
|
98
|
+
# is served from `main` for `curl … | sh`, so a broken change is live the
|
|
99
|
+
# instant it merges — this gate keeps that surface honest.
|
|
99
100
|
install-script:
|
|
100
101
|
name: install.sh (shellcheck + dry-run smoke)
|
|
101
102
|
runs-on: ubuntu-latest
|
|
@@ -111,7 +112,7 @@ jobs:
|
|
|
111
112
|
- name: shellcheck (POSIX sh)
|
|
112
113
|
run: shellcheck -s sh install.sh test/install-smoke.sh
|
|
113
114
|
|
|
114
|
-
- name:
|
|
115
|
+
- name: Smoke test (dry-run sequence + stubbed-console phase 2)
|
|
115
116
|
run: sh test/install-smoke.sh
|
|
116
117
|
|
|
117
118
|
derivation-parity:
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.154.0](https://github.com/nanobpm/nano-workforce/compare/v0.153.0...v0.154.0) (2026-08-29)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **onboarding:** three-state GitHub credential preflight in install.sh ([#599](https://github.com/nanobpm/nano-workforce/issues/599)) ([7e9f592](https://github.com/nanobpm/nano-workforce/commit/7e9f592632fc85cfea9f89843252c0128cec6c8d)), closes [#588](https://github.com/nanobpm/nano-workforce/issues/588)
|
|
6
|
+
|
|
7
|
+
## [0.153.0](https://github.com/nanobpm/nano-workforce/compare/v0.152.0...v0.153.0) (2026-08-29)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **onboarding:** install-script phase 2 — install, scaffold, configure & run the Workforce app ([#598](https://github.com/nanobpm/nano-workforce/issues/598)) ([e86f9ca](https://github.com/nanobpm/nano-workforce/commit/e86f9ca292998481e1fd194859832e9040bf0b51)), closes [nanobpm/nano-workforce#583](https://github.com/nanobpm/nano-workforce/issues/583) [Magikcraft/nano-bpm#1036](https://github.com/Magikcraft/nano-bpm/issues/1036) [#583](https://github.com/nanobpm/nano-workforce/issues/583)
|
|
12
|
+
|
|
1
13
|
## [0.152.0](https://github.com/nanobpm/nano-workforce/compare/v0.151.1...v0.152.0) (2026-08-28)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/README.md
CHANGED
|
@@ -83,41 +83,73 @@ it's the same app and the same behaviour.
|
|
|
83
83
|
|
|
84
84
|
If you already have one or more coding-agent CLIs installed (`kimi`, `qwen`,
|
|
85
85
|
`copilot`, `claude`, `pi`), one command takes you from there to a **running Nano
|
|
86
|
-
engine
|
|
86
|
+
Workforce**: engine, a supervised workforce of hired agents, **and the Nano
|
|
87
|
+
Workforce app itself, installed, configured, and running**:
|
|
87
88
|
|
|
88
89
|
```sh
|
|
89
90
|
curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh
|
|
90
91
|
```
|
|
91
92
|
|
|
92
|
-
It installs `@camunda8/cli` and the
|
|
93
|
-
completion, detects which of your
|
|
94
|
-
which to hire (with a model and an
|
|
95
|
-
`--rank senior --protocol acp --permission
|
|
96
|
-
**workforce manifest** (`c8 nano workforce add`),
|
|
97
|
-
(`c8 nano start` then `c8 nano workforce start`).
|
|
93
|
+
**Phase 1 — engine + workforce.** It installs `@camunda8/cli` and the
|
|
94
|
+
`c8ctl-plugin-nano` plugin, installs shell completion, detects which of your
|
|
95
|
+
harnesses are installed, lets you multi-select which to hire (with a model and an
|
|
96
|
+
instance count each), hires each with `--rank senior --protocol acp --permission
|
|
97
|
+
yolo`, composes a declarative **workforce manifest** (`c8 nano workforce add`),
|
|
98
|
+
and brings it up (`c8 nano start` then `c8 nano workforce start`).
|
|
99
|
+
|
|
100
|
+
**GitHub access preflight.** Because a workforce with no GitHub credential can
|
|
101
|
+
neither review, push, nor merge, the preflight checks for a **usable** credential
|
|
102
|
+
on this host before hiring: a `GITHUB_TOKEN`/`GH_TOKEN` in the environment
|
|
103
|
+
*(validated with `gh api user` / a raw `api.github.com/user` probe, not trusted
|
|
104
|
+
merely for being set — in the rare case where neither `gh` nor `curl` is present
|
|
105
|
+
to probe with, it cannot validate and warns that it is proceeding on trust)*, or
|
|
106
|
+
the `gh` CLI *installed, authenticated, and proven with
|
|
107
|
+
`gh api user`*. It
|
|
108
|
+
distinguishes gh missing, installed-but-unauthenticated, and
|
|
109
|
+
authenticated-but-unusable (expired token, missing `repo` scope, SAML SSO), warns
|
|
110
|
+
(but does not fail) on a missing `workflow` scope, and checks `git` too. It prints
|
|
111
|
+
a platform-aware install hint and the `gh auth login` / `GITHUB_TOKEN` remediation
|
|
112
|
+
— it never runs `gh auth login` for you. Interactive runs let you fix it and
|
|
113
|
+
re-check, or confirm-to-continue (default no); non-interactive runs **fail** unless
|
|
114
|
+
you pass `--allow-no-github`. The check covers **this host only** — remote worker
|
|
115
|
+
hosts each need their own credential.
|
|
116
|
+
|
|
117
|
+
**Phase 2 — the app.** It then talks to the nano console the engine brought up to
|
|
118
|
+
install the `@nanobpm/nano-workforce` extension, scaffold a **Workforce** project
|
|
119
|
+
from the `nano-workforce` template, write its `ProjectConfig.env` (including
|
|
120
|
+
`NANO_WORKFORCE_BASE_URL` set to the console origin + `/console/app-view/Workforce`
|
|
121
|
+
so the fleet's hooks resolve through the proxy — see [Fleet
|
|
122
|
+
networking](#fleet-networking-remote-workers)), and run it. It confirms readiness
|
|
123
|
+
by polling the app's own `GET /app/api/version` through the proxy, then prints the
|
|
124
|
+
cockpit URL, the Tasks inbox, the Delivery Graphs page, and the `/app/api/agent` +
|
|
125
|
+
`/app/mcp` pointers. Pass `--skip-app` to stop after phase 1.
|
|
98
126
|
|
|
99
127
|
> ⚠️ The hired agents run **unattended** with `--permission yolo` — full tool
|
|
100
128
|
> access (shell, file writes, network) as your user on this host. The script shows
|
|
101
|
-
> a confirmation summary before it hires anything
|
|
102
|
-
|
|
103
|
-
**What it does *not* do:** it stops at "engine up, workforce up, agents polling".
|
|
104
|
-
It does **not** install/deploy/run the Nano Workforce **app** itself — for that,
|
|
105
|
-
use Nano Studio (above) or the manual steps below.
|
|
129
|
+
> a confirmation summary before it hires anything (and before it installs the app);
|
|
130
|
+
> pass `--yes` to skip both.
|
|
106
131
|
|
|
107
132
|
Prompts read from `/dev/tty`, so the `curl … | sh` pipe works interactively. It is
|
|
108
|
-
also fully scriptable and safe to re-run (it converges rather than duplicating
|
|
133
|
+
also fully scriptable and safe to re-run (it converges rather than duplicating — an
|
|
134
|
+
existing project is configured and run, never re-scaffolded):
|
|
109
135
|
|
|
110
136
|
```sh
|
|
111
137
|
# Non-interactive (CI / re-provisioning); repeatable --harness <name>[:<model>][:<instances>]
|
|
112
138
|
curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh -s -- --harness copilot:gpt-5.4:5 --harness claude:opus:1 --yes
|
|
113
139
|
|
|
114
|
-
#
|
|
140
|
+
# Name the app's project, or stop after phase 1 (engine + workforce only)
|
|
141
|
+
curl -fsSL .../install.sh | sh -s -- --harness copilot:gpt-5.4:5 --yes --project-name Workforce
|
|
142
|
+
curl -fsSL .../install.sh | sh -s -- --harness copilot:gpt-5.4:5 --yes --skip-app
|
|
143
|
+
|
|
144
|
+
# Preview every command (including the console API calls) without changing anything
|
|
115
145
|
curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh -s -- --harness copilot:gpt-5.4:1 --dry-run
|
|
116
146
|
```
|
|
117
147
|
|
|
118
148
|
`qwen` must always be given an explicit model (it stalls without one). With no
|
|
119
149
|
controlling terminal and no `--harness`, the script exits with usage rather than
|
|
120
|
-
hanging.
|
|
150
|
+
hanging. Phase 2 needs the engine's console reachable (the default `c8 nano start`
|
|
151
|
+
`studio` mode); an unreachable console is reported as an early failure that changes
|
|
152
|
+
nothing. Because it is served from `main`, a merged change to `install.sh` is live
|
|
121
153
|
immediately.
|
|
122
154
|
|
|
123
155
|
### Prerequisites
|
package/install.sh
CHANGED
|
@@ -79,6 +79,10 @@ DRY_RUN=0
|
|
|
79
79
|
ASSUME_YES=0
|
|
80
80
|
SHELL_OVERRIDE=''
|
|
81
81
|
INSTALL_ADAPTERS=0 # auto-install missing adapters without prompting
|
|
82
|
+
ALLOW_NO_GITHUB=0 # non-interactive: proceed even without usable GitHub access
|
|
83
|
+
GITHUB_DEGRADED='' # non-empty => the GitHub preflight passed in a degraded state; repeated in the final summary
|
|
84
|
+
SKIP_APP=0 # phase 1 only: bring up engine + workforce, don't install the app
|
|
85
|
+
PROJECT_NAME='' # console project name for the Nano Workforce app (default: Workforce)
|
|
82
86
|
CLI_HARNESS_SPECS='' # newline-separated name[:model][:instances] from --harness
|
|
83
87
|
SELECTIONS='' # newline-separated "name<US>model<US>instances" (US = non-whitespace 0x1f, so an empty model field does not collapse under read)
|
|
84
88
|
FAILURES='' # newline-separated failure notes (partial-success report)
|
|
@@ -95,9 +99,39 @@ ADAPTERS_PRESENT="${NANO_INSTALL_ADAPTERS_PRESENT:-}"
|
|
|
95
99
|
if [ "${NANO_INSTALL_HARNESSES_OVERRIDE+set}" = set ]; then HARNESS_OVERRIDE_SET=1; else HARNESS_OVERRIDE_SET=0; fi
|
|
96
100
|
if [ "${NANO_INSTALL_ADAPTERS_PRESENT+set}" = set ]; then ADAPTERS_PRESENT_SET=1; else ADAPTERS_PRESENT_SET=0; fi
|
|
97
101
|
|
|
102
|
+
# Test hooks for the GitHub credential preflight (undocumented; keep the smoke
|
|
103
|
+
# test hermetic on runners that may or may not ship an authenticated gh):
|
|
104
|
+
# NANO_INSTALL_GH_STATE — force gh state: missing|unauthed|unusable|ok
|
|
105
|
+
# NANO_INSTALL_GH_SCOPES — comma/space list of token scopes gh reports
|
|
106
|
+
# NANO_INSTALL_GIT_STATE — force git state: missing|present
|
|
107
|
+
# NANO_INSTALL_TOKEN_STATE — force env-token validity: ok|bad (skips the real
|
|
108
|
+
# gh/api.github.com probe so tests need no network)
|
|
109
|
+
# A set-but-empty override still counts as set, so the probe never falls back to
|
|
110
|
+
# the real command -v / gh and the smoke test stays hermetic.
|
|
111
|
+
if [ "${NANO_INSTALL_GH_STATE+set}" = set ]; then GH_STATE_OVERRIDE_SET=1; else GH_STATE_OVERRIDE_SET=0; fi
|
|
112
|
+
if [ "${NANO_INSTALL_GIT_STATE+set}" = set ]; then GIT_STATE_OVERRIDE_SET=1; else GIT_STATE_OVERRIDE_SET=0; fi
|
|
113
|
+
if [ "${NANO_INSTALL_TOKEN_STATE+set}" = set ]; then TOKEN_STATE_OVERRIDE_SET=1; else TOKEN_STATE_OVERRIDE_SET=0; fi
|
|
114
|
+
|
|
98
115
|
CLI='' # resolved c8ctl / c8 binary
|
|
99
116
|
TTY='' # /dev/tty if usable, else empty
|
|
100
117
|
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Phase 2 (app install) configuration + test hooks.
|
|
120
|
+
# NANO_INSTALL_CONSOLE_ORIGIN — override the console/engine origin (tests).
|
|
121
|
+
# NANO_INSTALL_APP_POLL_ATTEMPTS — readiness-poll attempt count (tests).
|
|
122
|
+
# NANO_INSTALL_APP_POLL_INTERVAL — seconds between readiness polls (tests).
|
|
123
|
+
# CONSOLE_ORIGIN is the scheme://host:port the nano console+engine listen on
|
|
124
|
+
# (default http://localhost:8080); the console API lives under /console there.
|
|
125
|
+
CONSOLE_ORIGIN=''
|
|
126
|
+
APPVIEW_BASE=''
|
|
127
|
+
PROJECT=''
|
|
128
|
+
APP_POLL_ATTEMPTS="${NANO_INSTALL_APP_POLL_ATTEMPTS:-60}"
|
|
129
|
+
APP_POLL_INTERVAL="${NANO_INSTALL_APP_POLL_INTERVAL:-2}"
|
|
130
|
+
# Results of the last api() call.
|
|
131
|
+
API_STATUS=''
|
|
132
|
+
API_BODY=''
|
|
133
|
+
API_ERR=0
|
|
134
|
+
|
|
101
135
|
# ---------------------------------------------------------------------------
|
|
102
136
|
# Command runner — honours --dry-run for mutating commands.
|
|
103
137
|
# ---------------------------------------------------------------------------
|
|
@@ -200,6 +234,15 @@ Options:
|
|
|
200
234
|
-y, --yes Skip the confirmation summary.
|
|
201
235
|
--install-adapters Auto-install a selected harness's missing ACP adapter
|
|
202
236
|
(claude/pi) instead of prompting/skipping.
|
|
237
|
+
--allow-no-github Proceed even when no usable GitHub access is detected. In a
|
|
238
|
+
non-interactive run the preflight otherwise fails, since a
|
|
239
|
+
workforce with no GitHub credential cannot review, push, or
|
|
240
|
+
merge. Interactive runs prompt instead.
|
|
241
|
+
--project-name <name>
|
|
242
|
+
Console project name for the Nano Workforce app
|
|
243
|
+
(default: Workforce). [A-Za-z0-9._-] only.
|
|
244
|
+
--skip-app Run phase 1 only (engine + workforce); do NOT install,
|
|
245
|
+
scaffold, configure, or run the Nano Workforce app.
|
|
203
246
|
--shell <bash|zsh|fish>
|
|
204
247
|
Override shell-completion detection.
|
|
205
248
|
--dry-run Print every command that would run; change nothing.
|
|
@@ -208,6 +251,13 @@ Options:
|
|
|
208
251
|
Non-interactive example:
|
|
209
252
|
curl -fsSL .../install.sh | sh -s -- --harness copilot:gpt-5.4:5 --harness claude:opus:1 --yes
|
|
210
253
|
|
|
254
|
+
Phases:
|
|
255
|
+
1. (nanobpm/nano-workforce#576) install @camunda8/cli + the nano plugin, hire
|
|
256
|
+
your harnesses, compose a workforce manifest, bring up engine + workforce.
|
|
257
|
+
2. (nanobpm/nano-workforce#583) install the @nanobpm/nano-workforce console
|
|
258
|
+
extension, scaffold + configure + run a Workforce project, and print its
|
|
259
|
+
app-view URL. Skip it with --skip-app.
|
|
260
|
+
|
|
211
261
|
With no controlling terminal (/dev/tty) and no --harness, the script exits
|
|
212
262
|
non-zero rather than hanging on a prompt.
|
|
213
263
|
EOF
|
|
@@ -230,6 +280,12 @@ parse_args() {
|
|
|
230
280
|
shift ;;
|
|
231
281
|
--yes|-y) ASSUME_YES=1; shift ;;
|
|
232
282
|
--install-adapters) INSTALL_ADAPTERS=1; shift ;;
|
|
283
|
+
--allow-no-github) ALLOW_NO_GITHUB=1; shift ;;
|
|
284
|
+
--skip-app) SKIP_APP=1; shift ;;
|
|
285
|
+
--project-name)
|
|
286
|
+
[ $# -ge 2 ] || die "--project-name requires a value"
|
|
287
|
+
PROJECT_NAME=$2; shift 2 ;;
|
|
288
|
+
--project-name=*) PROJECT_NAME=${1#--project-name=}; shift ;;
|
|
233
289
|
--dry-run) DRY_RUN=1; shift ;;
|
|
234
290
|
--shell)
|
|
235
291
|
[ $# -ge 2 ] || die "--shell requires a value (bash|zsh|fish)"
|
|
@@ -241,6 +297,210 @@ parse_args() {
|
|
|
241
297
|
done
|
|
242
298
|
}
|
|
243
299
|
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
# GitHub credential preflight (nanobpm/nano-workforce#588)
|
|
302
|
+
#
|
|
303
|
+
# Two consumers need GitHub access and fail differently: the nwf app (review
|
|
304
|
+
# poller + merge stage) and each hired agent worker (shells out to gh/git). The
|
|
305
|
+
# real predicate is "a usable credential exists on this host", not "gh is
|
|
306
|
+
# installed" — a token in GITHUB_TOKEN/GH_TOKEN satisfies both without gh.
|
|
307
|
+
#
|
|
308
|
+
# Detection distinguishes three states beyond present/absent: not installed,
|
|
309
|
+
# installed-but-unauthenticated, and authenticated-but-unusable (a push/PR that
|
|
310
|
+
# 403s only later — expired token, missing scope, SAML SSO, fine-grained PAT
|
|
311
|
+
# without repo access). We prove usability with `gh api user`, never trusting
|
|
312
|
+
# `gh auth status` alone.
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
gh_present() { # 0 if the gh CLI is available (honours the test hook)
|
|
315
|
+
if [ "$GH_STATE_OVERRIDE_SET" -eq 1 ]; then
|
|
316
|
+
case "$NANO_INSTALL_GH_STATE" in missing) return 1 ;; *) return 0 ;; esac
|
|
317
|
+
fi
|
|
318
|
+
command -v gh >/dev/null 2>&1
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
gh_authed() { # 0 if gh auth status succeeds (honours the test hook)
|
|
322
|
+
if [ "$GH_STATE_OVERRIDE_SET" -eq 1 ]; then
|
|
323
|
+
case "$NANO_INSTALL_GH_STATE" in missing|unauthed) return 1 ;; *) return 0 ;; esac
|
|
324
|
+
fi
|
|
325
|
+
gh auth status >/dev/null 2>&1
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
gh_usable() { # 0 if gh api user actually works — validates token + network + SSO
|
|
329
|
+
if [ "$GH_STATE_OVERRIDE_SET" -eq 1 ]; then
|
|
330
|
+
case "$NANO_INSTALL_GH_STATE" in ok) return 0 ;; *) return 1 ;; esac
|
|
331
|
+
fi
|
|
332
|
+
gh api user --jq .login >/dev/null 2>&1
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
gh_token_usable() { # 0 if the env GITHUB_TOKEN/GH_TOKEN actually authenticates
|
|
336
|
+
# An expired/revoked/underscoped token is the *exact* late failure this
|
|
337
|
+
# preflight exists to catch, so a non-empty token is not trusted blindly — we
|
|
338
|
+
# prove it works (gh api user, or a raw api.github.com/user probe when gh is
|
|
339
|
+
# absent). Honours the test hook so the smoke suite needs no network.
|
|
340
|
+
if [ "$TOKEN_STATE_OVERRIDE_SET" -eq 1 ]; then
|
|
341
|
+
case "$NANO_INSTALL_TOKEN_STATE" in ok) return 0 ;; *) return 1 ;; esac
|
|
342
|
+
fi
|
|
343
|
+
_tok="${GITHUB_TOKEN:-${GH_TOKEN:-}}"
|
|
344
|
+
if gh_present; then
|
|
345
|
+
GITHUB_TOKEN="$_tok" GH_TOKEN="$_tok" gh api user --jq .login >/dev/null 2>&1
|
|
346
|
+
elif command -v curl >/dev/null 2>&1; then
|
|
347
|
+
# Pass the token via --config on stdin so it never lands in curl's argv
|
|
348
|
+
# (readable by other users via `ps` on a multi-user host).
|
|
349
|
+
printf 'header = "Authorization: Bearer %s"\n' "$_tok" | \
|
|
350
|
+
curl -fsS --connect-timeout 5 --max-time 10 --config - \
|
|
351
|
+
-H "User-Agent: nano-workforce-install" \
|
|
352
|
+
https://api.github.com/user >/dev/null 2>&1
|
|
353
|
+
else
|
|
354
|
+
# No gh and no curl to validate with — we cannot prove the token works,
|
|
355
|
+
# so warn explicitly rather than let an unvalidated token look "usable".
|
|
356
|
+
warn "cannot validate GITHUB_TOKEN/GH_TOKEN — neither gh nor curl is available on this host; proceeding on trust (an expired or under-scoped token will only fail later)."
|
|
357
|
+
return 0
|
|
358
|
+
fi
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
gh_scopes() { # print space-separated token scopes gh reports (honours the test hook)
|
|
362
|
+
if [ "$GH_STATE_OVERRIDE_SET" -eq 1 ]; then
|
|
363
|
+
printf '%s' "${NANO_INSTALL_GH_SCOPES:-}" | tr ',' ' '
|
|
364
|
+
return 0
|
|
365
|
+
fi
|
|
366
|
+
# gh auth status prints e.g.: - Token scopes: 'gist', 'read:org', 'repo', 'workflow'
|
|
367
|
+
gh auth status 2>&1 | sed -n "s/.*Token scopes:[ ]*//p" | tr -d "'" | tr ',' ' '
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
git_present() { # 0 if git is available (honours the test hook)
|
|
371
|
+
if [ "$GIT_STATE_OVERRIDE_SET" -eq 1 ]; then
|
|
372
|
+
case "$NANO_INSTALL_GIT_STATE" in missing) return 1 ;; *) return 0 ;; esac
|
|
373
|
+
fi
|
|
374
|
+
command -v git >/dev/null 2>&1
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
# Platform-aware install + remediation hint. We print the command; we NEVER run
|
|
378
|
+
# `gh auth login` for the user (it is an interactive device/OAuth flow that
|
|
379
|
+
# should stay in their hands).
|
|
380
|
+
gh_remediation_hint() {
|
|
381
|
+
note "GitHub access not detected. The workforce cannot review, push, or merge without it."
|
|
382
|
+
note ""
|
|
383
|
+
note " macOS brew install gh"
|
|
384
|
+
note " Debian/Ubuntu sudo apt install gh"
|
|
385
|
+
note " Fedora sudo dnf install gh"
|
|
386
|
+
note " Arch sudo pacman -S gh"
|
|
387
|
+
note " other https://github.com/cli/cli#installation"
|
|
388
|
+
note ""
|
|
389
|
+
note "Then: gh auth login (or export GITHUB_TOKEN=…)"
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
# One probe pass. Sets GH_PROBE_MSG (human summary) and returns 0 when a usable
|
|
393
|
+
# credential exists, 1 otherwise. Warns (non-fatal) when the workflow scope is
|
|
394
|
+
# absent, since that only bites on workflow-file changes.
|
|
395
|
+
probe_github() {
|
|
396
|
+
GH_PROBE_MSG=''
|
|
397
|
+
# 1. token in env → validate it (don't trust a non-empty value), skip gh
|
|
398
|
+
# scope checks (a raw token exposes no scopes here) once it authenticates.
|
|
399
|
+
if [ -n "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then
|
|
400
|
+
if ! gh_token_usable; then
|
|
401
|
+
GH_PROBE_MSG="a GITHUB_TOKEN/GH_TOKEN is set but it failed validation (gh api user / api.github.com/user) — it is expired, revoked, or lacks access. Fix or unset it."
|
|
402
|
+
return 1
|
|
403
|
+
fi
|
|
404
|
+
if gh_present; then
|
|
405
|
+
GH_PROBE_MSG="GitHub token detected in the environment (GITHUB_TOKEN/GH_TOKEN); gh CLI also present."
|
|
406
|
+
else
|
|
407
|
+
GH_PROBE_MSG="GitHub token detected in the environment (GITHUB_TOKEN/GH_TOKEN)."
|
|
408
|
+
note "A token is set, so the nwf app is fine (NANO_PR_GITHUB_TRANSPORT=token)."
|
|
409
|
+
note "But gh is NOT installed on this host — harnesses that shell out to 'gh' still won't work. Install gh too."
|
|
410
|
+
fi
|
|
411
|
+
return 0
|
|
412
|
+
fi
|
|
413
|
+
# 2. otherwise the host CLI must be present, authenticated, and usable.
|
|
414
|
+
if ! gh_present; then
|
|
415
|
+
GH_PROBE_MSG="no gh CLI and no GITHUB_TOKEN/GH_TOKEN in the environment."
|
|
416
|
+
return 1
|
|
417
|
+
fi
|
|
418
|
+
if ! gh_authed; then
|
|
419
|
+
GH_PROBE_MSG="gh is installed but not authenticated (gh auth status failed)."
|
|
420
|
+
return 1
|
|
421
|
+
fi
|
|
422
|
+
# 3. prove it actually works — don't just trust status.
|
|
423
|
+
if ! gh_usable; then
|
|
424
|
+
GH_PROBE_MSG="gh reports authenticated but 'gh api user' failed — the credential is unusable (expired/revoked token, network, or SAML SSO not authorized for the target org)."
|
|
425
|
+
return 1
|
|
426
|
+
fi
|
|
427
|
+
# 4. authenticated + usable: the repo scope is required, workflow only warns.
|
|
428
|
+
_scopes=$(gh_scopes)
|
|
429
|
+
case " $_scopes " in
|
|
430
|
+
*" repo "*) : ;;
|
|
431
|
+
*)
|
|
432
|
+
GH_PROBE_MSG="gh is authenticated but the token is missing the 'repo' scope — pushes and PR creation will 403. Re-auth with: gh auth refresh -s repo"
|
|
433
|
+
return 1 ;;
|
|
434
|
+
esac
|
|
435
|
+
case " $_scopes " in
|
|
436
|
+
*" workflow "*) : ;;
|
|
437
|
+
*) warn "gh token is missing the 'workflow' scope — harmless unless agents edit workflow files; add it with: gh auth refresh -s workflow" ;;
|
|
438
|
+
esac
|
|
439
|
+
GH_PROBE_MSG="GitHub access OK via the host gh CLI."
|
|
440
|
+
return 0
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
# The full preflight: probe, remediate, and decide whether to continue. Records
|
|
444
|
+
# a degraded state (repeated in the final summary) whenever we proceed without a
|
|
445
|
+
# usable credential, so a degraded install never *looks* complete.
|
|
446
|
+
add_degraded() { # accumulate a degraded reason so a later one never masks an earlier
|
|
447
|
+
if [ -n "$GITHUB_DEGRADED" ]; then
|
|
448
|
+
GITHUB_DEGRADED="${GITHUB_DEGRADED}; $1"
|
|
449
|
+
else
|
|
450
|
+
GITHUB_DEGRADED="$1"
|
|
451
|
+
fi
|
|
452
|
+
}
|
|
453
|
+
github_preflight() {
|
|
454
|
+
info "Checking GitHub access (this host only)"
|
|
455
|
+
|
|
456
|
+
# git is needed by every worker (clone/commit/push) — check it alongside gh.
|
|
457
|
+
if git_present; then
|
|
458
|
+
ok "git present"
|
|
459
|
+
else
|
|
460
|
+
warn "git not found on this host — agents cannot clone, commit, or push without it."
|
|
461
|
+
note "Install git from https://git-scm.com/downloads, then re-run."
|
|
462
|
+
add_degraded "git is not installed on this host"
|
|
463
|
+
fi
|
|
464
|
+
|
|
465
|
+
while : ; do
|
|
466
|
+
if probe_github; then
|
|
467
|
+
ok "$GH_PROBE_MSG"
|
|
468
|
+
note "This check covers THIS host only — remote worker hosts each need their own gh auth / token."
|
|
469
|
+
break
|
|
470
|
+
fi
|
|
471
|
+
|
|
472
|
+
warn "$GH_PROBE_MSG"
|
|
473
|
+
gh_remediation_hint
|
|
474
|
+
note "This check covers THIS host only — remote worker hosts each need their own gh auth / token."
|
|
475
|
+
|
|
476
|
+
# Non-interactive (--yes, no /dev/tty, or --dry-run): no prompt is possible.
|
|
477
|
+
if [ "$ASSUME_YES" -eq 1 ] || [ "$DRY_RUN" -eq 1 ] || [ -z "$TTY" ]; then
|
|
478
|
+
if [ "$ALLOW_NO_GITHUB" -eq 1 ]; then
|
|
479
|
+
warn "continuing without GitHub access (--allow-no-github) — the agents will not be able to do GitHub work."
|
|
480
|
+
add_degraded "$GH_PROBE_MSG"
|
|
481
|
+
break
|
|
482
|
+
fi
|
|
483
|
+
# Dry-run changes nothing, so never fail on it — just report what a real run would do.
|
|
484
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
485
|
+
note "dry-run: not blocking on GitHub access (a real run would fail here without --allow-no-github)."
|
|
486
|
+
break
|
|
487
|
+
fi
|
|
488
|
+
die "GitHub access not detected and running non-interactively — fix it (see above) and re-run, or pass --allow-no-github to proceed anyway."
|
|
489
|
+
fi
|
|
490
|
+
|
|
491
|
+
# Interactive: let the user fix it and re-check without re-running the whole
|
|
492
|
+
# script, else confirm-to-continue (default no).
|
|
493
|
+
if confirm "I've set up GitHub access (installed gh / ran gh auth login / exported a token). Re-check now?"; then
|
|
494
|
+
continue
|
|
495
|
+
fi
|
|
496
|
+
if confirm "Continue anyway? The agents won't be able to do GitHub work."; then
|
|
497
|
+
add_degraded "$GH_PROBE_MSG"
|
|
498
|
+
break
|
|
499
|
+
fi
|
|
500
|
+
die "aborted — set up GitHub access (see above) and re-run."
|
|
501
|
+
done
|
|
502
|
+
}
|
|
503
|
+
|
|
244
504
|
# ---------------------------------------------------------------------------
|
|
245
505
|
# Step 1 — Preflight
|
|
246
506
|
# ---------------------------------------------------------------------------
|
|
@@ -254,18 +514,10 @@ preflight() {
|
|
|
254
514
|
fi
|
|
255
515
|
ok "node $_nv (>= ${MIN_NODE}), npm present"
|
|
256
516
|
|
|
257
|
-
#
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
warn "gh is installed but not authenticated, and no GITHUB_TOKEN/GH_TOKEN is set."
|
|
262
|
-
note "Agents need GitHub access — run 'gh auth login' or export GITHUB_TOKEN before they start pulling work."
|
|
263
|
-
fi
|
|
264
|
-
else
|
|
265
|
-
warn "no gh CLI and no GITHUB_TOKEN/GH_TOKEN in the environment."
|
|
266
|
-
note "Agents need GitHub access — install gh (https://cli.github.com) and 'gh auth login', or export GITHUB_TOKEN."
|
|
267
|
-
fi
|
|
268
|
-
fi
|
|
517
|
+
# GitHub access — a workforce with no usable credential can do nothing useful,
|
|
518
|
+
# so this is a real three-state check with remediation (nanobpm/nano-workforce#588),
|
|
519
|
+
# not a passing mention. Covers this host only; remote worker hosts need their own.
|
|
520
|
+
github_preflight
|
|
269
521
|
}
|
|
270
522
|
|
|
271
523
|
# ---------------------------------------------------------------------------
|
|
@@ -438,7 +690,7 @@ live_models() { # $1 harness -> newline list on stdout
|
|
|
438
690
|
# Step 4/5 — interactive selection + model + instances
|
|
439
691
|
# ---------------------------------------------------------------------------
|
|
440
692
|
add_selection() { # $1 harness $2 model $3 instances
|
|
441
|
-
if [ -n "$2" ] && ! printf '%s' "$2" | grep -Eq '^[A-Za-z0-9._/@+-]+$'; then
|
|
693
|
+
if [ -n "$2" ] && ! printf '%s' "$2" | LC_ALL=C grep -Eq '^[A-Za-z0-9._/@+-]+$'; then
|
|
442
694
|
die "refusing model id '$2' for $1: only [A-Za-z0-9._/@+-] are allowed (':' is reserved as the --harness field separator, so it must not appear in a model id; guards against shell-metacharacter injection into the hire --command)."
|
|
443
695
|
fi
|
|
444
696
|
SELECTIONS="${SELECTIONS}$1${SEP}$2${SEP}$3
|
|
@@ -725,23 +977,418 @@ print_status_and_hints() {
|
|
|
725
977
|
$CLI nano supervisor logs <worker> --follow tail one worker's logs
|
|
726
978
|
$CLI nano workforce stop / $CLI nano stop shut the fleet / engine down
|
|
727
979
|
$CLI nano hire --list the hired profiles
|
|
980
|
+
EOF
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
# ---------------------------------------------------------------------------
|
|
984
|
+
# Phase 2 — install & run the Nano Workforce app via the nano console API
|
|
985
|
+
# (nanobpm/nano-workforce#583). Everything below talks to the console that
|
|
986
|
+
# `c8 nano start` brought up (default http://localhost:8080, console under
|
|
987
|
+
# /console). Under --dry-run, all live console calls are avoided — including
|
|
988
|
+
# read-only GETs: --dry-run prints the exact calls and mutates (and reads) nothing.
|
|
989
|
+
# ---------------------------------------------------------------------------
|
|
990
|
+
|
|
991
|
+
# scheme://host:port of a URL (drops any path), e.g. http://localhost:8080/v2
|
|
992
|
+
# -> http://localhost:8080.
|
|
993
|
+
origin_of() {
|
|
994
|
+
_u=$1
|
|
995
|
+
case "$_u" in
|
|
996
|
+
*://*) : ;;
|
|
997
|
+
*) die "invalid console origin '$_u': expected a URL with a scheme, e.g. http://localhost:8080 (set NANO_INSTALL_CONSOLE_ORIGIN or NANOBPMN_BASE_URL accordingly)." ;;
|
|
998
|
+
esac
|
|
999
|
+
_scheme=${_u%%://*}
|
|
1000
|
+
_rest=${_u#*://}
|
|
1001
|
+
_auth=${_rest%%/*}
|
|
1002
|
+
printf '%s://%s' "$_scheme" "$_auth"
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
# True (exit 0) iff $1 contains a control character (newline, tab, CR, …). Such
|
|
1006
|
+
# characters cannot appear literally in a JSON string without full \uXXXX
|
|
1007
|
+
# escaping, so we reject them at the config gate (see build_config_body) rather
|
|
1008
|
+
# than emit invalid JSON that fails the console API opaquely.
|
|
1009
|
+
has_control_chars() { [ "$(printf '%s' "$1" | LC_ALL=C tr -cd '[:cntrl:]' | wc -c)" -ne 0 ]; }
|
|
1010
|
+
|
|
1011
|
+
# Minimal JSON string escaper (backslash + double-quote). Control characters are
|
|
1012
|
+
# rejected up front by build_config_body via has_control_chars(), so the values
|
|
1013
|
+
# reaching here (URLs, ports, tokens) are already control-char-free.
|
|
1014
|
+
json_str() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
|
|
1015
|
+
|
|
1016
|
+
# Print the path of a freshly created, private (0600) temp file on stdout, or
|
|
1017
|
+
# return non-zero if none could be made. Prefers mktemp; when mktemp is absent
|
|
1018
|
+
# it falls back to a noclobber (set -C) create loop so a pre-existing path — a
|
|
1019
|
+
# symlink/clobber attack on a shared /tmp — can never be followed or reused, and
|
|
1020
|
+
# the file we read back is always the empty one we just created (never stale).
|
|
1021
|
+
mktemp_safe() {
|
|
1022
|
+
_mt=$(mktemp 2>/dev/null) && { printf '%s' "$_mt"; return 0; }
|
|
1023
|
+
_dir=${TMPDIR:-/tmp}; _n=0
|
|
1024
|
+
while [ "$_n" -lt 20 ]; do
|
|
1025
|
+
_cand="${_dir%/}/nwf-install.$$.${_n}.$(_rand)"
|
|
1026
|
+
# `set -C` makes `>` fail if the target exists (regular file OR symlink), so
|
|
1027
|
+
# the create is atomic and cannot clobber/follow an attacker-planted path.
|
|
1028
|
+
if ( set -C; umask 077; : >"$_cand" ) 2>/dev/null; then
|
|
1029
|
+
printf '%s' "$_cand"; return 0
|
|
1030
|
+
fi
|
|
1031
|
+
_n=$((_n + 1))
|
|
1032
|
+
done
|
|
1033
|
+
return 1
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
# Best-effort small random token for the mktemp fallback path (entropy only —
|
|
1037
|
+
# security rests on the noclobber create, not on this being unguessable; the
|
|
1038
|
+
# per-iteration counter in the candidate path guarantees uniqueness regardless).
|
|
1039
|
+
_rand() {
|
|
1040
|
+
awk 'BEGIN{srand();printf "%d", rand()*1000000}' 2>/dev/null && return 0
|
|
1041
|
+
date +%s 2>/dev/null || printf '%s' "$$"
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
# api METHOD PATH [BODY] [DISPLAY]
|
|
1045
|
+
# Sets API_STATUS (HTTP code, "000" on transport failure), API_BODY, API_ERR
|
|
1046
|
+
# (curl exit code, 0 on success). Under --dry-run nothing is sent: the call is
|
|
1047
|
+
# printed (DISPLAY overrides BODY in the printout, e.g. to redact a token).
|
|
1048
|
+
api() {
|
|
1049
|
+
_method=$1; _path=$2; _body=${3:-}; _display=${4:-}
|
|
1050
|
+
_url="${CONSOLE_ORIGIN}${_path}"
|
|
1051
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
1052
|
+
_shown=${_display:-$_body}
|
|
1053
|
+
if [ -n "$_shown" ]; then
|
|
1054
|
+
printf '+ %s %s %s\n' "$_method" "$_url" "$_shown" >&2
|
|
1055
|
+
else
|
|
1056
|
+
printf '+ %s %s\n' "$_method" "$_url" >&2
|
|
1057
|
+
fi
|
|
1058
|
+
API_STATUS='000'; API_BODY=''; API_ERR=0
|
|
1059
|
+
return 0
|
|
1060
|
+
fi
|
|
1061
|
+
# Bound every request so a stalled endpoint (SYN/DNS/proxy hang) can't wedge
|
|
1062
|
+
# the otherwise-bounded phase-2 poll loop: --connect-timeout caps the connect
|
|
1063
|
+
# phase, --max-time caps the whole request. A timeout surfaces as a non-zero
|
|
1064
|
+
# curl exit (API_ERR) → API_STATUS='000', handled like any transport failure.
|
|
1065
|
+
_tmp=$(mktemp_safe) || { API_STATUS='000'; API_BODY=''; API_ERR=1; return 0; }
|
|
1066
|
+
# Assemble curl's argument list with `set --` inside a SUBSHELL so it stays
|
|
1067
|
+
# local to that subshell and never touches the script's own positional
|
|
1068
|
+
# parameters ($1, $2, …) — keeping api() free of any $@ side effect. (POSIX
|
|
1069
|
+
# already saves/restores positionals across a function call, but scoping the
|
|
1070
|
+
# `set --` here makes that independent of shell quirks and future edits.)
|
|
1071
|
+
API_STATUS=$(
|
|
1072
|
+
set -- -sS --connect-timeout 10 --max-time 30 -X "$_method" -H 'Accept: application/json'
|
|
1073
|
+
if [ -n "$_body" ]; then
|
|
1074
|
+
set -- "$@" -H 'Content-Type: application/json' --data "$_body"
|
|
1075
|
+
fi
|
|
1076
|
+
curl "$@" -o "$_tmp" -w '%{http_code}' "$_url" 2>/dev/null
|
|
1077
|
+
) && API_ERR=0 || API_ERR=$?
|
|
1078
|
+
[ "$API_ERR" -ne 0 ] && API_STATUS='000'
|
|
1079
|
+
API_BODY=$(cat "$_tmp" 2>/dev/null || true)
|
|
1080
|
+
rm -f "$_tmp"
|
|
1081
|
+
return 0
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
# Human-readable outcome of the last api() call, for a user-facing error. On a
|
|
1085
|
+
# transport failure api() forces API_STATUS='000' (curl couldn't complete the
|
|
1086
|
+
# request — DNS/connect/timeout), which as a bare "HTTP 000" is meaningless and
|
|
1087
|
+
# hides the real cause; describe it as a transport failure (with curl's exit
|
|
1088
|
+
# code) instead. Otherwise report the actual HTTP status. One source so every
|
|
1089
|
+
# phase-2 console call site renders 000 the same way (no drift).
|
|
1090
|
+
api_outcome() {
|
|
1091
|
+
if [ "$API_STATUS" = '000' ]; then
|
|
1092
|
+
printf 'transport failure (curl exit %s) — could not reach %s' "$API_ERR" "$CONSOLE_ORIGIN"
|
|
1093
|
+
else
|
|
1094
|
+
printf 'HTTP %s' "$API_STATUS"
|
|
1095
|
+
fi
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
# The env map written into ProjectConfig.env (step 5). Real body + a token-
|
|
1099
|
+
# redacted display are built the same way so they never drift.
|
|
1100
|
+
build_config_body() { # $1 = redact? ("redact" to mask the token)
|
|
1101
|
+
_tok="${GITHUB_TOKEN:-${GH_TOKEN:-}}"
|
|
1102
|
+
# json_str() only escapes \ and " — it does NOT escape control characters — so
|
|
1103
|
+
# a stray newline/tab (from a mis-set env var or trailing whitespace) in ANY
|
|
1104
|
+
# interpolated value would corrupt the JSON body and fail the console API
|
|
1105
|
+
# opaquely. The config gate, not the escaper, is the single place that rejects
|
|
1106
|
+
# them, so validate every value that flows into the body, not just the token.
|
|
1107
|
+
for _cc in "GITHUB_TOKEN/GH_TOKEN=$_tok" \
|
|
1108
|
+
"NANOBPMN_BASE_URL=$CONSOLE_ORIGIN" \
|
|
1109
|
+
"NANO_WORKFORCE_BASE_URL=$APPVIEW_BASE" \
|
|
1110
|
+
"PR_REVIEW_PORT=${PR_REVIEW_PORT:-3000}"; do
|
|
1111
|
+
if has_control_chars "${_cc#*=}"; then
|
|
1112
|
+
err "${_cc%%=*} contains control characters (e.g. a stray newline or tab); refusing to build an invalid JSON config body — check the value for trailing whitespace."
|
|
1113
|
+
return 1
|
|
1114
|
+
fi
|
|
1115
|
+
done
|
|
1116
|
+
if [ -n "$_tok" ]; then
|
|
1117
|
+
if [ "${1:-}" = redact ]; then _tokval='***'; else _tokval=$(json_str "$_tok"); fi
|
|
1118
|
+
_gh="\"GITHUB_TOKEN\":\"${_tokval}\""
|
|
1119
|
+
else
|
|
1120
|
+
# No token in the environment: rely on the host `gh` CLI transport instead.
|
|
1121
|
+
_gh="\"NANO_PR_GITHUB_TRANSPORT\":\"auto\""
|
|
1122
|
+
fi
|
|
1123
|
+
printf '{"env":{%s,"NANOBPMN_BASE_URL":"%s","NANO_WORKFORCE_BASE_URL":"%s","PR_REVIEW_PORT":"%s"}}' \
|
|
1124
|
+
"$_gh" "$(json_str "$CONSOLE_ORIGIN")" "$(json_str "$APPVIEW_BASE")" "$(json_str "${PR_REVIEW_PORT:-3000}")"
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
# Step 9 — the console must be reachable before we mutate anything.
|
|
1128
|
+
app_preflight() {
|
|
1129
|
+
info "Preflighting the console at ${CONSOLE_ORIGIN}/console"
|
|
1130
|
+
api GET /console/api/projects
|
|
1131
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
1132
|
+
note "dry-run: assuming the console is reachable."
|
|
1133
|
+
return 0
|
|
1134
|
+
fi
|
|
1135
|
+
if [ "$API_ERR" -ne 0 ] || [ "$API_STATUS" = '000' ]; then
|
|
1136
|
+
err "console unreachable at ${CONSOLE_ORIGIN}/console/api (curl exit ${API_ERR})."
|
|
1137
|
+
note "The engine was likely started with NANOBPMN_CONSOLE=off/observe."
|
|
1138
|
+
note "'c8 nano start' defaults to 'studio' (console on) — check your engine config."
|
|
1139
|
+
note "Nothing was changed; phase 1 (engine + workforce) is intact."
|
|
1140
|
+
record_failure "app: console unreachable — no changes made"
|
|
1141
|
+
return 1
|
|
1142
|
+
fi
|
|
1143
|
+
case "$API_STATUS" in
|
|
1144
|
+
2*) ok "console reachable" ;;
|
|
1145
|
+
*) err "console preflight GET /console/api/projects -> HTTP $API_STATUS"
|
|
1146
|
+
record_failure "app: console preflight HTTP $API_STATUS"
|
|
1147
|
+
return 1 ;;
|
|
1148
|
+
esac
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
# Step 10 — confirm (keys, never values).
|
|
1152
|
+
confirm_app() {
|
|
1153
|
+
info "About to install the Nano Workforce app"
|
|
1154
|
+
note "console extension : @nanobpm/nano-workforce"
|
|
1155
|
+
note "project : ${PROJECT} (from template 'nano-workforce')"
|
|
1156
|
+
if [ -n "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then _ghkey='GITHUB_TOKEN'; else _ghkey='NANO_PR_GITHUB_TRANSPORT'; fi
|
|
1157
|
+
note "env keys written : ${_ghkey}, NANOBPMN_BASE_URL, NANO_WORKFORCE_BASE_URL, PR_REVIEW_PORT (values not shown)"
|
|
1158
|
+
if [ -z "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then
|
|
1159
|
+
note "no token in env : the app relies on the host gh CLI (NANO_PR_GITHUB_TRANSPORT=auto)."
|
|
1160
|
+
fi
|
|
1161
|
+
note "app-view URL : ${APPVIEW_BASE}/"
|
|
1162
|
+
if [ "$ASSUME_YES" -eq 1 ] || [ "$DRY_RUN" -eq 1 ]; then
|
|
1163
|
+
[ "$DRY_RUN" -eq 1 ] && note "dry-run: not asking for confirmation."
|
|
1164
|
+
return 0
|
|
1165
|
+
fi
|
|
1166
|
+
if [ -z "$TTY" ]; then
|
|
1167
|
+
die "refusing to install the app without confirmation and no /dev/tty — re-run with --yes."
|
|
1168
|
+
fi
|
|
1169
|
+
if confirm "Install and run the Nano Workforce app now?"; then
|
|
1170
|
+
return 0
|
|
1171
|
+
fi
|
|
1172
|
+
note "skipping app install at user request (phase 1 is up and usable)."
|
|
1173
|
+
SKIP_APP=1 # keep report_and_exit honest: the app was explicitly skipped
|
|
1174
|
+
return 1
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
# Steps 11.1–11.6 — install extension, scaffold, configure, run.
|
|
1178
|
+
app_provision() {
|
|
1179
|
+
# 11.1 — ensure the Urban toolkit (idempotent no-op when urban resolves).
|
|
1180
|
+
# Do NOT gate on urbanAvailable: a live instance can report false while a
|
|
1181
|
+
# Workforce project runs happily — treat the response as informational.
|
|
1182
|
+
info "Installing the Urban toolkit"
|
|
1183
|
+
api POST /console/api/urban/install
|
|
1184
|
+
if [ "$DRY_RUN" -eq 0 ]; then
|
|
1185
|
+
case "$API_STATUS" in
|
|
1186
|
+
2*) ok "urban toolkit ensured" ;;
|
|
1187
|
+
*) warn "urban install returned $(api_outcome) (informational — continuing)" ;;
|
|
1188
|
+
esac
|
|
1189
|
+
fi
|
|
1190
|
+
|
|
1191
|
+
# 11.2 — install the console extension. Idempotent: an existing extension is
|
|
1192
|
+
# upgraded (2xx) or already present (409/2xx) — either way, continue.
|
|
1193
|
+
info "Installing the @nanobpm/nano-workforce console extension"
|
|
1194
|
+
api POST /console/api/extensions/install '{"pkg":"@nanobpm/nano-workforce"}'
|
|
1195
|
+
if [ "$DRY_RUN" -eq 0 ]; then
|
|
1196
|
+
case "$API_STATUS" in
|
|
1197
|
+
2*) ok "extension installed/upgraded" ;;
|
|
1198
|
+
409) note "extension already installed — continuing." ;;
|
|
1199
|
+
*) err "extension install -> $(api_outcome)"
|
|
1200
|
+
[ -n "$API_BODY" ] && note "$API_BODY"
|
|
1201
|
+
record_failure "app: extension install $(api_outcome)"
|
|
1202
|
+
return 1 ;;
|
|
1203
|
+
esac
|
|
1204
|
+
fi
|
|
1205
|
+
|
|
1206
|
+
# 11.3 — confirm the pack contributed template id 'nano-workforce'. NB the
|
|
1207
|
+
# templates come from GET /console/api/projects, NOT GET /console/api/config/ide
|
|
1208
|
+
# (which returns {} live); the npm pkg name is not the template id.
|
|
1209
|
+
info "Confirming the 'nano-workforce' template is available"
|
|
1210
|
+
api GET /console/api/projects
|
|
1211
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
1212
|
+
note "dry-run: would confirm template 'nano-workforce' in the projects listing."
|
|
1213
|
+
else
|
|
1214
|
+
case "$API_STATUS" in
|
|
1215
|
+
2*) : ;;
|
|
1216
|
+
*) err "listing projects/templates -> $(api_outcome)"
|
|
1217
|
+
record_failure "app: list projects $(api_outcome)"
|
|
1218
|
+
return 1 ;;
|
|
1219
|
+
esac
|
|
1220
|
+
if printf '%s' "$API_BODY" | grep -Eq '"id"[[:space:]]*:[[:space:]]*"nano-workforce"'; then
|
|
1221
|
+
ok "template 'nano-workforce' present"
|
|
1222
|
+
else
|
|
1223
|
+
err "the extension did not contribute template 'nano-workforce'."
|
|
1224
|
+
note "Install may be mid-flight; re-run, or check the console extensions list."
|
|
1225
|
+
record_failure "app: template 'nano-workforce' missing after extension install"
|
|
1226
|
+
return 1
|
|
1227
|
+
fi
|
|
1228
|
+
fi
|
|
1229
|
+
|
|
1230
|
+
# 11.4 — scaffold the project. 409 => it already exists: DO NOT re-scaffold
|
|
1231
|
+
# (app.db lives in the project); fall through to configure + run.
|
|
1232
|
+
info "Scaffolding project '$PROJECT' from template 'nano-workforce'"
|
|
1233
|
+
_create_body=$(printf '{"name":"%s","template":"nano-workforce"}' "$(json_str "$PROJECT")")
|
|
1234
|
+
api POST /console/api/projects "$_create_body"
|
|
1235
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
1236
|
+
note "dry-run: on 409 (project exists) we skip scaffolding and continue to configure + run."
|
|
1237
|
+
else
|
|
1238
|
+
case "$API_STATUS" in
|
|
1239
|
+
2*) ok "project '$PROJECT' created" ;;
|
|
1240
|
+
409) note "project '$PROJECT' already exists — configuring and running it (not re-scaffolding)." ;;
|
|
1241
|
+
*) err "createProject -> $(api_outcome)"
|
|
1242
|
+
[ -n "$API_BODY" ] && note "$API_BODY"
|
|
1243
|
+
record_failure "app: createProject $(api_outcome)"
|
|
1244
|
+
return 1 ;;
|
|
1245
|
+
esac
|
|
1246
|
+
fi
|
|
728
1247
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
1248
|
+
# 11.5 — write ProjectConfig.env (token redacted in dry-run output).
|
|
1249
|
+
info "Configuring project '$PROJECT' (ProjectConfig.env)"
|
|
1250
|
+
_cfg_body=$(build_config_body) || {
|
|
1251
|
+
record_failure "app: invalid ProjectConfig.env value (control characters)"
|
|
1252
|
+
return 1
|
|
1253
|
+
}
|
|
1254
|
+
_cfg_show=$(build_config_body redact) || {
|
|
1255
|
+
record_failure "app: invalid ProjectConfig.env value (control characters)"
|
|
1256
|
+
return 1
|
|
1257
|
+
}
|
|
1258
|
+
api PUT "/console/api/projects/${PROJECT}/config" "$_cfg_body" "$_cfg_show"
|
|
1259
|
+
if [ "$DRY_RUN" -eq 0 ]; then
|
|
1260
|
+
case "$API_STATUS" in
|
|
1261
|
+
2*) ok "config written (NANO_WORKFORCE_BASE_URL=${APPVIEW_BASE})" ;;
|
|
1262
|
+
*) err "PUT project config -> $(api_outcome)"
|
|
1263
|
+
[ -n "$API_BODY" ] && note "$API_BODY"
|
|
1264
|
+
record_failure "app: PUT config $(api_outcome)"
|
|
1265
|
+
return 1 ;;
|
|
1266
|
+
esac
|
|
1267
|
+
fi
|
|
1268
|
+
|
|
1269
|
+
# 11.6 — run. Running an already-running project is a no-op that returns its
|
|
1270
|
+
# current RunState, so this is safe to re-run (converges, never force-restarts).
|
|
1271
|
+
info "Running project '$PROJECT'"
|
|
1272
|
+
api POST "/console/api/projects/${PROJECT}/run"
|
|
1273
|
+
if [ "$DRY_RUN" -eq 0 ]; then
|
|
1274
|
+
case "$API_STATUS" in
|
|
1275
|
+
2*) ok "run requested" ;;
|
|
1276
|
+
*) err "runProject -> $(api_outcome)"
|
|
1277
|
+
[ -n "$API_BODY" ] && note "$API_BODY"
|
|
1278
|
+
record_failure "app: runProject $(api_outcome)"
|
|
1279
|
+
return 1 ;;
|
|
1280
|
+
esac
|
|
1281
|
+
fi
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
# Step 12 — assert readiness by polling the app's OWN /app/api/version through
|
|
1285
|
+
# the proxy. The runProject response reports the supervisor's state, not the
|
|
1286
|
+
# app's readiness, so never trust it alone.
|
|
1287
|
+
app_verify() {
|
|
1288
|
+
info "Waiting for the app to answer /app/api/version"
|
|
1289
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
1290
|
+
note "dry-run: would poll GET ${APPVIEW_BASE}/app/api/version until HTTP 200."
|
|
1291
|
+
return 0
|
|
1292
|
+
fi
|
|
1293
|
+
# APP_POLL_ATTEMPTS/APP_POLL_INTERVAL come from env hooks and feed `test`
|
|
1294
|
+
# arithmetic + `sleep`. A non-integer value would make `set -e` abort with an
|
|
1295
|
+
# opaque shell error mid-loop; validate them as non-negative integers up front
|
|
1296
|
+
# and record a clear failure instead.
|
|
1297
|
+
case "$APP_POLL_ATTEMPTS" in
|
|
1298
|
+
''|*[!0-9]*) record_failure "app: NANO_INSTALL_APP_POLL_ATTEMPTS must be a non-negative integer (got '${APP_POLL_ATTEMPTS}')"; return 1 ;;
|
|
1299
|
+
esac
|
|
1300
|
+
case "$APP_POLL_INTERVAL" in
|
|
1301
|
+
''|*[!0-9]*) record_failure "app: NANO_INSTALL_APP_POLL_INTERVAL must be a non-negative integer (got '${APP_POLL_INTERVAL}')"; return 1 ;;
|
|
1302
|
+
esac
|
|
1303
|
+
_n=0
|
|
1304
|
+
while [ "$_n" -lt "$APP_POLL_ATTEMPTS" ]; do
|
|
1305
|
+
api GET "/console/app-view/${PROJECT}/app/api/version"
|
|
1306
|
+
case "$API_STATUS" in
|
|
1307
|
+
2*) ok "app is up ($(printf '%s' "$API_BODY" | tr -d '[:space:]' | cut -c1-80))"; return 0 ;;
|
|
1308
|
+
esac
|
|
1309
|
+
_n=$((_n + 1))
|
|
1310
|
+
[ "$_n" -lt "$APP_POLL_ATTEMPTS" ] && sleep "$APP_POLL_INTERVAL"
|
|
1311
|
+
done
|
|
1312
|
+
err "the app did not answer 200 on /app/api/version within $((APP_POLL_ATTEMPTS * APP_POLL_INTERVAL))s (last HTTP ${API_STATUS})."
|
|
1313
|
+
note "It may have booted but not finished self-healing its Urban surface (deps + codegen)."
|
|
1314
|
+
note "Verify your nano-bpm build carries the self-heal-on-Run fix (Magikcraft/nano-bpm#1036); check: ${APPVIEW_BASE}/app/api/version"
|
|
1315
|
+
record_failure "app: readiness poll timed out (last HTTP ${API_STATUS})"
|
|
1316
|
+
return 1
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
# Step 13 — the operator surfaces.
|
|
1320
|
+
app_surfaces() {
|
|
1321
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
1322
|
+
info "dry-run: no calls were made — the following surfaces would be available once installed:"
|
|
1323
|
+
else
|
|
1324
|
+
info "Nano Workforce is up"
|
|
1325
|
+
fi
|
|
1326
|
+
cat >&2 <<EOF
|
|
1327
|
+
App (cockpit) : ${APPVIEW_BASE}/
|
|
1328
|
+
Tasks inbox : ${APPVIEW_BASE}/tasks
|
|
1329
|
+
Delivery Graphs : ${APPVIEW_BASE}/delivery-graphs
|
|
1330
|
+
Agent guide : ${APPVIEW_BASE}/app/api/agent
|
|
1331
|
+
MCP endpoint : ${APPVIEW_BASE}/app/mcp
|
|
1332
|
+
|
|
1333
|
+
Drive it by pointing a coding agent at the agent guide (GET /app/api/agent),
|
|
1334
|
+
or add the instance's /app/mcp as an MCP server, e.g.:
|
|
1335
|
+
copilot mcp add --transport http workforce ${APPVIEW_BASE}/app/mcp
|
|
732
1336
|
EOF
|
|
733
1337
|
}
|
|
734
1338
|
|
|
1339
|
+
# Phase 2 entrypoint. Returns non-zero on a genuine failure (recorded) so the
|
|
1340
|
+
# final report exits non-zero; a benign user decline returns 1 WITHOUT recording.
|
|
1341
|
+
install_app() {
|
|
1342
|
+
info "Phase 2 — install & run the Nano Workforce app (nanobpm/nano-workforce#583)"
|
|
1343
|
+
|
|
1344
|
+
PROJECT=${PROJECT_NAME:-Workforce}
|
|
1345
|
+
case "$PROJECT" in
|
|
1346
|
+
''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0-9._-]*) die "invalid --project-name '$PROJECT': only [A-Za-z0-9._-] are allowed." ;;
|
|
1347
|
+
esac
|
|
1348
|
+
CONSOLE_ORIGIN=$(origin_of "${NANO_INSTALL_CONSOLE_ORIGIN:-${NANOBPMN_BASE_URL:-http://localhost:8080}}")
|
|
1349
|
+
APPVIEW_BASE="${CONSOLE_ORIGIN}/console/app-view/${PROJECT}"
|
|
1350
|
+
|
|
1351
|
+
if [ "$DRY_RUN" -eq 0 ] && ! command -v curl >/dev/null 2>&1; then
|
|
1352
|
+
err "curl not found — phase 2 needs curl to talk to the console API."
|
|
1353
|
+
note "Install curl and re-run (phase 1 is up and usable), or use --skip-app."
|
|
1354
|
+
record_failure "app: curl missing — phase 2 skipped"
|
|
1355
|
+
return 1
|
|
1356
|
+
fi
|
|
1357
|
+
|
|
1358
|
+
app_preflight || return 1
|
|
1359
|
+
confirm_app || return 1
|
|
1360
|
+
app_provision || return 1
|
|
1361
|
+
app_verify || return 1
|
|
1362
|
+
app_surfaces
|
|
1363
|
+
}
|
|
1364
|
+
|
|
735
1365
|
# ---------------------------------------------------------------------------
|
|
736
1366
|
# Partial-success report
|
|
737
1367
|
# ---------------------------------------------------------------------------
|
|
738
1368
|
report_and_exit() {
|
|
1369
|
+
# A degraded GitHub-access install must never *look* complete — repeat the
|
|
1370
|
+
# warning here and exit non-zero so the failure is visible, not deferred.
|
|
1371
|
+
if [ -n "$GITHUB_DEGRADED" ] && [ "$DRY_RUN" -eq 0 ]; then
|
|
1372
|
+
record_failure "GitHub/git preflight degraded (this host only): ${GITHUB_DEGRADED} — the workforce cannot review, push, or merge until it is fixed"
|
|
1373
|
+
fi
|
|
739
1374
|
if [ -n "$FAILURES" ]; then
|
|
740
1375
|
warn "Completed with some steps skipped or failed:"
|
|
741
1376
|
printf '%s\n' "$FAILURES" | sed '/^$/d' | while IFS= read -r _f; do note "- $_f"; done
|
|
742
1377
|
exit 1
|
|
743
1378
|
fi
|
|
744
|
-
|
|
1379
|
+
if [ "$SKIP_APP" -eq 1 ]; then
|
|
1380
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
1381
|
+
ok "Dry-run complete — no changes were made (app install skipped via --skip-app)."
|
|
1382
|
+
else
|
|
1383
|
+
ok "Done — engine up, workforce of hired agents up (app install skipped)."
|
|
1384
|
+
fi
|
|
1385
|
+
else
|
|
1386
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
1387
|
+
ok "Dry-run complete — no changes were made; the above is what would run."
|
|
1388
|
+
else
|
|
1389
|
+
ok "Done — engine + workforce up, and the Nano Workforce app is running."
|
|
1390
|
+
fi
|
|
1391
|
+
fi
|
|
745
1392
|
exit 0
|
|
746
1393
|
}
|
|
747
1394
|
|
|
@@ -776,7 +1423,23 @@ main() {
|
|
|
776
1423
|
compose_workforce
|
|
777
1424
|
bring_up || true
|
|
778
1425
|
print_status_and_hints
|
|
1426
|
+
if [ "$SKIP_APP" -eq 1 ]; then
|
|
1427
|
+
info "Phase 2 skipped (--skip-app)"
|
|
1428
|
+
note "The Nano Workforce app was NOT installed — re-run without --skip-app to"
|
|
1429
|
+
note "install the console extension, scaffold + configure + run the app."
|
|
1430
|
+
else
|
|
1431
|
+
install_app || true
|
|
1432
|
+
fi
|
|
779
1433
|
report_and_exit
|
|
780
1434
|
}
|
|
781
1435
|
|
|
1436
|
+
# Undocumented test hook: exercise phase 2 in isolation against a stub console
|
|
1437
|
+
# (no CLI install, no engine), used by the hermetic readiness/convergence tests.
|
|
1438
|
+
if [ "${NANO_INSTALL_TEST_PHASE2_ONLY:-0}" = 1 ]; then
|
|
1439
|
+
parse_args "$@"
|
|
1440
|
+
init_tty
|
|
1441
|
+
install_app || true
|
|
1442
|
+
report_and_exit
|
|
1443
|
+
fi
|
|
1444
|
+
|
|
782
1445
|
main "$@"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.154.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/test/install-smoke.sh
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
# NANO_INSTALL_ADAPTERS_PRESENT. Only `node` and `npm` are required (the
|
|
8
8
|
# preflight probe still runs), so run it where both are available.
|
|
9
9
|
#
|
|
10
|
+
# The phase-2 (app install, nano-workforce#583) scenarios also drive the real
|
|
11
|
+
# curl-backed console flow against an in-process node stub console via the
|
|
12
|
+
# NANO_INSTALL_TEST_PHASE2_ONLY hook — those need `node` + `curl`, and are
|
|
13
|
+
# skipped (still PASS) if either is missing.
|
|
14
|
+
#
|
|
10
15
|
# Run from the repo root: sh test/install-smoke.sh
|
|
11
16
|
set -eu
|
|
12
17
|
|
|
@@ -94,6 +99,347 @@ assert_contains "s5: later selection defaults to 1 instance" \
|
|
|
94
99
|
assert_contains "s5: default model => bare adapter command, empty --model" \
|
|
95
100
|
"--command 'claude-code-acp' --model ''" "$OUT"
|
|
96
101
|
|
|
102
|
+
# ===========================================================================
|
|
103
|
+
# GitHub credential preflight — three-state check (nano-workforce#588)
|
|
104
|
+
# ===========================================================================
|
|
105
|
+
# The probe is stubbed hermetically via NANO_INSTALL_GH_STATE / NANO_INSTALL_GH_SCOPES
|
|
106
|
+
# / NANO_INSTALL_GIT_STATE so no real gh/git auth is needed. All run under
|
|
107
|
+
# --dry-run (never blocks) unless a scenario specifically exercises the
|
|
108
|
+
# non-interactive fail path.
|
|
109
|
+
|
|
110
|
+
# --- Scenario G1: token in env is usable, gh absent → app-fine caveat -------
|
|
111
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
|
|
112
|
+
NANO_INSTALL_TOKEN_STATE="ok" GITHUB_TOKEN="fake-usable-token" \
|
|
113
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
|
|
114
|
+
assert_contains "sG1: token detected treated as usable" \
|
|
115
|
+
"GitHub token detected in the environment" "$OUT"
|
|
116
|
+
assert_contains "sG1: token+no-gh caveat about harnesses shelling out to gh" \
|
|
117
|
+
"harnesses that shell out to 'gh' still won't work" "$OUT"
|
|
118
|
+
|
|
119
|
+
# --- Scenario G1b: a set-but-invalid token FAILS validation (not trusted) ---
|
|
120
|
+
# A non-empty token is not trusted blindly: an expired/revoked/underscoped
|
|
121
|
+
# token is the exact late failure this preflight exists to surface early.
|
|
122
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
|
|
123
|
+
NANO_INSTALL_TOKEN_STATE="bad" GITHUB_TOKEN="fake-invalid-token" \
|
|
124
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
|
|
125
|
+
assert_contains "sG1b: invalid token reported as failing validation" \
|
|
126
|
+
"failed validation" "$OUT"
|
|
127
|
+
assert_contains "sG1b: invalid token names remediation (fix or unset)" \
|
|
128
|
+
"expired, revoked, or lacks access" "$OUT"
|
|
129
|
+
|
|
130
|
+
# --- Scenario G2: gh missing, no token → not-detected + platform hint -------
|
|
131
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
|
|
132
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
|
|
133
|
+
assert_contains "sG2: no gh + no token reported" \
|
|
134
|
+
"no gh CLI and no GITHUB_TOKEN/GH_TOKEN" "$OUT"
|
|
135
|
+
assert_contains "sG2: not-detected headline" \
|
|
136
|
+
"GitHub access not detected" "$OUT"
|
|
137
|
+
assert_contains "sG2: Debian/Ubuntu install hint" "sudo apt install gh" "$OUT"
|
|
138
|
+
assert_contains "sG2: macOS install hint" "brew install gh" "$OUT"
|
|
139
|
+
assert_contains "sG2: gh auth login remediation printed" "gh auth login" "$OUT"
|
|
140
|
+
assert_contains "sG2: this-host-only caveat" "covers THIS host only" "$OUT"
|
|
141
|
+
# The script must NEVER run gh auth login itself — only print it.
|
|
142
|
+
assert_not_contains "sG2: never runs gh auth login (no 'Running' style exec)" \
|
|
143
|
+
"would run: gh auth login" "$OUT"
|
|
144
|
+
|
|
145
|
+
# --- Scenario G3: gh present but unauthenticated ---------------------------
|
|
146
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="unauthed" \
|
|
147
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
|
|
148
|
+
assert_contains "sG3: installed-but-unauthenticated state" \
|
|
149
|
+
"gh is installed but not authenticated" "$OUT"
|
|
150
|
+
|
|
151
|
+
# --- Scenario G4: gh authenticated but unusable (gh api user fails) ---------
|
|
152
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="unusable" \
|
|
153
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
|
|
154
|
+
assert_contains "sG4: authenticated-but-unusable state (gh api user failed)" \
|
|
155
|
+
"'gh api user' failed" "$OUT"
|
|
156
|
+
assert_contains "sG4: names SAML SSO as a likely cause" "SAML SSO" "$OUT"
|
|
157
|
+
|
|
158
|
+
# --- Scenario G5: authenticated + usable, missing repo scope FAILS ----------
|
|
159
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="ok" \
|
|
160
|
+
NANO_INSTALL_GH_SCOPES="gist,read:org" \
|
|
161
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
|
|
162
|
+
assert_contains "sG5: missing repo scope fails the check" \
|
|
163
|
+
"missing the 'repo' scope" "$OUT"
|
|
164
|
+
|
|
165
|
+
# --- Scenario G6: usable with repo but missing workflow scope only WARNS ----
|
|
166
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="ok" \
|
|
167
|
+
NANO_INSTALL_GH_SCOPES="repo,read:org" \
|
|
168
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
|
|
169
|
+
assert_contains "sG6: missing workflow scope warns (non-fatal)" \
|
|
170
|
+
"missing the 'workflow' scope" "$OUT"
|
|
171
|
+
assert_contains "sG6: repo+ passes the check" "GitHub access OK via the host gh CLI" "$OUT"
|
|
172
|
+
|
|
173
|
+
# --- Scenario G7: git absence is flagged -----------------------------------
|
|
174
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="ok" \
|
|
175
|
+
NANO_INSTALL_GH_SCOPES="repo,workflow" NANO_INSTALL_GIT_STATE="missing" \
|
|
176
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
|
|
177
|
+
assert_contains "sG7: git presence checked alongside gh" \
|
|
178
|
+
"git not found on this host" "$OUT"
|
|
179
|
+
|
|
180
|
+
# --- Scenario G8: non-interactive (--yes) FAILS without --allow-no-github ----
|
|
181
|
+
if NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
|
|
182
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes >/dev/null 2>&1; then
|
|
183
|
+
fail "sG8: non-interactive run with no GitHub access should exit non-zero"
|
|
184
|
+
else
|
|
185
|
+
pass "sG8: non-interactive run with no GitHub access exits non-zero"
|
|
186
|
+
fi
|
|
187
|
+
|
|
188
|
+
# --- Scenario G9: --allow-no-github lets a non-interactive run proceed -------
|
|
189
|
+
# Under --dry-run this exercises the --allow-no-github *continue* decision (the
|
|
190
|
+
# preflight records GITHUB_DEGRADED and breaks instead of dying). The final
|
|
191
|
+
# summary's degraded-failure line is intentionally NOT emitted here: dry-run
|
|
192
|
+
# makes no changes, so report_and_exit() only records the degraded failure when
|
|
193
|
+
# DRY_RUN=0 — that real-run summary path is a deliberate no-op under --dry-run.
|
|
194
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
|
|
195
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run --allow-no-github 2>&1)
|
|
196
|
+
assert_contains "sG9: --allow-no-github continues" \
|
|
197
|
+
"continuing without GitHub access" "$OUT"
|
|
198
|
+
|
|
199
|
+
# --- Scenario G9b: git-missing AND github-missing both surface (no masking) --
|
|
200
|
+
# The GitHub-degraded reason must ACCUMULATE onto the earlier git-missing one,
|
|
201
|
+
# not overwrite it — so a host lacking both never loses the git problem behind
|
|
202
|
+
# the GitHub message. Both warnings must appear in the same run.
|
|
203
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
|
|
204
|
+
NANO_INSTALL_GIT_STATE="missing" \
|
|
205
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run --allow-no-github 2>&1)
|
|
206
|
+
assert_contains "sG9b: git-missing warning still shown alongside github" \
|
|
207
|
+
"git not found on this host" "$OUT"
|
|
208
|
+
assert_contains "sG9b: github-missing continue also shown" \
|
|
209
|
+
"continuing without GitHub access" "$OUT"
|
|
210
|
+
|
|
211
|
+
# ===========================================================================
|
|
212
|
+
# Phase 2 — install & run the Nano Workforce app (nano-workforce#583)
|
|
213
|
+
# ===========================================================================
|
|
214
|
+
|
|
215
|
+
# --- Scenario 6: phase-2 dry-run emits the full console call sequence -------
|
|
216
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" \
|
|
217
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
|
|
218
|
+
assert_contains "s6: urban toolkit install" \
|
|
219
|
+
"POST http://localhost:8080/console/api/urban/install" "$OUT"
|
|
220
|
+
assert_contains "s6: extension install with pkg" \
|
|
221
|
+
'POST http://localhost:8080/console/api/extensions/install {"pkg":"@nanobpm/nano-workforce"}' "$OUT"
|
|
222
|
+
assert_contains "s6: GET projects to confirm the template" \
|
|
223
|
+
"GET http://localhost:8080/console/api/projects" "$OUT"
|
|
224
|
+
assert_contains "s6: create project from template nano-workforce" \
|
|
225
|
+
'{"name":"Workforce","template":"nano-workforce"}' "$OUT"
|
|
226
|
+
assert_contains "s6: 409-exists convergence branch documented" \
|
|
227
|
+
"on 409 (project exists) we skip scaffolding" "$OUT"
|
|
228
|
+
assert_contains "s6: NANO_WORKFORCE_BASE_URL is console origin + app-view prefix" \
|
|
229
|
+
'"NANO_WORKFORCE_BASE_URL":"http://localhost:8080/console/app-view/Workforce"' "$OUT"
|
|
230
|
+
assert_contains "s6: NANOBPMN_BASE_URL written" \
|
|
231
|
+
'"NANOBPMN_BASE_URL":"http://localhost:8080"' "$OUT"
|
|
232
|
+
assert_contains "s6: PR_REVIEW_PORT written" '"PR_REVIEW_PORT":"3000"' "$OUT"
|
|
233
|
+
assert_contains "s6: run the project" \
|
|
234
|
+
"POST http://localhost:8080/console/api/projects/Workforce/run" "$OUT"
|
|
235
|
+
assert_contains "s6: readiness poll of the app's own /app/api/version" \
|
|
236
|
+
"poll GET http://localhost:8080/console/app-view/Workforce/app/api/version" "$OUT"
|
|
237
|
+
assert_contains "s6: surface app-view URL" \
|
|
238
|
+
"http://localhost:8080/console/app-view/Workforce/" "$OUT"
|
|
239
|
+
assert_contains "s6: surface Tasks inbox" \
|
|
240
|
+
"http://localhost:8080/console/app-view/Workforce/tasks" "$OUT"
|
|
241
|
+
assert_contains "s6: surface Delivery Graphs" \
|
|
242
|
+
"http://localhost:8080/console/app-view/Workforce/delivery-graphs" "$OUT"
|
|
243
|
+
assert_contains "s6: surface agent guide" \
|
|
244
|
+
"http://localhost:8080/console/app-view/Workforce/app/api/agent" "$OUT"
|
|
245
|
+
assert_contains "s6: surface MCP endpoint" \
|
|
246
|
+
"http://localhost:8080/console/app-view/Workforce/app/mcp" "$OUT"
|
|
247
|
+
# Dry-run must not assert readiness: it changed nothing, so it must not claim the
|
|
248
|
+
# app "is up" / "is running" — it states the surfaces are what *would* be available.
|
|
249
|
+
assert_not_contains "s6: dry-run does not falsely claim the app is up" \
|
|
250
|
+
"Nano Workforce is up" "$OUT"
|
|
251
|
+
assert_not_contains "s6: dry-run does not falsely claim the app is running" \
|
|
252
|
+
"the Nano Workforce app is running" "$OUT"
|
|
253
|
+
assert_contains "s6: dry-run frames surfaces as would-be-available" \
|
|
254
|
+
"would be available" "$OUT"
|
|
255
|
+
assert_contains "s6: dry-run final report states no changes were made" \
|
|
256
|
+
"Dry-run complete — no changes were made" "$OUT"
|
|
257
|
+
|
|
258
|
+
# --- Scenario 6b: --project-name flows through every URL and body ----------
|
|
259
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" \
|
|
260
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run --project-name Fleet 2>&1)
|
|
261
|
+
assert_contains "s6b: project name in the create body" \
|
|
262
|
+
'{"name":"Fleet","template":"nano-workforce"}' "$OUT"
|
|
263
|
+
assert_contains "s6b: project name in the app-view base" \
|
|
264
|
+
'"NANO_WORKFORCE_BASE_URL":"http://localhost:8080/console/app-view/Fleet"' "$OUT"
|
|
265
|
+
assert_contains "s6b: project name in the run URL" \
|
|
266
|
+
"POST http://localhost:8080/console/api/projects/Fleet/run" "$OUT"
|
|
267
|
+
|
|
268
|
+
# --- Scenario 6c: GITHUB_TOKEN is redacted in dry-run, never printed --------
|
|
269
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" GITHUB_TOKEN="SHOULD-NOT-APPEAR-TOKEN" \
|
|
270
|
+
NANO_INSTALL_TOKEN_STATE="ok" \
|
|
271
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
|
|
272
|
+
assert_contains "s6c: token key present, masked" '"GITHUB_TOKEN":"***"' "$OUT"
|
|
273
|
+
assert_not_contains "s6c: token value never printed" "SHOULD-NOT-APPEAR-TOKEN" "$OUT"
|
|
274
|
+
|
|
275
|
+
# --- Scenario 7: --skip-app runs phase 1 only, emits no console calls -------
|
|
276
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" \
|
|
277
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run --skip-app 2>&1)
|
|
278
|
+
assert_not_contains "s7: no console API calls under --skip-app" "/console/api/" "$OUT"
|
|
279
|
+
assert_contains "s7: skip note shown" "Phase 2 skipped (--skip-app)" "$OUT"
|
|
280
|
+
assert_contains "s7: dry-run + skip-app report states no changes were made" \
|
|
281
|
+
"Dry-run complete — no changes were made" "$OUT"
|
|
282
|
+
|
|
283
|
+
# --- Scenarios 8-15: live phase-2 flow against a stubbed node console -------
|
|
284
|
+
if command -v node >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then
|
|
285
|
+
STUBDIR=$(mktemp -d "${TMPDIR:-/tmp}/nwf-smoke.XXXXXX")
|
|
286
|
+
STUB="$STUBDIR/stub-console.cjs"
|
|
287
|
+
# The repo's package.json sets "type":"module", so the CommonJS stub must be
|
|
288
|
+
# a .cjs file. It answers just enough of the console API to exercise the flow.
|
|
289
|
+
cat > "$STUB" <<'CJS'
|
|
290
|
+
const http = require('http');
|
|
291
|
+
const mode = process.env.STUB_MODE || 'happy'; // happy | exists | timeout
|
|
292
|
+
let ran = false;
|
|
293
|
+
const srv = http.createServer((req, res) => {
|
|
294
|
+
const url = req.url, m = req.method;
|
|
295
|
+
const json = (c, o) => { res.writeHead(c, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
|
|
296
|
+
req.on('data', () => {}); req.on('end', () => {
|
|
297
|
+
if (m === 'GET' && url === '/console/api/projects') {
|
|
298
|
+
const projects = mode === 'exists' ? [{ name: 'Workforce', template: 'nano-workforce' }] : [];
|
|
299
|
+
return json(200, { templates: [{ id: 'nano-workforce', pack: 'nano-workforce' }], projects });
|
|
300
|
+
}
|
|
301
|
+
if (m === 'POST' && url === '/console/api/urban/install') return json(200, { urbanAvailable: false });
|
|
302
|
+
if (m === 'POST' && url === '/console/api/extensions/install') return json(201, { id: 'nano-workforce' });
|
|
303
|
+
if (m === 'POST' && url === '/console/api/projects') return mode === 'exists' ? json(409, { error: 'exists' }) : json(201, { name: 'Workforce' });
|
|
304
|
+
if (m === 'PUT' && url === '/console/api/projects/Workforce/config') return json(200, { ok: true });
|
|
305
|
+
if (m === 'POST' && url === '/console/api/projects/Workforce/run') { ran = true; return json(200, { state: 'running' }); }
|
|
306
|
+
if (m === 'GET' && url === '/console/app-view/Workforce/app/api/version') {
|
|
307
|
+
if (mode === 'timeout') return json(404, {});
|
|
308
|
+
return ran ? json(200, { version: '9.9.9' }) : json(404, {});
|
|
309
|
+
}
|
|
310
|
+
return json(404, { error: 'nomatch', url });
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
srv.listen(0, '127.0.0.1', () => console.log('PORT ' + srv.address().port));
|
|
314
|
+
setTimeout(() => process.exit(0), 30000); // self-terminate: never leak the stub
|
|
315
|
+
CJS
|
|
316
|
+
|
|
317
|
+
# run_phase2 <mode> [extra install.sh args...] -> sets PHASE2_RC, PHASE2_OUT
|
|
318
|
+
run_phase2() {
|
|
319
|
+
_mode=$1; shift
|
|
320
|
+
STUB_MODE="$_mode" node "$STUB" > "$STUBDIR/$_mode.log" 2>&1 &
|
|
321
|
+
_pid=$!
|
|
322
|
+
_port=''; _i=0
|
|
323
|
+
while [ "$_i" -lt 50 ]; do
|
|
324
|
+
_port=$(sed -n 's/^PORT //p' "$STUBDIR/$_mode.log" 2>/dev/null)
|
|
325
|
+
[ -n "$_port" ] && break
|
|
326
|
+
_i=$((_i + 1)); sleep 1
|
|
327
|
+
done
|
|
328
|
+
if [ -z "$_port" ]; then
|
|
329
|
+
kill "$_pid" 2>/dev/null || true
|
|
330
|
+
wait "$_pid" 2>/dev/null || true
|
|
331
|
+
PHASE2_OUT="stub console never reported a port for mode '$_mode'"; PHASE2_RC=127
|
|
332
|
+
return 0
|
|
333
|
+
fi
|
|
334
|
+
set +e
|
|
335
|
+
PHASE2_OUT=$(NANO_INSTALL_TEST_PHASE2_ONLY=1 \
|
|
336
|
+
NANO_INSTALL_CONSOLE_ORIGIN="http://127.0.0.1:$_port" \
|
|
337
|
+
NANO_INSTALL_APP_POLL_ATTEMPTS=3 NANO_INSTALL_APP_POLL_INTERVAL=0 \
|
|
338
|
+
sh "$SCRIPT" --yes "$@" 2>&1)
|
|
339
|
+
PHASE2_RC=$?
|
|
340
|
+
set -e
|
|
341
|
+
kill "$_pid" 2>/dev/null || true
|
|
342
|
+
wait "$_pid" 2>/dev/null || true
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if [ -z "$STUBDIR" ] || [ ! -f "$STUB" ]; then
|
|
346
|
+
fail "s8-s15: could not create the stub console"
|
|
347
|
+
else
|
|
348
|
+
# Scenario 8 — happy path: success asserted via /app/api/version, exit 0.
|
|
349
|
+
run_phase2 happy
|
|
350
|
+
if [ "$PHASE2_RC" -eq 0 ]; then pass "s8: happy path exits zero"; else
|
|
351
|
+
fail "s8: happy path should exit zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
|
|
352
|
+
assert_contains "s8: readiness asserted via /app/api/version" "app is up" "$PHASE2_OUT"
|
|
353
|
+
assert_contains "s8: project created (not 409)" "project 'Workforce' created" "$PHASE2_OUT"
|
|
354
|
+
|
|
355
|
+
# Scenario 9 — existing project (409): configure + run, never re-scaffold.
|
|
356
|
+
run_phase2 exists
|
|
357
|
+
if [ "$PHASE2_RC" -eq 0 ]; then pass "s9: 409-exists converges (exit zero)"; else
|
|
358
|
+
fail "s9: 409-exists path should exit zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
|
|
359
|
+
assert_contains "s9: existing project configured+run, not re-scaffolded" \
|
|
360
|
+
"already exists — configuring and running it (not re-scaffolding)" "$PHASE2_OUT"
|
|
361
|
+
|
|
362
|
+
# Scenario 10 — readiness poll timeout: clear message, exit non-zero.
|
|
363
|
+
run_phase2 timeout
|
|
364
|
+
if [ "$PHASE2_RC" -ne 0 ]; then pass "s10: readiness timeout exits non-zero"; else
|
|
365
|
+
fail "s10: readiness timeout should exit non-zero"; fi
|
|
366
|
+
assert_contains "s10: timeout is a clear readiness message" \
|
|
367
|
+
"did not answer 200 on /app/api/version" "$PHASE2_OUT"
|
|
368
|
+
assert_contains "s10: timeout names the self-heal-on-Run dependency" \
|
|
369
|
+
"self-heal-on-Run" "$PHASE2_OUT"
|
|
370
|
+
|
|
371
|
+
# Scenario 11 — console off/unreachable: early failure that mutates nothing.
|
|
372
|
+
set +e
|
|
373
|
+
OFF=$(NANO_INSTALL_TEST_PHASE2_ONLY=1 NANO_INSTALL_CONSOLE_ORIGIN="http://127.0.0.1:1" \
|
|
374
|
+
sh "$SCRIPT" --yes 2>&1)
|
|
375
|
+
OFFRC=$?
|
|
376
|
+
set -e
|
|
377
|
+
if [ "$OFFRC" -ne 0 ]; then pass "s11: console-off exits non-zero"; else
|
|
378
|
+
fail "s11: console-off should exit non-zero"; fi
|
|
379
|
+
assert_contains "s11: console-off is a clear message" "console unreachable" "$OFF"
|
|
380
|
+
assert_not_contains "s11: console-off mutates nothing (no extension install)" \
|
|
381
|
+
"extensions/install" "$OFF"
|
|
382
|
+
|
|
383
|
+
# Scenario 12 — schemeless console origin: fail early with a clear message,
|
|
384
|
+
# never emit the "localhost:8080://localhost:8080"-style invalid origin.
|
|
385
|
+
set +e
|
|
386
|
+
SCHEMELESS=$(NANO_INSTALL_TEST_PHASE2_ONLY=1 NANO_INSTALL_CONSOLE_ORIGIN="localhost:8080" \
|
|
387
|
+
sh "$SCRIPT" --yes 2>&1)
|
|
388
|
+
SCHEMELESSRC=$?
|
|
389
|
+
set -e
|
|
390
|
+
if [ "$SCHEMELESSRC" -ne 0 ]; then pass "s12: schemeless origin exits non-zero"; else
|
|
391
|
+
fail "s12: schemeless origin should exit non-zero"; fi
|
|
392
|
+
assert_contains "s12: schemeless origin is a clear message" \
|
|
393
|
+
"expected a URL with a scheme" "$SCHEMELESS"
|
|
394
|
+
assert_not_contains "s12: schemeless origin never doubles the authority" \
|
|
395
|
+
"localhost:8080://localhost:8080" "$SCHEMELESS"
|
|
396
|
+
|
|
397
|
+
# Scenario 13 — mktemp unavailable: api() must fall back to a safe noclobber
|
|
398
|
+
# temp file and the happy path must still converge. A PATH-shim `mktemp` that
|
|
399
|
+
# always fails forces mktemp_safe() down its fallback branch for the script.
|
|
400
|
+
SHIMDIR="$STUBDIR/shim"
|
|
401
|
+
mkdir -p "$SHIMDIR"
|
|
402
|
+
printf '#!/bin/sh\nexit 1\n' > "$SHIMDIR/mktemp"
|
|
403
|
+
chmod +x "$SHIMDIR/mktemp"
|
|
404
|
+
_oldpath="$PATH"
|
|
405
|
+
PATH="$SHIMDIR:$PATH"; export PATH
|
|
406
|
+
run_phase2 happy
|
|
407
|
+
PATH="$_oldpath"; export PATH
|
|
408
|
+
if [ "$PHASE2_RC" -eq 0 ]; then pass "s13: mktemp-unavailable fallback converges (exit zero)"; else
|
|
409
|
+
fail "s13: mktemp-unavailable fallback should exit zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
|
|
410
|
+
assert_contains "s13: fallback still asserts readiness via /app/api/version" "app is up" "$PHASE2_OUT"
|
|
411
|
+
|
|
412
|
+
# Scenario 14 — a GITHUB_TOKEN carrying a control character (e.g. a stray
|
|
413
|
+
# newline) must be rejected with a clear error before any JSON config body
|
|
414
|
+
# is emitted, never producing invalid JSON that fails the console API
|
|
415
|
+
# opaquely. The token flows through json_str() unredacted on the live PUT.
|
|
416
|
+
_ctrl_token="$(printf 'fake-bad\ntoken')"
|
|
417
|
+
export GITHUB_TOKEN="$_ctrl_token"
|
|
418
|
+
run_phase2 happy
|
|
419
|
+
unset GITHUB_TOKEN
|
|
420
|
+
if [ "$PHASE2_RC" -ne 0 ]; then pass "s14: control-char token rejected (exit non-zero)"; else
|
|
421
|
+
fail "s14: control-char token should exit non-zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
|
|
422
|
+
assert_contains "s14: control-char token has a clear message" \
|
|
423
|
+
"control characters" "$PHASE2_OUT"
|
|
424
|
+
|
|
425
|
+
# Scenario 15 — the control-char gate covers EVERY interpolated config value,
|
|
426
|
+
# not just the token: a non-token field (here PR_REVIEW_PORT) carrying a
|
|
427
|
+
# control character must be rejected the same way, naming the offending key.
|
|
428
|
+
_ctrl_port="$(printf '3000\nbad')"
|
|
429
|
+
export PR_REVIEW_PORT="$_ctrl_port"
|
|
430
|
+
run_phase2 happy
|
|
431
|
+
unset PR_REVIEW_PORT
|
|
432
|
+
if [ "$PHASE2_RC" -ne 0 ]; then pass "s15: control-char non-token field rejected (exit non-zero)"; else
|
|
433
|
+
fail "s15: control-char non-token field should exit non-zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
|
|
434
|
+
assert_contains "s15: rejection names the offending field" \
|
|
435
|
+
"PR_REVIEW_PORT contains control characters" "$PHASE2_OUT"
|
|
436
|
+
|
|
437
|
+
rm -rf "$STUBDIR"
|
|
438
|
+
fi
|
|
439
|
+
else
|
|
440
|
+
pass "s8-s15: skipped (node or curl unavailable for the stubbed-console tests)"
|
|
441
|
+
fi
|
|
442
|
+
|
|
97
443
|
if [ "$FAILED" -ne 0 ]; then
|
|
98
444
|
printf '\nSMOKE TEST FAILED\n' >&2
|
|
99
445
|
exit 1
|