fix(core,tooling): keep the harness's own ports out of the Agent's environment and off the dev server (#100)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: J.Li <jl2388@cam.ac.uk>
This commit is contained in:
Yaowei Zheng
2026-07-29 16:15:23 +08:00
committed by GitHub
parent 5530b7ffad
commit 6b3574f5c9
14 changed files with 237 additions and 15 deletions
+3 -2
View File
@@ -15,10 +15,11 @@ pnpm install
pnpm build # build first: core's exports point at dist/
pnpm dev # backend + web app together (prefixed logs, deps built once)
pnpm dev:server # backend at 127.0.0.1:7364
pnpm dev:web # web app (Vite) at 127.0.0.1:7365, /api proxied
pnpm dev:server # backend at 127.0.0.1:7368 (not the installed server's 7364)
pnpm dev:web # web app (Vite) at 127.0.0.1:7365, /api proxied to 7368
pnpm dev:docs # docs site (Vite) at 127.0.0.1:7367
pnpm dev:landing # landing page (Vite) at 127.0.0.1:7366
pnpm penguin ... # CLI from source; `penguin web` serves at 127.0.0.1:7369
BASE_PATH=/ pnpm build:site # assemble landing + docs exactly like the Pages deploy
```
@@ -0,0 +1,15 @@
# Tooling: harness environment variables no longer leak into Agent commands
Agent-spawned commands now start from a host environment with PenguinHarness-owned server variables removed, and the development backend moves off the installed server's default port so local app work is less likely to talk to the wrong process.
## Child command environment
`exec_command` and `input_command` build their child process environment through the command session manager. That path now deletes `PORT`, `HOST`, `PENGUIN_CLI_ENTRY`, and `PENGUIN_WEB_DIST` from the inherited host environment before applying the Agent vault and command-hardening defaults.
The change keeps the harness's own listen address and internal launch paths out of programs an Agent starts. A generated Vite, Next.js, Express, or similar dev server can therefore choose its own port instead of inheriting the port that PenguinHarness itself is using. When an Agent really does need to force a command's `PORT`, the vault still applies after the host strip, so that explicit per-Agent value wins.
## Development ports
The repository's development backend now defaults to `7368`, leaving the installed server and Web UI on the packaged default `7364`. The Web package's Vite proxy points at that development backend by default, while `PENGUIN_API_PROXY` can still override the whole proxy target and `PORT` can still move the backend/proxy pair together for local experiments.
The port allocation table in core documents the local development ports alongside the installed default, and the development docs now call out the split so developers can tell which process their browser is reaching.
+2
View File
@@ -1,3 +1,5 @@
# Unreleased
Changes since v0.1.4. The version number is assigned at release, when this folder is renamed.
- [2026-07-29] Tooling: Agent-spawned commands no longer inherit PenguinHarness-owned server variables such as `PORT` / `HOST`, while the development backend moves off the installed server's default port and the Web dev proxy follows that backend. ([details](2026-07-29-harness-env-and-dev-ports.md))
+1 -1
View File
@@ -15,7 +15,7 @@
"test:e2e": "pnpm --filter @prismshadow/penguin-core test:e2e",
"build": "pnpm -r build && pnpm link:cli",
"link:cli": "pnpm --dir packages/cli link --global || echo '[link:cli] pnpm global directory not configured; skipping the global penguin link (run pnpm setup once first)'",
"penguin": "node scripts/run-with-env.mjs PENGUIN_HOME=~/.penguin/dev-data -- tsx packages/cli/src/index.ts",
"penguin": "node scripts/run-with-env.mjs PENGUIN_HOME=~/.penguin/dev-data PORT=7369 -- tsx packages/cli/src/index.ts",
"dev": "concurrently -n server,web -c cyan,magenta \"pnpm dev:server\" \"pnpm dev:web\"",
"dev:server": "pnpm --filter @prismshadow/penguin-server dev",
"dev:web": "node scripts/dev-prebuild.mjs && pnpm --filter @prismshadow/penguin-web dev",
+1 -1
View File
@@ -19,7 +19,7 @@
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run --passWithNoTests",
"build": "tsup",
"penguin": "node ../../scripts/run-with-env.mjs PENGUIN_HOME=~/.penguin/dev-data -- tsx src/index.ts"
"penguin": "node ../../scripts/run-with-env.mjs PENGUIN_HOME=~/.penguin/dev-data PORT=7369 -- tsx src/index.ts"
},
"dependencies": {
"@prismshadow/agenthub": "^0.4.1",
@@ -32,6 +32,50 @@ const HARDENED_ENV: NodeJS.ProcessEnv = {
GIT_PAGER: "cat",
};
/**
* Harness-owned variables **removed** from the child environment (removed, not blanked: a
* program that checks `PORT` for presence rather than value must see nothing at all).
*
* `PORT` / `HOST` are stripped because they are never about the command being run. On the
* serving paths they are the harness's own listener: `penguin web` / `penguin server` write both
* into their own `process.env` as the channel to the server module (see the CLI's `startServer`).
* On the CLI-only paths (`penguin run`, `penguin chat`, the REPL) nothing listens at all, but the
* CLI still loads `dotenv/config`, so a `PORT` there is the one the *user's own project* picked
* for *its* server. `npm run dev`, Vite, Next and most Express templates read `PORT`, so either
* way an inherited value makes a server the Agent starts bind a port it was never asked to take —
* the harness's own in the first case, one already spoken for in the second. A command that needs
* a particular port should be told so in its own invocation (or through the vault), never by
* ambient inheritance.
*
* `PENGUIN_CLI_ENTRY` is internal plumbing: the CLI uses it to tell the server which script to
* re-run for self-update. It means nothing to any other program and leaks the install path.
*
* `PENGUIN_WEB_DIST` is *not* internal — it is a documented deployment override (see the
* configuration reference and the server README) — and is stripped anyway because it names this
* installation's front-end build. In the self-development case an Agent that starts a
* PenguinHarness server would otherwise serve the deployment's assets instead of the ones it just
* built in the workspace, silently and with no error to read.
*
* Deliberately **not** stripped: `PENGUIN_HOME`, `PENGUIN_WEB_DB` and the rest of the user-facing
* `PENGUIN_*` settings. Those select the *data* an Agent-started harness works against, and the
* self-development case may legitimately want the same data root — sharing state is a config
* decision, whereas serving a deployment's code from a workspace checkout never is.
*/
const STRIPPED_ENV_KEYS = new Set(["PORT", "HOST", "PENGUIN_CLI_ENTRY", "PENGUIN_WEB_DIST"]);
/** The host environment minus {@link STRIPPED_ENV_KEYS}. */
function hostEnvForChild(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
// Matched case-insensitively rather than deleting the upper-case spellings: Windows resolves
// environment names without regard to case but stores whatever casing was written, so a
// `set Port=3000` before `penguin web` would survive a `delete env.PORT` and still reach the
// child as PORT. On POSIX the two are distinct names and only the exact one exists.
for (const [key, value] of Object.entries(process.env)) {
if (!STRIPPED_ENV_KEYS.has(key.toUpperCase())) env[key] = value;
}
return env;
}
export class CommandSessionManager {
private readonly registry = new BackgroundRegistry<ManagedSession>({
idPrefix: "proc",
@@ -55,8 +99,10 @@ export class CommandSessionManager {
cwd: opts.cwd,
// Spread order is priority: vault overrides host variables of the same name, but must
// come before HARDENED_ENV — the hardening entries (GIT_EDITOR/PAGER etc. that prevent
// interactive hangs) must never be overridable by vault.
env: { ...process.env, ...this.vault, ...HARDENED_ENV },
// interactive hangs) must never be overridable by vault. The host side is stripped of
// the harness's own variables first (see STRIPPED_ENV_KEYS); the vault still wins, so a
// user who genuinely wants PORT in commands can set it there.
env: { ...hostEnvForChild(), ...this.vault, ...HARDENED_ENV },
});
}
+25
View File
@@ -6,5 +6,30 @@
* runtime.
*/
/**
* Port allocation across the repo (documented here because it is the one place a reader
* looks for it; the dev ports themselves live in vite configs and package.json scripts,
* neither of which can import this module):
*
* | port | who | where |
* | ---- | ---------------------------------- | ------------------------------------------- |
* | 7364 | installed server / Web UI | `DEFAULT_SERVER_PORT` below |
* | 7365 | `pnpm dev:web` (Vite) | `packages/web/vite.config.ts` |
* | 7366 | `pnpm dev:landing` (Vite) | `packages/landing/vite.config.ts` |
* | 7367 | `pnpm dev:docs` (Vite) | `packages/docs/vite.config.ts` |
* | 7368 | `pnpm dev:server` (dev backend) | `packages/server/package.json` `dev` |
* | 7369 | `pnpm penguin web` (dev CLI) | the root and cli `penguin` scripts |
*
* The development backend deliberately does **not** share 7364 with an installed one: the
* two are routinely running at once, and before they were split, `pnpm dev` either failed
* to bind or -- worse -- the Vite proxy silently talked to the installed server instead of
* the one being worked on. The dev data root is separated for the same reason.
*
* The dev CLI gets a third port rather than reusing the backend's 7368 because the two also
* run at once: a harness started as `pnpm penguin web` is exactly what asks an Agent to run
* `pnpm dev` in this repo, and sharing the number would reintroduce that collision one step
* to the left -- `dev:server` failing to bind, or the Vite proxy answering from the harness.
*/
/** Default main server / Web UI port; deliberately avoids common defaults like 3000/8080. */
export const DEFAULT_SERVER_PORT = 7364;
+78
View File
@@ -292,3 +292,81 @@ describe("exec_command — long-running command sessions", () => {
}
});
});
describe("harness environment variables never reach a spawned command", () => {
const KEYS = ["PORT", "HOST", "PENGUIN_CLI_ENTRY", "PENGUIN_WEB_DIST"] as const;
const saved: Partial<Record<(typeof KEYS)[number], string | undefined>> = {};
beforeEach(() => {
// `penguin web` writes PORT/HOST into its own process env as the channel to the server
// module, so this is exactly the state a real serving process is in.
for (const k of KEYS) saved[k] = process.env[k];
process.env.PORT = "7364";
process.env.HOST = "127.0.0.1";
process.env.PENGUIN_CLI_ENTRY = "/opt/penguin/lib/dist/index.js";
process.env.PENGUIN_WEB_DIST = "/opt/penguin/web";
});
afterEach(() => {
for (const k of KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
});
// Read through node rather than the shell: `echo $PORT` would mean different things in
// bash and PowerShell, and the resolver picks either depending on the machine.
const READ_ENV = KEYS.map((k) => `${k}=[' + (process.env.${k} ?? '') + ']`).join(", ");
it("PORT/HOST and the CLI plumbing are absent, so a dev server the Agent starts picks its own port", async () => {
const res = await runTool(env, "exec_command", {
cmd: `node -e "console.log('${READ_ENV}')"`,
});
for (const k of KEYS) {
expect(res.output, `${k} must not reach the child`).toContain(`${k}=[]`);
}
});
it("a differently-cased spelling is stripped too, for Windows' sake", async () => {
// Windows looks environment names up without regard to case but stores the casing that was
// written, so `set Port=3000` before `penguin web` reaches a child as PORT — invisible to a
// strip that only removes the upper-case name. POSIX keeps `Port` and `PORT` apart, which is
// what lets this run here at all: without the case-insensitive match it passes through.
process.env.Port = "3000";
try {
const res = await runTool(env, "exec_command", {
cmd: `node -e "console.log('Port=[' + (process.env.Port ?? '') + ']')"`,
});
expect(res.output).toContain("Port=[]");
} finally {
delete process.env.Port;
}
});
it("the rest of the host environment still passes through", async () => {
process.env.PENGUIN_TEST_PASSTHROUGH = "kept";
try {
const res = await runTool(env, "exec_command", {
cmd: `node -e "console.log('V=[' + (process.env.PENGUIN_TEST_PASSTHROUGH ?? '') + ']')"`,
});
expect(res.output).toContain("V=[kept]");
} finally {
delete process.env.PENGUIN_TEST_PASSTHROUGH;
}
});
it("the vault can put PORT back — stripping the host value is not a hard ban", async () => {
const vaultEnv = new Environment({
workspaceDir: tmp,
toolConfig: sessionConfig(),
vault: { PORT: "3000" },
});
try {
const res = await runTool(vaultEnv, "exec_command", {
cmd: `node -e "console.log('PORT=[' + (process.env.PORT ?? '') + ']')"`,
});
expect(res.output).toContain("PORT=[3000]");
} finally {
vaultEnv.dispose();
}
});
});
@@ -20,6 +20,8 @@ The CLI and the server automatically load a `.env` file from the working directo
| `PENGUIN_LANG` | CLI language (`en` / `zh`), set via `penguin config lang` | `en` |
| `PENGUIN_UPDATE_CHECK` | `off` disables the web app's new-release check (the server's only outbound internet call) | enabled |
These configure PenguinHarness itself, so `PORT`, `HOST`, `PENGUIN_WEB_DIST` and the internal `PENGUIN_CLI_ENTRY` are **removed from the environment of commands the Agent runs** — otherwise a dev server started by `exec_command` would read `PORT` and try to bind the port meant for PenguinHarness instead of choosing its own. The rest of the host environment passes through, with one further exception: `GIT_EDITOR`, `GIT_TERMINAL_PROMPT`, `TERM`, `NO_COLOR`, `PAGER` and `GIT_PAGER` are always forced to fixed values, so that a command cannot hang waiting on an editor, a credential prompt or a pager. The Agent's [vault](#vault) is applied on top of the host environment — setting `PORT` there does reach commands — but not on top of those six.
`PENGUIN_PREVIEW_ORIGIN` must differ from the app's origin by **hostname**, not just port: cookies ignore ports, so a second port would still share the session cookie. Leave it unset for local use — the app is canonicalized onto `localhost` and previews are served from `127.0.0.1`, which needs no configuration and no DNS. Set it when the app is reached over a LAN address or a real domain; otherwise previews there fall back to a same-origin sandbox where `localStorage`, cookies and third-party embeds do not work. When you do set it on a real domain, keep the session cookie host-only (no `Domain=`), or a sibling subdomain shares it. An unparseable value is a startup error rather than a silent fallback.
### Provider credential variables
@@ -20,6 +20,8 @@ CLI 与服务端启动时会自动加载工作目录下的 `.env` 文件。
| `PENGUIN_LANG` | CLI 语言(`en` / `zh`),用 `penguin config lang` 设置 | `en` |
| `PENGUIN_UPDATE_CHECK` | 设为 `off` 关闭 Web 应用的新版本检查(服务端唯一的对外网络请求) | 开启 |
这些变量配置的是 PenguinHarness 自身,因此 `PORT`、`HOST`、`PENGUIN_WEB_DIST` 以及内部使用的 `PENGUIN_CLI_ENTRY` **不会出现在 Agent 所执行命令的环境变量中**——否则 `exec_command` 启动的开发服务器会读到 `PORT`,去占用留给 PenguinHarness 的端口,而不是自己另选一个。宿主环境中的其余变量原样透传,但还有一处例外:`GIT_EDITOR`、`GIT_TERMINAL_PROMPT`、`TERM`、`NO_COLOR`、`PAGER`、`GIT_PAGER` 一律被固定值覆盖,以免命令因等待编辑器、凭证输入或分页器而挂起。Agent 的 [vault](#vault) 覆盖在宿主环境之上——在 vault 里设置 `PORT` 仍然可以送达命令——但覆盖不了这六个变量。
`PENGUIN_PREVIEW_ORIGIN` 必须与应用源在**主机名**上不同,只换端口不行:Cookie 不区分端口,换端口仍然共用会话 Cookie。本地使用不必配置——App 固定在规范主机 `localhost`,预览用 `127.0.0.1`,既不需要配置也不需要 DNS。经 LAN 地址或真实域名访问时才需要设置,否则那里的预览会回退到同源沙箱,`localStorage`、Cookie 与第三方 embed 都不可用。在真实域名上设置时,会话 Cookie 必须保持 host-only(不带 `Domain=`),否则同注册域下的兄弟子域会共享它。取值无法解析时启动即报错,不会静默回退。
### Provider 凭证环境变量
+1 -1
View File
@@ -25,7 +25,7 @@
"node": ">=24"
},
"scripts": {
"dev": "node ../../scripts/dev-prebuild.mjs && node ../../scripts/run-with-env.mjs PENGUIN_HOME=~/.penguin/dev-data -- tsx watch src/index.ts",
"dev": "node ../../scripts/dev-prebuild.mjs && node ../../scripts/run-with-env.mjs PENGUIN_HOME=~/.penguin/dev-data PORT=7368 -- tsx watch src/index.ts",
"start": "node --disable-warning=ExperimentalWarning dist/index.js",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run --passWithNoTests",
+2 -2
View File
@@ -23,11 +23,11 @@ DTO types are imported type-only from `@prismshadow/penguin-server/api`; no serv
Prereqs: Node >= 24, pnpm; run `pnpm install` at the repo root first (core must be built — the root `dev:*` scripts handle that).
```bash
pnpm dev:server # backend at 127.0.0.1:7364
pnpm dev:server # backend at 127.0.0.1:7368 (dev port, not the installed server's 7364)
pnpm dev:web # Vite dev server at 127.0.0.1:7365; /api proxied (SSE passes through)
```
The proxy target defaults to `http://127.0.0.1:7364` (`PENGUIN_API_PROXY` overrides). Auth is a same-origin HttpOnly cookie, so the proxy keeps everything same-origin.
The proxy target defaults to `http://127.0.0.1:7368` — the development backend, kept off the installed server's 7364 so the two can run at once (`PORT` moves both, `PENGUIN_API_PROXY` overrides the target outright). Auth is a same-origin HttpOnly cookie, so the proxy keeps everything same-origin.
```bash
pnpm --filter @prismshadow/penguin-web typecheck
+32
View File
@@ -0,0 +1,32 @@
/**
* Dev-server `/api` proxy target resolution (vite.config.ts). The interesting case is an
* empty `PORT=`: shells export it, `.env` files carry it, and resolving it to a bare
* `http://127.0.0.1:` sends every API call to port 80 without ever failing — a wrong backend
* is far harder to notice than a broken one, so the empty case is pinned here.
*/
import { describe, expect, it } from "vitest";
import { apiProxyTarget } from "../vite.config.js";
describe("apiProxyTarget", () => {
it("defaults to the development backend, not the installed server's 7364", () => {
expect(apiProxyTarget({})).toBe("http://127.0.0.1:7368");
});
it("treats an empty PORT as unset rather than as port 80", () => {
expect(apiProxyTarget({ PORT: "" })).toBe("http://127.0.0.1:7368");
});
it("follows PORT so moving the backend moves the proxy with it", () => {
expect(apiProxyTarget({ PORT: "9999" })).toBe("http://127.0.0.1:9999");
});
it("PENGUIN_API_PROXY replaces the whole target, PORT and all", () => {
expect(apiProxyTarget({ PORT: "9999", PENGUIN_API_PROXY: "http://10.0.0.2:8080" })).toBe(
"http://10.0.0.2:8080",
);
});
it("an empty PENGUIN_API_PROXY falls back instead of proxying to nowhere", () => {
expect(apiProxyTarget({ PENGUIN_API_PROXY: "" })).toBe("http://127.0.0.1:7368");
});
});
+25 -6
View File
@@ -1,9 +1,11 @@
/**
* Vite config: React SPA + Tailwind CSS 4.
*
* Dev server listens on 7365; `/api` is proxied to the local Web server (defaults to 127.0.0.1:7364,
* overridable via PENGUIN_API_PROXY). SSE (text/event-stream) passes through http-proxy transparently, no
* special config needed.
* Dev server listens on 7365; `/api` is proxied to the **development** backend (127.0.0.1:7368 --
* `pnpm dev:server`, deliberately not the installed server's 7364, which is routinely running at the
* same time). Honors PORT so overriding the backend port moves the proxy with it, and
* PENGUIN_API_PROXY overrides the whole target. SSE (text/event-stream) passes through http-proxy
* transparently, no special config needed.
* The vitest config is kept separate in vitest.config.ts (its embedded vite 5 types conflict with this
* package's vite 7 plugin types, hence the separate file to avoid the clash).
*/
@@ -11,15 +13,32 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
/**
* Resolves the `/api` proxy target: PENGUIN_API_PROXY replaces it outright, otherwise the
* development backend on PORT — the same variable `pnpm dev:server` binds — defaulting to 7368.
*
* Empty counts as unset, as everywhere else PORT is read in this repo (server/src/config.ts,
* cli/src/commands/serve.ts, scripts/run-with-env.mjs). Here `??` would be actively harmful: an
* exported-but-empty `PORT=` yields `http://127.0.0.1:` — port 80 — and every /api call would be
* answered by whatever happens to listen there, silently and without an error, which is the exact
* wrong-backend failure this default exists to prevent.
*
* Takes the environment as an argument so the resolution can be unit-tested (a vite config's
* `server.proxy` cannot be exercised without starting a dev server).
*/
export function apiProxyTarget(env: Record<string, string | undefined> = process.env): string {
return env.PENGUIN_API_PROXY || `http://127.0.0.1:${env.PORT || "7368"}`;
}
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
// Fixed PenguinHarness dev port (stands alone — only the main server default is
// shared, as DEFAULT_SERVER_PORT in core; vite configs cannot import core TS).
// Fixed PenguinHarness dev port (stands alone — vite configs cannot import core TS,
// so the numbers are literals here; the allocation table lives in core's internal/ports.ts).
port: 7365,
proxy: {
"/api": {
target: process.env.PENGUIN_API_PROXY ?? "http://127.0.0.1:7364",
target: apiProxyTarget(),
changeOrigin: false,
},
},