Skip to content

feat(source-cache): add CP1.1 config and presigner - #2765

Open
Harbor404 wants to merge 4 commits into
agentconnect-md:mainfrom
Harbor404:feat/source-cache-config-signer
Open

Harbor404 wants to merge 4 commits into
agentconnect-md:mainfrom
Harbor404:feat/source-cache-config-signer

Conversation

@Harbor404

Copy link
Copy Markdown
Contributor

Part of #2732. This PR implements CP1.1 only: Source Cache configuration and presigning.

Included

  • Off-by-default Helm sourceCache.* values for endpoint, region, bucket, prefix, path-style addressing, credential source, and the §10 limits.
  • Member-only delivery into the daemon-pool container. Static credentials are projected by secretKeyRef; sandbox pods and the reconciler receive no Source Cache configuration.
  • Zod-validated daemon configuration. Absence is the disabled no-op.
  • AWS Signature V4 presigner for GET (5 minute default) and PUT (15 minute default).
  • PUT signatures bind Content-Length, x-amz-checksum-sha256, and x-amz-tagging=ac-cache=pending.
  • Unit, Helm render, signature contract, and opt-in real MinIO integration coverage.

Not included

CP1.2+ tables, quota accounting, data-plane paths, shim/inventory changes, workspace read/write-back, sweep, metrics, or end-to-end cache rollout. Those remain in #2732 and its later checkpoints.

Verification

  • pnpm exec vitest run packages/daemon/test/source-cache-config.test.ts packages/daemon/test/source-cache-signer.test.ts packages/daemon/test/config.test.ts — 62 passed.
  • Real MinIO contract test — signed PUT/GET succeeded; a PUT that changed the signed x-amz-tagging value was rejected with 403.
  • helm lint charts/agentconnect and ruby scripts/test-chart-render.rb — passed.
  • pnpm lint — 0 errors.
  • pnpm typecheck — passed.

Risk

The signer is intentionally not wired into a data-plane path yet; CP1.2+ remains a no-op with respect to object access. ServiceAccount credentials support the environment, IRSA/web-identity, and container-credential provider shapes; the opt-in MinIO test covers static Secret credentials.

Part of agentconnect-md#2732 (CP1.1).

- add off-by-default sourceCache Helm values with member-only delivery
- validate daemon configuration and add SigV4 GET/PUT presigning
- cover chart rendering, config validation, signature contracts, and MinIO

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Architecture review: CP1.1 Source Cache config + presigner (01c58ad)

The scope matches docs/designs/source-cache.md P1 and the PR states clearly what it leaves out. Most of the rules settled in #2729 hold:

  • Only the pool member gets credentials; sandbox pods get no bucket config (§6.4).
  • Credentials come from serviceAccount or a referenced Secret, and nothing is inlined into values.
  • The defaults are GET 5 min and PUT 15 min.
  • The PUT signs Content-Length, x-amz-checksum-sha256 and the ac-cache=pending tag (§9.3).
  • The §10 limits are Helm values, and the feature is off by default as a no-op (§12).
  • The chart refuses extraEnv names that collide with its own.

One thing blocks approval. It's cheap to fix now and expensive once later checkpoints build on it.

Blocking: Source Cache config is in the general daemon Config, not a pool-member-only document

The design says "Cluster daemons only" and "Only pool members receive them" (§12). The chart does deliver it only to members, but the daemon side doesn't enforce that:

  • sourceCache is added to the shared ConfigSchema with a secret credentials arm. So any daemon's config.json (self-hosted, srt, microsandbox) can enable it and store static S3 keys at rest. load-config.ts also says "a file that states sourceCache wins", which makes the file a supported way to configure it.
  • loadConfig reads AC_SOURCE_CACHE_* from the environment for every caller, not just run --k8s. That includes chat, agent list, auth, prepare-upgrade and the evaluation runner (which also copies the raw config file into evaluation roots).
  • Once it's in Config, the bucket credentials reach every consumer of the daemon config object. That widens the secret surface past the one component that signs.

The codebase already has the pattern for member-only config: the data-plane document (store/postgres-config.ts). It has its own schema and is read only under --k8s from the pool's Secret mount. It's never part of Config and never read elsewhere. Please give the Source Cache the same shape: a separate member-only document and loader, called from the K8s/pool composition path. With no document the result is undefined, the disabled no-op. Static keys can arrive the same way, as a mounted Secret file, so they don't sit in the member's process environment and any child process it starts can't inherit them. Making that change now means CP1.2+ consumers inject a member-scoped signer and never read cfg.sourceCache.

Non-blocking (please settle before the signer gets its first caller)

  1. Callers shouldn't choose the presign TTL. presignGet and presignPut accept a ttlSeconds up to the 7-day SigV4 ceiling. The §10 sweep's safety rule is defined by the GET lifetime: it retags a bundle unreferenced only after the pointer has stopped naming it for longer than 5 min. A longer GET URL could therefore outlive a collectable object. Please make the design lifetimes fixed ceilings, ideally with no per-call override.
  2. serviceAccount only works with AWS identities. The chain covers env keys, IRSA web-identity and the ECS/EKS Pod Identity container endpoint. GKE Workload Identity and R2 can't produce SigV4 credentials this way, yet the values comment says "IAM/Workload Identity". Also, the default pair region: auto (an R2 value) plus credentialSource: serviceAccount (AWS only) can't work together. Please say in the values and chart docs that the other stores need secret, and pick defaults that are consistent with each other.
  3. Hand-rolled SigV4 and STS. This is a reasonable way to avoid the AWS SDK dependency. But it means this module owns every S3 call. The later HEAD, retag, conditional If-Match pointer write and sweep deletes should extend it rather than bring in an SDK client alongside it. Otherwise there will be two signing and credential stacks to keep correct.
  4. Lifecycle rule template. The chart change index (§15) includes the bucket lifecycle rule template. The guarantee "no object without a row or a pending tag" depends on it. It needs to ship no later than the checkpoint that issues the first PUT URL, and I'll look for it there.

sent by architect (Claude Agent · default) · open in session

relays: z.array(RelayRosterEntry).default([]),
// Optional Source Cache. Absence is the disabled no-op; the environment document is
// parsed by loadConfig and never exposed to sandboxes or the control plane.
sourceCache: SourceCacheConfigSchema.optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: this makes Source Cache, including the static-key secret arm, part of every daemon's config.json. That goes against §12, which says only pool members get it. Please follow the DataPlaneConfigSchema precedent instead: a separate member-only document read only under --k8s, never part of Config.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 45d9a61. sourceCache is no longer part of ConfigSchema; readSourceCacheConfig now owns a strict versioned member-only document at /var/run/ac-source-cache/config.json, and Daemon.startClusterPlanes composes the signer only under --k8s. Static credentials come from member-local Secret files, not config.json or the process environment. Regression coverage asserts the shared Config has no sourceCache arm.

// Pool members have no writable config file. The chart supplies the Source Cache
// document by environment; a file that states sourceCache wins (same precedence as
// the other operator-owned policy overrides below).
if ((raw as { sourceCache?: unknown } | null)?.sourceCache === undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking, same root cause: loadConfig serves chat, agent list, auth, prepare-upgrade and evaluation, and all of them would now read AC_SOURCE_CACHE_* and accept a file-stated sourceCache. Please load it in the pool-member composition path only.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 45d9a61. loadConfig no longer imports or reads Source Cache configuration; production no longer uses AC_SOURCE_CACHE_*. The regression sets a malformed AC_SOURCE_CACHE_CONFIG and a file-stated sourceCache arm and proves both are ignored/stripped, while the k8s-mode test proves the member loader is called only under --k8s.

return { timestamp, day: timestamp.slice(0, 8) }
}

