Files
taodengandClaude Opus 4.7 2600185edb fix+test: D35 — pre-Phase-2 batch #1 (issues #4 #9 #10 #11 #12)
First batch of pre-Phase-2 cleanup work. 5 GitHub issues closed in
one cohesive commit covering streaming-path correctness, IR
validator hardening, and CI path-trigger hygiene.

Changes (4 files, +302 / -5):

**Code fixes**

1. **#9 — Streaming empty-then-clean-exit headers** (server.mjs)

   Pre-D35: when a provider's streaming spawn finished cleanly with
   zero chunks (e.g. spec-degenerate stop with no content), the
   response went out the SSE_DONE / res.end path without ever calling
   writeHead. Result: client saw stream open + close with no headers,
   no status code path applied. Now: zero-chunk branch guards
   `!res.headersSent` and emits Content-Type + Cache-Control +
   Connection + X-Accel-Buffering + all 5 X-OLP-* headers via
   olpHeaders (provider attempted, cache miss) before writing the
   terminator. Zero-chunk path correctly does NOT cache (cache write
   remains gated on irChunk.type === 'stop').

2. **#10 — Streaming post-first-chunk error truncation marker**
   (server.mjs, two sibling sites)

   Pre-D35: if a provider yielded an error AFTER first content chunk
   was emitted, the SSE stream was abandoned with raw socket close.
   Client SDKs that wait for finish_reason hung. Now:

   - Catch-block firstChunkEmitted=true path: emit synthetic
     `{type:'stop', finish_reason:'length'}` via irChunkToOpenAISSE,
     write SSE_DONE, end. Per ADR 0004 § Fallback safety: post-first-
     chunk truncation surfaces as `length` finish, not a hang.

   - Sibling fix in error-chunk path (provider yields `type:'error'`
     chunk AFTER first content chunk): same recovery (marker + DONE +
     end). Scope-creep acknowledged but identical semantic; clean to
     fix together. Comments cross-reference D26 F19 and D35 #10.

