Skip to content

fix(cli): os dev -p and os serve --port export the port as OS_PORT/PORT, so a config reads the port the server listens on - #26268

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-uplqo0
Oct 26, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-uplqo0

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26243

Clause-②: no
Metadata-protocol: none

What was wrong

os dev -p N forwarded the port to its serve child as --port on argv, and os serve --port N handed it to its listener. Neither put it in the environment, so a config that reads OS_PORT/PORT while it loads saw the inherited value or the 3000 default. examples/app-showcase's self URL is such a config. os start --port was already right, because it hands the flag to its child as OS_PORT + PORT (#12992).

Measured on examples/app-showcase at 81bba6c3 (main), with no OS_PORT/PORT/SHOWCASE_SELF_URL in the environment. "ping" is POST /api/v1/automation/showcase_declarative_connector_ping/trigger, and "meta" is providerConfig.baseUrl from GET /api/v1/meta/connector/showcase_status_api.

boot meta before ping before meta after (af26658b) ping after
os dev --fresh -p 41302 http://127.0.0.1:3000 400 FLOW_FAILED … fetch failed http://127.0.0.1:41302 200, status: ok
os serve --port 41303 objectstack.config.ts (production) http://127.0.0.1:3000 400 FLOW_FAILED … fetch failed http://127.0.0.1:41303 200, status: ok
os start --port 41304 (control) http://127.0.0.1:41304 200 unchanged unchanged

os serve --port showed the same symptom, so it is fixed through the same source, per the ruling.

The fix

There is one writer for the port's environment, and all three doors call it. No door restates the precedence.

  • packages/cli/src/utils/port-contract.ts: os start's childPortEnv moves here as portFlagEnv(port), unchanged in behaviour. It writes { OS_PORT, PORT } with one value when a flag was given, and nothing otherwise. Its docblock moves with it and now names all three callers.
  • packages/cli/src/commands/dev.ts: the port door (resolution plus refusal) moves ahead of the compile, because the compile is the first child that evaluates the config. Once a -p passes the door, os dev merges portFlagEnv(parseRequestedPort(port)) into its own process.env. The compile, the watch recompiles and the serve child all run in that environment (the one fix(cli): os dev compiles the config before it boots, so served connector metadata matches dispatch #26240's devCompilesAtBoot relies on), so both copies of the stack read the flag's port. An invalid -p is now refused before the compile instead of after it. The argv forwarding is unchanged.
  • packages/cli/src/commands/serve.ts: once the port is validated, a --port given on argv (oclif's setFromDefault is not true) is merged into process.env the same way, before the config is loaded. A port that came from OS_PORT/PORT or the default leaves the environment as it is.
  • packages/cli/src/commands/start.ts: imports portFlagEnv; its two comment references are re-pointed. The behaviour is unchanged.
  • examples/app-showcase/src/system/self-url.ts: header comment only. Its claim that it reads "the same names … the CLI itself reads" is now true for the flag too, and it says how.

The exported value is the parsed port, not the text: --port 3e3 selects port 3, so it exports 3. os dev keeps forwarding its flag text on argv, so the child's text-read notice still names --port "3e3". Both channels come from one variable at one door. The portFlagEnv docblock says why that is deliberate, and why os start, whose integer flag has no such text, stays on the environment alone.

Tests

At 45116cbc (the head; af26658b plus the changeset):

  • packages/cli/test/dev-connector-env-parity.integration.test.ts (fix(cli): os dev compiles the config before it boots, so served connector metadata matches dispatch #26240's file, reshaped): two boots of the built bin/run.js on the minimal host config.
    • os serve objectstack.config.ts --port N, production posture, with no OS_PORT. The materialized connector baseUrl must be http://127.0.0.1:N.
    • os dev -p N over an inherited stale OS_PORT=41302 and an artifact built with :3000. The served and the materialized baseUrl must both be :N. This one boot holds both cards: before fix(cli): os dev compiles the config before it boots, so served connector metadata matches dispatch #26240 the served copy read :3000, and before this PR both copies read :41302.
    • #26130's old boot was -p 0 with OS_PORT=41302 as the config's value. This PR makes -p 0 win for the config too, so that shape no longer holds. Each boot now draws a non-zero port with reservePort(), because the config has to know the number before the boot. The draw is ledgered in released-port-production-spawn.pin.test.ts (2 draws, with the reason), and a lost race reads as portDriftError/portContentionError.
  • start-port-forwarding-channel.pin.test.ts now imports portFlagEnv from the contract module. Its five behavioural cases are unchanged.
  • Touched files together: vitest run on the integration file, released-port-production-spawn.pin.test.ts, start-port-forwarding-channel.pin.test.ts and port-contract-single-source.test.ts gave Test Files 4 passed (4), Tests 21 passed (21).
  • pnpm --filter @objectstack/cli exec vitest run --project unit gave Test Files 334 passed (334) and Tests 4878 passed (4878) (lock VERDICT command-exit 0). pnpm --filter @objectstack/cli typecheck exited 0. The integration tier ran locally for the touched file only; the rest is declared to CI.
  • Ablations, one per export, each through scripts/ablation-replace.mjs. In each case the CLI was rebuilt and ablation-dist-preflight found the marker in packages/cli/dist/commands/*.js.
    • The dev export was made a no-op: the served leg went red (expected 'http://127.0.0.1:41302' to be 'http://127.0.0.1:33115') and the materialized leg stayed green. That shows why the parent has to export: the serve child re-exports from its own argv, but the compile only sees the parent's environment.
    • The serve export was made a no-op: the serve leg went red (expected 'http://127.0.0.1:3000' to be 'http://127.0.0.1:44503') and both dev legs stayed green.
    • Both were restored (blob equals HEAD, git status --porcelain empty), the CLI was rebuilt, and ablation-dist-preflight --absent exited 0 for both markers.
    • A first attempt at the dev ablation did not compile (TS2367 on the literal comparison). Nothing ran, and it is not counted.

Gates

Local subset: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 45116cbc derived 73 families. --ran reconciliation: 72 ran with exit 0, 1 NOT MEASURED, 0 unrun.

  • NOT MEASURED, reason: prerequisite not met (exit 3). pnpm check:dual-build-cjs-loads reads a full-repo build that this container did not make. CI runs it.
  • node scripts/check-plugin-teardown-shape.mjs --self-test exited 3 on the shallow clone. After git fetch origin 621a4876… (its positive control), it passed 48 cases.
  • pnpm check:cli-test-child-env passed (exit 0). The reshaped test is already in its census, and the new serve leg also spawns bin/run.js with NODE_ENV unset.
  • pnpm exec eslint --no-inline-config on the 8 touched source and test files exited 0. Neither @objectstack/cli nor the showcase has a lint script.

Acceptance notes

  • Dev auto-shift. Measured at af26658b with 41310 held by another listener: os dev --fresh -p 41310 printed Port 41310 is in use — serving on 41311 instead. Its /meta served :41310 and the ping answered 400 FLOW_FAILED … fetch failed. The config is evaluated before the bind, so it reads the requested port, not the shifted one. A bare os dev with 3000 busy has the same shape on main. This is the Showcase REST connectors hard-wire baseUrl to http://127.0.0.1:3000, so self-ping flows fail fetch failed on any isolated instance — a port mismatch, not an egress block #7538 family, and this PR does not widen to it, per the ruling. It goes to the seat as a finding.
  • --port 0 now exports 0, so a config like the showcase's builds :0 where it used to build :3000 (or the inherited OS_PORT). Neither address serves anything. A kernel-assigned port cannot be known before the bind.
  • Port text from the environment (OS_PORT=3e3) is not normalized. Only a flag is exported, as os start already did, so an operator's own variables reach the config exactly as set.

#26242 remains open; this PR does not address it.


Generated by Claude Code

… config's environment

`os dev -p N` and `os serve --port N` handed the flag to the listener only,
so a config reading OS_PORT/PORT at module evaluation (the showcase's self
URL) saw the inherited value or the 3000 default: the self-ping connector
failed `fetch failed` and /meta served :3000. `os start` already exported
its flag as OS_PORT/PORT; that writer moves into utils/port-contract.ts as
`portFlagEnv`, and dev (into its own environment, before the compile) and
serve (before it loads the config) call it too.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrUJpT4UGJT1bwN5uvT8dh
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 5 documentable anchor(s).

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 0453fabef70c1b2a8276508d37c350d3487badce.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: os dev (command, 39 pages), os serve (command, 40 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 31 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0453fabef70c1b2a8276508d37c350d3487badce → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 49c0e3f2ba3b8faf21c5778bd0324bb179079676 — the merge of head 45116cbc104801a811f4ee0399e3fef69fe5f806 into base 0453fabef70c1b2a8276508d37c350d3487badce, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 49c0e3f2ba3b8faf21c5778bd0324bb179079676 && git checkout 49c0e3f2ba3b8faf21c5778bd0324bb179079676
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0453fabef70c1b2a8276508d37c350d3487badce 45116cbc104801a811f4ee0399e3fef69fe5f806 && git checkout -B drift-repro 0453fabef70c1b2a8276508d37c350d3487badce && git merge --no-ff 45116cbc104801a811f4ee0399e3fef69fe5f806

node scripts/docs-audit/affected-docs.mjs --json 0453fabef70c1b2a8276508d37c350d3487badce

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0453fabef70c1b2a8276508d37c350d3487badce → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-26T18:27Z · head 45116cbc104801a811f4ee0399e3fef69fe5f806 · #26243

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape: a draft against main with Fixes #26243. Clause-②: no and Metadata-protocol: none are line-initial. One patch changeset on @objectstack/cli. check-governed-merges --pr 26268: NOT governed, 470 changed lines.
  2. Premise and review: triage 6422193836 asked for one boot port that the self URL reads. The seat read the diff. os start's existing OS_PORT/PORT writer moves unchanged into utils/port-contract.ts as portFlagEnv, and os dev (before its compile) and os serve (before it loads the config) now call it too, so all three doors write the pair the same way and no second copy of the precedence exists. Only a port that came from the flag is exported (flags.port !== undefined in dev, setFromDefault !== true in serve); with no flag the operator's variables stay as they are. dev's port refusal moves ahead of the compile, which is the first process that evaluates the config, and still guards exactly the text it forwards. os serve --port N showed the same symptom and is fixed through the same source, which the order allowed. self-url.ts's header now names the flag path and stays true. Clause-②: no holds on the diff: nothing that was accepted is refused, and a config that reads the port now sees the one the listener binds. The dev reports red on 81bba6c3 and green after on the showcase (:41302 for dev, :41303 for serve, ping 200), both ablations red on the leg each export owns, and the cli unit tier at 4,878 of 4,878.
  3. Gates: at this stamp the head's 47 check-runs read 36 success and 11 skipped (path-filtered UI, docs-build, dogfood matrix placeholders and opt-in harnesses), with none failing or in progress; mergeable, clean. Locally the dev reports 72 of 73 derived gate families exit 0, with dual-build-cjs-loads left to CI, which is green.
  4. Accepted deviations: the two acceptance notes in the PR: --port 0 now exports 0, so the showcase builds :0 where it built :3000 (neither serves anything; a kernel-assigned port cannot be known before the bind), and port text from the environment reaches a config unnormalised, as before.
  5. Out of scope: the busy-port shift (os dev serves on N+1 while the config read N) is filed as cli/dev: when the requested port is busy, os dev serves on N+1 but the config already read N, so the showcase self URL and /meta point at a port nothing serves and the self-ping fails #26270.
  6. Landing: this seat runs pr_ready and then automerge_enable through the relay, confirms MERGED on both readings, and records the landing on cli/dev: os dev -p N without OS_PORT leaves the showcase self URL at 3000, so its self-ping connector fails fetch failed and /meta serves :3000 (the #7538 symptom on the port-flag path) #26243, which the merge closes. cli/serve: os serve --dev with OS_PORT set still serves the build-time connector baseUrl from /meta while dispatch reaches the live port (the #26130 split on the serve door) #26242's thread also touches serve.ts (the dev artifact door, another region); whichever lands second merges main.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants