Skip to content

hyp daemon status cleans what it reads out of status.json, plus the etag and probe-error residue from #681 (#776) - #777

Open
philcunliffe wants to merge 6 commits into
masterfrom
fix/issue-776
Open

hyp daemon status cleans what it reads out of status.json, plus the etag and probe-error residue from #681 (#776)#777
philcunliffe wants to merge 6 commits into
masterfrom
fix/issue-776

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

hyp daemon status interpolated every field of status.json straight into the
terminal, and two files other than status.json reached hyp status's text
surface raw. Both items from #776 are fixed here.

Item 1: runDaemonStatus

state, pid, startedAt / healthyAt / stoppedAt, uptime_ms, and each
sources[] / sinks[] entry (name, plugin, state, error, instance,
kind) now go through the same sanitizeLabel / printable() treatment #681
applied on the hyp status side. hyp status was already cleaning what it
reads back out of this same file at the last point before render (LLP 0164),
for a reason that is a property of the file and not of one command: core
cannot assume the daemon that wrote it was this version, this build, or well
behaved, and readStatusFile validates nothing beyond "is an object".

pid and uptime_ms are typed number and neither is validated on read, so
they get printableNumber: printed as themselves when the file really holds a
finite number, cleaned as a label when it does not, so a wrong-typed field
still shows something rather than an empty column.

Cleaning sits at each interpolation and never in readStatusFile, because the
same read feeds --json, which is the machine copy and stays byte-exact
(LLP 0225). This follows #681's split: sanitize where text is rendered for a
person, not where values are collected.

The uncaught readStatusFile throw is handled too. A status.json that is
not valid JSON reached JSON.parse, whose message quotes an excerpt of the
input verbatim, and surfaced as a raw stack trace: useless as a diagnosis, and
the one path on which the file reached the terminal entirely unfiltered. It now
exits 1 with a cleaned hyp daemon status: <message> on stderr, the shape
every other daemon subcommand's failures already use. readPidFile throws the
same way on a malformed pid.json and is inside the same guard. A well-formed
file of the wrong shape used to get a TypeError off status.sources.length;
a non-array sources / sinks now renders (none).

Item 2: the residue from #681's triage

  • Remote-authored config etags and reasons. running etag, probation,
    last rollback and bad etag in renderStatusText, plus the
    remote_config_rolled_back diagnostic message built in
    collectHypAwareStatus. An etag is authored by whatever server the install
    joined; the reasons and timestamps come back through
    config-control/state.json, which readControlState accepts on "is an
    object" alone, so they are this build's vocabulary only if this build wrote
    the file. All of it is display-only at those points. One exception, found in review
    and pinned by test: the remote_config_rolled_back message is assembled once
    in the collector from cleaned components, so --json carries that cleaned
    prose at diagnostics[].message (stripped, each component clamped to 120).
    That is accepted rather than overlooked: the prose is not a parsing surface on
    either render, and the structured remote_config.last_rollback beside it
    stays byte-exact. Moving the clean to the single render interpolation would
    make diagnostics byte-exact too, but needs one clamp width chosen across all
    14 diagnostic kinds, so it is deferred rather than done here.
  • The client attach probe error. For a settings file that is not valid
    JSON, probeClientAttachFromDescriptor returns JSON.parse's message, which
    quotes an excerpt of the client's own file back. Cleaned at the error: line
    with the wider 400-character error clamp, so a message naming a full path is
    not cut short.

Evidence

16 new tests across two files. 13 fail on master and pass with the fix;
3 are guards, green either way. (Round 2 added two tests seeding
last_rollback, which the original guards never exercised - the gap that let
the overclaim above survive.)

test/core/daemon-status-hostile.test.js (8 tests, 6 fail before):

