Skip to content

feat: add Windows cross-platform support#2

Open
winrey wants to merge 19 commits into
devfrom
worktree-windows-support
Open

feat: add Windows cross-platform support#2
winrey wants to merge 19 commits into
devfrom
worktree-windows-support

Conversation

@winrey
Copy link
Copy Markdown
Contributor

@winrey winrey commented Apr 14, 2026

Summary

  • IPC: Generic AsyncRead/AsyncWrite refactor + Named Pipe server on Windows. Peer identity via GetNamedPipeClientProcessIdLookupAccountSidW. Rejects connections with unverifiable identity.
  • Security: DPAPI encryption for Ed25519 private keys (zeroed before LocalFree). Cross-platform ACL file permissions (SetNamedSecurityInfoW / chmod).
  • Process management: Locale-independent tasklist PID check, taskkill, CREATE_NO_WINDOW | DETACHED_PROCESS.
  • Signal handling: tokio::signal::ctrl_c() on Windows, SIGTERM/SIGINT on Unix.
  • Scripts: upgrade.rs / install_daemon.rs full Rust rewrite with SHA-256 checksum verification (hard-fail on missing/mismatch). install.ps1 PowerShell installer with enforced checksum + PATH setup.
  • browser-init: Node.js .zip on Windows with zip-slip starts_with defense-in-depth. Chrome/Edge detection.
  • CI: Windows build target + cross-platform test workflow + Swatinem/rust-cache@v2.

Review Summary

  • 12/12 tasks passed spec review
  • 3 rounds of review-loop: 29 issues found, 10 fixed, 13 rejected (pre-existing/false positive/by design), 6 skipped (minor)
  • Final status: 0 Critical, 0 Important remaining
  • 108 tests passing (13 IPC integration, 8 frame protocol, 8 fs_perms, DPAPI cfg-gated, device identity roundtrip)

Spec

docs/superpowers/specs/2026-04-05-windows-support-design.md

Test plan

  • cargo test -p ahandd -p ahandctl passes on macOS/Linux
  • Windows CI build compiles (x86_64-pc-windows-msvc)
  • Windows CI tests pass (IPC Named Pipe, DPAPI, ACL, tasklist)
  • install.ps1 installs correctly on Windows
  • Daemon start/stop lifecycle works on Windows

🤖 Generated with Claude Code

winrey and others added 19 commits April 5, 2026 16:23
… path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add fs_perms module providing restrict_owner_only (chmod 600 equivalent)
and restrict_owner_and_group (chmod 660 equivalent) that work on both
Unix (via PermissionsExt) and Windows (via Win32 ACL APIs). Includes 9
integration tests covering happy paths, error cases, idempotency, and
permission transitions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add [lib] and [[bin]] sections to Cargo.toml for clarity when both
lib.rs and main.rs coexist. Improve fs_perms_test.rs with better test
isolation (tempdir for error cases), separate nonexistent-file tests
for both functions, and proper #[cfg(unix)] gating on platform-specific
assertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add protect/unprotect functions wrapping CryptProtectData/CryptUnprotectData
for encrypting Ed25519 private keys at rest on Windows, bound to the current
user account. Module is cfg(windows)-gated and skipped on other platforms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Generic handle_ipc_conn<R: AsyncRead, W: AsyncWrite>
- serve_ipc dispatches to serve_ipc_unix / serve_ipc_windows via #[cfg]
- spawn_ipc_handler helper prevents generic propagation
- Windows peer identity via GetNamedPipeClientProcessId + LookupAccountSidW
- Replace set_permissions with fs_perms::restrict_owner_and_group

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace all 5 UnixStream::connect call sites (ipc_exec, ipc_cancel,
ipc_approve, ipc_policy, ipc_session) with a single ipc_connect()
helper that abstracts over Unix domain sockets and Windows named pipes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…latform paths

Use #[cfg(windows)]/#[cfg(unix)] to branch Node.js download format
(zip vs tar.xz), binary paths (node.exe vs bin/node, npm.cmd vs
bin/npm), playwright-cli paths, and clean logic (taskkill on Windows).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…dmin

Replace the bash-shelling upgrade.rs with a pure Rust implementation
that uses reqwest to fetch releases from GitHub and downloads binaries
directly. Includes Windows support with .exe rename-before-overwrite
strategy. Replace admin panel's bash browser-setup subprocess with an
SSE message directing users to the native ahandd browser-init command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Exercises the length-prefixed framing layer (write_frame / read_frame)
that underpins cross-platform IPC. Covers: basic roundtrip, protobuf
envelope serialisation, multiple sequential frames, empty frames,
bidirectional request-response, oversized-frame rejection, and
truncated-payload error handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…64 platform suffix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add integration tests that exercise the real serve_ipc production server
over Unix sockets, including JobRequest->JobRejected/JobFinished roundtrip,
AutoAccept mode job execution, SessionQuery->SessionState roundtrip,
query-all-sessions, and peer identity format verification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix tasklist sentinel string in daemon.rs (C1)
- Make Named Pipe accept loop resilient to transient errors (C2)
- Fix install.ps1 checksum filename mapping (I1)
- Add write_frame size guard matching read side (I2)
- Fix admin.rs is_process_running for Windows (I3)
- Use enclosed_name() to prevent zip-slip in browser_init (I4)
- Replace sleep-based test sync with socket polling (I5)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- B1: locale-safe tasklist PID check (daemon.rs, admin.rs)
- B3: SHA-256 checksum verification on upgrade/install-daemon downloads
- C2: zip-slip defense-in-depth (starts_with check in browser_init)
- C3: install.ps1 asserts both binaries were verified
- C6: add Swatinem/rust-cache@v2 to CI workflows
- C7: remove system-wide taskkill /IM node.exe from clean()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Reject Named Pipe connections with unverifiable identity (B-1)
- Guard against zero-size GetTokenInformation buffer (B-2)
- Hard-fail on checksum fetch/missing entry in upgrade and install-daemon (B-3)
- Zero DPAPI plaintext buffer before LocalFree (B-8)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
winrey added a commit that referenced this pull request Apr 27, 2026
…istency

- docs/remote-control-roadmap.md §2: list the new POST /api/control/browser endpoint and CloudClient.browser() under "Current status".
- packages/sdk/CHANGELOG.md: fix Before-table types (Buffer optional, not Uint8Array | null) to match the actual pre-rename type; clarify that hub-side dedupe is tracked as cross-repo spec follow-up #3.
- crates/ahand-hub/src/browser_service.rs: correct the NOTE(idempotency) comment to point at follow-up #3 (hub-side idempotency) instead of #2 (tool-args redaction); fully-qualify the cross-repo spec path so a reader inside the aHand checkout doesn't follow a broken link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
winrey added a commit that referenced this pull request Apr 27, 2026
* feat(sdk): add timeout error code; map HTTP 504

Extend CloudClientErrorCode union with 'timeout' and update HTTP error
mapping to route 504 responses to the new timeout error code. This enables
the new CloudClient.browser() method to surface gateway timeouts without
resorting to generic server_error. Existing spawn() call sites are
unaffected, as hub timeouts have always reached them via SSE error events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sdk): add CloudClient.browser() — control-plane browser invoke

Implements POST /api/control/browser as a single request-response RPC
(no SSE) matching the wire schema Task 9 will land. Decodes base64
binary_data into Uint8Array under result.binary, maps snake_case fields
(device_id, session_id, timeout_ms, correlation_id, duration_ms) to
camelCase BrowserParams/BrowserResult, and reuses the existing
toTypedHttpError taxonomy (401/403/404/429/504/5xx + abort/network).

Renames the existing connection.ts WebSocket-side BrowserResult to
DeviceBrowserResult to free the public BrowserResult export for the
new control-plane shape. No external consumer references the renamed
symbol — verified across aHand workspace and the team9-agent-pi
feature worktree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(hub): extract browser_service::execute() shared function

Move the device-lookup, capability-check, oneshot-registration,
envelope-construction, WS-dispatch, and timeout-await machinery out of
the dashboard `POST /api/browser` handler into a new shared service
function `browser_service::execute()`. The dashboard handler is now a
thin wrapper that handles auth + body parsing + JSON encoding around
the service call. `map_service_error` lives `pub(crate)` in
`http/browser.rs` so Task 9's `/api/control/browser` endpoint can reuse
it.

Pure refactor: external behavior of `POST /api/browser` is unchanged
(all 6 dashboard browser_api integration tests pass without
modification). `BrowserCommandInput.correlation_id` is wired now but
unused; the dashboard passes None, Task 9's worker handler will pass
the caller-supplied id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(hub): POST /api/control/browser — worker-side browser endpoint

Mounts under control_plane::router() so the existing
require_control_plane_jwt middleware handles auth. The handler
verifies device ownership (claims.external_user_id ==
device.external_user_id) before delegating to browser_service::execute(),
mirrors the dashboard wire format with one addition (duration_ms),
and shares the same error mapper as the dashboard endpoint.

Tests cover 200/401/403/404/400/504 plus a binary base64 round-trip.

* fix(hub): /api/control/browser scope check + tests for allowlist/rate-limit; clarify idempotency doc

Parity with sibling control-plane handlers (`create_job`, `stream_job`,
`cancel_job`):

- `browser_command_control` now validates `claims.scope == "jobs:execute"`
  before any DB / WS work, mirroring the other handlers. A token minted
  with a non-execute scope (e.g. `jobs:read`) is rejected 403 FORBIDDEN.
- New test `control_browser_403_when_device_not_in_allowlist` covers the
  `device_ids` allowlist branch (mirrors
  `create_job_rejects_device_not_in_allowlist`).
- New test `control_browser_returns_429_when_rate_limited` covers the
  per-user rate-limit branch (mirrors `rate_limit_returns_429`).
- Module doc-comment tightened to clarify that hub-layer idempotency
  applies only to `POST /api/control/jobs` today; `/api/control/browser`
  accepts `correlation_id` in the wire schema but does not currently
  dedupe (tracked as a follow-up).

Spec drift on idempotency for `/api/control/browser` will be addressed
in a follow-up commit on the team9-agent-pi side (cross-repo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sdk): wrap res.json/strict success check; cover post-token abort + 400/418/502/503 mappings

Source fixes in cloud-client.ts:
- Wrap success-path `res.json()` in try/catch so AbortError during body
  read becomes CloudClientError("abort"), SyntaxError (HTML 502 page
  with 200 status) becomes server_error, others become network. Error
  path was already wrapped via toTypedHttpError.
- Replace `success: json.success === true` coercion with strict
  typeof === "boolean" check; throw server_error on malformed shapes
  (e.g. success: 1, missing field) rather than silently flipping to
  false. Catches future hub serializer drift early.

Tests added (7 new, 45 → 52 in src/cloud-client.test.ts):
- Post-token abort path: signal aborted while getAuthToken pending →
  fetch never called, code=abort. Regression guard for the second
  abort fast-path.
- success === true strict: missing field → server_error;
  non-boolean (success: 1) → server_error.
- Status mappings: 400 → bad_request; 502 / 503 → server_error
  (non-mapped 5xx default branch); 418 → bad_request (non-mapped 4xx
  default branch).

* test(hub): cover scope/idempotency/channel-closed/wire-omission paths for /api/control/browser

Add 5 new tests + strengthen 1 existing assertion to close gaps
identified during review of the /api/control/browser endpoint:

- T1: control_browser_403_when_scope_not_jobs_execute pins the
  claims.scope guard at control_plane.rs:626-632 (mirrors the
  sibling create_job_rejects_wrong_scope template).
- T2: control_browser_correlation_id_does_not_dedupe_currently
  pins the documented "no dedupe" behavior of correlation_id so
  a future regression that accidentally implements idempotency
  fails loudly.
- T3: control_browser_500_when_response_channel_closed exercises
  the BrowserServiceError::ChannelClosed -> 500 INTERNAL_ERROR
  branch by reaching into state.browser_pending and dropping the
  oneshot tx after dispatch.
- T4: control_browser_404_device_offline_on_send_failure is
  filed as #[ignore] with a sketch + unblock condition because
  ConnectionRegistry has no trait surface for clean injection.
- T5: control_browser_returns_429_when_rate_limited gains a
  follow-up assertion that non-429 responses are exactly 404
  DEVICE_NOT_FOUND, guarding against a regression where the
  rate-limiter fires before auth/lookup.
- T6: control_browser_200_with_valid_jwt_and_owner_match now
  asserts the optional fields (error / binary_data / binary_mime)
  are OMITTED from the JSON object, not serialized as null —
  matching the dashboard test invariant.

Also adds mint_cp_jwt_with_scope helper for T1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hub): RAII cleanup for browser_pending; sanitize internal store error

Two review findings on `browser_service::execute()`:

A2 (Important): when the HTTP client disconnects between the
`browser_pending.insert(request_id, tx)` and the `tokio::time::timeout(rx)`
resolving, axum cancels the handler future at the `.await`. The explicit
`.remove()` calls in each error branch never run, so the entry leaks for
the lifetime of the hub process. Replace the per-branch removes with an
RAII `PendingGuard` whose `Drop` removes the entry — runs on every exit
path, including future cancellation. `DashMap::remove` returns `Option<_>`
so the guard's drop and the WS gateway's success-path remove at
`device_gateway.rs:685` are both idempotent.

A4 (Important): the device-store error message was being echoed to
clients via `format!("device store: {e}")` -> `Internal(msg)` ->
`map_service_error` -> response body. Pre-refactor this went through
`HubError::Internal` -> ApiError mapping which produced a generic
"Internal server error" string, so the refactor regressed the error
contract by leaking driver / SQL details. Sanitize at the source: log
the raw error via `tracing::error!` with structured fields, return a
generic "device store error" string to the client.

Regression test `browser_command_pending_cleared_on_client_cancel`
already exists in HEAD (added in 6a1695b) and exercises A2 directly —
verified it FAILS with the pre-fix code (entry leaks) and PASSES with
the guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hub): restore 'Internal server error' message; ignored stub for device-store error path

* fix(sdk): guard malformed JSON root; cover res.json catch branches + signal forwarding

* docs: add SDK CHANGELOG note for BrowserResult rename; clarify deferred-idempotency comment

- packages/sdk/CHANGELOG.md (new): document the WS-side BrowserResult → DeviceBrowserResult rename and the new HTTP-side BrowserResult shape; flag correlation_id as not-yet-deduped on the hub side.
- browser_service.rs: replace the misleading "suppress unused-field warning" comment with a NOTE(idempotency) block pointing at the module doc-comment and the spec follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hub): apply rustfmt + Copilot review nits (PendingGuard doc, SendFailed comment)