function ttlSeconds(value: number | undefined, fallback: number): number {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: the §10 sweep's retag rule assumes the GET lifetime is 5 min. If callers can pick up to 7 days, an issued URL could outlive that rule. Please make the design lifetimes fixed ceilings.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 45d9a61. Removed the per-call ttlSeconds override entirely. GET is fixed at 300s and PUT at 900s, with unit and real-MinIO coverage for the contract.

forcePathStyle: false
# serviceAccount uses the pool member's IAM/Workload Identity. secret projects static keys
# from an existing Secret by name; it never embeds credential values in this values file.
credentialSource: serviceAccount

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: serviceAccount only resolves AWS identities (env, IRSA, container endpoint). The comment above suggests Workload Identity works too. Also, region: auto (R2) together with serviceAccount can't work. Please document which stores need secret.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 45d9a61. Values and chart README now say serviceAccount resolves AWS identities only, requires a concrete region, and that R2/MinIO/GKE Workload Identity need secret. The default region is us-east-1; the chart now rejects region=auto with serviceAccount, and chart render coverage asserts the AWS-only behavior.

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Request changes: enabling Source Cache with a normal two-key static S3 Secret can prevent the daemon pool from starting. The chart defaults sourceCache.sessionTokenKey to AWS_SESSION_TOKEN and renders it as a required Secret key reference, although static credentials do not have a session token. Make that reference optional or leave it disabled by default. The rest of the CP1.1 config and presigner diff did not reveal another blocking issue.

sent by review-bot (Codex · gpt-6-sol) · open in session

Comment thread charts/agentconnect/values.yaml Outdated
accessKeyIdKey: AWS_ACCESS_KEY_ID
secretAccessKeyKey: AWS_SECRET_ACCESS_KEY
# Optional; set to "" for stores or identities that do not issue a session token.
sessionTokenKey: AWS_SESSION_TOKEN

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Defaulting this optional token key to AWS_SESSION_TOKEN makes the static-key Secret path require three keys. When an operator supplies the ordinary AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY pair and enables credentialSource: secret, the template renders AC_SOURCE_CACHE_SESSION_TOKEN without secretKeyRef.optional; a missing key prevents the daemon-pool pod from starting, so turning on this optional cache can take pool members down. Default this to '' or mark only this Secret key reference optional. Kubernetes documents the missing-key startup behavior: https://kubernetes.io/docs/concepts/configuration/secret/.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 45d9a61. sessionTokenKey now defaults to ``; when empty the credential Secret volume contains only the access-key and secret-key items, so ordinary two-key credentials cannot block the pod. The chart-render contract also covers the explicit session-token case.

@Harbor404

Copy link
Copy Markdown
Contributor Author

@agentconnect-md-test please re-review 45d9a61. The two blocking findings and non-blocking 3-5 are addressed in-thread; local verification includes Node config/signer tests, the targeted k8s composition regression, real MinIO PUT/GET, chart render/helm lint, full typecheck, and lint with 0 errors.

@agentconnect-md-test

Copy link
Copy Markdown
Contributor

This agent responds to requests from this repository's maintainers and trusted contributors. A maintainer can mention it on this thread to have it take a look.

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Architecture review: CP1.1 Source Cache config + presigner (45d9a61), approved

I checked this against docs/designs/source-cache.md. The blocking finding from the last round is fixed, and so are the non-blocking points.

  • Only pool members get the config (§12). sourceCache is gone from ConfigSchema, and loadConfig no longer reads Source Cache settings. A strict, versioned document at /var/run/ac-source-cache/config.json replaces it. Daemon.startClusterPlanes reads it and builds the signer only under --k8s. If the document isn't mounted, the feature stays off. If it's present but malformed, startup fails. This follows the data-plane config pattern (store/postgres-config.ts).
  • Credentials. The ConfigMap holds only file paths. Static keys come from a Secret mounted as files, so they never sit in the member's process environment or in config.json. Sandbox pods still get nothing (§6.4).
  • Presign lifetimes. GET is fixed at 300 s and PUT at 900 s, and callers can no longer override them. That keeps the §10 sweep's retag rule, which assumes a 5-minute GET, sound.
  • Credential sources. The chart now documents serviceAccount as AWS-only, defaults the region to us-east-1, and rejects region=auto together with serviceAccount. R2, MinIO and GKE need secret.
  • Session token. sessionTokenKey now defaults to '', so an ordinary two-key Secret can no longer keep pool pods from starting. That was the other reviewer's finding.

For later checkpoints (not blocking)

  1. One S3 client. The later HEAD, retag, conditional If-Match pointer write and sweep deletes should extend this signer and credentials module rather than add an SDK client alongside it.
  2. Lifecycle rule template. The chart's bucket lifecycle rule (§15) must ship no later than the checkpoint that issues the first PUT URL. The guarantee "no object without a row or a pending tag" depends on it.
  3. Static key rotation. The member reads the static key files once, at startup. Rotating the Secret therefore takes a pool rollout. Either document that or re-read the files when the signer needs credentials.
  4. How later code gets the signer. CP1.2+ consumers should receive the member-scoped signer from startClusterPlanes. They shouldn't import the reader or open the mount themselves, so --k8s remains the only place the signer is created.

sent by architect (Claude Agent · default) · open in session

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved for CP1.1 at 45d9a61. The two-key static Secret now mounts without requiring a session token; Source Cache config is a versioned, member-only document read only during --k8s startup, outside the shared daemon Config. The signer has the fixed 5-minute GET and 15-minute PUT lifetimes, and the chart keeps the document and credentials out of sandbox pods. One non-blocking follow-up before cache use: changing an already enabled Source Cache ConfigMap does not change the Deployment pod template, while the signer reads its values only at startup, so a Helm update to endpoint, bucket, prefix, region, or limits needs a member rollout.

sent by review-bot (Codex · gpt-6-sol) · open in session

{{- include "agentconnect.labels" . | nindent 4 }}
{{- include "agentconnect.componentLabels" (dict "ctx" . "component" "daemon-pool") | nindent 4 }}
data:
config.json: {{ $sourceCacheDocument | toJson | quote }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking follow-up: this ConfigMap's data can change on helm upgrade without changing the daemon-pool pod template (its annotation covers only the runtime image). readSourceCacheConfig() runs once during member startup, so an already enabled pool keeps signing with the old endpoint, bucket, region, prefix, and limits until a manual restart. Add a document hash to the pod template before the first cache callers ship, or document the required rollout.

@spacedragon

Copy link
Copy Markdown
Contributor

Non-blocking, from the P0 S3 matrix (#2731). The signer is right to keep all three headers in X-Amz-SignedHeaders instead of hoisting them into the query. On MinIO RELEASE.2025-10-15T17-29-55Z, a query-hoisted x-amz-checksum-sha256 accepted a wrong body (200, no checksum stored). A query-hoisted x-amz-tagging was overridden by an uploader header. A default presign that did not sign content-length accepted any length.

The MinIO test covers the tag tamper (403). Since the failure mode is "accepted when not enforced", two more negative cases would pin the other two guarantees against a future signer change:

  • same URL, body of a different length: expect 403 SignatureDoesNotMatch;
  • same URL, same length but different bytes: expect 400 XAmzContentChecksumMismatch.

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