paperclipai/paperclip · error · Error

Set both namespaced page uploader credential variables

Error message

Set both namespaced page uploader credential variables

What it means

AWS credentials may be provided via namespaced PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID / PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY (mapped onto the standard AWS_* names for the upload). This error means exactly one of the pair is set, i.e. they must be provided together or not at all.

Solutions

  1. Set both PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID and PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY together
  2. Or unset both to fall back to ambient AWS credentials/profile (e.g. PAPERCLIP_PAGE_AWS_PROFILE or default chain)
  3. Fix the CI secrets mapping so both variables are injected from their paired secrets
  4. Verify both with: printenv | grep PAPERCLIP_PAGE_AWS

Example fix

// before
export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID=AKIA...
// after
export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID=AKIA...
export PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY=****
Defensive patterns

Strategy: validation

Validate before calling

const k = !!process.env.PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID, s = !!process.env.PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY;
if (k !== s) throw new Error("set both or neither of the namespaced page uploader credentials");

Try / catch

try { await main(); } catch (e) { if (e.message.includes("namespaced page uploader credential")) { /* set/unset the credential pair together */ } }

Prevention

When it happens

Trigger: Setting PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID but forgetting PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY (or vice versa) when not relying on ambient AWS credentials/profile.

Common situations: Partial secret injection in CI where only one of the two variables was configured; hand-copying credentials and missing the second line; rotating keys and updating only one variable.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/f4867a2773c7e7c9. Report an issue: GitHub.

Appendix: source

Thrown at scripts/publish-announcements.ts:89

    "--content-type", file.contentType, "--cache-control", file.cacheControl];
}

async function main() {
  const { sourceDirectory, staging, publish } = parseAnnouncementPublishArgs(process.argv.slice(2));
  const hostPrefix = process.env.PAPERCLIP_PAGE_DEFAULT_PREFIX;
  const prepared = await prepareAnnouncementPublish(sourceDirectory, staging, hostPrefix);
  const bucket = process.env.PAPERCLIP_PAGE_BUCKET;
  const baseUrl = process.env.PAPERCLIP_PAGE_BASE_URL?.replace(/\/+$/, "") ?? "https://pages.paperclip.ing";
  const url = `${baseUrl}/${announcementPublishPrefix(staging, hostPrefix)}/current.json`;
  const parsed = new URL(url);
  if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) throw new Error("Invalid public base URL");
  console.log(JSON.stringify({ mode: publish ? "publish" : "dry-run", target: staging ? `staging/${staging}` : "production", bucket: bucket ?? "(unset)", url, announcementId: prepared.manifest.announcement?.id ?? null, files: prepared.files }, null, 2));
  if (!publish) return;
  if (!bucket) throw new Error("Set PAPERCLIP_PAGE_BUCKET before publishing");
  const env = { ...process.env };
  const key = env.PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID;
  const secret = env.PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY;
  if (Boolean(key) !== Boolean(secret)) throw new Error("Set both namespaced page uploader credential variables");
  if (key && secret) {
    env.AWS_ACCESS_KEY_ID = key;
    env.AWS_SECRET_ACCESS_KEY = secret;
    delete env.AWS_SESSION_TOKEN;
    if (env.PAPERCLIP_PAGE_AWS_SESSION_TOKEN) env.AWS_SESSION_TOKEN = env.PAPERCLIP_PAGE_AWS_SESSION_TOKEN;
  } else if (env.PAPERCLIP_PAGE_AWS_PROFILE) {
    delete env.AWS_ACCESS_KEY_ID;
    delete env.AWS_SECRET_ACCESS_KEY;
    delete env.AWS_SESSION_TOKEN;
    env.AWS_PROFILE = env.PAPERCLIP_PAGE_AWS_PROFILE;
  }
  // Only validated files, assets before manifest; credentials are scoped to AWS.
  for (const file of prepared.files) execFileSync("aws", announcementUploadArgs(bucket, file), { env, stdio: "pipe" });
  console.log("Uploaded. Checking the public manifest (CDN propagation can take five minutes)…");
  for (let attempt = 0; attempt < 23; attempt++) {
    try {
      const response = await fetch(url, { signal: AbortSignal.timeout(10_000), credentials: "omit", redirect: "error" });
      const body = announcementManifestSchema.parse(await response.json());

View on GitHub (pinned to 3f1d897a7c)