- cargo fmt: bring tests/browser_api.rs and browser_service.rs in line with CI rustfmt.
- PendingGuard doc: drop stale "unless explicitly disarmed" — there is no disarm path; the guard always fires on Drop. (Copilot review #3144279775)
- SendFailed branch comment: was "via Internal", actual variant is SendFailed. (Copilot review #3144279781)

Rejected Copilot review: double device-store lookup. Pre-existing pattern from create_job; documented as follow-up in spec; perf overhead acceptable for v1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: cover timeout floor, malformed body, camelCase rejection, daemon-error wire, SDK array-root

Adds 6 missing tests identified by a test-completeness audit:

- T1: control_browser_timeout_ms_zero_floors_to_1000ms — pins the
  `timeout_ms.max(1000)` floor in browser_service::execute. A regression
  that drops the floor would surface as flaky sub-second 504s.
- T2: browser_command_returns_400_for_malformed_json_body /
  browser_command_returns_400_for_missing_required_field — pins the
  JsonRejection -> 400 VALIDATION_ERROR contract on the dashboard
  /api/browser handler.
- T3: control_browser_returns_400_for_malformed_json_body /
  control_browser_returns_400_for_missing_required_field — same
  coverage on the worker-side /api/control/browser handler.
- T4: control_browser_400_when_request_uses_camelcase_keys — pins the
  snake_case wire contract on ControlBrowserRequest. A future commit
  that flipped to camelCase would silently change the API.
- T5: control_browser_daemon_failure_returned_as_200_with_error_echoed —
  pins that daemon-reported success: false is NOT promoted to an HTTP
  4xx/5xx; the body carries success: false + error verbatim with a
  duration_ms field, so SDK consumers can branch on body.success.
- T6 (TS): rejects malformed root: response is a JSON array — pins
  that the SDK strict guard rejects array roots (typeof [] === "object"
  but .success !== boolean -> server_error).

All tests-only; no source changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update roadmap, CHANGELOG, browser_service comment for doc-consistency

- docs/remote-control-roadmap.md §2: list the new POST /api/control/browser endpoint and CloudClient.browser() under "Current status".
- packages/sdk/CHANGELOG.md: fix Before-table types (Buffer optional, not Uint8Array | null) to match the actual pre-rename type; clarify that hub-side dedupe is tracked as cross-repo spec follow-up #3.
- crates/ahand-hub/src/browser_service.rs: correct the NOTE(idempotency) comment to point at follow-up #3 (hub-side idempotency) instead of #2 (tool-args redaction); fully-qualify the cross-repo spec path so a reader inside the aHand checkout doesn't follow a broken link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(sdk): bump @ahandai/sdk to 0.1.6 for browser() release

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
winrey added a commit that referenced this pull request Apr 27, 2026
…opy/move/symlink/chmod) (#1)

* feat(protocol): add file operations proto definitions

Define FileRequest/FileResponse messages with 14 operations (read text/binary/image,
write, edit, delete, chmod, stat, list, glob, mkdir, copy, move, symlink) and wire
them into Envelope payload oneof as fields 31/32. Generate both Rust and TypeScript
types via prost_build and buf.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(daemon): add FileManager skeleton with policy and routing

Introduce the FileManager module with FilePolicyChecker (path allowlist/denylist,
traversal detection, dangerous-path approval hint) and wire FileRequest routing
through ahand_client into a dispatch-ready FileManager::handle. Individual
operations remain unimplemented placeholders until Tasks 2-6.

Adds file_policy config section, image/glob/trash/encoding_rs/chardetng
dependencies, and renames FileErrorCode and AclEntryType enum prefixes so
prost strips them cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(daemon): implement FileStat, FileList, FileGlob, FileMkdir

Add the metadata-query and directory-creation operations in the new fs_ops
submodule. Each handler maps std::io::Error to FileErrorCode, reports mtime/
size/symlink targets, and respects policy-resolved paths. FileList paginates by
offset+max_results with mtime-desc ordering; FileGlob resolves patterns against
an optional base_path and caps total results.

Comes with 20 integration tests covering happy paths, pagination, hidden
filtering, not-a-directory, path-traversal rejection, and policy denial.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(daemon): implement FileReadText with triple-limit pagination

Adds text_read submodule handling FileReadText with:
- Triple-limit stopping: max_lines, max_bytes, target_end — whichever fires first
- Start position by line, byte, or line+col
- Per-line truncation via max_line_width with UTF-8-safe cuts and remaining_bytes
- PositionInfo for both start and end positions (line, byte_in_file, byte_in_line)
- Encoding auto-detection (BOM, chardetng) plus forced encoding via parameter
- Total file bytes + total lines + remaining bytes after the stop point

Includes 13 integration tests covering happy paths, all three stop reasons,
each start variant, truncation semantics, empty files, UTF-8 boundary safety,
forced GBK decoding, and error propagation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(daemon): implement FileReadBinary and FileReadImage

Add binary byte-range reading with offset + length + max_bytes caps, and
on-device image reading with optional resize/format-conversion/compression.
Image decoding happens in spawn_blocking so we don't block the tokio runtime.
Lossy encoders (JPEG) iteratively lower quality to fit max_bytes; lossless
formats (PNG) return a single pass.

Comes with 9 integration tests covering full-file/range/max_bytes/past-EOF
reads and image passthrough/resize/format-conversion/budget-compression/
not-an-image error propagation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(daemon): implement FileWrite and FileEdit operations

Adds the write_ops submodule with FullWrite, FileAppend, StringReplace,
LineRangeReplace, and ByteRangeReplace for FileWrite, plus the edit variants
that require existing files. StringReplace returns the proto MatchError code
for not-found and multi-match cases (FileWrite errors out, FileEdit reports
match_error without touching the file). All methods enforce max_write_bytes
from policy.

Comes with 14 integration tests covering happy paths, overwrite, append,
create_parents, replace-all vs single, line/byte-range semantics, too-large
writes, and edit-on-missing-file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(daemon): implement Delete, Copy, Move, Symlink, Chmod

Add the remaining mutation operations in fs_ops:
- FileDelete: TRASH (via system trash crate) and PERMANENT modes with
  recursive guard (returns NotEmpty when non-empty + recursive=false)
- FileCopy: file + recursive directory copy with overwrite control
- FileMove: rename with cross-filesystem copy+delete fallback
- FileCreateSymlink: Unix symlink creation (gated by cfg(unix))
- FileChmod: Unix mode setting with optional recursive descent; chown
  returns PermissionDenied for now

Comes with 12 integration tests covering all mutation paths including
recursive delete, copy, chmod, plus overwrite guards and error cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(hub): add file operation HTTP endpoints and response routing

Add POST /api/devices/{device_id}/files that accepts a base64-encoded
FileRequest proto, forwards it to the connected device via the WebSocket
gateway, and waits (30s) for the correlated FileResponse to come back.
Responses are matched by request_id via a new PendingFileRequests map
owned by AppState and resolved from JobRuntime::handle_device_frame when a
FileResponse arrives on the device socket.

Also fixes two pre-existing compile breaks in the hub crate caused by
proto field additions (HelloAccepted.update_suggestion,
JobRequest.interactive) that hadn't been updated in the hub sources.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(hub): add S3 pre-signed URL flow for large file transfer

Introduces the S3Config section in hub config, an S3Client wrapper around
aws-sdk-s3 for generating presigned PUT/GET URLs and uploading/downloading
raw bytes, and a new POST /api/devices/{device_id}/files/upload-url endpoint
that hands out presigned upload URLs to dashboard clients.

AppState now carries an optional Arc<S3Client>. When S3 is not configured,
the upload-url endpoint returns 503 Service Unavailable with a stable error
code; the inline-content file flow continues to work below the configured
transfer threshold. Hub-driven upload/download inside the existing file
operation endpoint is still inline-only — wiring the full threshold-based
read/write transfer path is left as a focused follow-up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(daemon): add comprehensive file operations E2E tests

Adds 17 end-to-end tests that exercise the full proto round trip for every
file operation: encode a FileRequest into bytes, decode, run it through
FileManager::handle, encode the FileResponse, then decode and validate the
result. Complements tests/file_ops.rs (which tests the handler surface)
by catching any wire-format regressions that type-level tests would miss.

Covers the happy path for all 13 operations plus policy-denied and
not-found error flows, for a total of 68 + 17 = 85 file-op integration
tests passing on the daemon side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(protocol): add update_suggestion field to HelloAccepted test case

Pre-existing compile break from the update_suggestion field that was added
to HelloAccepted alongside the auto-update feature but never propagated to
the roundtrip test. Caught while running the full test suite for the file
operations branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(hub): gate S3 upload-url flow until end-to-end transfer lands

Round 1 review flagged that POST /api/devices/{id}/files/upload-url returns
a valid presigned PUT URL but the daemon still rejects any FileRequest
carrying FullWrite.s3_object_key, so the half-wired S3 path is only usable
up to the S3 PUT itself. Remove the route and handler to avoid exposing a
half-working API surface while keeping the underlying s3.rs infrastructure,
S3Config, and AppState.s3_client ready for the follow-up PR that wires the
full hub-side transfer path.

Also adds the Round 1 review-fixes plan and task tracker.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(hub): swap base64 JSON wrapper for application/x-protobuf on /files

Round 1 review #15: the POST /api/devices/{id}/files handler accepted an
opaque JSON shape ({"proto_b64": "..."}) instead of following hub REST
convention or just using the proto binary directly. Switch to raw
application/x-protobuf for both the request body (axum::body::Bytes) and
response body (IntoResponse with an explicit content-type header).

Deletes the hand-rolled simple_base64 module (fixes #27 padding bug for
free), removes FileOperationRequest/FileOperationResponse wrapper structs,
and simplifies the handler signature. Hub lib tests still pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): image read bomb guard and raw passthrough mode

Round 1 review #9 and #21:
- Before decoding, read the image header via ImageReader::into_dimensions()
  and reject when width * height * 4 exceeds max_read_bytes (policy). Avoids
  decompression-bomb OOM before the pixel buffer gets allocated.
- When the caller supplies no max_width/max_height/max_bytes/quality and
  leaves output_format at Original, return the original file bytes
  byte-for-byte instead of running them through decode→encode. That's what
  the proto spec promised by "omit compression params = raw transfer".

Propagates policy.max_read_bytes() into handle_read_image via a new third
argument. All 5 existing image tests still pass; byte-for-byte passthrough
assertion will be added in task T19.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): gate fs_ops Unix-only APIs behind cfg(unix)

Round 1 review #13: fs_ops.rs unconditionally imported
std::os::unix::fs::PermissionsExt and used Permissions::from_mode / .mode()
in handle_mkdir, handle_chmod, set_unix_mode_sync, and
unix_permission_from_metadata. That would break Windows compilation on
first contact with the crate.

Gate the Unix-only pieces behind #[cfg(unix)]. On Windows:
- handle_mkdir ignores the requested mode (advisory, no warning)
- handle_chmod's Unix branch returns Unspecified "not supported on this
  platform" until a proper Windows ACL implementation lands
- unix_permission_from_metadata returns mode: None

The existing Windows ACL fallback path is left untouched. All 68 file_ops
integration tests still pass on macOS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(hub): add unit tests for s3.rs construction and presigning

Round 1 review #17: s3.rs had zero tests. Add four tests that run without
any real S3 endpoint or AWS credentials:

- s3_config_default_values: S3Config::default() field values
- s3_client_constructs_with_fake_endpoint: S3Client::new against an
  unreachable localhost endpoint doesn't panic, accessors return config
- s3_client_generate_upload_url_returns_populated_url: presigned PUT URL
  has the forced endpoint, bucket in path-style form, object key, and
  a positive expiration
- s3_client_generate_download_url_returns_populated_url: same for GET

Uses synthetic AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY env vars because
the aws-sdk-s3 presigner needs *something* to HMAC with, even though the
computation is purely local. Full round-trip tests against MinIO remain
follow-up work.

Hub lib test count: 31 → 35.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): ByteRangeReplace uses checked_add to reject u64 overflow

Round 1 review #10: apply_byte_range_replace computed
(br.byte_offset + br.byte_length) as usize without checking for u64
overflow. Inputs like byte_offset=5, byte_length=u64::MAX wrapped below
start, passed the bounds check, and panicked during Vec::with_capacity.

Switch to u64::checked_add for overflow detection (returns InvalidPath on
overflow) and do the bounds check in u64 before casting to usize. The
existing out-of-bounds error code/message is preserved to avoid breaking
downstream expectations. All existing byte-range tests still pass; the
u64::MAX regression test is added in task T18.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(daemon): advertise file capability in Hello when FileManager is enabled

Round 1 review #19: the daemon's Hello.capabilities only surfaced "exec" and
optionally "browser", so hub/dashboard consumers had no way to tell which
devices could take a FileRequest. Add a file_enabled parameter to
build_hello_envelope and push "file" into the capabilities vector when
true, mirroring the existing browser handling. Production call site in
connect_with_auth now passes file_mgr.is_enabled(); hub_handshake tests
pass false since they don't test this axis.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): canonicalize paths in policy checker to resolve symlinks

Round 1 review #2: FilePolicyChecker.check_path was doing purely lexical
normalization, so a symlink inside the allowlist pointing at /etc/passwd
would pass the check and any subsequent read/write would operate on the
outside target.

Replace `normalize_path` with `canonicalize_or_parent` which walks symlinks
via std::fs::canonicalize. For paths that don't exist yet (destinations of
Write/Mkdir/Copy/Move) walk up to find the deepest existing ancestor,
canonicalize that ancestor, and re-append the remaining suffix. The `..`
rejection in step 1 of check_path guarantees the suffix is free of
traversal.

