Skip to content

feat(agents): add cf-now skill (Cloudflare R2 sharing), mount for Claude - #47

Open
alycda wants to merge 5 commits into
mainfrom
claude/cf-now-cloudflare-skill-f5h84g
Open

feat(agents): add cf-now skill (Cloudflare R2 sharing), mount for Claude#47
alycda wants to merge 5 commits into
mainfrom
claude/cf-now-cloudflare-skill-f5h84g

Conversation

@alycda

@alycda alycda commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Splits an R2-backed sibling of s3-now onto the cross-tool skill layout (issue #40 shape: canonical content in the repo, each runtime mounts it natively) — following the same split that landed html-deck in #46. Same workflow and trust model as s3-now — private bucket, share via time-limited pre-signed URLs, nothing publicly hosted — but backed by Cloudflare R2 instead of AWS S3.

Why R2 via the S3 API, not wrangler

wrangler can create buckets and put objects but cannot mint pre-signed URLs, which is the whole point of this skill. R2 speaks the S3 API, so the scripts drive the AWS CLI against the R2 endpoint (https://{account-id}.r2.cloudflarestorage.com, region auto).

What changes from s3-now, and why

  • Auth: a long-lived R2 API token configured once as static creds, instead of an interactive aws sso login every session. The URL-expiry caveat flips accordingly — a link lasts its full requested lifetime (7-day SigV4 cap), with no SSO-session shortening; rotating/revoking the token invalidates outstanding URLs instead.
  • Setup: no public-access-block step — R2 buckets are private by default (no ACLs, no public-access-block API); privacy is simply never attaching an r2.dev URL or custom domain. CreateBucket takes no LocationConstraint.
  • Auth probe: ListBuckets rather than sts get-caller-identity (R2 has no STS).
  • R2 compat: exports AWS_REQUEST_CHECKSUM_CALCULATION=when_required so AWS CLI v2's newer default integrity checksums don't trip R2 uploads (a known 400 / XAmzContentSHA256Mismatch failure).
  • State/config move to ~/.cfnow/config.json and .cfnow/state.json.

Kept from s3-now

Opaque random slugs, ephemeral-by-default tmp/ prefix with a 7-day lifecycle rule, --permanent, --presign refresh, stable-slug re-upload, --list/--unpublish, inline Content-Type for HTML, and the "never persist a pre-signed URL" state posture.

Wiring

agent-skills.nix mounts cf-now for Claude only, mirroring the html-deck mount; other runtimes follow when more skills are ported.

Verification

Cannot validate live in this sandbox (no aws/Cloudflare creds). Both scripts are bash -n clean, scanned for leaked account IDs / keys (none), and the nix change is a one-attribute addition matching the existing oosSymlink pattern.

🤖 Generated with Claude Code


Generated by Claude Code

@alycda
alycda marked this pull request as draft July 28, 2026 04:49
Comment on lines +48 to +49
! aws configure set aws_access_key_id <R2_ACCESS_KEY_ID> --profile alyssa-r2
! aws configure set aws_secret_access_key <R2_SECRET_ACCESS_KEY> --profile alyssa-r2

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀

alycda added a commit that referenced this pull request Aug 5, 2026
…ctor

Completes the token tier the previous commit deliberately left unwired. The
helper in claude-mcp.nix already resolves agenix's default path and degrades
to {} when nothing is there, so this delivers the value without touching that
module: the attribute name `cloudflare-api-token` produces
~/.local/share/agenix/cloudflare-api-token, which is exactly where the helper
looks. That makes the name load-bearing in a way a build cannot catch -
renaming it unauthenticates the connector silently rather than failing, since
emitting {} for a missing file is the designed behaviour.

Personal-only, so unlike the Linear keys this name needs no directory to
disambiguate it - there is no work Cloudflare account to collide with.
Committed ARMORED for the reason secrets.nix already documents: a binary .age
blob is valid on disk but does not reliably survive the trip into a commit.

Both new entries go inside the existing attrsets rather than as new dotted
assignments - `age.secrets` was already at three entries, and statix's
repeated_keys fires on the third, which is what broke CI in #79.

Recorded in secrets.nix because it will otherwise be assumed: this is NOT the
credential cf-now (#47) needs. R2's S3 API takes a separate R2 API token - an
access-key/secret pair minted in the R2 dashboard, not at /profile/api-tokens
- and the two are not interchangeable in either direction. Scope guidance for
both now lives in the PR discussion.

Verified: statix, deadnix, and `nix flake check --all-systems` all pass (the
flake check being the one that would have caught an age.secrets entry
pointing at a missing file); ciphertext round-trips under the identity at
~/.age/personal-key.txt, whose public key matches the recipient in
secrets.nix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alycda added a commit that referenced this pull request Aug 5, 2026
Two secrets rather than two values: decrypted, each is a valid AWS ini that
agenix drops straight into ~/.aws, so `aws --profile alyssa-r2` works with no
flags - endpoint and region come from the config file - and cf-now's
`aws configure set` setup step disappears entirely.

Split the way the AWS CLI splits them, keys in `credentials` and everything
else in `config`, because the two use different profile header syntax
(`[alyssa-r2]` vs `[profile alyssa-r2]`). Merging them does not error; it
yields a profile the CLI cannot find, which is a worse way to fail.

r2-config holds no credential at all. It is encrypted because it embeds the
Cloudflare account ID in the endpoint URL and this repo is public.

Both take `path` overrides - unavoidable here, since the consumer is the AWS
CLI, which reads two fixed locations and accepts no override short of
$AWS_SHARED_CREDENTIALS_FILE. That is also why the previous commit had to land
first: a `path` override is exactly what was silently doing nothing.

Carried before the consumer exists, the same bet as linear-api-key-personal.
cf-now is still an unmerged PR (#47), but an R2 token that lives only in a
container's runtime dir dies with the container, and minting another is a trip
back to the dashboard.

Scoped tightly on the Cloudflare side, which is worth recording because the
skill does not expect it: the token is Object Read & Write limited to the
cfnow bucket, with the bucket and its 7-day tmp/ lifecycle rule created by
hand in the dashboard. CreateBucket and PutBucketLifecycleConfiguration need
Admin Read & Write, and an admin token could delete the eight unrelated
buckets already in that account. Consequences for #47: setup.sh will fail and
should be skipped, and its ListBuckets auth probe cannot work with a
bucket-scoped token - it needs to become head-bucket, since an auth probe
requiring broader rights than the tool itself is backwards.

Verified: statix, deadnix and `nix flake check --all-systems` pass; both
ciphertexts round-trip under the identity at ~/.age/personal-key.txt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alycda alycda closed this Aug 5, 2026
claude and others added 4 commits August 5, 2026 07:21
Splits an R2-backed sibling of s3-now onto the cross-tool skill layout
(issue #40 shape: canonical content in the repo, each runtime mounts it
natively). Same workflow and trust model as s3-now — private bucket, share
via time-limited pre-signed URLs, nothing publicly hosted — but backed by
Cloudflare R2 instead of AWS S3.

Why R2 via the S3 API, not wrangler: wrangler can create buckets and put
objects but cannot mint pre-signed URLs, which is the whole point. R2 speaks
the S3 API, so the scripts drive the AWS CLI against the R2 endpoint
(https://{account-id}.r2.cloudflarestorage.com, region "auto").

What changes from s3-now, and why:
- Auth: a long-lived R2 API token configured once as static creds, instead of
  an interactive `aws sso login` every session. The URL-expiry caveat flips
  accordingly — a link lasts its full requested lifetime (7-day SigV4 cap),
  with no SSO-session shortening; rotating the token invalidates outstanding
  URLs instead.
- Setup: no public-access-block step — R2 buckets are private by default (no
  ACLs, no public-access-block API); privacy is simply never attaching an
  r2.dev URL or custom domain. CreateBucket takes no LocationConstraint.
- Auth probe: ListBuckets rather than `sts get-caller-identity` (R2 has no STS).
- R2 compat: export AWS_REQUEST_CHECKSUM_CALCULATION=when_required so AWS CLI
  v2's newer default integrity checksums don't trip R2 uploads (a known 400 /
  XAmzContentSHA256Mismatch failure).
- State/config move to ~/.cfnow and .cfnow/state.json.

Kept from s3-now: opaque random slugs, ephemeral-by-default tmp/ prefix with a
7-day lifecycle rule, --permanent, --presign refresh, stable-slug re-upload,
--list/--unpublish, inline Content-Type for HTML, and the "never persist a
pre-signed URL" state posture.

Wired for Claude only (mirrors the html-deck mount); other runtimes follow
when more skills are ported. Cannot validate live in this sandbox (no aws /
Cloudflare creds) — scripts are bash -n clean and scanned for leaked
identifiers (none).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX4vn3ufMhMm6yRiiYphpB
…ycle

Three fixes from PR review; the first two are inherited from s3-now and want
fixing upstream too.

1. --unpublish failed OPEN (blocker). State lives in .cfnow/state.json in the
   working directory, so unpublishing from a different dir than you published
   from means state is absent. The old fallback then set PREFIX="$SLUG" and
   ran `s3 rm` on a nonexistent prefix — which exits 0 and prints nothing —
   so the script reported "removed" while the object (ephemeral uploads live
   at tmp/$SLUG/, the default) stayed live and every outstanding pre-signed
   URL kept working until the 7-day lifecycle fired. This is the revocation
   path; it must fail closed. Now it probes the live bucket for the real
   prefix (tmp/ first, then the bare permanent slug), the same way --presign
   resolves its key, and dies if nothing is found. Mock-verified across
   ephemeral / permanent / absent-state.

2. --expires evaluated unvalidated input in arithmetic. The `*m)` glob matched
   any string ending in m, then $(( ${EXPIRES_RAW%m} * 60 )) ran — and bash
   performs command substitution on array subscripts inside $(( )), so
   `--expires 'a[$(touch x)]m'` executed. The same path let `-1h` through as
   -3600 and "" through as "". Now parsed with a strict
   ^([0-9]+)([mhd]?)$ regex before any arithmetic, with a positive lower
   bound; injection/negative/empty/abcm all rejected (verified).

3. Lifecycle could silently no-op. If R2 doesn't accept the
   PutBucketLifecycleConfiguration payload, "ephemeral by default" becomes
   false and everything is permanent. setup.sh now reads the rule back with
   get-bucket-lifecycle-configuration and refuses to write config unless the
   enabled rule is present — a loud setup-time error instead of a silent lie.

Confirmed against Cloudflare's S3 docs: region `auto` and presigning against
<account>.r2.cloudflarestorage.com are the documented path (presign host
resolves), and R2's PutBucketLifecycleConfiguration rejects checksum headers —
reinforcing the AWS_REQUEST_CHECKSUM_CALCULATION=when_required export.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX4vn3ufMhMm6yRiiYphpB
Both fixes come from running these scripts against a real R2 token for the
first time.

**Auth probe.** publish.sh probed with ListBuckets, an account-level call. The
least privilege it needs is an Object Read & Write token scoped to the one
bucket, and such a token cannot make that call - so the probe reported "not
authenticated" against a token that could read, write, delete and pre-sign in
the target bucket perfectly well. An auth probe demanding broader rights than
the tool itself is backwards. HeadBucket on the configured bucket tests
exactly what the script goes on to use.

setup.sh keeps its account-level probe deliberately. That script creates the
bucket and writes its lifecycle rule, so it needs Admin Read & Write anyway,
and probing account-level fails early with a clear message rather than dying
at create-bucket. Its error now names the permission level, and points at the
tighter alternative: make the bucket and its tmp/ rule in the dashboard, mint
an Object-scoped token, and skip setup.sh entirely. Worth spelling out because
an Admin Read & Write token can delete every unrelated bucket in the account.

**Bucket name.** The default was `cfnow`; the bucket is `cf-now`. Worth more
than a typo fix: R2 answers AccessDenied - not NoSuchBucket - for a bucket
outside a scoped token's reach, so a wrong name and a wrong scope are
indistinguishable from the client. The old probe turned that into "not
authenticated", which sends you to re-mint a token that was fine. The new
error names both possibilities.

Verified live against R2, which had never happened before: publish.sh uploads,
returns an opaque slug under tmp/, and pre-signs; the URL fetches HTTP 200
with Content-Type text/html; `--presign --slug` refreshes a working URL;
`--unpublish --slug` removes the prefix and leaves the bucket empty. The
unsigned URL is rejected (HTTP 400), so the bucket is genuinely private.
state.json holds only ephemeral/key/publishedAt - the never-persist-a-URL
posture holds.

Two things this does NOT fix, both found while verifying:
- publish.sh calls awk (line 122, the --list formatter) without a need_cmd
  for it, so on a host without awk `--list` dies with a raw "command not
  found" instead of the script's own error. The dev container has no awk at
  all.
- The stale nix hunk: agent-skills.nix here still uses the oosSymlink helper
  main replaced with skillSource, so this branch will not evaluate after the
  rebase it also needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`--list` shelled out to awk to reformat `s3 ls --recursive` columns, but
need_cmd only ever declared aws and jq. On a host without awk the subcommand
died with a raw "awk: command not found" instead of this script's own error -
and the dev container has no awk at all, no gawk, mawk or busybox. That is how
this surfaced: --list was the one subcommand that could not be verified during
the live R2 run.

Declaring awk would have been the smaller change, but it only converts a
confusing failure into a clear one. jq is already a hard dependency and can do
the whole job, so the fix removes the dependency rather than documenting it.

Switched to list-objects-v2 while here, for two reasons beyond taste. It
returns structured JSON rather than human-formatted columns that a positional
field parse can silently misread, and `.Contents[]?` yields nothing on an
empty bucket - where the --query/--output text alternative prints the literal
string "None", which would have read as a filename.

Verified against the live bucket: --list prints nothing and exits 0 when empty,
and lists LastModified and key for two published files. Both then unpublished,
leaving the bucket empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alycda alycda reopened this Aug 5, 2026
@alycda
alycda force-pushed the claude/cf-now-cloudflare-skill-f5h84g branch from 7cad4d0 to 54ef1da Compare August 5, 2026 07:29
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

⊕ Entity-level changes

CONCEPTS.md

Status Type Name
+ heading Sharing
+ heading Slug

docs/solutions/integration-issues/r2-auth-probe-fails-on-least-privilege-token.md

Status Type Name
+ preamble (preamble)
+ heading R2 reports AccessDenied for a working token: the auth probe needed wider scope than the tool
+ heading Problem
+ heading Symptoms
+ heading What Didn't Work
+ heading Solution
+ heading publish.sh:98 — was: s3api list-buckets
+ heading Why This Works
+ heading Prevention
+ heading Related Issues

home-manager/modules/tools/agent-skills.nix

Status Type Name
Δ binding config

tools/agents/skills/cf-now/SKILL.md

Status Type Name
+ preamble (preamble)
+ heading cf-now
+ heading Requirements
+ heading Authentication
+ heading One-time setup
+ heading Publish (upload + pre-sign)
+ heading URL expiry — the part that surprises people
+ heading Refresh a URL without re-uploading
+ heading Update content at a stable slug
+ heading Permanent storage (opt-in)
+ heading Manage
+ heading State file
+ heading What to tell the user
+ heading Content posture
+ heading Markdown and other formats
+ heading R2 compatibility notes

tools/agents/skills/cf-now/scripts/publish.sh

Status Type Name
+ orphan module-level
+ function die
+ function need_cmd
+ orphan module-level
+ function state_get_key
+ function state_put
+ function presign
+ orphan module-level
+ function gen_slug
+ orphan module-level
+ function content_type_flag
+ orphan module-level

tools/agents/skills/cf-now/scripts/setup.sh

Status Type Name
+ orphan module-level
+ function die
+ function need_cmd
+ orphan module-level

Summary: 44 added, 1 modified across 6 files (7 added orphans)

functions and classes, not lines · sem

@alycda
alycda marked this pull request as ready for review August 5, 2026 07:47
An auth probe that needs broader permissions than the tool it guards fails
exactly for correctly-scoped credentials — the tighter the token, the more
likely the false alarm. publish.sh probed ListBuckets (account-level) while
needing only object access to one bucket, so a least-privilege R2 token that
could upload, list, delete and pre-sign was reported as "not authenticated".

Compounded here rather than left in the PR because the second half does not
live in this repo at all: R2 answers AccessDenied, not NoSuchBucket, for any
bucket outside the credential's reach. That is deliberate — NoSuchBucket
would let a caller enumerate bucket names — but it means a typo and a scope
error are indistinguishable from the client, and no amount of inspecting the
token reveals which one you have. Worth writing down once so the next person
reads the error as "name or scope" instead of re-minting a working token.

Also records the misdiagnosis itself, since that is where the time went: the
credentials looked like the obvious suspect, and a test harness with inverted
redirection reported two working probes as denied. Believe the direct run.

CONCEPTS.md gains Slug, which inverts its usual meaning here — deliberately
unreadable, because unguessability is what keeps a private object private.
Accretion from this learning, not a seed of the sharing area; a Full run owns
seeding.

Verified: both bundled validators pass (frontmatter parser-safety; mechanical
claims — 1 path, 1 link, 0 flags). The probe results in the table were
measured against a live Object Read & Write token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants