Commit Graph
6 Commits
Author SHA1 Message Date
pastilhas a467000b3d a symlink the owner put inside the root may lead out of it
Two failures on 2026-09-09, one cause each, both found by a 212-track album that would not convert.

── Symlinks ──

paths.ts ran realpath FIRST and required the resolved path to be inside WORK_ROOT, so a link in the
owner's home pointing at another disk was refused. On a machine whose home is BUILT from such links —
~/Music, ~/Movies, ~/Torrents each pointing at a mount — that leaves av-club able to see almost
nothing. It refused every track of a folder the owner had open in the file browser at the time.

That is the tell. The file browser decides what the owner may touch and it launches these jobs; its
resolveUserPath checks the path as written and never calls realpath. Two confinement rules over the
same files means the platform offers a right-click action its own sidecar cannot perform.

What the strict rule was worth is smaller than it looks: this process runs AS the owner, so it reads
nothing the owner cannot already read, and a link out of home reaches only more of the owner's disks.
realpath-first was never the privilege boundary — the OS is.

`..` is now refused rather than normalised, which is what realpath-first actually bought. Normalising
is not what the kernel does: for /home/me/Music/.. the string says /home/me while the kernel follows
Music to another filesystem first. Refusing removes the divergence instead of picking a side.

The narrower claim that remains, and is the one to defend: a caller cannot NAME a path outside the
root; they can only follow a link the owner put inside it.

── The token ──

requireToken() threw when AV_CLUB_TOKEN was unset. That is right for a service a person starts with a
shell and wrong for a sidecar the platform starts: not a safe default, a crash loop nobody sees. It
was exactly that — 15 restarts, every proxied request 503, and an error that named the files rather
than the missing variable.

Generating one loses nothing: authorised() already accepts the platform's X-Officer-User header with
no token at all, so no real caller carried this secret. It guards the direct loopback door, and 32
random bytes guard it better than a value copied into a shell profile. Kept 0600 beside the assets so
replacing the working tree does not change the service's identity.

AV_CLUB_TOKEN still wins when set. There is no unauthenticated mode; this only decides where the
token comes from.
2026-09-09 23:34:39 +01:00
pastilhasandClaude Opus 5 62972c1146 an encode ceiling that fits real video
JOB_TIMEOUT_MS was 120 seconds, which is a budget rather than a ceiling: it fits a short clip and
nothing else. Video encoding is measured against the length of the video — a 30-second hero at two
renditions takes about 40 seconds — so a two-minute upload could not finish and failed with "Encoding
took longer than 120s and was stopped". The file was fine, the encoder was working, and it was killed
for being ordinary.

Thirty minutes now. It is still a ceiling and still does the job it was written for: a crafted file
cannot hold a worker for the life of the process. Nothing legitimate this service is asked for
approaches half an hour, and a runaway is still stopped.

The default has to fit the worst LEGITIMATE case, because guessing low costs a rejected upload and
guessing high costs a runaway running a little longer before being killed anyway. A machine that only
converts photographs can tighten it with AV_CLUB_JOB_TIMEOUT_MS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 23:10:23 +01:00
pastilhasandClaude Opus 5 ec6e09014f a photograph may be slow, but it may not take the machine down
ImageMagick sizes its pixel cache against the machine and will hold an entire
decoded image in RAM. On a host with room that is right. On a small one it is
how resizing a photograph kills whatever else is running — and "whatever else"
includes the website this service exists to serve, which is not an acceptable
way for an image to fail.

So the decode runs under `-limit memory` / `-limit map` / `-limit disk`. Past
the caps ImageMagick spills to a temporary file and carries on, which turns an
out-of-memory kill of whatever the kernel picks into a conversion that is
slower. Nobody minds a slow photograph; everybody minds a site that went down
while one was being resized.

Measured on fofos. A 12 MP HEIC peaks at 145 MB, so the 256 MB default does not
bind and the output is byte-identical — it is a ceiling, not a tax. Forced to
bind, on a 36 MP image with a 32 MiB cap, peak RSS is 12 MB and it still exits 0
with the file written.

`-limit thread 1` for the same reason: one decode is one image, and each thread
carries its own share of the cache the caps exist to bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:32:25 +01:00
pastilhasandClaude Opus 5 bd8b7b9ad4 ffmpeg can work on a file where it already is
adds a path mode to /v1/ffmpeg:

  { input: "as_…" }                      → stored as a new asset, 201
  { inputPath: "/…", outputPath: "/…" }  → written where you said, 200 { path, bytes }

THIS REVISES THE INVARIANT IN docs/SECURITY.md, deliberately, and that document now
says so at the top rather than being quietly contradicted by the code.

it used to read "the caller never supplies a path, for input or output". that was
right for an upload service and av-club is not one: both of its real consumers —
a right-click on ~/Movies/thing.mkv, and a commander watching an upload folder —
already have the file, and obeying the rule meant copying gigabytes in and back out
to convert something that never moved. a rule that expensive gets worked around,
and the workaround is where the hole would have been.

so it is narrower now and says what it was always for. argv never a shell,
-protocol_whitelist file, filters allowlisted, work bounded — all unchanged. what
is gone is only the claim that a path never appears, replaced by one small module
every path goes through:

  absolute (a relative path resolves against a cwd the caller cannot see)
  realpath FIRST, then check — checking the path as written and opening the
    resolved one is the classic way this fails
  inside as `root` or `root + sep`, never a bare startsWith, which accepts
    /home/user-backup for a root of /home/user
  for an output the PARENT is resolved, and must already exist
  errors never say which of "outside" or "missing" it was, or this becomes an
    oracle for what exists elsewhere on the disk

VERIFIED against the running service, not reasoned about: `..` traversal, an
absolute path outside the root, a write outside the root, a relative path, a
symlink pointing into /etc, and a write THROUGH a symlinked directory — all
refused, nothing written outside the root.

the output is moved into place at the END. ffmpeg encodes into a temp dir, so a job
killed by the timeout or a restart leaves the caller's directory as it was. pointing
ffmpeg at the destination would leave a truncated file where a good one might have
been — and a task that then deleted its source would have destroyed something.
rename first, copy on EXDEV, which is the normal case rather than the error one
since tmp is often tmpfs.

docs/API.md said this endpoint was "designed, not yet built" while it was routed and
working. that is fixed too: it was read as a gap and nearly rebuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 02:07:02 +01:00
pastilhasandClaude Opus 5 e2ed76eac4 bind a free port, do not assert one
a plugin sidecar does not choose a port. it binds whatever is free and REPORTS
it, which is how every other sidecar here works and why none of them can collide.
av-club asserted 3020 because it started life as a standalone service.

that assumption cost a deploy the same day it became a plugin: fofos still had a
copy of the pre-plugin code running from ~/garbage/av-club, on `main`, holding
3020 — so the plugin's sidecar died on EADDRINUSE in a restart loop while the
platform reported "sidecar not available". two things claiming one number, and
neither able to say so.

AV_CLUB_PORT still pins it, for running outside Officer — the standalone mode
fofos/platform was written against, where the other side has to know where to
look. inside Officer nothing sets it.

the announcement now sends `server.port`, never the requested value. reporting
what you asked for rather than what you got is how a sidecar ends up advertising
a port it is not on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 22:41:52 +01:00
pastilhasandClaude Opus 5 79ff366802 become an Officer plugin
av-club keeps doing exactly what it did — sharp, ffmpeg and ffprobe behind one
API, and the invariant that the caller never names a path. What changes is how it
is reached and who decides who may reach it.

`src/` is `sidecar/`, because that is the name the platform looks for:
`sidecar/index.ts` present means "this plugin ships a process", and presence is
the declaration for everything structural here. `manifest.ts` at the root is the
other half of that rule.

api/router.ts is a proxy and nothing else. the platform authenticates, checks the
`av-club` permission, and forwards — so the sidecar now accepts a request
carrying `X-Officer-User` and no longer demands its own bearer token from
callers who came that way. it binds LOOPBACK to make that trustworthy, which is
the same boundary caldav's DAV door already relies on. AV_CLUB_TOKEN stays, and
still guards anything calling the port directly: fofos/platform is the stated
first consumer and is not Officer.

THREE THINGS HAD TO GO, and each one broke it in a way worth writing down.

package.json and bun.lock: a package.json inside a plugin is a package boundary,
so bun stops walking up for the platform's tsconfig and `@@/sidecar/connect`
stops resolving. the sidecar died on its first import, fifteen times. npm
dependencies go in the manifest instead, where npm-deps.ts intersects them across
plugins into one plugins/node_modules — which is also how this surfaced a real
conflict: sharp ^0.33.5 (atelier) and ^0.35.3 here do not overlap, since a 0.x
caret pins the minor. atelier moved up; it calls one method.

tsconfig.json: same failure one layer earlier, shadowing the platform's `@@/*`
paths so api/router.ts would not mount. no other plugin has one.

no screens, deliberately. this is an API and a body of documentation; its callers
are a commander watching a folder, an agent, or curl. a dock tile would advertise
a page with nothing on it.

verified through /api/av-club with an agent key: a 3000x2000 jpeg uploaded,
probed, and resized to a 1200x800 avif — 166 KB to 1 KB — without the caller ever
holding av-club's own token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 22:36:53 +01:00