Add a `no_follow_symlink: bool` parameter so operations that explicitly
opt out of following the final component (e.g. `Stat { no_follow_symlink }`
calling symlink_metadata downstream) get a resolved parent + preserved
filename instead of a target-resolved path. Every dispatch site in
FileManager is updated to pass req.no_follow_symlink when the op has one,
false otherwise. CreateSymlink passes true because we're creating the
symlink file itself at link_path.

Test module rewritten to use real tempdir fixtures (previously used
hardcoded `/home/user/**` patterns that don't exist on macOS). New test
`symlink_pointing_outside_allowlist_is_rejected` pins the security fix.
12 policy tests + 68 file_ops integration tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): FileGlob rejects hostile patterns and re-checks every match

Round 1 review #1: handle_glob fed req.pattern directly to glob::glob with
no re-check on the resulting paths. An absolute pattern like `/etc/**` or a
traversal pattern like `../../*` bypassed the allowlist entirely, and even
benign `**/*.txt` patterns could surface symlinks inside the allowlist
whose targets pointed outside.

Two-part fix:

1. Dispatcher rejects glob patterns that start with `/` (absolute) or
   contain a `..` component before calling handle_glob. This stops the most
   obvious escape vectors at the API boundary.

2. handle_glob now receives a `&FilePolicyChecker` and re-checks every
   matched path via `policy.check_path(..., no_follow_symlink=false)`.
   Because policy canonicalizes, symlinks whose canonical target lies
   outside the allowlist are silently filtered out of the result set.

Adds 3 new integration tests:
- glob_absolute_pattern_without_base_is_rejected
- glob_traversal_pattern_is_rejected
- glob_skips_symlink_pointing_outside_allowlist

All 71 file_ops tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): enforce policy max_read_bytes for text and binary reads

Round 1 review #6: FilePolicyConfig.max_read_bytes was defined but only
read by handle_read_image (via T10). Text and binary read handlers let the
caller pass an arbitrarily large per-request max_bytes with no policy
ceiling.

Thread max_read_bytes into handle_read_text and handle_read_binary and
clamp the effective per-call cap against it. Text reads now refuse to
buffer the whole file when metadata.len() > max_read_bytes, returning
FILE_ERROR_CODE_TOO_LARGE before any allocation. Binary reads clamp
max = req.max_bytes.unwrap_or(DEFAULT_BINARY_MAX).min(max_read_bytes)
so a caller-provided 1 GB max is still capped at the policy value.

All 68+ file_ops integration tests and 12 policy unit tests still pass;
T19 will add a dedicated max_read_bytes clamp regression test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): reject non-UTF-8 write encoding with clear error

Round 1 review #18: FileWrite.encoding and FileEdit.encoding were proto
fields but write_ops never consulted them, silently downgrading any
requested encoding (e.g. GBK) to UTF-8 writes.

V1 scope is UTF-8 only; full encoding support is future work. Add
ensure_encoding_supported() at the top of handle_write and handle_edit
to reject anything that isn't empty/utf-8/utf8 (case-insensitive) with
FILE_ERROR_CODE_ENCODING before any filesystem I/O. Update the proto
comment on both FileWrite and FileEdit encoding fields to document the
v1 limitation and regenerate TypeScript bindings.

All 71 file_ops tests still pass; T18 will add the "FileWrite with
encoding=gbk returns encoding error" regression test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): strict-mode file operations now go through approval flow

Round 1 review #3: handle_file_request was treating
SessionDecision::NeedsApproval as Allow, so STRICT session mode let
file-op requests through without any approval round trip. Only jobs had
a real approval path.

Mirror handle_job_request's approval flow:
1. Synthesize a JobRequest with tool="file", job_id=request_id, args=[op]
2. On NeedsApproval, submit to ApprovalManager → get (ApprovalRequest, rx)
3. Send the ApprovalRequest to cloud via WS and broadcast to IPC clients
4. Spawn a task that awaits the response with the default timeout
5. On approved → dispatch to FileManager as usual
6. On denied/timeout → send FileResponse{error: PolicyDenied}

The spawned task mirrors the job approval shape but produces FileResponse
(not JobRejected). ApprovalResponse's job_id field lines up with the
synthetic request_id so approval_mgr.resolve routes correctly. Thread
approval_mgr and approval_broadcast_tx into handle_file_request at the
call site. Wire the dispatch loop to pass them in.

All 148 ahandd tests still pass; T5 will plumb dangerous_paths into the
same approval path, and T18/T19 will add the strict-mode file-op approval
integration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): dangerous_paths force approval even in Allow sessions

Round 1 review #5: PolicyResult.needs_approval was computed but never
consulted, so paths listed in FilePolicyConfig.dangerous_paths never
actually forced STRICT approval regardless of session mode — they just
got the same Allow treatment as any other path.

Two-part fix in handle_file_request:

1. Pre-flight policy check: before consulting the session manager, walk
   every path the FileRequest touches (via the new
   FileManager::check_request_approval helper + collect_request_paths)
   and record whether any path is in dangerous_paths. Outright policy
   denials short-circuit with a FileError before we ever consult the
   session manager.

2. Unify Allow-dangerous and NeedsApproval into the same approval-
   submission path. Allow + dangerous uses the reason "path is listed
   in dangerous_paths" with no prior refusals; NeedsApproval forwards
   the session reason and previous_refusals. Both fall through to a
   single spawned task that awaits the approval response and dispatches
   the FileRequest on approval (or returns PolicyDenied on deny/timeout).

The new collect_request_paths helper mirrors the dispatch arms so
future op additions must keep both call sites in sync — documented in
the helper's rustdoc.

All 155 ahandd tests still pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): text read stops mid-line at byte limits and reports raw positions

Two tightly coupled Round 1 fixes (T7 + T8):

**T7 — target_end / max_bytes can now stop partway through a line.**
The previous implementation only evaluated stop conditions at line
boundaries, so a target_end pointing into the middle of line N still
returned the whole line, and max_bytes=120 could overshoot to the next
newline. The rewritten loop computes per-line cut points for both limits,
stripped to the earliest, and the emitted TextLine content is truncated
at that exact raw byte. end_byte / bytes_accumulated / PositionInfo all
advance to the cut, not to the line boundary.

**T8 — all reported positions are now on-disk raw bytes, not decoded UTF-8.**
Line offsets are computed on the raw buffer via compute_line_offsets_raw;
start_byte / target_end / byte_in_file / byte_in_line / remaining_bytes
are all measured in raw bytes. Line bodies are decoded per-line via
encoding_rs::Encoding::decode only for the output string, so non-UTF-8
inputs (GBK, Shift-JIS) get correct file-byte positions instead of
decoded-buffer offsets. This preserves the proto contract for
FileReadText.PositionInfo.byte_in_file.

Special case: target_end landing exactly on a line boundary stops
WITHOUT emitting an empty TextLine, matching the existing
read_text_respects_target_end_line test's "exclusive" semantics.

All 13 read_text tests + 68 other file_ops tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): honor no_follow_symlink in write/edit/chmod handlers

Round 1 review #14: FileWrite.no_follow_symlink, FileEdit.no_follow_symlink,
and FileChmod.no_follow_symlink were proto fields but every handler
ignored them. Callers setting the flag to avoid touching a symlink's
target still had it modified because write/edit/chmod go through
path-based syscalls (tokio::fs::write, fs::set_permissions, etc.) that
follow symlinks on the final component.

Add a shared pub(super) helper `reject_if_final_component_is_symlink`
in file_manager::mod that uses symlink_metadata (never follows) to
inspect the final component. When the flag is set and the component
is a symlink, return FILE_ERROR_CODE_INVALID_PATH before any
filesystem mutation. Missing files fall through so the downstream
handlers keep their existing NotFound behavior.

Called from handle_write and handle_edit in write_ops.rs (after the
existing encoding check, before any I/O) and from handle_chmod in
fs_ops.rs (after the permission presence check, before the permission
branches).

delete/copy/move keep their own symlink handling — those already use
symlink_metadata or have different semantics around following.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(hub): key PendingFileRequests by (device_id, request_id)

Round 1 review #4: PendingFileRequests keyed only on request_id, which
is caller-chosen, so two devices with a colliding ID could cross-
contaminate — one device's FileResponse could resolve the other's
waiting HTTP handler. Even a single device could clobber its own
in-flight request on retry.

Change the DashMap key from `String` to `(String, String)` tuple
`(device_id, request_id)`. register/resolve/cancel now take both,
and the three call sites in `file_operation` (pre-send register,
send-failure cancel, timeout cancel) pass `&device_id` alongside the
request id. The FileResponse arm of `handle_device_frame` in jobs.rs
passes its existing `device_id: &str` parameter when resolving.

T17 will add the cross-device-collision regression test; current 35
hub lib tests still pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(hub): cap PendingFileRequests at 1024 waiters (admission control)

Round 1 review #12: PendingFileRequests had no admission control, so an
authenticated dashboard user could spam /api/devices/*/files and force the
hub to retain an unbounded number of 30-second waiters until timeout.

Add a hard cap (`MAX_PENDING_FILE_REQUESTS = 1024`) enforced inside
`register`, which now returns a `Result<oneshot::Receiver, AtCapacity>`
instead of a bare Receiver. The file_operation handler maps the capacity
error to a 503 Service Unavailable with the stable
`FILE_REQUESTS_SATURATED` error code.

Makes `PendingFileRequests` a proper struct with a capacity field, plus
a `new(capacity)` constructor and a test-only `in_flight()` accessor for
T17's saturation regression test.

35 hub lib tests still pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(daemon): add write/edit/fs edge-case tests (T18 + T20 operation=None)

Covers the gaps Reviewer C pinned in Round 1:

- file_write_string_replace_not_found_returns_error: FileWrite path for
  the old_string-not-found case (T23 from review). Pins that FileWrite
  errors outright while FileEdit uses match_error (already tested).
- line_range_replace_start_line_past_eof_errors: start_line beyond
  total_lines is rejected.
- line_range_replace_end_line_clamped_past_total: end_line > total is
  clamped to the last line rather than failing.
- byte_range_replace_at_eof_inserts: pure insertion at EOF with
  byte_length=0.
- byte_range_replace_pure_insert_mid_file: same with an interior offset.
- byte_range_replace_u64_overflow_returns_error: T9 regression —
  byte_length=u64::MAX returns InvalidPath instead of panicking.
- file_write_encoding_non_utf8_rejected + file_edit_encoding_non_utf8_*: T14
  regression — FileWrite/FileEdit with encoding="gbk" return Encoding
  error and leave the target untouched.
- write_refuses_symlink_when_no_follow_set + edit_refuses_* +
  chmod_refuses_*: T11 regressions — no_follow_symlink=true on a symlink
  errors without touching the target.
- delete_symlink_no_follow_removes_link_not_target: delete with
  no_follow_symlink=true removes just the symlink file, leaving the
  target intact.
- copy_recursive_overwrite_merges_into_existing_destination: T23 gap.
- file_request_with_no_operation_returns_unspecified_error: T20 tiny
  hole — FileRequest { operation: None } → Unspecified.

Test count: 71 → 85 (+14). Full ahandd test suite green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(hub): cover POST /api/devices/{id}/files handler and PendingFileRequests

Round 1 review #16: http/files.rs had zero tests. Add two test layers:

**Inline unit tests** in `src/http/files.rs`:
- pending_file_requests_cross_device_collision_does_not_resolve_other_waiter
  (T12 regression — caller-chosen request_ids do not bleed across devices)
- pending_file_requests_resolves_correct_device (positive path)
- pending_file_requests_admission_control_rejects_over_capacity
  (T13 regression — the 3rd register past capacity=2 returns AtCapacity)
- pending_file_requests_admission_control_accepts_after_cancel (capacity
  frees up)

**Axum integration tests** in `crates/ahand-hub/tests/http_files.rs`:
- file_operation_happy_path_returns_device_response — encodes a proto
  FileRequest, attaches a fake device via the support helpers, drives
  the full HTTP round trip, and verifies the returned proto body
- file_operation_unauthenticated_returns_401
- file_operation_device_offline_returns_409 (pre-registered but never
  attached)
- file_operation_empty_body_returns_400
- file_operation_malformed_proto_returns_400
- file_response_from_wrong_device_does_not_resolve_other_waiter —
  device-b sends a FileResponse with device-a's request_id; device-a's
  HTTP waiter stays blocked, then device-a's real response completes it

Added `TestDevice::recv_file_request` and `TestDevice::send_file_response`
to `tests/support/mod.rs`, plus `TestServer::state_ref()` so tests can
seed extra device records and issue dashboard tokens.

Timeout test is explicitly deferred — the 30s default is compile-time
constant in files.rs and exposing a test-only override would add more
production surface than the test is worth.

Hub lib tests: 35 → 39. New http_files integration tests: 6 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(daemon): add text/image edge-case coverage (T19 + T20 plan doc fix)

Covers the gaps Reviewer C pinned for Task 3 (text) and Task 4 (image):

**Text (T8 regressions):**
- read_text_reports_total_lines_and_full_position_info: total_lines is
  populated, start_pos and end_pos both set line/byte_in_file/byte_in_line.
- read_text_start_line_col_reports_full_position_info: PositionInfo is
  byte-accurate after a mid-line start_line_col.
- read_text_gbk_autodetect_without_forced_encoding: chardetng detects GBK
  for a buffer that's not valid UTF-8 and decodes it without requiring
  the caller to set encoding.
- read_text_byte_positions_match_raw_on_disk_bytes_for_gbk: PositionInfo
  is in raw bytes (4, 8) not decoded bytes (6, 12) for GBK input. This
  is the core T8 contract.

**Image (T10 regressions):**
- read_image_passthrough_is_byte_for_byte_identical: passthrough mode
  now returns the original file bytes unchanged (no decode/encode round
  trip).
- read_image_max_height_only_preserves_aspect_ratio: max_height-only
  resize scales width proportionally.
- read_image_both_axis_resize_preserves_aspect_ratio: both axes set,
  smaller axis wins, ratio preserved.
- read_image_jpeg_source_can_be_read: non-PNG source works end-to-end.
- read_image_quality_clamp_accepts_out_of_range_values: quality=0 and
  quality=200 both produce valid JPEG output (clamp not reject).
- read_image_bomb_guard_rejects_oversized_dimensions: 1024x1024 RGBA
  = 4MB decoded > 1MB max_read_bytes → TooLarge, rejected before
  decode. Uses a tight-budget FileManager to exercise the header-only
  path.

**T20 plan doc alignment:**
- Update the inline code comment in the plan for
  test_read_text_target_end to say "4 lines returned" (exclusive) with
  a note that target_end is EXCLUSIVE. Matches the implementation and
  the existing read_text_respects_target_end_line test assertion.

Test count: 85 → 95. Full ahandd suite green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): text read truncate_line uses raw bytes, not decoded UTF-8

Round 2 C1: truncate_line previously cut decoded.as_bytes() (UTF-8 byte
length of the decoded string) and then subtracted cut.len() from the raw
on-disk slice length. For non-UTF-8 encodings (GBK, Shift-JIS, …) the
decoded UTF-8 length differs from the raw length, so a GBK file with
max_line_width=4 reported the wrong content and wrong remaining_bytes.

Rewrite truncate_line to take (raw_line: &[u8], encoding, max_width),
cut the raw slice at max_width raw bytes first (backing up to a UTF-8
char boundary only for UTF-8 input), then decode only the retained
prefix. remaining_bytes is now always in raw on-disk bytes, matching the
proto contract. decode_slice and safe_utf8_prefix helpers dropped since
everything flows through truncate_line.

The new read_text_truncates_gbk_in_raw_bytes_not_decoded_bytes test pins
the fix: GBK "你好世界" (8 raw bytes) with max_line_width=4 returns
content="你好" and remaining_bytes=4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(hub): PendingFileRequests atomic admission + reject duplicate keys

Round 2 C2 + C3: register() did a separate len()-then-insert which could
over-subscribe the cap under concurrent callers, and it unconditionally
replaced existing entries for the same (device_id, request_id) — causing
the first waiter to fail through the closed-channel path with a 500.

Switch to an AtomicUsize-backed in_flight counter: register()
fetch_adds first, rolls back on over-capacity, and only then enters a
DashMap::entry critical section to check for duplicates. The new
PendingFileRequestsError enum has AtCapacity and Duplicate variants;
file_operation maps them to 503 FILE_REQUESTS_SATURATED and 409
FILE_REQUEST_DUPLICATE respectively.

Two new regression tests pin the behavior:
- pending_file_requests_rejects_duplicate_keys_explicitly
- pending_file_requests_capacity_is_atomic_under_concurrent_registers
  (spawns 32 parallel tasks against a cap-4 table and asserts exactly
   4 accepted / 28 rejected)

Hub lib tests: 35 → 37 (plus 2 from R1).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): image read enforces max_read_bytes and fails on unreachable budget

Round 2 C5 + C6:

- handle_read_image called tokio::fs::read() BEFORE checking the file
  size against max_read_bytes. A 500 MB file with tiny image dimensions
  (header-only payload) blew the daemon's read budget at the read step.
  Passthrough mode had the same hole. Fix: check metadata.len() against
  max_read_bytes right after the is_file check, returning TooLarge
  before the read. This covers both passthrough and the normal pipeline.

- encode_image previously returned the last encode even if it still
  exceeded the requested max_bytes after the quality floor. For JPEG /
  WebP this meant callers silently got oversize output; for PNG
  (lossless) max_bytes was essentially ignored. Now both paths return
  TooLarge when the floor/lossless encode still exceeds the budget.

Adds two regression tests to file_ops.rs. The noise-image tests use
wrapping arithmetic so debug-mode u8 overflow doesn't panic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): list uses symlink_metadata + bounded heap (no follow, no DoS)

Round 2 C8 + S9: handle_list had two problems.

1. entry.metadata() follows symlinks, so a symlink in an allowed
   directory that points at /etc/passwd would surface the target's
   type, size, and mtime in the listing — leaking metadata for a file
   outside the allowlist.

2. Every directory entry was collected into an unbounded Vec before
   sorting. A malicious or just very large directory with millions of
   entries could OOM the daemon regardless of max_read_bytes.

Rewrite handle_list to use tokio::fs::symlink_metadata on each entry
(never follows) and a bounded min-heap keyed by (mtime, index) that
caps retained entries at offset + max_results + 64 (and an absolute
ceiling of 100_000). Total_count still tracks every non-hidden scanned
entry so has_more stays correct.

The new list_does_not_follow_symlink_into_target_metadata test
(cfg(unix)) pins the symlink-leak fix by creating a symlink pointing
at /etc/hosts inside the listed directory and asserting that the
reported file_type is Symlink (not File) with the target surfaced via
symlink_target.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(hub): s3.rs tests use explicit credentials instead of env mutation

Round 2 I7 + S16: the s3 test module previously called
`unsafe { std::env::set_var(...) }` in an ensure_fake_aws_creds helper.
Cargo runs tests in parallel, so env mutation across threads is
unsound (Rust 2024 edition explicitly marks it unsafe) and can
intermittently corrupt other tests' env state.

Replace the env mutation with an explicit credentials provider built
via `aws_credential_types::Credentials::new("test", "test", ...)`.
Add a new `#[cfg(test)] impl S3Client { fn for_test(...) }` constructor
that accepts a pre-built `aws_sdk_s3::Client`, bucket, threshold, and
expiration. Tests now build the SDK client through
`sdk_client_with_explicit_creds` which goes through the explicit
`aws_sdk_s3::config::Builder` path, never touching process env.

Also moves the newly-required `aws-credential-types = "1"` dep from
`[dependencies]` to `[dev-dependencies]` — it's now only referenced by
the test module, so ahand-hub's runtime dependency graph loses that
crate (resolving the original "unused aws-credential-types" concern
too).

Production `S3Client::new` is unchanged; the 4 existing s3 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(daemon): add Round 2 Wave 1 regression tests (R0, R3, R5, R13)

Tests for the Wave 1 fixes:
- read_text_truncates_gbk_in_raw_bytes_not_decoded_bytes (R0)
- read_image_input_size_exceeds_max_read_bytes_is_rejected (R3)
- read_image_max_bytes_unreachable_returns_too_large (R3)
- list_does_not_follow_symlink_into_target_metadata (R5/R13)

Also lands the Round 2 fixes plan doc and task tracker.

99 file_ops tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): symlink target check, glob approval escalation, recursive delete STRICT, TOCTOU mitigation

Four related policy-layer fixes to file_manager/mod.rs (Round 2 C4, C7, S2, S1):

**R2 (symlink target check)**: FileCreateSymlink dispatch previously
only validated the link_path, so a caller could create an allowed
symlink pointing at /etc/passwd and later use it as an allowlist
bypass surface. Now collect_request_paths emits BOTH link_path AND
target, and the dispatch arm re-checks the target (resolved against
the link's parent if relative) before calling handle_create_symlink.

**R4 (glob approval escalation)**: check_request_approval previously
only checked base_path for `dangerous_paths` hits. A glob over an
allowed directory could surface files listed in `dangerous_paths`
without triggering the approval flow. The new glob_has_dangerous_match
helper expands the glob (up to a 10_000-match safety cap) and
re-checks each match against policy. check_request_approval is now
async so it can perform this walk.

**R9 (recursive PERMANENT delete → STRICT)**: spec rule
(design.md:635) requires recursive DELETE_MODE_PERMANENT in TRUST
mode to escalate to STRICT approval. check_request_approval now
returns true for that shape regardless of which paths are involved.

**R10 (nonexistent-path TOCTOU mitigation)**: canonicalize_or_parent
rebuilds a non-existing path from its deepest existing ancestor, so
an attacker could swap a component for a symlink between the policy
check and the actual operation. The new verify_post_create helper
re-canonicalizes the resolved path AFTER write/mkdir/copy/move/
symlink creation and deletes (best-effort) anything that escaped the
allowlist. This is a mitigation — full TOCTOU protection requires
openat2+RESOLVE_NO_SYMLINKS and is deferred to a follow-up PR, as
documented in the helper's rustdoc.

All 102 file_ops integration tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): text read empty-encoding auto-detect, LineCol clamp, max_bytes=0

Three small Round 2 fixes in text_read.rs:

R7 — empty encoding string means auto-detect, not UTF-8. The proto
contract says encoding="" should auto-detect via BOM/chardetng, but
resolve_encoding returned UTF_8 on empty. Now `Some("")` falls through
to the same auto-detect path as `None`.

R12 — LineCol.col is clamped against the current line's end, not raw_len.
For both resolve_start_raw and resolve_position_raw, when col exceeds
the line's printable length the resolved byte position lands at the
line's end (or the trailing newline) instead of spilling into the next
line. Honours the proto's "byte offset within line" semantics.

R16 — max_bytes=0 returns zero lines (no empty TextLine). The previous
implementation emitted one empty `TextLine` then stopped with
STOP_REASON_MAX_BYTES; now we early-break when the byte budget is
exhausted before pushing.

Three new regression tests pin all three fixes:
- read_text_empty_encoding_triggers_auto_detect
- read_text_line_col_with_col_past_eol_clamps_to_line_end
- read_text_max_bytes_zero_returns_no_lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): TRASH delete populates trash_path on macOS / freedesktop Linux

Round 2 S10: handle_delete previously hardcoded trash_path: None even
in TRASH mode. The trash crate (5.x) doesn't expose a per-item
destination from `trash::delete`, but on platforms with a
deterministic per-user trash directory we can reproduce the path well
enough to give the caller a useful hint.

Add `guess_trash_path(original)` that resolves:
- macOS: $HOME/.Trash/<basename>
- Freedesktop Linux: $XDG_DATA_HOME/Trash/files/<basename> (fallback to
  $HOME/.local/share/Trash/files/<basename>)
- Windows / non-Unix / no $HOME: None

The handle_delete TRASH branch now calls this and reports it in the
result. A long rustdoc comment on both the call site and the helper
documents the limits: the trash system can rename on collisions and
non-home volumes use $VOLUME/.Trashes/<uid>/, so this is a hint, not a
guarantee.

Two new unit tests + one #[ignore]d real-trash integration test (gated
to multi_thread runtime because handle_delete uses block_in_place).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): config loader expands ~ in file_policy path patterns

Round 2 S4: the design spec uses `~/.ssh/**`, `~/.gnupg/**`, and
`~/.bashrc` in its example file_policy config, but FilePolicyChecker
stores patterns verbatim and glob_match compares them literally
against canonicalized absolute paths. So `~/.ssh/**` would never
actually match `/home/user/.ssh/id_rsa` and the spec's own examples
were silently broken.

Add `expand_tilde(&str)` and `expand_tildes_in_place(&mut Vec<String>)`
helpers in config.rs. Supports `~/...` (Unix), `~\...` (Windows), and
bare `~`. When `dirs::home_dir()` returns `None`, log a `tracing::warn!`
and leave the original pattern unchanged so daemon startup is never
blocked by tilde expansion failures.

Wire into `Config::load` so FilePolicyChecker only ever sees absolute
paths. `path_allowlist`, `path_denylist`, and `dangerous_paths` are
all expanded.

Three unit tests inside config.rs cover the home-rooted, leave-alone,
and middle-tilde cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(hub): file_operation enforces application/x-protobuf content-type

Round 2 S11 / Path A #6: the handler documented that the request body
must be application/x-protobuf, but never actually checked. A client
sending a valid protobuf body with Content-Type: application/json was
silently accepted, which masked schema confusion until much later in
the device runtime.

Add a content-type check immediately after auth: if the header is
present and its base type isn't application/x-protobuf (after stripping
any `; charset=...` suffix), return 415 Unsupported Media Type with
the stable error code UNSUPPORTED_MEDIA_TYPE. Missing or empty
Content-Type is still tolerated for backwards compatibility with older
test harnesses that don't set it.

The existing 6 http_files integration tests still pass, plus the new
file_operation_rejects_non_protobuf_content_type which posts a valid
protobuf body labelled application/json and asserts 415.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): namespace file approvals, include paths, cancel on connection close

Three related Round 2 fixes to the file-request approval flow in
handle_file_request:

R6 — file approvals now use a dedicated `file-req:<request_id>`
namespace inside ApprovalManager. Previously file requests reused the
caller-chosen request_id as the synthetic JobRequest.job_id, which
shares ApprovalManager's HashMap with real job approvals. A file
request could evict an unrelated pending job approval (or vice versa).
The namespaced key is opaque to cloud — the wire envelope still echoes
back exactly the job_id we sent, so no cloud-side change is needed.

R8 — the synthetic JobRequest's args now contains the actual paths the
request touches (via FileManager::request_paths), not just `[op_name]`.
The dashboard approval prompt now shows the user what file is being
approved instead of just `tool=file, args=[read_text]`.

R20 — the detached approval-await task now selects on a watch::Receiver
fed by a CloseGuard owned by connect_with_auth. When the WS connection
closes (cleanly or by error), the guard drops, the watch flips, and
all pending file-approval tasks bail out immediately instead of
waiting up to 24 hours for the ApprovalManager default timeout.
ApprovalManager.expire is still called on the bail path so the
manager's HashMap doesn't leak.

FileManager::request_paths is a thin wrapper over the existing
collect_request_paths helper that returns just the path strings,
keeping ahand_client.rs from needing to know the helper exists.

All 104 file_ops integration tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(plan): mark Round 2 Wave 2 tasks completed (R6, R7, R8, R11, R12, R14-R16, R20)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(hub): move PendingFileRequests out of http into its own module

Round 2 S15: PendingFileRequests was defined in src/http/files.rs but
referenced by JobRuntime::handle_device_frame in the WS layer
(src/http/jobs.rs) and by AppState (src/state.rs). That coupled
WebSocket frame handling to an HTTP endpoint module, which is a
layering inversion — the HTTP and WS layers should both depend on a
transport-neutral piece of state.

Move PendingFileRequests + PendingFileRequestsError +
MAX_PENDING_FILE_REQUESTS + new_pending_requests + the inline
#[cfg(test)] mod into a new top-level module
`crate::pending_file_requests`. http/files.rs now imports
PendingFileRequestsError from there; state.rs and http/jobs.rs do the
same for PendingFileRequests itself.

No behavior change. All 6 inline unit tests still pass under the new
module path; the http_files integration tests are unaffected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(daemon): cover dangerous_paths approval, recursive delete, file capability

Round 2 R21 — close the test coverage gaps Reviewer B flagged.

Adds 6 new tests in tests/file_ops.rs:
- check_request_approval_returns_true_for_dangerous_path_read: a read of
  a path listed in dangerous_paths returns Ok(true) so the approval
  flow fires regardless of session mode.
- check_request_approval_returns_false_for_normal_path: control case.
- check_request_approval_returns_true_for_recursive_permanent_delete: R9
  spec rule (design.md:635) — recursive PERMANENT delete always
  escalates, even on a path NOT in dangerous_paths.
- check_request_approval_returns_false_for_non_recursive_permanent_delete:
  control case proving the escalation is recursive-only.
- check_request_approval_returns_true_for_dangerous_path_trash_delete:
  TRASH mode is not auto-escalated, but a dangerous path still is.
- check_request_approval_denies_symlink_target_outside_allowlist (R2):
  CreateSymlink with target=/etc/passwd is rejected at preflight with
  PolicyDenied, never reaching the dispatch handler.

Adds 2 new tests in tests/hub_handshake.rs:
- build_hello_envelope_includes_file_capability_when_enabled:
  capabilities.contains("file") when file_enabled=true (the positive
  case was previously uncovered — every existing handshake test passes
  false).
- build_hello_envelope_excludes_file_capability_when_disabled: control.

Plus a new manager_with_dangerous helper for tests that need to set up
a temp-dir-scoped FilePolicyConfig with dangerous_paths populated.

110 file_ops tests + 7 hub_handshake tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): C1 break connection-teardown deadlock in ahand_client

Round 3 critical: the existing CloseGuard formed a circular wait on
disconnect — send_handle.await blocked on tx_clone, the spawned approval
task held tx_clone and waited on close_rx, close_rx waited on close_tx,
close_tx waited on _close_guard's drop, and the guard waited for
connect_with_auth to return — which was waiting on send_handle.await.
The result was a permanent hang on disconnect rather than a clean
reconnect.

Fix the cycle so the close signal fires before the joins, letting
spawned tasks observe shutdown and release tx_clone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hub): I1 release pending file-request slot on client cancel

Round 3 important: when an HTTP client closed the connection mid-flight
(browser navigates away, network drop), Axum dropped the file_operation
handler future without ever reaching the timeout/error/channel-close
branches that explicitly call cancel(). The slot stayed in
PendingFileRequests until the device responded — and if the device
never did, the entry was permanently leaked. After 1024 such leaks the
admission cap rejected every new request, producing a slow DoS.

Wrap the slot in a PendingSlotGuard whose Drop calls cancel(). Now
every exit path — explicit error returns, timeout, channel close,
the success return after encoding the response, AND the early
future-drop case from client cancellation — releases the slot.
cancel() is idempotent against a slot already removed by resolve(),
so the success path is a no-op.

Promote PendingFileRequests::in_flight() from cfg(test) to a public
observability helper so integration tests (and future metrics) can
count live slots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(daemon): I2 fail-loud on tilde expansion when HOME unset

Round 3 important: expand_tilde used to log a warning and return the
original pattern when dirs::home_dir() resolved to None. The downstream
glob_match compares canonicalized absolute paths against the literal
pattern, so an unexpanded "~/.ssh/**" silently never matches anything.
That fails the *wrong* way for path_denylist / dangerous_paths — the
operator's intent ("this path is dangerous") flips to "this path is
allowed" without any error surface.

Reject the config at startup instead. expand_tilde_with(pattern, home)
returns Result; a tilde pattern paired with home == None now produces
an anyhow::Error that propagates out of Config::load. Patterns without
a leading tilde pass through unchanged so containerized deployments
(no HOME) continue to work as long as they use absolute paths.

The home_dir lookup is also factored into a wrapper so the failure
case is testable without mucking with environment variables.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(daemon): file-ops review round 3 — C2/C3/C4/C5 + I3/I4/I6

Bundles the remaining file-ops daemon fixes from Round 3 because their
changes share types (EscalationKind, ApprovalEscalation) and test
fixtures, and splitting them per item would leave intermediate states
where check_request_approval's signature didn't match its call sites.

C2 — relative symlink targets bypassing dangerous_paths approval.
collect_request_paths only handed absolute targets to the dangerous-path
scan; a crafted FileCreateSymlink with target "../secret.txt" and a
link_path inside the allowlist slipped past the check. We now resolve
relative targets against link_path's parent (lexical normalization, no
filesystem touch) before classifying.

C3 — glob approval scan failed open at the safety cap. After scanning
GLOB_APPROVAL_SCAN_CAP (10_000) matches without seeing a dangerous one,
the old code returned "no approval needed" — but match #10_001 might
have been dangerous. We now distinguish CapHit from Clean: if the glob
iterator still has items past the cap, we fail closed with the new
EscalationKind::GlobScanCapHit. The cap is now a static AtomicUsize +
public set_glob_approval_scan_cap helper so tests can lower it without
materializing 10k+ files.

C4 — paginated list with offset > LIST_HEAP_RETAIN_CAP returned an
empty page with has_more=true forever. The bounded-heap retains at
most LIST_HEAP_RETAIN_CAP entries, so we structurally cannot serve a
page past that window. handle_list now refuses up front with
InvalidPath and a clear hint to use FileGlob for deep pagination.

C5 — TRASH delete ignored the recursive flag. PERMANENT delete enforced
"non-empty + recursive=false → NotEmpty"; TRASH did not, silently moving
whole subtrees to the system trash. The TRASH branch now runs the same
guard before invoking trash::delete(), and items_deleted reflects the
recursive count instead of always being 1.

I3 — replace/edit OOM on oversized existing files. apply_string_replace
/ apply_line_range_replace / apply_byte_range_replace and the inline
read in handle_string_replace_edit all called read_to_string / read on
the existing file *before* any size check. A 100 GB file would OOM
during the read even though the post-read enforce_size_limit was
guaranteed to reject. New enforce_existing_size_limit stat-checks the
file first and returns TooLarge before the slurp.

I4 — structured ApprovalEscalation reason. check_request_approval used
to return bool; the approval prompt then hardcoded
"path is listed in dangerous_paths" regardless of why. It now returns
Option<ApprovalEscalation> with a discriminated EscalationKind
(RecursivePermanentDelete / DangerousPath / DangerousGlobMatch /
GlobScanCapHit), the specific path that triggered escalation when
applicable, and a precise human-readable reason for the prompt.

I6 — EXDEV detection wasn't portable. handle_move only treated
raw_os_error == Some(18) as cross-device, which is EXDEV on Linux/
macOS but NOT Windows' ERROR_NOT_SAME_DEVICE = 17. A cross-volume
MoveFile on Windows would skip the copy+delete fallback and surface
as a generic IO error. New is_cross_device_error prefers the stable
io::ErrorKind::CrossesDevices and keeps numeric checks (Unix 18,
Windows 17) as a defensive belt-and-suspenders.

Tests: regression coverage for each item — relative-target escalation
(C2), glob CapHit via lowered cap with RAII guard (C3), deep-offset
list returns InvalidPath (C4), TRASH non-recursive on non-empty dir
returns NotEmpty without touching the system trash (C5), oversized
existing-file refusal for both Write StringReplace and Edit
StringReplace paths (I3), updated approval tests to assert the new
EscalationKind variants (I4), and platform-aware unit tests for
is_cross_device_error (I6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(hub): clear pre-existing fmt + clippy drift to unblock hub-CI

The hub-CI workflow gates on \`cargo fmt --check\` and
\`cargo clippy -- -D warnings\` over ahand-protocol / ahand-hub-core /
ahand-hub-store / ahand-hub. This branch had accumulated 18 fmt diffs
and 5 clippy errors before the round-3 review work, leaving CI red on
every push. None of the issues were introduced by the round-3 fixes —
they are all in modules the round-3 commits did not touch (http/jobs.rs,
http/terminal.rs, ws/device_gateway.rs, output_stream.rs, support/mod.rs,
etc.).

This commit applies pure mechanical cleanup so hub-CI can pass:

- \`cargo fmt -p ahand-protocol -p ahand-hub-core -p ahand-hub-store
  -p ahand-hub\` for the 18 formatting diffs (mostly use-statement
  ordering and closure-block indentation).
- Three \`single_match\` rewrites in tests/support/mod.rs that flipped
  \`match { Binary(data) => ..., _ => {} }\` to \`if let Binary(data) = ...\`
  in recv_job_request / recv_file_request / recv_cancel_request.
- Two \`collapsible_if\` rewrites: jobs.rs:263 (job-status guard) and
  device_gateway.rs:573 (bootstrap-reservation release on disconnect),
  both folding nested \`if let\` into \`if let ... && let ...\`.
- One \`#[allow(clippy::too_many_arguments)]\` on JobRuntime::new
  (8 args / threshold 7); refactoring this constructor into a builder
  is out of scope for a cleanup commit.

Verified: \`cargo fmt --check\` clean, \`cargo clippy -- -D warnings\`
clean, and all non-Docker hub integration tests still pass
(http_files / job_flow / device_gateway / system_api: 56 / 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(hub): rewrite loop+let-else-break as while-let for clippy 1.95

CI runs Rust 1.95.0, which added the \`clippy::while_let_loop\` lint.
The previous cleanup commit verified locally on Rust 1.94.1, where the
lint did not yet exist. The shape

  loop {
      let Some(x) = expr else { break };
      ...body...
  }

is semantically identical to \`while let Some(x) = expr { ...body... }\`,
so flatten it accordingly. Affects only TestServer::collect_sse_body in
tests/support/mod.rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(hub): make sent_job_fails_after_disconnect_grace_without_reconnect robust on slow CI

Pre-existing test (added in 2106912) that asserted job status was
still "sent" exactly 40 ms after the device was dropped, then waited
for it to flip to "failed". On slow CI runners the 40 ms snapshot
caught the post-grace "failed" state and tripped the assertion —
both auto-triggered and manual hub-CI runs on this branch failed
identically at job_flow.rs:267.

Two changes:

1. Wait for the server to confirm Pending → Sent before drop. On
   slow CI the local view of the job state lags the in-flight
   transitions performed by create_job, so dropping the device
   could race that transition and exercise the wrong code path
   (Pending ⇒ instant fail rather than Sent ⇒ grace).

2. Replace the 40-ms snapshot assertion with an elapsed-time check
   on the actual grace window. We measure the wall time from drop
   to "failed" and assert it ≥ 50 ms (tight enough to catch a
   regression to grace=0, loose enough to absorb scheduler jitter
   on a heavily loaded CI runner where 40 ms isn't reliable).

Local re-run on the file-ops worktree: 11/0 (job_flow). The change
is in test code only; the daemon/hub disconnect-grace plumbing is
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(daemon): thread file_mgr through run_with_reporter (merge follow-up)

The previous merge commit (ef0d92b) intended to add \`file_mgr:
Arc<FileManager>\` to \`run_with_reporter\`'s parameter list and to
the inner \`run\` → \`run_with_reporter\` forwarding call so the
manager reaches \`connect_reporting\` (which already accepts it).
The edits landed in the working tree and the local build passed,
but were never staged before \`git commit\`, so the merge commit
shipped with the half-applied diff: \`connect_reporting\`'s body
referenced \`file_mgr\` while \`run_with_reporter\` no longer
defined it. CI caught it (\`cannot find value file_mgr in scope\`
at the connect_reporting call site).

This commit adds the two missing lines so the signature and the
internal call site agree. \`cargo build --workspace\` passes and the
local test suite still 0-fails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(daemon): Step 2 spec/quality follow-ups (relative symlink dispatch, I3 line/byte coverage, cross-device fallback test)

Surfaced by `winrey-toolkit:finish-feature` Step 2 (spec/quality
check). Three Important findings, all real:

1. **Relative symlink dispatch path was missing lexical normalization.**
   `collect_request_paths` (the approval surface) normalized
   \`parent.join(target)\` so the dangerous-path scan saw a clean
   canonical string. The dispatch arm in
   \`FileManager::dispatch\` did not — it left raw \`..\` components in
   the policy-check input. Result: an operator who approved a request
   like \`target = "../target.txt"\` against a link inside \`/tmp/sub/\`
   would observe \`InvalidPath\` from \`policy.check_path\` at execution
   time, because the post-approval string still contained \`..\`. The
   approval prompt and the actual creation path disagreed on what the
   request meant. Fixed by reusing \`lexically_normalize\` so the
   dispatch policy check sees the same canonical string the prompt
   showed the operator. New test
   \`create_symlink_with_relative_target_escaping_parent_is_normalized\`
   pins the contract: a relative target that escapes its own parent
   directory but stays inside the allowlist must complete end-to-end.

2. **I3 (size-check before read) regression coverage was incomplete.**
   The original I3 fix added \`enforce_existing_size_limit\` to all
   four slurp sites — Write StringReplace / Edit StringReplace /
   Write LineRangeReplace / Write ByteRangeReplace — but the regression
   tests only covered the StringReplace pair. Added
   \`line_range_replace_refuses_oversized_existing_file_before_read\`
   and \`byte_range_replace_refuses_oversized_existing_file_before_read\`
   so a future refactor that drops the guard from the line/byte
   helpers fails the suite.

3. **\`handle_move\` cross-device fallback had no payload-level test.**
   \`is_cross_device_error\` is well unit-tested, but the actual
   copy+delete branch was only exercised through the end-to-end
   \`handle_move\` entry, which can't be tripped on a single-FS test
   environment. Extracted the payload into
   \`cross_device_move_fallback(req, src, dst)\` so the trigger
   (\`tokio::fs::rename\` returning \`CrossesDevices\`) and the payload
   (copy+delete with the right unlink primitive per file type) can
   be tested independently. Added two unit tests: one for files,
   one for directory trees. The wiring in \`handle_move\` is now a
   one-line call site that's small enough to inspect.

## Verification

- \`cargo test -p ahandd --test file_ops\` — 119/0 (1 ignored, system trash)
- \`cargo test -p ahandd --lib file_manager::fs_ops\` — 5/0
- \`cargo test -p ahand-hub --test http_files --test job_flow --test
  device_gateway --test system_api\` — 56/0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(daemon): Step 4 test-completeness gap fills (Tier 1 + Tier 2, +31 tests)

Surfaced by `winrey-toolkit:finish-feature` Step 4 (test-completeness
audit). Three independent scenario auditors flagged 4 Critical + ~9
Important + a long tail of defensive/pedantic gaps. User picked Tier
1 + Tier 2 — Critical + Important — and skipped the rest.

## Tier 1 (Critical)

1. **store.rs::describe_payload exhaustive test.** All 25 Payload
   variants + the `None` envelope sentinel are now pinned by a
   table-driven test. The recently added FileRequest / FileResponse /
   Heartbeat arms had zero coverage; a regression that dropped one
   would silently emit the wrong trace label without any test
   failing. (Inline `#[cfg(test)] mod tests`.)

2. **Config::load end-to-end tilde wiring.** The I2 fix relies on
   `expand_tildes_in_place` running INSIDE `Config::load`, but only
   the helper itself was unit-tested. New `mod load_tilde_tests`
   covers: round-trip with home expansion across all three
   file_policy lists, post-load assertion that no `~/...` remains
   (proves the wiring is intact), absolute-path passthrough,
   malformed TOML error, missing-file error.

3. **EscalationKind::DangerousGlobMatch.** The C3 sister branch had
   no test. `check_request_approval_glob_escalates_when_match_is_in_dangerous_paths`
   pins the `FoundDangerous` outcome and asserts the escalation
   names the specific dangerous match (so the operator's prompt
   shows what tripped the gate).

4. **handle_file_request glue test (mock_hub extension).** Extended
   `tests/mock_hub/mod.rs` with `Behavior::SendFileRequest(_)` that
   accepts the handshake, injects a FileRequest envelope, and
   captures every FileResponse coming back. New test
   `daemon_responds_to_file_request_through_ahand_client_glue` proves
   the entire WS round-trip works: envelope decode in the read loop
   → `handle_file_request` dispatch → `FileManager::handle` →
   `BufferedEnvelopeSender` → wire → mock capture. Uses the
   disabled-FileManager early-return (PolicyDenied) which is
   exactly the contract `public_api::spawn` produces — testing the
   wiring without needing public-API enabled-policy support, which
   isn't wired yet.

## Tier 2 (Important)

14 bad-case tests in `tests/file_ops.rs`:

- `read_text_on_directory_returns_is_a_directory`
- `read_text_exceeding_max_read_bytes_returns_too_large`
- `read_binary_on_missing_file_returns_not_found`
- `read_binary_on_directory_returns_is_a_directory`
- `read_image_on_directory_returns_is_a_directory`
- `glob_with_invalid_pattern_returns_invalid_path` (unclosed character
  class — reaches `glob::glob()`, distinct from the dispatcher's
  absolute / `..` rejections)
- `copy_directory_without_recursive_returns_is_a_directory`
- `move_to_existing_destination_without_overwrite_returns_already_exists`
- `write_with_no_method_returns_unspecified_error`
- `edit_with_no_method_returns_unspecified_error`
- `full_write_with_no_source_returns_unspecified_error`
- `full_write_with_s3_object_key_source_returns_unspecified_error`
  (so the deferred S3 path fails loud rather than silently)
- `append_exceeding_total_size_limit_returns_too_large` (existing
  file at limit + non-empty append must surface as TooLarge before
  any write)
- `chmod_with_no_permission_returns_unspecified_error`
- `chmod_with_unix_permission_but_no_mode_or_owner_returns_unspecified_error`

## Skipped

Defensive (resolve/cancel idempotency on missing keys, S3
upload_bytes / download_bytes) and pedantic (lexically_normalize
empty-input, WebP output, etc.) — recorded in audit report;
follow-up if needed.

## Verification

- `cargo test -p ahandd`: 345 / 0 (1 ignored — system trash test)
  → up from 314 by +31 tests
- `cargo test -p ahand-hub --test http_files --test job_flow
  --test device_gateway --test system_api`: 56 / 0
- `cargo build --workspace`: clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(daemon,hub): Copilot review — populate FileError.path + case-insensitive content-type + saturating append size

Five Copilot comments on PR #1, all genuinely valid:

1. **text_read.rs `resolve_encoding`** — when an explicit but unknown
   encoding…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant