@nanobpm/nano-workforce 0.186.2 → 0.186.4
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/CHANGELOG.md +12 -0
- package/app/consoleRedirect.test.ts +127 -0
- package/app/consoleRedirect.ts +122 -0
- package/app/contracts.ts +2 -2
- package/app/deliveryRunner.test.ts +3 -0
- package/app/repoEnvelope.ts +13 -0
- package/app/service.test.ts +18 -0
- package/main.ts +21 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.186.4](https://github.com/nanobpm/nano-workforce/compare/v0.186.3...v0.186.4) (2026-09-12)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **agentic:** allow c8ctl GitHub auth for repository provisioning ([#784](https://github.com/nanobpm/nano-workforce/issues/784)) ([323bb05](https://github.com/nanobpm/nano-workforce/commit/323bb05060e63ca57082d42e3bf9c91f63abaa0a)), closes [#770](https://github.com/nanobpm/nano-workforce/issues/770) [#770](https://github.com/nanobpm/nano-workforce/issues/770) [#770](https://github.com/nanobpm/nano-workforce/issues/770)
|
|
6
|
+
|
|
7
|
+
## [0.186.3](https://github.com/nanobpm/nano-workforce/compare/v0.186.2...v0.186.3) (2026-09-12)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **app:** redirect standalone /console routes to the engine console ([#780](https://github.com/nanobpm/nano-workforce/issues/780)) ([103e967](https://github.com/nanobpm/nano-workforce/commit/103e967b95191bb04239b5c6a2195913e50b8b9b)), closes [#771](https://github.com/nanobpm/nano-workforce/issues/771)
|
|
12
|
+
|
|
1
13
|
## [0.186.2](https://github.com/nanobpm/nano-workforce/compare/v0.186.1...v0.186.2) (2026-09-11)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Tests for app/consoleRedirect.ts — the standalone `/console` → engine-console redirect (issue
|
|
2
|
+
// #771). Covers the pure match/target derivation, origin resolution from NANOBPMN_BASE_URL, and the
|
|
3
|
+
// live listener-rewiring mount over a real node:http server (redirect fires for `/console/*`,
|
|
4
|
+
// passthrough for everything else, teardown restores the original handler).
|
|
5
|
+
import { createServer, type Server } from "node:http";
|
|
6
|
+
import type { AddressInfo } from "node:net";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assert, assertEquals } from "#test-assert";
|
|
9
|
+
import {
|
|
10
|
+
consoleRedirectLocation,
|
|
11
|
+
mountConsoleRedirect,
|
|
12
|
+
resolveConsoleOrigin,
|
|
13
|
+
} from "./consoleRedirect.ts";
|
|
14
|
+
|
|
15
|
+
test("redirects a bare /console path to the console origin", () => {
|
|
16
|
+
assertEquals(consoleRedirectLocation("/console", "http://localhost:8080"), "http://localhost:8080/console");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("preserves the full path and query under /console/", () => {
|
|
20
|
+
assertEquals(
|
|
21
|
+
consoleRedirectLocation("/console/app-view/Foo?tab=bar&x=1", "http://localhost:8080"),
|
|
22
|
+
"http://localhost:8080/console/app-view/Foo?tab=bar&x=1",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("leaves /app/* and root routes unchanged (null)", () => {
|
|
27
|
+
assertEquals(consoleRedirectLocation("/app/api/agent", "http://localhost:8080"), null);
|
|
28
|
+
assertEquals(consoleRedirectLocation("/", "http://localhost:8080"), null);
|
|
29
|
+
assertEquals(consoleRedirectLocation("/agentic", "http://localhost:8080"), null);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("does not match a sibling route that merely starts with 'console'", () => {
|
|
33
|
+
assertEquals(consoleRedirectLocation("/console-x", "http://localhost:8080"), null);
|
|
34
|
+
assertEquals(consoleRedirectLocation("/consolexyz/app", "http://localhost:8080"), null);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("matches /console immediately followed by a query", () => {
|
|
38
|
+
assertEquals(consoleRedirectLocation("/console?next=1", "http://localhost:8080"), "http://localhost:8080/console?next=1");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("normalises a trailing slash on the console origin", () => {
|
|
42
|
+
assertEquals(consoleRedirectLocation("/console/x", "http://localhost:8080/"), "http://localhost:8080/console/x");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("handles an absent url", () => {
|
|
46
|
+
assertEquals(consoleRedirectLocation(undefined, "http://localhost:8080"), null);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("resolveConsoleOrigin gives CAMUNDA_REST_ADDRESS precedence over NANOBPMN_BASE_URL", () => {
|
|
50
|
+
const read = (name: string): string | null =>
|
|
51
|
+
name === "CAMUNDA_REST_ADDRESS"
|
|
52
|
+
? "http://engine.example:8080/v2"
|
|
53
|
+
: name === "NANOBPMN_BASE_URL"
|
|
54
|
+
? "http://localhost:9999"
|
|
55
|
+
: null;
|
|
56
|
+
assertEquals(resolveConsoleOrigin(read), "http://engine.example:8080");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("resolveConsoleOrigin uses NANOBPMN_BASE_URL when CAMUNDA_REST_ADDRESS is unset", () => {
|
|
60
|
+
const read = (name: string): string | null =>
|
|
61
|
+
name === "NANOBPMN_BASE_URL" ? "https://engine.example.com:9000" : null;
|
|
62
|
+
assertEquals(resolveConsoleOrigin(read), "https://engine.example.com:9000");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("resolveConsoleOrigin derives the origin from NANOBPMN_BASE_URL", () => {
|
|
66
|
+
const read = (name: string): string | null =>
|
|
67
|
+
name === "NANOBPMN_BASE_URL" ? "https://engine.example.com:9000" : null;
|
|
68
|
+
assertEquals(resolveConsoleOrigin(read), "https://engine.example.com:9000");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("resolveConsoleOrigin strips any path from the base", () => {
|
|
72
|
+
assertEquals(resolveConsoleOrigin(() => "https://engine.example.com/v2/"), "https://engine.example.com");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("resolveConsoleOrigin defaults to localhost:8080 when unset", () => {
|
|
76
|
+
assertEquals(resolveConsoleOrigin(() => null), "http://localhost:8080");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("resolveConsoleOrigin falls back to the default on an unparseable value", () => {
|
|
80
|
+
assertEquals(resolveConsoleOrigin(() => "not a url"), "http://localhost:8080");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** Spin up a real server with an app handler, mount the redirect, and return a fetch helper. */
|
|
84
|
+
async function withServer(
|
|
85
|
+
run: (base: string, teardown: () => void) => Promise<void>,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
const server: Server = createServer((_req, res) => {
|
|
88
|
+
res.statusCode = 200;
|
|
89
|
+
res.setHeader("content-type", "text/plain");
|
|
90
|
+
res.end("app-handled");
|
|
91
|
+
});
|
|
92
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
93
|
+
const teardown = mountConsoleRedirect(server, "http://console.example:8080");
|
|
94
|
+
const { port } = server.address() as AddressInfo;
|
|
95
|
+
try {
|
|
96
|
+
await run(`http://127.0.0.1:${port}`, teardown);
|
|
97
|
+
} finally {
|
|
98
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test("mount: /console/* is answered with a 302 to the console origin, not the app handler", async () => {
|
|
103
|
+
await withServer(async (base) => {
|
|
104
|
+
const res = await fetch(`${base}/console/app-view/Workforce?tab=x`, { redirect: "manual" });
|
|
105
|
+
assertEquals(res.status, 302);
|
|
106
|
+
assertEquals(res.headers.get("location"), "http://console.example:8080/console/app-view/Workforce?tab=x");
|
|
107
|
+
// 302 body is empty — the app handler must not have run.
|
|
108
|
+
assertEquals(await res.text(), "");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("mount: a non-console request is delegated to the app handler unchanged", async () => {
|
|
113
|
+
await withServer(async (base) => {
|
|
114
|
+
const res = await fetch(`${base}/app/api/agent`, { redirect: "manual" });
|
|
115
|
+
assertEquals(res.status, 200);
|
|
116
|
+
assertEquals(await res.text(), "app-handled");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("mount: teardown restores the original app handler for /console too", async () => {
|
|
121
|
+
await withServer(async (base, teardown) => {
|
|
122
|
+
teardown();
|
|
123
|
+
const res = await fetch(`${base}/console/x`, { redirect: "manual" });
|
|
124
|
+
assertEquals(res.status, 200);
|
|
125
|
+
assert((await res.text()) === "app-handled");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Standalone `/console` → engine-console redirect (issue #771).
|
|
2
|
+
//
|
|
3
|
+
// When Nano Workforce runs **embedded** behind the nano console, the console origin serves
|
|
4
|
+
// `/console/*` (its app-view host, `/console/app-view/Workforce/…`) and the reverse proxy strips the
|
|
5
|
+
// `/console/app-view/Workforce` prefix before the app's own HTTP server ever sees the request — so
|
|
6
|
+
// the app only ever handles `/app/*`, `/agentic`, and its page routes at the root.
|
|
7
|
+
//
|
|
8
|
+
// Run **standalone** (the default `npm start` on a bare port, no console proxy in front), links the
|
|
9
|
+
// UI emits under `/console` — the engine console's own origin — hit this app's port instead, where
|
|
10
|
+
// nothing serves them: the runtime answers a bare 503 because `/console/*` is not a registered
|
|
11
|
+
// app-view route. This is a narrow Node request redirect that rewrites those `/console/*` requests to
|
|
12
|
+
// the engine console origin, preserving the full path + query, and leaves every other route
|
|
13
|
+
// (`/app/*`, `/agentic`, the Workforce page routes at the root) untouched — it only ever fires for a
|
|
14
|
+
// path that is exactly `/console` or under `/console/`.
|
|
15
|
+
//
|
|
16
|
+
// The redirect is derived, not configured twice: the engine console shares the engine's origin, so
|
|
17
|
+
// the target origin comes from the CANONICAL engine-address resolution (`resolveEngineAddress`,
|
|
18
|
+
// `app/enginePreflight.ts`) — an explicit `CAMUNDA_REST_ADDRESS` wins over `NANOBPMN_BASE_URL`
|
|
19
|
+
// (default `http://localhost:8080`), the same precedence and single source of truth the engine
|
|
20
|
+
// client uses, so the redirect target can never drift from the engine the app actually talks to.
|
|
21
|
+
// Mounting it when embedded is harmless: the proxy owns `/console` there, so the app's server never
|
|
22
|
+
// sees such a path.
|
|
23
|
+
|
|
24
|
+
import type { IncomingMessage, RequestListener, Server, ServerResponse } from "node:http";
|
|
25
|
+
import { resolveEngineAddress } from "./enginePreflight.ts";
|
|
26
|
+
import { envVar } from "./version.ts";
|
|
27
|
+
|
|
28
|
+
/** A minimal logging surface (structurally a subset of `Logger`) the mount uses at boot. */
|
|
29
|
+
export interface ConsoleRedirectLog {
|
|
30
|
+
info(msg: string): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The `Location` a `/console`-prefixed request should be redirected to, or `null` when the request
|
|
35
|
+
* is NOT a console route (and so must be left for the app's own handlers).
|
|
36
|
+
*
|
|
37
|
+
* Matches ONLY a path that is exactly `/console` or begins with `/console/` — a sibling route such
|
|
38
|
+
* as `/console-x` or `/app/console` is deliberately NOT matched, keeping the redirect narrow. The
|
|
39
|
+
* full original request target (path + query) is preserved by appending it verbatim to the console
|
|
40
|
+
* origin: because the request path already carries the `/console` prefix, the result is
|
|
41
|
+
* `<consoleOrigin>/console/<rest>?<query>`.
|
|
42
|
+
*
|
|
43
|
+
* @param url the raw request target (`req.url`), e.g. `/console/app-view/Foo?tab=bar`.
|
|
44
|
+
* @param consoleOrigin the engine console origin (scheme + authority, no trailing slash), e.g.
|
|
45
|
+
* `http://localhost:8080`.
|
|
46
|
+
*/
|
|
47
|
+
export function consoleRedirectLocation(
|
|
48
|
+
url: string | undefined,
|
|
49
|
+
consoleOrigin: string,
|
|
50
|
+
): string | null {
|
|
51
|
+
if (!url) return null;
|
|
52
|
+
const queryAt = url.indexOf("?");
|
|
53
|
+
const path = queryAt === -1 ? url : url.slice(0, queryAt);
|
|
54
|
+
if (path === "/console" || path.startsWith("/console/")) {
|
|
55
|
+
return `${consoleOrigin.replace(/\/+$/, "")}${url}`;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the engine console origin (scheme + authority) from the CANONICAL engine-address
|
|
62
|
+
* resolution (`resolveEngineAddress`, `app/enginePreflight.ts`). The engine console is served on the
|
|
63
|
+
* engine's own origin, so this reuses the single engine-base source of truth — including its
|
|
64
|
+
* precedence — rather than reading one input directly. That means an explicit `CAMUNDA_REST_ADDRESS`
|
|
65
|
+
* (already the `/v2` REST address) wins over `NANOBPMN_BASE_URL`, exactly as the engine client
|
|
66
|
+
* resolves it; the redirect target can never drift from the engine the app actually talks to. Only
|
|
67
|
+
* the origin is meaningful for the redirect, so any path/query on the resolved address (e.g. the
|
|
68
|
+
* `/v2` suffix) is discarded. Falls back to the localhost default when the resolved value is
|
|
69
|
+
* unparseable.
|
|
70
|
+
*
|
|
71
|
+
* `read` is injectable so resolution is testable without mutating `process.env`.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveConsoleOrigin(read: (name: string) => string | null = envVar): string {
|
|
74
|
+
const { restAddress } = resolveEngineAddress(read);
|
|
75
|
+
try {
|
|
76
|
+
return new URL(restAddress).origin;
|
|
77
|
+
} catch {
|
|
78
|
+
return "http://localhost:8080";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Prepend a `/console` → engine-console redirect in front of `server`'s existing request handling.
|
|
84
|
+
*
|
|
85
|
+
* The runtime has already attached the app's own `request` listener(s) by the time this runs, so we
|
|
86
|
+
* capture them, remove them, and install a single wrapper that either answers a `/console/*` request
|
|
87
|
+
* with a `302` (preserving path + query) or delegates to every captured listener unchanged. This
|
|
88
|
+
* keeps the app's own routing (control API, pages, agentic upgrade) intact for every non-console
|
|
89
|
+
* request while giving the redirect first refusal on the console prefix.
|
|
90
|
+
*
|
|
91
|
+
* Returns a teardown that restores the original listener set.
|
|
92
|
+
*/
|
|
93
|
+
export function mountConsoleRedirect(
|
|
94
|
+
server: Server,
|
|
95
|
+
consoleOrigin: string,
|
|
96
|
+
log?: ConsoleRedirectLog,
|
|
97
|
+
): () => void {
|
|
98
|
+
// `Server#listeners` is typed `Function[]` by Node; narrow to the request-listener signature so we
|
|
99
|
+
// can re-invoke and later restore them.
|
|
100
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for Node's untyped listeners()
|
|
101
|
+
const existing = server.listeners("request") as RequestListener[];
|
|
102
|
+
server.removeAllListeners("request");
|
|
103
|
+
|
|
104
|
+
const handler = (req: IncomingMessage, res: ServerResponse): void => {
|
|
105
|
+
const location = consoleRedirectLocation(req.url, consoleOrigin);
|
|
106
|
+
if (location !== null) {
|
|
107
|
+
res.statusCode = 302;
|
|
108
|
+
res.setHeader("Location", location);
|
|
109
|
+
res.end();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const listener of existing) listener.call(server, req, res);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
server.on("request", handler);
|
|
116
|
+
log?.info(`console redirect mounted: /console/* → ${consoleOrigin}/console/* (standalone links)`);
|
|
117
|
+
|
|
118
|
+
return () => {
|
|
119
|
+
server.removeListener("request", handler);
|
|
120
|
+
for (const listener of existing) server.on("request", listener);
|
|
121
|
+
};
|
|
122
|
+
}
|
package/app/contracts.ts
CHANGED
|
@@ -399,9 +399,9 @@ export const WIRE_CONTRACTS = {
|
|
|
399
399
|
name: "io.nanobpm.agentTask.repository",
|
|
400
400
|
owner: "app/repoEnvelope.ts",
|
|
401
401
|
semantics:
|
|
402
|
-
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted only for a single-task feature run; the epic seed AND the delivery-graph run-root seed omit it so each fan-out slice's agent branches per node/MI child). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
|
|
402
|
+
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted only for a single-task feature run; the epic seed AND the delivery-graph run-root seed omit it so each fan-out slice's agent branches per node/MI child). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Alongside the `repository` slice the envelope carries a sibling `task.allowPr: true` (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git credential (GITHUB_TOKEN, or the `gh` default) for repo provisioning behind that flag, so every repo-backed envelope sets it or the clone dies with `unable to get password from user`; the repoless path emits no envelope and so no `task`. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
|
|
403
403
|
shape:
|
|
404
|
-
'{ provider: "github", url: string, ref
|
|
404
|
+
'io.nanobpm.agentTask: { repository: { provider: "github", url: string, ref?: string, singleBranch: true, filter: "blob:none", cloneTimeoutMs: number, baseRef?: string, sha?: string, branch?: { create: string } }, task: { allowPr: true } }',
|
|
405
405
|
},
|
|
406
406
|
"epicSet.submit": {
|
|
407
407
|
category: "wire",
|
|
@@ -485,6 +485,9 @@ test("prepareDeliveryGraph injects the repository envelope PER agent cell from t
|
|
|
485
485
|
assert(headers.some((h) => h.includes('repository.provider" value="github"')), "provider header");
|
|
486
486
|
assert(headers.some((h) => h.includes('repository.singleBranch" value="true"')), "branch-scoped blobless clone (#287)");
|
|
487
487
|
assert(headers.some((h) => h.includes('repository.filter" value="blob:none"')), "blobless filter");
|
|
488
|
+
// Repo-provisioning auth gate (issue #770): the per-cell envelope carries `task.allowPr:true` so the
|
|
489
|
+
// c8ctl harness resolves the git credential for the clone instead of failing on password prompt.
|
|
490
|
+
assert(headers.some((h) => h.includes('task.allowPr" value="true"')), `expected a task.allowPr header, got ${JSON.stringify(headers)}`);
|
|
488
491
|
// No `__repoSpec` marker survives injection — it is the compiler's digest-stable anchor only.
|
|
489
492
|
assert(!p.bpmn.includes("__repoSpec"), "the __repoSpec marker is fully replaced");
|
|
490
493
|
// No run-root `io.nanobpm.agentTask` variable — the envelope rides headers now, not a run variable.
|
package/app/repoEnvelope.ts
CHANGED
|
@@ -191,6 +191,14 @@ export function repoEnvelopeVars(
|
|
|
191
191
|
? { branch: { create: branchCreate.trim() } }
|
|
192
192
|
: {}),
|
|
193
193
|
},
|
|
194
|
+
// Repo-provisioning auth gate (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the
|
|
195
|
+
// git credential (GITHUB_TOKEN, or the `gh` default when it's absent) for repository
|
|
196
|
+
// provisioning when `task.allowPr` is truthy — `resolveJobSecrets` pulls the token behind that
|
|
197
|
+
// flag. Without it, a repo-backed clone dies with `unable to get password from user` before the
|
|
198
|
+
// agent even starts. So EVERY repo-backed envelope (this builder only runs past the `owner/repo`
|
|
199
|
+
// guard above) opts the harness into auth-resolved provisioning. The repoless path emits `{}`
|
|
200
|
+
// (no envelope, no `task`), so its behaviour — and its auth posture — is unchanged.
|
|
201
|
+
task: { allowPr: true },
|
|
194
202
|
},
|
|
195
203
|
};
|
|
196
204
|
}
|
|
@@ -222,6 +230,11 @@ export function agentNodeRepoEnvelope(repo: string, base: string | null): Record
|
|
|
222
230
|
// `feat/<node.id>` off. Omitted when unknown so the harness clones the repo's default branch.
|
|
223
231
|
...(ref ? { ref, baseRef: ref } : {}),
|
|
224
232
|
},
|
|
233
|
+
// Repo-provisioning auth gate (issue #770): as in `repoEnvelopeVars`, a repo-backed cell must
|
|
234
|
+
// opt the harness into git-credential resolution (`task.allowPr`) or the clone fails with
|
|
235
|
+
// `unable to get password from user`. Emitted on every resolved cell (past the `owner/repo`
|
|
236
|
+
// guard above); an unresolved/`repoless` cell returns `{}` and carries no `task`.
|
|
237
|
+
task: { allowPr: true },
|
|
225
238
|
},
|
|
226
239
|
};
|
|
227
240
|
}
|
package/app/service.test.ts
CHANGED
|
@@ -487,6 +487,24 @@ test("repoEnvelopeVars emits the repository envelope keyed on the PR head branch
|
|
|
487
487
|
assertEquals(env.repository.cloneTimeoutMs, 600000);
|
|
488
488
|
});
|
|
489
489
|
|
|
490
|
+
// Repo-provisioning auth gate (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git
|
|
491
|
+
// credential (GITHUB_TOKEN, or the `gh` default when it's absent) for repository provisioning when
|
|
492
|
+
// `task.allowPr` is truthy. Without it a repo-backed clone dies with `unable to get password from
|
|
493
|
+
// user` before the agent starts. Every repo-backed envelope must therefore carry `task.allowPr:
|
|
494
|
+
// true`; the repoless (unresolved) path emits nothing, so it carries no `task` and is unchanged.
|
|
495
|
+
test("repoEnvelopeVars emits task.allowPr:true on every repo-backed envelope (#770)", () => {
|
|
496
|
+
// PR-based path (review-round / fix-ci / rebase).
|
|
497
|
+
const pr = (repoEnvelopeVars("owner/repo", "feat/issue-12", "main") as any)["io.nanobpm.agentTask"];
|
|
498
|
+
assertEquals(pr.task.allowPr, true, "the PR-based envelope opts the harness into auth-resolved provisioning");
|
|
499
|
+
// Pre-PR implementation path (feature.bpmn / plan-fanout's implement-cell, #684).
|
|
500
|
+
const prePr = (repoEnvelopeVars("owner/repo", "main", null, null, "feat/issue-7") as any)["io.nanobpm.agentTask"];
|
|
501
|
+
assertEquals(prePr.task.allowPr, true, "the pre-PR envelope opts in too");
|
|
502
|
+
// A repoless / unresolved input emits NOTHING — no envelope, and so no `task` (auth posture
|
|
503
|
+
// unchanged: the launch-dir fallback needs no repo credential).
|
|
504
|
+
assertEquals(Object.keys(repoEnvelopeVars("owner/repo", null)).length, 0, "unresolved head → no envelope, no task");
|
|
505
|
+
assertEquals(Object.keys(repoEnvelopeVars("not-owner-repo", "feat/x")).length, 0, "malformed repo → no envelope, no task");
|
|
506
|
+
});
|
|
507
|
+
|
|
490
508
|
test("repoEnvelopeVars emits cloneTimeoutMs from NANO_PR_CLONE_TIMEOUT_MS, default 600000 (#694)", () => {
|
|
491
509
|
// Default (knob unset): 600000ms = 10 min, raising the harness's 120000ms default so a
|
|
492
510
|
// branch-scoped blobless clone of a large monorepo provisions instead of dying at 120s.
|
package/main.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { Server } from "node:http";
|
|
|
21
21
|
import { createNanoSdkEngineClient, runFromEnv, selectHost } from "@nanobpm/urban";
|
|
22
22
|
import { type AgenticChannelHandle, mountAgenticChannel } from "./app/agentic/channel.ts";
|
|
23
23
|
import { makeElementInstanceResolver } from "./app/agentic/element-instance.ts";
|
|
24
|
+
import { mountConsoleRedirect, resolveConsoleOrigin } from "./app/consoleRedirect.ts";
|
|
24
25
|
import { announceEngine, resolveEngineAddress } from "./app/enginePreflight.ts";
|
|
25
26
|
import { runEngineReconcile } from "./app/reconcile.ts";
|
|
26
27
|
import { MAX_ROUNDS, pollOnce } from "./app/service.ts";
|
|
@@ -103,6 +104,20 @@ if (httpServer instanceof Server) {
|
|
|
103
104
|
app.log.warn("agentic channel not mounted: app.httpServer is not a node:http Server on this host");
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
// Standalone `/console` redirect (issue #771). Running behind the nano console, the console origin
|
|
108
|
+
// serves `/console/*` and the reverse proxy strips its app-view prefix before the app ever sees a
|
|
109
|
+
// request — but STANDALONE, the UI's `/console` links land on this app's own port, where nothing
|
|
110
|
+
// serves them, so the runtime answers a bare 503. Mount a narrow request redirect that rewrites
|
|
111
|
+
// `/console/*` to the engine console origin (derived from the CANONICAL engine-address resolution,
|
|
112
|
+
// `resolveEngineAddress`, so an explicit `CAMUNDA_REST_ADDRESS` wins over `NANOBPMN_BASE_URL`),
|
|
113
|
+
// preserving path + query, and leaves `/app/*`, `/agentic`, and the Workforce page routes untouched.
|
|
114
|
+
// Mounted last so it captures every request listener the runtime + agentic channel attached, and
|
|
115
|
+
// delegates each non-console request to them unchanged.
|
|
116
|
+
let consoleRedirectTeardown: (() => void) | undefined;
|
|
117
|
+
if (httpServer instanceof Server) {
|
|
118
|
+
consoleRedirectTeardown = mountConsoleRedirect(httpServer, resolveConsoleOrigin(), app.log);
|
|
119
|
+
}
|
|
120
|
+
|
|
106
121
|
// Engine-reset reconciliation (issues #622, #630). On boot, compare the engine's incarnation epoch
|
|
107
122
|
// against the last-seen value; on a REGRESSION (the engine was reset/restored/rewound and re-minted
|
|
108
123
|
// its keys, Magikcraft/nano-bpm#1065) drive every dangling engine-backed inflight row to the defined
|
|
@@ -153,6 +168,12 @@ async function drainAndExit(): Promise<void> {
|
|
|
153
168
|
if (shuttingDown) return;
|
|
154
169
|
shuttingDown = true;
|
|
155
170
|
if (pollTimer) clearTimeout(pollTimer);
|
|
171
|
+
// Restore the original request listener set (drops the /console redirect wrapper).
|
|
172
|
+
if (consoleRedirectTeardown) {
|
|
173
|
+
try {
|
|
174
|
+
consoleRedirectTeardown();
|
|
175
|
+
} catch { /* best-effort redirect teardown */ }
|
|
176
|
+
}
|
|
156
177
|
// Tear the agentic families + hub down (releases the WS clients) before the app stops its HTTP
|
|
157
178
|
// server, which the channel shares.
|
|
158
179
|
if (agentic) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.186.
|
|
3
|
+
"version": "0.186.4",
|
|
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",
|