Skip to content

finding: raw CI job logs ARE reachable from an agent seat via the GitHub MCP server, so ci-failure.mjs's "not a foundation any seat-facing tool can stand on" is true only of direct egress #10141

Description

@os-steve

Recording, not claiming. Found while implementing #10128 (needed the incident's own job logs as a test corpus); filed unassigned for triage routing.

What scripts/pm/ci-failure.mjs says today

Its header records a 2026-08-19 measurement in an agent container and builds the whole tool's design on it:

GET /repos/{o}/{r}/actions/jobs/{job}/logs -> 302 to productionresultssa8.blob.core.windows.net -> CONNECT 403
[...] Raw logs are therefore NOT a foundation any seat-facing tool can stand on

What was measured today (2026-08-20), in this container

Half of it reproduces exactly. Direct egress is still refused:

curl -sSL -H "Authorization: Bearer $GITHUB_TOKEN" \
  https://api.github.com/repos/objectstack-ai/objectstack/actions/jobs/96319093505/logs
curl: (56) CONNECT tunnel failed, response 403

The other half does not. The GitHub MCP server's get_job_logs with return_content: true returned real log content for the same jobs — 96306174446, 96308505284, 96319093505 — including the vitest output, the ##[error] lines and the turbo task summary. It answers a tail window (tail_lines, default 500), not the whole archive, and it lands in the caller's context rather than on disk.

So the accurate statement is narrower than the one in the header: direct fetches of the log archive are denied by this session's egress policy, while a proxied path exists and works. That is a difference in kind, not in degree — "no seat can read raw logs" and "a seat can read the tail of raw logs through one specific tool" support different tool designs.

Why it may matter

  1. The header is a load-bearing self-description. A future author reading it will not try the reachable path, and finding: retrieving a failed CI job's actual assertion takes a manual log-zip dance — script it #9777's whole "walk the surfaces that DO answer" architecture is justified by the sentence above.
  2. It is adjacent to the two open ci-failure.mjs cards — scripts/pm/ci-failure.mjs's --self-test runs nowhere in CI, unlike its four sibling PM tools #9898 (--self-test runs nowhere in CI) and ci-failure.mjs's transport probe has the same false green as #9946 — it classifies from /rate_limit alone, then walks repo-scoped Actions paths #9966 (transport probe carries a false green) — and any of the three touching that header should agree on what it claims.

Leads, not decisions

  • Narrow the header's claim to what was measured (direct egress denied), and say what else was tried.
  • Decide separately whether ci-failure.mjs should reach for the MCP path. Arguments against are real: it would make a seat-facing CLI depend on an MCP server being present, it returns only a tail, and the annotation-based walk already retrieves the assertion in the cases that matter. "Reachable" is not "worth depending on" — that is the decision this issue is asking someone to take deliberately rather than by omission.
  • Whichever way it goes, the measurement should be recorded so the next reader is not told a reachable path is closed.