test before the fix
a hostile state and timestamps cannot drive the terminal from hyp daemon status a raw ESC [ 2 K and an embedded newline reach stdout, enough to forge a plausible extra status line
a non-numeric pid and uptime from the status file are cleaned, not printed raw both print the file's bytes verbatim
hostile source and sink fields cannot drive the terminal from hyp daemon status every source/sink field prints raw
an unbounded field from the status file is clamped all 5000 characters are printed
a malformed status file reports a clean error rather than a raw stack the command throws; the operator gets a stack trace carrying the file's bytes
a status file holding the wrong shape does not crash the command TypeError: Cannot read properties of undefined (reading 'length')
(guard) a well-formed pid and uptime still print as the numbers they are green either way
(guard) hyp daemon status --json stays byte-exact green either way

test/core/status-hostile-non-status-file.test.js (6 tests, 5 fail before):

test before the fix
a hostile remote-config etag cannot drive the terminal from hyp status a raw erase-line sequence plus a newline forges a second running etag: line
a hostile rollback reason cannot drive the terminal, in the block or the diagnostic the same, in both the block and remote_config_rolled_back's message
an unbounded remote-config etag is clamped all 5000 characters are printed
a hostile client probe error cannot drive the terminal from hyp status the probe's JSON.parse message forges an extra client row
an unbounded client probe error is clamped all 5000 characters are printed
(guard) hyp status --json still reports the etag and probe error the files hold green either way

npm test: 4096 pass / 1 fail. npm run typecheck: clean. The single failure
is inherited from master (no LLP number is claimed by two documents: two
documents numbered LLP 0223, which #775 fixes). The master baseline in the
same worktree is 4082 pass / 1 fail, the same test. Smokes run green:
daemon_foreground_start_stop, status_diagnostics, package_bin_boot,
cli_bundled_plugins_activated, client_attach_idempotent,
walkthrough_picker_to_first_query.

Note on overlap with #681

This is based on master, not on #681, so it can land on its own. #681 adds a
printable() helper to src/core/commands/status.js for the sources/sinks and
describeDaemon lines; this PR adds a helper of the same name and signature to
the same file for the remote-config and client-error lines, and its own copy in
src/core/commands/daemon.js. Whichever lands second will conflict on that one
block, and the resolution is to keep a single printable and a single error
clamp constant (MAX_ERROR_CHARS here, MAX_DAEMON_ERROR_CHARS there - same
400) and let both sets of call sites use them. Nothing else in the two diffs
touches the same lines.

Deliberately not changed

Fixes #776

test and others added 2 commits August 14, 2026 21:03
…o files hyp status still printed raw (#776)

`hyp daemon status` interpolated every field of `status.json` straight into
the terminal: `state`, `pid`, the timestamps, `uptime_ms`, and each
`sources[]` / `sinks[]` entry. `hyp status` already cleans what it reads back
out of that same file at the last point before render (LLP 0164), for a
reason that is a property of the file and not of one command: `status.json`
is a *file*, and core cannot assume the daemon that wrote it was this
version, this build, or well behaved. Nothing validates a field on read, so
every one of them was a way to repaint the operator's screen or forge a
plausible extra status line.

The same read also threw straight out through the CLI. A `status.json` that
is not valid JSON reached `JSON.parse`, whose message quotes an excerpt of
the input verbatim, and surfaced as a raw stack trace: useless as a
diagnosis, and the one path on which the file reached the terminal entirely
unfiltered. A well-formed file of the wrong shape got a `TypeError` off
`status.sources.length` instead.

Cleaning happens at each interpolation, never in the reader: the same read
feeds `--json`, which is the machine copy and stays byte-exact (LLP 0225).

Two more files reach `hyp status`'s text surface and were printed raw:
`config-control/state.json` and its etag sidecar (remote-authored etags, plus
reasons and timestamps that are this build's vocabulary only if this build
wrote the file), and a client's own settings file by way of the attach
probe's `error`, which for an unparseable settings file is `JSON.parse`
quoting that file back. Both are display-only where they are printed, so
both are cleaned at the render and left alone under `--json`.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Verdict: findings (1), none fixed here

Reviewed 55fef27 manually against the review brief. codex / code-review are
not invocable in this environment, so this is a hand review: full diff read,
three of the four focus areas verified by running code, and the regression claim
verified by reverting src/ wholesale.

One finding, and it is the exact thing focus item 1 asked me to look for: the
--json surface is not byte-exact, in one place, and the PR body states the
opposite. Everything else in the sweep checks out, including the parts I
expected to be the weak points.


Finding 1 (blocker for the stated invariant): the rollback diagnostic is cleaned in the collector, and that value is what hyp status --json prints

src/core/daemon/status.js:1049 composes the remote_config_rolled_back
message out of sanitizeLabel'd components. That message is stored on the
report, and src/core/commands/status.js:283-286 maps report.diagnostics
straight into the --json payload as message: d.message. So the cleaning does
not stop at the text render: it lands in the machine copy too.

The PR body says of item 2 "all of it is display-only at those points, so all of
it is cleaned there and left alone under --json", and the code comment added
at status.js:1043-1047 says the message is cleaned there "rather than in
readConfigControlStatus, whose result also feeds --json". Both are true of
readConfigControlStatus. Neither is true of the diagnostic itself: the
diagnostic is a --json field.

Failure scenario. config-control/state.json holds a last_rollback whose
reason is probation_expired\nFORGED and whose etag carries an ESC [ 2 K.
Run hyp status --json and read diagnostics[]:

before (master)
  diagnostics[].message = "remote config W/\"rev-7\u001b[2K\" rolled back at ... (probation_expired\nFORGED)"
after (this PR)
  diagnostics[].message = "remote config W/\"rev-7[2K\" rolled back at ... (probation_expiredFORGED)"

The ESC is stripped and the newline collapsed; on master both survive.

(Reproduced by calling collectHypAwareStatus then renderStatusJson against a
seeded state.json, once on 55fef27 and once with src/ checked out from
origin/master. The remote_config.last_rollback.reason field beside it is
"probation_expired\nFORGED" in both runs, correctly raw.)

Note the second line: the newline is gone, so a program reading
diagnostics[].message can no longer tell probation_expired\nFORGED from a
reason literally spelled probation_expiredFORGED. Each component is also
clamped to 120 by sanitizeLabel's default, so a long real etag is silently
truncated in the machine surface.

Why the test suite does not catch it. The --json guard test in
test/core/status-hostile-non-status-file.test.js:214 seeds probation, not
last_rollback, so it never renders this diagnostic. The neighbouring test at
:132-135 asserts the opposite property directly on the report object
(assert.ok(!CONTROL_EXCEPT_NEWLINE.test(diag.message))) - that assertion is
what pins the cleaning into the collector.

Why I did not fix it. Both resolutions are defensible and the choice is a
design call, not a mechanical one:

  • Move it to the render. Keep the message raw in collectHypAwareStatus and
    clean at src/core/commands/status.js:559, the single ${d.message}
    interpolation in the diagnostics loop. This is the choke point the rest of the
    PR argues for, and it would incidentally cover the other twelve diagnostic
    messages, several of which already interpolate untrusted-ish strings raw
    (status.js:566 an err.message, :608 a validator pointer, :994 and
    :1100 a filePath). The cost is picking a clamp for all diagnostic
    messages: the longest template in the file is around 230 characters before
    interpolation, so MAX_ERROR_CHARS' 400 is tight and a wider constant would
    be needed. That is a policy decision and a scope widening past Sweep hyp daemon status interpolations through sanitizeLabel, plus #681's non-blocking residue #776.
  • Keep it where it is and correct the prose. Argue that
    diagnostics[].message is display prose that merely happens to be carried in
    the JSON envelope, and that the structured remote_config.last_rollback
    beside it is the machine copy of those three values (it is, and it stays
    raw). Then the body's "left alone under --json" and the status.js:1043-1047
    comment both need rewording, because as written they assert something the code
    does not do.

I have no standing to pick between those, so it is recorded rather than pushed.
Security posture is fine either way - nothing here makes the terminal less
safe, and the text surface is correctly defended in both. What is wrong today is
the invariant the PR claims to hold.


What I verified, and what held

Focus 2, the collector stays semantically raw: confirmed. This is the part I
expected to find a problem in and did not.

  • attributeDroppedUpstreams (src/core/daemon/status.js:253) and its
    registered_presets intersection are untouched by the diff. The set is still
    built from raw stringList(details.registered_presets), so Upstream names in the idle-gateway warning are sanitized and capped like every other status-file label (#680) #681's hazard (a
    sanitized name failing to intersect a raw one) is not reintroduced.
  • Every printable() call sits inside a write template. Every predicate still
    reads the raw value: if (status.healthyAt), if (c.error),
    if (rc.runningEtag), if (remoteConfig?.lastRollback). liveUptimeMs is
    computed from raw status.healthyAt via Date.parse, not from the printable
    form.
  • sources[].name / sinks[].instance as handed out by the collector are
    unchanged; the cleaning in runDaemonStatus is local to the loop body.

Focus 1, hyp daemon status --json: confirmed unchanged.
src/core/commands/daemon.js:159 still builds { running, ...status, uptimeMs: liveUptimeMs } from the raw readStatusFile result and returns before any
printable() runs. readStatusFile itself is not touched. The one behaviour
change on that path is the new try/catch: a malformed status.json under
--json now exits 1 with a cleaned line on stderr instead of throwing a stack.
That is strictly better and not a JSON-shape change.

hyp status --json: renderStatusJson is untouched by the diff (all edits are
below it), so the only --json delta on that command is the one in Finding 1.

Focus 3, the 11 regression tests: verified, exactly. Rather than spot-check
three, I reverted all three source files to origin/master in the worktree and
ran both new test files:

not ok 1  - a hostile state and timestamps cannot drive the terminal ...
not ok 2  - a non-numeric pid and uptime from the status file are cleaned ...
ok    3   - a well-formed pid and uptime still print as the numbers they are   (guard)
not ok 4  - hostile source and sink fields cannot drive the terminal ...
not ok 5  - an unbounded field from the status file is clamped
not ok 6  - a malformed status file reports a clean error rather than a raw stack
not ok 7  - a status file holding the wrong shape does not crash the command
ok    8   - hyp daemon status --json stays byte-exact                          (guard)
not ok 9  - a hostile remote-config etag cannot drive the terminal ...
not ok 10 - a hostile rollback reason cannot drive the terminal ...
not ok 11 - an unbounded remote-config etag is clamped
not ok 12 - a hostile client probe error cannot drive the terminal ...
not ok 13 - an unbounded client probe error is clamped
ok    14  - hyp status --json still reports the etag and probe error ...       (guard)
# pass 3 / fail 11

11 failing before, 3 guards green either way. The body's claim is accurate to
the test.

Focus 4, the clamps: consistent, and printableNumber is right.

  • 400 is applied to exactly the three error-message sites
    (daemon.js:143 the reader throw, daemon.js:178 source.error,
    status.js:404 c.error) and nowhere else. Everything that is an identifier
    or a timestamp takes the 120 default. A remote-config reason is an enum-ish
    token (probation_expired), so 120 is the right side of the line for it.
  • MAX_ERROR_CHARS is defined twice at 400, once per file, which is the
    duplication the body already flags for the Upstream names in the idle-gateway warning are sanitized and capped like every other status-file label (#680) #681 merge. Fine as-is.
  • printableNumber's finite branch is safe without a clamp, which is the
    non-obvious part: String() over any finite JSON number is short ASCII
    (1e308 renders as 1e+308), and every non-finite or wrong-typed value falls
    to the label path. Checked the edges: NaN and Infinity cannot survive
    JSON.parse as numbers and arrive as strings, which take the label path;
    null / undefined / a bare '' all render exactly as they did on master.
    No regression for a well-formed file, which the guard test at :3 pins.
  • entryList turning a non-array or a ['x'] sources into (none) is an
    improvement over master's undefined (undefined): undefined.

Focus 5, conventions: clean. No semicolons on any added line, zero U+2014 in
the diff (checked the raw patch, including runtime strings and JSDoc), no
@typedef, no inline import('...') types (the two await import(...) hits
are pre-existing lazy value imports), no TypeScript annotations.

The two new @refs resolve. LLP 0225#decision is ## Decision {#decision};
LLP 0164#status-reads-it-from-the-status-file is the explicit
<a id="..."> anchor at llp/0164-...md:108, and matches the five existing
uses on master.

Coverage against #776: complete. Item 1's five field groups, the uncaught
readStatusFile throw, and item 2's two residue classes are all addressed. The
"deliberately not changed" list (report.layered.drops, the collector's identity
keys) matches what #776 does and does not ask for, so I am not counting it as a
gap.

Checks

  • npm test: 4097 pass / 0 fail / 1 skipped (4098 total). The duplicate-LLP
    failure the body mentions is gone: Renumber the colliding prune decision from LLP 0223 to 0226 (#774) #775 is merged into this branch at 366410e.
  • npm run typecheck: clean.
  • npm run smoke -- status_diagnostics: ok.
  • npm run smoke -- daemon_foreground_start_stop: ok.
  • Fresh npm install in a detached worktree, not a symlinked node_modules.

Not counted as findings

Nothing was pushed

Head is unchanged at 55fef27a63e04ab8b2bfa82bee7764118307b6f1. Finding 1 needs
a human to choose between the two resolutions above; whichever is chosen, the
body's "left alone under --json" sentence and the comment at
src/core/daemon/status.js:1043-1047 need to end up saying what the code does.

Review round 1 found the PR's stated invariant false. The
`remote_config_rolled_back` message is assembled in the collector out of
`sanitizeLabel`'d components, and `renderStatusJson` maps that message
straight through, so `--json` carries the cleaned prose too: the
newline in a hostile `reason` closes up, and each component is clamped
at 120. The claim that all of it is "cleaned there and left alone under
`--json`" held for the block beside it, not for the diagnostic.

The behaviour is kept as it is. The security posture is the same either
way, the prose line is not a parsing surface on either render, and the
unedited values sit one key away at `remote_config.last_rollback`, which
stays byte-exact. Moving the clean to the single `${d.message}`
interpolation in `renderStatusText` would keep the prose raw for
`--json`, but it needs one clamp width that holds for every diagnostic
kind, which is a wider decision than #776.

So: correct the comment to state what the code does, and close the test
gap that let the false claim through. The existing `--json` guard seeds
`probation`, which only ever reaches the text render, so it could never
have caught this. Two new tests seed `last_rollback` and assert what
`--json` really contains, prose and values. Both fail on master.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Round 2 of 2. Verdict: approve, with round 1's finding resolved in this round and one follow-up deferred to triage.

Reviewed 55fef27a63e04ab8b2bfa82bee7764118307b6f1 (unchanged since round 1). Manual review only: codex is not installed here and code-review is not invocable in this environment, so nothing automated ran over the diff.

Round 1's finding: confirmed at head, resolved as option (b)

Reproduced against the real collectHypAwareStatus + renderStatusJson at 55fef27, seeding last_rollback with a hostile reason:

DIAG MESSAGE: "remote config W/\"rev-7[2K rolled back at 2026-05-21T00:00:00.000Z (probation_expiredFORGED)"
STRUCTURED  : {"etag":"W/\"rev-7�[2K","reason":"probation_expired\nFORGED","at":"..."}

The message is assembled once in the collector (src/core/daemon/status.js) out of sanitizeLabel'd components, and src/core/commands/status.js:286 maps it straight into --json as message: d.message. So --json receives cleaned prose: the newline closes up (a consumer can no longer tell probation_expired\nFORGED from probation_expiredFORGED) and each component is clamped at 120. The structured remote_config.last_rollback beside it stays byte-exact, which is why the security posture is unaffected either way and why the defect is the stated invariant, not the behaviour.

Resolved the conservative way (option b): behaviour unchanged, claim corrected, contract pinned. Commit 078d762, pushed to fix/issue-776. New head 078d7627c9e06207c942bd56bb6740f5d62e44f4.

Before taking (b) I checked whether any --json consumer depends on raw diagnostic text. None does: remote_config_rolled_back appears only at its push site and in tests, and no smoke, plugin, or MCP path reads diagnostics[].message as data. So (b) costs no consumer anything.

1. The in-source comment now says what the code does

src/core/daemon/status.js (the block above the diagnostics.push). It previously ended "...cleaned here rather than in readConfigControlStatus, whose result also feeds --json", which reads as "so --json is untouched". It now states plainly that the assembled prose is what --json carries at diagnostics[].message, stripped and clamped to 120, that this is accepted rather than overlooked, that the prose is not a parsing surface on either render, and that the byte-exact values are one key away at remote_config.last_rollback. The alternative (clean at the single ${d.message} interpolation in renderStatusText) is named in the comment along with the reason it is not taken here. The @ref LLP 0225#decision gloss was rewritten to match.

Two incidental inaccuracies in my own first draft of that comment were corrected before commit: there is one ${d.message} text interpolation (src/core/commands/status.js:559), not two, and this is not the only value the collector cleans (recentEntrypointsFromSources also calls sanitizeLabel, on entrypoint and client_name).

2. Two new tests pin the real --json contract

test/core/status-hostile-non-status-file.test.js. The gap that let this through is that the existing --json guard seeds probation, which only ever reaches the text render, so it could never have caught the diagnostic path. Both new tests seed last_rollback:

test asserts on master
hyp status --json carries the cleaned rollback prose, and the raw values beside it the --json message has no control byte, no zero-width run, no newline, and the stripped reason closes up (probation_expiredFORGED); remote_config.last_rollback deep-equals the raw seeded object fails
a long rollback etag is clamped in the --json message but whole in the --json values the --json message clamps at 117 + ...; remote_config.last_rollback.etag keeps all 5000 characters fails

Before/after, run in a clean origin/master worktree with only the test file copied in, then at the new head:

  • against master: # pass 1 # fail 7 (the 5 the PR already claimed, plus both new ones)
  • at 078d762: # pass 8 # fail 0

Verification at the new head

  • npm test: 4099 pass / 0 fail / 1 skipped. Note the PR body's "4096 pass / 1 fail" is stale: the inherited two documents numbered LLP 0223 failure is gone, because Renumber the colliding prune decision from LLP 0223 to 0226 (#774) #775 landed on master and is in this branch's merge.
  • npm run typecheck: clean.
  • npm run smoke -- status_diagnostics: ok.
  • No em dash and no statement semicolon in any added line.

Round 1's passing items, re-verified at head

  • hyp daemon status --json is genuinely unchanged: the json branch returns { running, ...status, uptimeMs: liveUptimeMs } before any printable call touches anything.
  • hyp status --json stays raw for everything except the one diagnostic string above: probation.etag, last_rollback (all three fields), and the client attach error all come through byte-exact, now asserted by deep-equal rather than by a single field.
  • The collector is otherwise semantically raw; sanitizeLabel in src/core/daemon/status.js is confined to recentEntrypointsFromSources (pre-existing, LLP 0164) and this one message.
  • The rest of the diff's claims hold: entryList really does stop the TypeError on a non-array sources/sinks, the readStatusFile/readPidFile guard really is the only path on which the file reached the terminal unfiltered, and the 400-character error clamp is applied at every error interpolation the PR body names.

Deferred follow-up for triage: option (a)

Not implemented here, deliberately. Move the cleaning of diagnostic messages out of the collector to the single ${d.message} interpolation in renderStatusText (src/core/commands/status.js:559), leaving --json byte-exact for diagnostics too, which is what LLP 0225 #decision states as the general rule.

The open question it needs answered first is the clamp width. A clamp at the render applies to the whole assembled sentence, for all 14 diagnostic kinds in src/core/daemon/status.js, not to each interpolated component. The longest existing template (client_attached_not_configured, src/core/daemon/status.js:964) is a ~230-character source line, so before any captured value is substituted a message is already around 200 characters of this build's own prose. That makes the existing MAX_ERROR_CHARS of 400 tight enough that a legitimate message naming a full path could be truncated, while a value-level clamp of 120 could not reach it at all. Someone has to pick that number and defend it across every kind. That is a wider decision than #776 asked for, which is why it is filed rather than done.

Classification: preference, not a blocker. The behaviour on this branch is safe (nothing hostile reaches a terminal on either surface), the values a program should read are byte-exact, and the code now describes itself accurately.

Action needed from the orchestrator, not from a reviewer

The PR body still carries the corrected claim and I cannot fix it (reviewers do not run gh pr edit). Under Item 2, the first bullet ends:

All of it is display-only at those points, so all of it is cleaned there and left alone under --json.

That sentence is false for the remote_config_rolled_back diagnostic. Suggested replacement:

All of it is display-only at those points, so all of it is cleaned there, and the values stay byte-exact under --json. One exception is worth stating plainly: the remote_config_rolled_back message is assembled in the collector out of cleaned components, so --json carries that cleaned sentence at diagnostics[].message too. That is accepted rather than overlooked. The line is prose, not a parsing surface, and a program reads the unedited values one key away at remote_config.last_rollback, which is byte-exact. Cleaning instead at the single ${d.message} interpolation would keep the prose raw for --json, but it needs one clamp width that holds for all 14 diagnostic kinds, which is wider than #776 and is deferred.

The Evidence section's counts are also stale twice over: test/core/status-hostile-non-status-file.test.js is now 8 tests (7 fail before, not 5 of 6), for 16 new tests across two files, 13 failing on master, and npm test is now 4099 pass / 0 fail, with the inherited LLP 0223 failure gone since #775 landed.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage at 078d7627c9e06207c942bd56bb6740f5d62e44f4: nothing blocking, one preference deferred to #784

Both review rounds are at cap, so this rung decides. Everything was re-verified independently in a fresh detached worktree at the current head, not taken from the review summaries.

Round 2's resolution of round 1's finding: verified

  • Ran the real collectHypAwareStatus + renderStatusJson against a seeded config-control/state.json holding a hostile last_rollback (embedded ESC [2K, embedded newline in the reason). At head, diagnostics[].message under --json carries the cleaned prose (no ESC, newline collapsed, probation_expiredFORGED), and remote_config.last_rollback beside it deep-equals the raw seeded object, byte-exact. That is exactly what the corrected comment at src/core/daemon/status.js:1038-1057 now says the code does. The comment is honest.
  • The two round-2 tests genuinely pin it: with src/ reverted wholesale to origin/master in the same worktree, both fail (along with the 11 original regression tests: 13 fail, 3 guards pass); at head, all 16 pass.

Round 1's other passing items: still hold at head

  • hyp daemon status --json is unchanged: the json branch in src/core/commands/daemon.js returns the raw readStatusFile result before any printable() runs, and the guard test pins it.
  • The collector stays semantically raw: sanitizeLabel in src/core/daemon/status.js is confined to recentEntrypointsFromSources (pre-existing on master, lines 412/418) and the one rollback message.
  • npm test at head: 4099 pass / 0 fail / 1 skipped. npm run typecheck: clean.
  • The PR body was corrected in place and now states the --json exception plainly, with the Evidence section at 16 tests / 13 failing on master. Verified against the actual before/after runs above; the counts are right.

The one unresolved item: preference, not a blocker

Option (a), moving the diagnostic-message cleaning to the single ${d.message} interpolation at src/core/commands/status.js:559, so --json diagnostics become byte-exact too. It needs one clamp width chosen across all 14 diagnostic kinds (confirmed: 14 distinct kind: values), where the longest template at src/core/daemon/status.js:963 is already about 200 characters of fixed prose, so 120 is unreachable and 400 is tight. Nothing about deferring it is unsafe: no hostile byte reaches a terminal on either surface, and the structured values a program reads are byte-exact. Filed as #784.

Verdict: ship. No true blocker remains.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 15, 2026
One conflict, in src/core/commands/status.js: both sides added an import
line at the same spot. #792 (proxy-mode trust) added `ENV_VAR_NAME` from
`../daemon/launchd_env.js`; this branch added `sanitizeLabel` from
`../util/json_util.js`. Both kept.

The `printable()` collision this PR anticipated did not happen: #681 has
not landed, so `src/core/commands/status.js` still holds exactly one
`printable()` and one `MAX_ERROR_CHARS` (400), and `src/core/commands/
daemon.js` holds its own single pair. Label sites use `sanitizeLabel`'s
120 default, error sites pass 400.

Both intents survive unchanged: this branch's cleaning of every
interpolation it targets, including the collector-assembled
`remote_config_rolled_back` message whose cleaned prose reaches `--json`
while `remote_config.last_rollback` beside it stays byte-exact, and
#792's `proxy trust` text block plus tri-state `proxy_trust` JSON key,
which stays raw and absent off-darwin exactly as it landed.
@philcunliffe philcunliffe removed the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 17, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Re-triage at 37a79168 after the merge of master (#789, #792): clean, nothing new beyond #784

This is a post-merge re-triage of a PR already cleared at 078d7627. Everything below was verified in a fresh detached worktree at the merge head, not taken from the resolver's report.

The resolution, verified

  • The merge (37a79168, parents 078d7627 and 192d3f9e) was replayed with git merge-tree: the auto-merge tree conflicts in exactly one file, src/core/commands/status.js, on the import block. The committed tree differs from the auto-merge tree by exactly that block: HEAD's sanitizeLabel import and hyp status reports proxy-mode trust and the launchd env (LLP 0237/0239) #792's ENV_VAR_NAME import both kept, nothing else hand-edited. The other two files both sides touched (src/core/commands/daemon.js, src/core/daemon/status.js) auto-merged.
  • One printable() and one MAX_ERROR_CHARS = 400 per file: src/core/commands/status.js (helper plus constant at line 315, error site at 419) and src/core/commands/daemon.js (helper at 88, constant at 66, error sites at 143 and 178). Label sites use sanitizeLabel's 120 default (remote-config block at status.js lines 540 to 548). The printableNumber at daemon.js:101 is the pid/uptime wrapper, not a second copy.

The --json nuance, verified especially

  • Collector side: src/core/daemon/status.js:1066 still assembles remote_config_rolled_back from sanitizeLabel'd components, with the corrected explaining comment (1042 to 1060) and the @ref LLP 0225#decision [implements] annotation at 1061 intact through the merge.
  • Render side: src/core/commands/status.js:301 maps message: d.message into --json (the cleaned prose), and line 265 maps last_rollback: report.remoteConfig.lastRollback byte-exact.
  • Pinned dynamically: the round-2 test hyp status --json carries the cleaned rollback prose, and the raw values beside it (test/core/status-hostile-non-status-file.test.js:257) seeds a hostile rollback through the real collector and renderer, asserts the message is control-free and one line with probation_expiredFORGED closed up, and deepEquals remote_config.last_rollback against the raw seeded object. All 16 hostile tests pass at head via node --test.

#792's tri-state, verified

collectProxyTrust (src/core/daemon/status.js:1222) returns null off darwin, and trusted / launchdEnvSet fall back to null (not false) when a probe cannot run; the --json mapping at src/core/commands/status.js:291 keeps the null-not-omitted contract with its @ref LLP 0237#consequences comment.

CI and tests

  • Check runs at commit 37a79168: duplicate-numbers, test (22), test (24), typecheck (22), typecheck (24), all success.
  • Local full-suite runs at head and at origin/master with the identical environment fail the same 22 parquet-adjacent tests (a stale local dependency set, inherited, not merge-introduced); the failure sets are byte-identical, and CI is green at head.

#784 still stands, with minor line drift

The deferred option (a) is untouched: the message is still assembled once in the collector, the single ${d.message} text interpolation is still the choke point, and the diagnostic-kind count is still 14 (#792 added a report field, not a diagnostic kind). The merge shifted the line numbers #784 cites: assembly is now src/core/daemon/status.js:1066 (was 1062) and the render interpolation is src/core/commands/status.js:590 (was 559).

The body's #681 overlap paragraph: left as is, deliberately

The resolver flagged it as stale. It is not, read as written: it never claimed master already held a printable(), only that "whichever lands second will conflict on that one block". With this PR landing first, that is now a correct forecast about #681, and the paragraph's resolution guidance (keep a single printable and a single 400 error clamp, let both call-site sets share them) is exactly what #681's resolver should do. Editing a settled body bought nothing, so it stands.

Verdict

Path A. Marker appended for 37a79168932f2ef72f1cfd6a05b25009ac2becdb; the prior marker and the Fixes #776 trailer are preserved. Not marking ready and not merging, per the rung contract.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 17, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Merge-order note: this PR and #681 each add the same printable() helper

Both PRs are neutral:approved and both report MERGEABLE against master, but they were each written on master rather than on each other. Merging the second one will not be automatic. Everything below is from trial merges I ran locally, not from reading the diffs.

The collision

Trial merge of this branch onto #681's head: 2 conflicts, both in src/core/commands/status.js. Nothing else conflicts; src/core/daemon/status.js auto-merges cleanly even though both PRs touch it (the hunks are far apart: #681 at lines 125/151/191/324/337/860, this PR at 1039).

The two conflicts are:

  1. The 400-char error constant. Same value, different name and different doc comment.

    Neither can simply be deleted, because each has its own live call site:

  2. The printable() JSDoc block. Both PRs insert it at the identical point, right after renderStatusJson.

Worth knowing: the function body itself is byte-identical in both branches, so git merges the body outside the conflict markers. Only the JSDoc and the constant conflict. The result of a careless resolution is therefore a file that looks fine.

The part that will actually break the build

The two JSDoc blocks declare different signatures:

This PR's call sites pass values that are not narrowed to string (they come out of a status.json / config-control/state.json that nothing validates beyond "is an object"). Resolving the conflict by keeping #681's narrower JSDoc will fail npm run typecheck. Keep {unknown}.

Suggested resolution for whichever lands second

  • Keep one printable(), with this PR's {unknown} signature.
  • Keep both @ref lines. They are not redundant: Upstream names in the idle-gateway warning are sanitized and capped like every other status-file label (#680) #681's @ref LLP 0164#status-reads-it-from-the-status-file justifies cleaning what core reads back out of status.json, and this PR's @ref LLP 0225#decision justifies why the clean sits on the text render and not in the collector. Each covers a different half of why the helper exists.
  • Pick one constant name and update both call sites. MAX_ERROR_CHARS is the better of the two, since after this PR the same constant is also wanted outside the daemon line.

Suggested order: #681 first, then rebase this PR onto it and take the resolution above. #681 is the narrower change and its scope was already ruled (the option-2 descope that created #776, which is this PR).

A third copy, for the follow-up list

This PR also defines its own printable(), printableNumber() and MAX_ERROR_CHARS in src/core/commands/daemon.js (lines 88, 101, 66). Once both PRs land there are three copies of the same one-line helper across two files. Not a blocker, and reaching across command modules would be worse than duplicating a one-liner, but the obvious follow-up is to lift printable / printableNumber into a small shared module and have all three read from it.

Two smaller notes

Posted by a maintainer review pass over the open neutral:approved queue. No change requested to the substance of this PR, which is unaffected: this is purely about how it lands alongside #681.

@philcunliffe philcunliffe removed the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 17, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Re-triage at 5a392193 after the batch merge (#681 et al.) and the master refresh: clean, no blocker, deferred findings tracked in #784

Review rounds are at cap, so this rung decides. Everything below was verified in a fresh detached worktree at the current head, not taken from prior notes. The two head moves since the last cleared triage (37a79168) are d21b7479 (conflict resolution merging the batch that landed #681, #794, #797) and 5a392193 (refresh merge of a now-green master, bringing #821). Neither is a content change of this PR's own.

The merged sanitizing surface, verified for coherence

Three PRs converged on this surface (#681, this one, and the fixture change from #553), so this was the focus:

  • Exactly one sanitizer per file. printable() at src/core/commands/status.js:352 (one MAX_ERROR_CHARS = 400 at :319) and at src/core/commands/daemon.js:88 with its printableNumber wrapper at :101 (constant at :66). The conflict resolution was replayed with git merge-tree: the committed tree differs from the auto-merge by exactly the conflicted block, resolved as the merge-order note prescribed - one helper, the wider {unknown} signature kept (typecheck passes), both @refs kept (LLP 0225#decision and LLP 0164#status-reads-it-from-the-status-file, both anchors resolve at head), MAX_DAEMON_ERROR_CHARS collapsed into MAX_ERROR_CHARS with Upstream names in the idle-gateway warning are sanitized and capped like every other status-file label (#680) #681's describeDaemon call site updated (:725).
  • Each value cleaned exactly once, none twice. Render-side cleaning covers sources/sinks labels (:394/:403), the client probe error (:436), the remote-config block (:557-:565), describeDaemon (:722-:725), and every runDaemonStatus interpolation (daemon.js:165-:187). The two collector-side cleanings (recentEntrypointsFromSources, pre-existing LLP 0164, and the remote_config_rolled_back message) are printed raw at their renders, so nothing is double-sanitized. pid=${daemon.pid} in describeDaemon is deliberately raw and safe: every path feeding it (readPidFile, the launchd/systemd probes) number-validates.
  • --json contracts hold. hyp daemon status --json returns the raw readStatusFile result before any printable runs; hyp status --json is byte-exact except the one documented diagnostics[].message exception, pinned by test.
  • The Proxy mode is the default and hyp attach claude migrates old installs (LLP 0242-0244) #794 caution from the merge-order note, checked. probeClientAttachFromDescriptor (src/core/daemon/status.js:1618) still returns JSON.parse's file-quoting message as error, the collector still forwards it (with the hyp status: a probe-less client is unattachable, not unattached #553 attachable gate, fixtures updated), and the render cleans it at :436.
  • PR hyp status names the CA's permitted hosts, uninstall clears every duplicate root (#793) #800 (displayableCaHosts), checked for collision. It defines no printable and no MAX_ERROR_CHARS; its hunks sit in disjoint regions of the two shared files. Different value class, no collision beyond at worst a trivial textual merge for whichever lands second.

Checks at head

  • npm test: 4273 pass / 0 fail / 1 skipped.
  • npm run typecheck: clean.
  • Both hostile-input test files: 16/16 pass.
  • CI green at 5a392193, MERGEABLE.

Residual findings, all non-blocking, tracked in #784

  1. Option (a) for the rollback diagnostic (--json diagnostics[].message carries collector-cleaned prose; byte-exact values sit beside it at remote_config.last_rollback) - preference, the issue's original subject, line drift re-recorded there.
  2. Three copies of the one-line printable helper across two files after the merge - preference, non-behavioural refactor, now recorded on Follow-up: deferred review findings from PR #777 #784.
  3. PR hyp status names the CA's permitted hosts, uninstall clears every duplicate root (#793) #800 adjacency note - awareness only, recorded on Follow-up: deferred review findings from PR #777 #784.

Details and evidence: #784 (comment)

Verdict: no true blocker at 5a392193ec9f299f8e9dc24ba01daa412106f1ff. Not marking ready and not merging, per the rung contract.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sweep hyp daemon status interpolations through sanitizeLabel, plus #681's non-blocking residue

1 participant