3. **#11 — validateIRRequest irVersion strict check** (lib/ir/types.mjs)

   ADR 0003 IR contract pins irVersion to '1.0'. Validator pre-D35
   accepted ANY value (including no value, undefined, '2.0',
   numeric 1.0). Now: `obj.irVersion !== undefined && obj.irVersion
   !== '1.0'` → rejection. Strict string match. `undefined` still
   accepted (pre-D35 IRs without the field remain valid — back-compat
   with sites that haven't yet been migrated to emit it). Error
   message uses JSON.stringify for safe rendering.

4. **#12 — alignment.yml scripts/** trigger removal**
   (.github/workflows/alignment.yml)

   Pre-D35 push.paths and pull_request.paths listed scripts/**. The
   scripts/ directory does not currently exist (per AGENTS.md note:
   scripts/migrate-from-ocp.mjs is planned for Phase 7). A path
   filter referencing a non-existent directory has no effect on
   trigger evaluation BUT misleads readers about the workflow's
   intent. Removed from both push.paths and pull_request.paths. When
   scripts/ lands in Phase 7, the trigger should be re-added at the
   same time (see release_kit_overlay.bootstrap_quirk_policy).

**Verification — #4 (uniform X-OLP-Latency-Ms across error paths)**

#4 was found to already be correct via D32. Re-audit of all 7
in-handler sendError sites in handleChatCompletions confirmed all
attach a 5-header set via olpHeaders or olpErrorHeaders:
- 360-361 (415 wrong Content-Type) → olpErrorHeaders
- 368-369 (400 bad JSON) → olpErrorHeaders
- 378-379 (400 BadRequestError IR translation) → olpErrorHeaders
- 402-407 (503 no chain) → olpErrorHeaders
- 617-618 (503 provider disappeared) → olpErrorHeaders
- 760-761 (502 streaming pre-first-chunk error) → olpHeaders
- 778-779 (500 fallback engine error) → olpErrorHeaders
The 404 (line 922) and outer 500 (line 926) are router-level paths
without startMs context and correctly lack OLP headers. D35 adds
the #4-audit regression test pinning the 5-header invariant on the
503 no-provider response so future drift is caught immediately.

**Tests** (test-features.mjs): 416 → 424 (+8):
- #4-audit ×1 (5-header invariant on 503 no-provider sendError)
- #9 ×1 (zero-chunk streaming → 200 + Content-Type=text/event-stream
  + 5 X-OLP-* headers + [DONE])
- #10 ×1 (catch-throw after first chunk → marker + length finish + DONE)
- #10b ×1 (provider error chunk after first chunk → same recovery)
- #11a ×1 (irVersion undefined accepted)
- #11b ×1 (irVersion '1.0' accepted)
- #11c ×1 (irVersion '2.0' rejected)
- #11d ×1 (irVersion numeric 1.0 rejected)

Pre-commit fold-in (per evidence-first checkpoint #4):
- **D35 reviewer flagged JSDoc/validator drift on irVersion**
  (Suggestion #1, non-blocking). The @property typedef at
  lib/ir/types.mjs:43 said `{string} irVersion - always IR_VERSION`
  but the validator at lines 185-186 accepts `undefined`. Future
  reader who scans the @property alone sees contradiction without
  the rationale comment 140 lines below. Folded: typedef marked
  `[irVersion]` (optional) and description updated to "optional;
  when present must equal IR_VERSION ('1.0'). Pre-D35 IRs lack
  this field and remain valid."

Two other non-blocking reviewer suggestions not folded (out of
scope for D35; tracked as future polish):
- Distinct event names for the two streaming_error_after_first_chunk
  log sites (provider-emitted string vs JS exception message).
- Phase 7 TODO: re-add scripts/** trigger to alignment.yml when
  scripts/migrate-from-ocp.mjs lands.

Authority:
- ADR 0004 § Fallback safety — post-first-chunk truncation surfaces
  as `length` finish (#10 + #10b)
- ADR 0003 § IR contract — irVersion pinned to '1.0' (#11)
- AGENTS.md § Implementation status — scripts/ planned for Phase 7
  (#12)
- CLAUDE.md release_kit_overlay phase_rolling_mode — D35 lands
  under "Unreleased" against Phase 2; no version bump
- CC 开发铁律 v1.6 § 10.x — independent fresh-context reviewer
  required for code change

Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus,
independent of drafter): APPROVE_WITH_MINOR. Critical depth checks:
- Verified all 7 sendError sites in handleChatCompletions attach
  5-header set (cited line numbers reconciled with current state)
- Verified writeHead block guarded by !res.headersSent; correctly
  placed AFTER optional truncation marker, BEFORE SSE_DONE
- Verified irVersion validator strict-equality semantics across all
  4 cases (undefined / '1.0' / '2.0' / numeric 1.0)
- Verified scripts/** removed from both push and pull_request paths
- Verified hygiene: 0 hits for personal markers, home paths, OAuth
  tokens, internal IPs
- 424/424 tests pass independently in reviewer's run

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 20:20:49 +10:00

202 lines
8.3 KiB
YAML

name: Alignment Guardrail
on:
pull_request:
paths:
- 'server.mjs'
- 'lib/**'
- 'models-registry.json'
- '.github/workflows/alignment.yml'
push:
branches: [main]
paths:
- 'server.mjs'
- 'lib/**'
- 'models-registry.json'
- '.github/workflows/alignment.yml'
jobs:
blacklist:
name: source blacklist (hard fail)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Scan source for hallucinated tokens
shell: bash
run: |
set -euo pipefail
# Known-hallucinated tokens. Extend only via an ALIGNMENT.md amendment PR.
# Each token is matched as a fixed string against the OLP source tree
# (excluding docs/, CHANGELOG, README, this workflow, and tests that
# may legitimately reference the historical token as a guardrail).
#
# Inherited transitively from OCP's 2026-04-11 drift:
# - api.anthropic.com/api/oauth/usage : fabricated Anthropic OAuth
# usage endpoint. Does not appear in any shipped @anthropic-ai/
# claude-code cli.js. Carried forward as a transitive guardrail.
#
# OLP-native entries: added as drift incidents accumulate.
BLACKLIST=(
"api.anthropic.com/api/oauth/usage"
)
# Source files in scope. Exclude docs, CHANGELOG, README, the
# workflow itself, and the test file (which may pin historical
# strings intentionally).
SOURCE_FILES="$(git ls-files \
| grep -E '\.(mjs|js|ts|json)$' \
| grep -v -E '^(docs/|CHANGELOG\.md|README\.md|test-features\.mjs|\.github/workflows/alignment\.yml)')"
FAIL=0
# 1. Blacklist scan
for token in "${BLACKLIST[@]}"; do
if echo "$SOURCE_FILES" | xargs grep -n -F "$token" 2>/dev/null; then
echo "::error::Blacklisted token '$token' detected in OLP source."
FAIL=1
fi
done
# 2. Excluded-provider scan: Google Antigravity is permanently
# excluded per ADR 0006 / ALIGNMENT.md Risk Tier A. Any reference
# to a `google-antigravity` plugin file or provider key in source
# (outside docs which may discuss the exclusion) is a finding.
FORBIDDEN_PROVIDER_TOKENS=(
"google-antigravity"
"antigravity"
)
for token in "${FORBIDDEN_PROVIDER_TOKENS[@]}"; do
HITS="$(echo "$SOURCE_FILES" | xargs grep -n -F "$token" 2>/dev/null || true)"
if [ -n "$HITS" ]; then
echo "::error::Tier-A-excluded provider token '$token' detected in OLP source. Per ALIGNMENT.md / ADR 0006, this provider is excluded by default; re-inclusion requires ADR 0006 supersession/amendment with new primary-source evidence."
echo "$HITS"
FAIL=1
fi
done
if [ "$FAIL" -ne 0 ]; then
echo ""
echo "============================================================"
echo "ALIGNMENT GUARDRAIL FAILURE"
echo "============================================================"
echo "OLP source contains a token on the alignment blacklist or"
echo "references a Tier-A-excluded provider."
echo ""
echo "Blacklist tokens were introduced by LLM hallucinations and"
echo "do not appear in the relevant authority (provider CLI,"
echo "OpenAI spec, or ADR). See ALIGNMENT.md and (where the"
echo "token is inherited from OCP) the OCP 2026-04-11 drift"
echo "record at https://github.com/dtzp555-max/ocp."
echo ""
echo "Excluded providers are listed in ALIGNMENT.md \xc2\xa7 Risk Tier"
echo "Framework and ADR 0006. Tier-A exclusion means not"
echo "bundled, not pluggable, not added via opt-in;"
echo "re-inclusion requires ADR 0006 amendment with new"
echo "primary-source evidence."
echo ""
echo "Required action:"
echo " 1. Remove the token from source."
echo " 2. Cite the real authority (provider CLI doc / OpenAI"
echo " spec section / ADR) for the operation you intended."
echo " 3. See ALIGNMENT.md Rules 1, 2, and 5."
echo ""
echo "Do not add allowlist entries to this workflow without an"
echo "amendment PR to ALIGNMENT.md (see Amendment Procedure)."
echo "============================================================"
exit 1
fi
echo "Blacklist + excluded-provider scan clean."
models-registry:
name: models-registry.json sanity (hard fail)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate models-registry.json
shell: bash
run: |
set -euo pipefail
if [ ! -f models-registry.json ]; then
echo "models-registry.json not found yet (bootstrap phase). Skipping."
exit 0
fi
# Basic JSON validity.
if ! node -e "JSON.parse(require('fs').readFileSync('models-registry.json','utf8'))"; then
echo "::error::models-registry.json is not valid JSON."
exit 1
fi
# Provider keys in models-registry.json must match the inventory
# in ALIGNMENT.md.
KNOWN_PROVIDERS='["anthropic","openai","mistral","grok","kimi","minimax","glm","qwen"]'
node -e "
const fs = require('fs');
const known = ${KNOWN_PROVIDERS};
const reg = JSON.parse(fs.readFileSync('models-registry.json','utf8'));
const providers = reg.providers || {};
const bad = Object.keys(providers).filter(p => !known.includes(p));
if (bad.length > 0) {
console.error('::error::Unknown provider key(s) in models-registry.json: ' + bad.join(', '));
console.error('Known providers per ALIGNMENT.md: ' + known.join(', '));
process.exit(1);
}
console.log('models-registry.json provider keys OK: ' + Object.keys(providers).join(', '));
"
commit-citation:
name: per-provider commit citation (soft check)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout full history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Scan PR commits for uncited assertions
if: github.event_name == 'pull_request'
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
if [ -z "${BASE_SHA:-}" ] || [ -z "${HEAD_SHA:-}" ]; then
echo "No PR context; skipping."
exit 0
fi
# Use process substitution `< <(...)` rather than piping into the
# while loop. A piped while runs in a subshell, so `WARN=1` would
# never propagate back out to this scope — the if-check below would
# always report "clean" even when warnings were emitted. Classic
# Bash subshell trap; see commit history for the codex review that
# caught this.
WARN=0
while read -r sha; do
BODY="$(git log -1 --format=%B "$sha")"
if echo "$BODY" | grep -E -i -q '(provider|claude|codex|vibe|grok|kimi|minimax|glm|qwen|cli)[[:space:]]+(code[[:space:]]+)?uses'; then
if echo "$BODY" | grep -E -i -q '(cli[[:space:]]+v[0-9]+|https?://|ADR[[:space:]]+[0-9]{4})'; then
echo "OK $sha: assertion cited."
else
echo "::warning::Commit $sha asserts 'Provider X uses ...' or '<provider> CLI uses ...' but does not cite a CLI version, docs URL, or ADR number. See CLAUDE.md -> Commit message conventions."
WARN=1
fi
fi
done < <(git log --format="%H" "${BASE_SHA}..${HEAD_SHA}")
if [ "$WARN" -ne 0 ]; then
echo "Soft check raised warnings. Reviewer: please enforce per CLAUDE.md."
else
echo "Commit citation soft check clean."
fi