Refs: #9777 (the tool), #9898, #9966, #10128 (where this surfaced).

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 20, 2026
  2. added
    domain:devxDomain lane — seat card indexed by label:pm:seat
    on Aug 20, 2026
  3. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    Contributor

    Triage routing: prose-accuracy observation about scripts/pm/ci-failure.mjs's reachability claim ⇒ domain:devx; kept finding. Its fate is coupled to #10155/#9966 (the same file's transport work) — cheapest resolution is a one-sentence correction riding whichever of those lands last.


    Generated by Claude Code

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 21, 2026
  5. claude commented on Aug 21, 2026

    @claude
    Contributor
    {
      "issue": 10141,
      "status": "done",
      "branch": "claude/issue-10141-ci-failure-log-reachability",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10545",
      "premise_still_valid": true,
      "summary": "Verified independently, without inheriting the card's numbers: raw Actions job logs ARE reachable from this agent seat. GET /repos/{o}/{r}/actions/jobs/96639318058/logs redirected 302 to productionresultssa16.blob.core.windows.net and returned HTTP 200, 1,741,198 bytes / 17,436 lines via curl -sSL, and 1,741,195 bytes via node 22 fetch with NODE_USE_ENV_PROXY=1 (the 3-byte delta is the BOM that Response.text() drops); the run-level archive returned 200, a 2,015,644-byte zip of 184 entries, 15,676,280 bytes unpacked. Cross-check: the single-job fetch equals that job's entry inside the archive to the byte. The proxy's own /__agentproxy/status shows recentRelayFailures: []. The script's 2026-08-19 CONNECT-403 reading was real; the defect was freezing a per-session environment fact into a source constant, so the fix does NOT hard-code the opposite - it fetches and reports what the response said. The claim was load-bearing, so the routing was fixed too, not just the sentence: on the no-anchor/none branch (Lint & Repo Gates, TypeScript Type Check) the report used to print 'that stdout exists only in the Actions log blob ... 403 on CONNECT' and route the seat to a LOCAL RE-RUN of the failing step, while verdictOf counted the check unretrievable and forced exit 2 UNDETERMINED. Now fetchJobLog spends one request on exactly that branch, classifyLogFetch names expired/denied/absent/transport as four different facts, extractLogAssertion returns anchored/errors-only/tail/empty (tail explicitly labelled a WINDOW), assertionStatus gains source: 'job-log', and verdictOf counts either source so those families can reach exit 1 RED. LOG_BLOB_HOSTS ('hosts this session's egress policy denies') became LOG_DOWNLOAD_HOSTS, orientation only, printed solely on the transport/denied branch as the host to report.",
      "tests": "All runs on final head 6e854a5a29 (clean tree, git status --porcelain empty), each result read from the gate's own verdict line, never from a bare $? after a pipe. No build/dist is involved: ci-failure.mjs is a plain .mjs script node runs directly, so there is no artifact that could go stale between edit and run. (1) node scripts/pm/dispatch-gates.mjs, NO path arguments -> 'change set derived from git - 1 path(s) vs merge base 4544be260'; 2 families matched. (2) pnpm check:cross-package-test-inputs -> 'All 60 self-test cases passed.' + 'OK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.' (3) pnpm check:nul-bytes -> 'check-nul-bytes: OK (scanned 6169 text file(s) ... no raw ASCII control bytes).' plus a direct grep -naP scan of the changed file returning zero hits, with a POSITIVE CONTROL (printf of an ESC byte into a temp file) proving the same pattern matches when a control byte is present. (4) pnpm exec eslint scripts/pm/ci-failure.mjs -> exit 0. (5) node scripts/pm/ci-failure.mjs --self-test (the exact command lint.yml's 'PM ci-failure self-test' job runs) -> exit 0. LIVE end-to-end on two real red shas: 80024aa3 printed 'job log 17437 line(s) / 1741195 bytes ... ANCHORED' and 'assertion RETRIEVED from the JOB LOG', resolving the annotation's file-less 'start_line: 42' to src/commands/serve-verify-security-parity.contract.test.ts(42,1): error TS2578; 38f68602 (Lint & Repo Gates, one exit-status annotation) printed 'ERRORS-ONLY' and surfaced the gate's actual message where the old code printed the egress-denial paragraph - and correctly stayed UNDETERMINED, because the log anchored no file. ABLATION: mutated classifyLogLine's parenthesised anchor from 'if (paren)' to 'if (paren && false)'. Mutation confirmed ON DISK by counting both strings (removed form 0, injected form 1), never by an editor's exit code. --self-test went exit 0 -> exit 1 with 8 LISTED failures, including 'an assertion that came from the JOB LOG counts toward RED - expected 1, actual 2', i.e. the exit code reverting to the pre-fix UNDETERMINED. Restore leg: git checkout HEAD -- the file, byte-identity proved with git hash-object (f70ceb93d09bcc67be8e9803fd104f9d70e0a79b) equal to git rev-parse HEAD:scripts/pm/ci-failure.mjs, restored/mutated string counts 1/0, porcelain empty, self-test back to exit 0. The FIRST ablation pass found a defect in my own harness - a bare assertions[0].logLine threw a TypeError and killed the run before any verdict printed, hiding every pin after it; fixed in its own commit (optional chaining) and the numbers above are from the second pass. MEASURED BOUNDARIES: retention ~90 days with 410 (not an empty 200) past it - bisected on this repo's runs, 88d -> 200 (19,574 bytes), 90d/92d/95d/174d -> 410 with a JSON body; the Location is a short-lived Azure SAS URL, both specimens declaring st/se exactly 10m05s apart; the Authorization header must NOT survive the hop - curl --location-trusted -> 401 InvalidAuthenticationInfo from Azure while plain curl -L and node fetch -> 200 because both drop it cross-origin; no size cap observed, largest single job log in the sampled run 1,770,448 bytes; node fetch WITHOUT the proxy flag still answers 401 (control, unchanged). MCP get_job_logs also works (original_length: 17436) but is not adopted: it spends the 5,000/h GraphQL budget this lane exhausts, and its tail_lines default of 500 is a window.",
      "open_questions": [
        {
          "question": "GOVERNED EDIT PROPOSED, NOT MADE (.claude/** is human-merge only). .claude/skills/pm-dispatch/references/platform-readings.md line 102 (from #9778, measured 2026-08-18 on run 32195337852) says get_job_logs returns only post-step teardown 'regardless of tail_lines'. Its CONCLUSION is corroborated by my measurements; the quantifier is over-broad. Measured today on job 96638884991 at tail_lines: 40 - 34 of the 40 lines were git-credential teardown and the remaining 6 were the failing gate's own message, so the tail IS dominated by teardown but a large enough window does reach the answer. Should that clause be narrowed?",
          "options": [
            "A - narrow the clause, keep the bullet's conclusion. Replace `get_job_logs` 无论 `tail_lines` 只回占满日志尾部的 post-step service-container teardown with: `get_job_logs` 的窗口贴在日志尾部,而尾部被 post-step teardown 占满 —— 默认 `tail_lines: 500` 下几乎全是拆卸噪声(2026-08-21 实测 job 96638884991,`tail_lines: 40` 里 34 行是 git 凭据清理,余 6 行才是失败 gate 的正文);`original_length` 随回,窗口是申报过的,把窗口当全量读才是坑。⇒ 别拿尾窗口下结论,取完整日志。 And append one measured line: 2026-08-21 实测原始 job 日志可直取(`GET /repos/{o}/{r}/actions/jobs/{job}/logs` 跟随 302 到 blob → 200,17,436 行 / 1,741,198 字节),但 egress 策略是每会话事实(2026-08-19 同一路径曾 CONNECT 403)⇒ 判可达性靠当场发一次请求,不靠任何一次冻结的读数;边界:保留期约 90 天,过期是 410 不是空 200;跳转是 10 分钟有效期的 SAS,Authorization 头不得跟随(跟随得 401 InvalidAuthenticationInfo)。",
            "B - leave the file untouched; the bullet's operative advice (fetch the full archive) is already correct, and #9778 is closed.",
            "C - open a separate finding issue so the narrowing is triaged on its own card rather than inside this one."
          ],
          "recommendation": "A. The bullet is a fact table, and the clause is the kind of universal that a later seat will read as 'the MCP tool can never help' - which is how a seat ends up with no instrument at all. The narrowing keeps the operative advice intact and adds the reachability reading with its boundaries, so the same freeze-a-session-fact-into-prose defect this card fixed in code is not left standing one directory over. It must be merged by the maintainer by hand (Prime Directive #14)."
        }
      ],
      "out_of_scope_findings": [
        "not filed, proposed in open_questions above instead: the over-broad `regardless of tail_lines` clause in .claude/skills/pm-dispatch/references/platform-readings.md - the dispatch constraint for governed surfaces is to propose wording in the report rather than file or edit. Searched open issues for platform-readings / get_job_logs / tail_lines first; the only match is #9778, which is closed and is the line's origin.",
        "no other finding: the false claim appears in no governed file (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md) beyond the clause above - swept for the blob hosts, '403 on CONNECT', 'connect_rejected', 'log archive', 'raw log', 'job log', with a positive control proving the sweep really read those trees. Outside the governed set the only hits are .github/workflows/merge-queue-triage.yml and ci.yml, which already pull job logs from inside CI."
      ]
    }

    Generated by Claude Code

  6. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:devxDomain lane — seat card indexed by label:pm:seat

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions