Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f917378
feat(release): add smoke-gated nightly channel and lane-separated Doc…
devinfoley Aug 10, 2026
5ca752d
fix(server): raise company import zip upload limit to 1 GB and make i…
devinfoley Aug 10, 2026
30f6999
fix(release-smoke): configurable readiness timeout and diagnostics fo…
devinfoley Aug 10, 2026
f94f600
fix(release-smoke): pin the smoke container to the lan bind preset (#…
devinfoley Aug 10, 2026
5a0985f
test(release-smoke): update onboarding spec for the mission-first wiz…
devinfoley Aug 10, 2026
4594696
feat(adapter-codex-local): add secure device-login building blocks (#…
nickyleach Aug 10, 2026
8f7b8b3
feat(release): add human-gated beta channel with stable soak enforcem…
devinfoley Aug 10, 2026
d816eb8
fix(server): keep imported tasks quiescent under the productivity rev…
devinfoley Aug 11, 2026
4c062a0
fix(ui): stop coercing imported agents to the destination CEO adapter…
devinfoley Aug 11, 2026
35aaaa0
feat(server): preserve task timestamps and hierarchy through company …
devinfoley Aug 11, 2026
6601014
fix(release): reject promotion sources that predate their channel too…
devinfoley Aug 11, 2026
d648bec
refactor(ci): split workspaces-a into two Vitest native shards
devinfoley Aug 11, 2026
66575fe
fix(paperclip-page): scope uploader credentials to the publish helper…
cryppadotta Aug 11, 2026
9cdaa54
fix(ui): remember folded inbox subtasks (#11069)
cryppadotta Aug 11, 2026
b58ce27
fix: isolate execution workspace summaries (#10790)
cryppadotta Aug 11, 2026
c4abecb
fix(skills): refresh project folders in place (#11066)
cryppadotta Aug 11, 2026
5cf2071
sync: merge upstream/master through c4abecb2c into sync/upstream-2026…
Aug 11, 2026
3c9cb15
Merge fork/main into sync/upstream-2026-08-11
Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions .agents/skills/paperclip-page/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,24 +73,44 @@ Required for live publishes:
export AWS_REGION=us-east-1
export PAPERCLIP_PAGE_BUCKET=paperclip-pages-prod
export PAPERCLIP_PAGE_BASE_URL=https://pages.paperclip.ing
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID=...
export PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY=...
```

Optional:

```bash
export PAPERCLIP_PAGE_DEFAULT_PREFIX=""
export PAPERCLIP_PAGE_AWS_PROFILE=paperclip-page-uploader
export PAPERCLIP_PAGE_AWS_SESSION_TOKEN=... # only with the namespaced key pair
```

Credential resolution order inside `publish.sh`:

1. `PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID` + `PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY`
(scoped to the helper's `aws` calls; the surrounding process identity is
untouched)
2. `PAPERCLIP_PAGE_AWS_PROFILE`, passed to `aws` as `--profile` (ambient
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` /
`AWS_PROFILE` are stripped from the helper's `aws` calls so the named
profile always wins)
3. The ambient AWS credential chain

Setting both the namespaced key pair and `PAPERCLIP_PAGE_AWS_PROFILE` is an
error.

Recommended Paperclip secret names:

- `paperclip-page-aws-access-key-id`
- `paperclip-page-aws-secret-access-key`

Bind those secrets into publisher agents as `AWS_ACCESS_KEY_ID` and
`AWS_SECRET_ACCESS_KEY`. Do not reuse Paperclip's internal S3 attachment/object
Bind those secrets into publisher agents as `PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID`
and `PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY`. Never bind them as the global
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` names: static env keys override
`AWS_PROFILE` in the AWS CLI and every SDK, so global names silently switch the
whole agent run — and every subprocess — to the page-uploader identity and
break access to anything the uploader cannot reach (Secrets Manager, STS role
use, other buckets). Do not reuse Paperclip's internal S3 attachment/object
storage credentials.

## AWS Setup
Expand Down Expand Up @@ -484,16 +504,18 @@ pnpm paperclipai secrets create \
--value-env PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY
```

Bind runtime env to publishing agents:
Bind runtime env to publishing agents. Use the namespaced names — never the
global `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, which would shadow the
host `AWS_PROFILE` identity for the entire agent run:

```json
{
"AWS_ACCESS_KEY_ID": {
"PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID": {
"type": "secret_ref",
"secretId": "<access-key-secret-id>",
"version": "latest"
},
"AWS_SECRET_ACCESS_KEY": {
"PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY": {
"type": "secret_ref",
"secretId": "<secret-key-secret-id>",
"version": "latest"
Expand Down
14 changes: 12 additions & 2 deletions .agents/skills/paperclip-page/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,20 @@ host, for example `https://pages.paperclip.ing/<slug>/`.
- `PAPERCLIP_PAGE_BUCKET`
- `PAPERCLIP_PAGE_BASE_URL`
- `AWS_REGION`
- AWS credentials via Paperclip Secrets or an approved AWS vault
- `PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID` and `PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY`
with the page-uploader credentials from Paperclip Secrets
- Optional environment variables:
- `PAPERCLIP_PAGE_DEFAULT_PREFIX`
- `PAPERCLIP_PAGE_AWS_PROFILE`
- `PAPERCLIP_PAGE_AWS_PROFILE` (alternative to the namespaced key pair)
- `PAPERCLIP_PAGE_AWS_SESSION_TOKEN` (only together with the namespaced key
pair)

Do not bind the page-uploader credentials as global `AWS_ACCESS_KEY_ID` /
`AWS_SECRET_ACCESS_KEY`: static env keys take precedence over `AWS_PROFILE` in
every AWS SDK, so global names silently replace the host identity for every
process in the agent run. The namespaced variables scope the uploader identity
to this helper only. The ambient credential chain still works as a fallback
when none of the `PAPERCLIP_PAGE_AWS_*` credential variables are set.

## Workflow

Expand Down
52 changes: 50 additions & 2 deletions .agents/skills/paperclip-page/scripts/publish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ Required environment for live publish:

Optional environment:
PAPERCLIP_PAGE_DEFAULT_PREFIX, PAPERCLIP_PAGE_AWS_PROFILE
PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID, PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY,
PAPERCLIP_PAGE_AWS_SESSION_TOKEN

Credential resolution for aws calls made by this helper:
1. PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID + PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY
(used only by this helper; ambient AWS_PROFILE/AWS_* identity is untouched)
2. PAPERCLIP_PAGE_AWS_PROFILE (passed as --profile; ambient AWS_* identity
variables are stripped from the helper's aws calls)
3. Ambient AWS credential chain (env keys, profile, instance role)

Options:
--slug SLUG Lowercase URL slug. Allowed: a-z, 0-9, hyphen.
Expand Down Expand Up @@ -125,9 +134,26 @@ join_prefix() {
}

aws_base_args=()
aws_env_unset=()
aws_env_overrides=()

aws_cli() {
aws "${aws_base_args[@]}" "$@"
local name pair
if [[ ${#aws_env_unset[@]} -gt 0 || ${#aws_env_overrides[@]} -gt 0 ]]; then
# Scope the page-uploader identity to this helper's aws calls only, and
# drop the ambient identity variables that would otherwise mix with or
# shadow the configured credential source. Apply the overrides with shell
# builtins in a subshell — passing them to an external `env` command would
# expose the credential values in its argv (world-readable via
# /proc/<pid>/cmdline) while it runs.
(
for name in "${aws_env_unset[@]}"; do unset "$name"; done
for pair in "${aws_env_overrides[@]}"; do export "$pair"; done
exec aws "${aws_base_args[@]}" "$@"
)
else
aws "${aws_base_args[@]}" "$@"
fi
}

object_exists() {
Expand Down Expand Up @@ -297,6 +323,18 @@ default_prefix="$(normalize_default_prefix "${PAPERCLIP_PAGE_DEFAULT_PREFIX:-}")
[[ -n "$base_url" ]] || die "PAPERCLIP_PAGE_BASE_URL is required"
base_url="$(normalize_base_url "$base_url")"

page_access_key_id="${PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID:-}"
page_secret_access_key="${PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY:-}"
if [[ -n "$page_access_key_id" || -n "$page_secret_access_key" ]]; then
[[ -n "$page_access_key_id" && -n "$page_secret_access_key" ]] ||
die "PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID and PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY must be set together"
[[ -z "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]] ||
die "set PAPERCLIP_PAGE_AWS_PROFILE or the PAPERCLIP_PAGE_AWS_* key pair, not both"
fi
if [[ -n "${PAPERCLIP_PAGE_AWS_SESSION_TOKEN:-}" && -z "$page_access_key_id" ]]; then
die "PAPERCLIP_PAGE_AWS_SESSION_TOKEN requires the PAPERCLIP_PAGE_AWS_* key pair"
fi

explicit_slug=0
if [[ -n "$slug_arg" ]]; then
explicit_slug=1
Expand All @@ -310,8 +348,18 @@ if [[ "$dry_run" == "0" ]]; then
require_command curl
[[ -n "$region" ]] || die "AWS_REGION is required for live publish"
aws_base_args=(--region "$region")
if [[ -n "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]]; then
if [[ -n "$page_access_key_id" ]]; then
aws_env_unset=(AWS_PROFILE AWS_SESSION_TOKEN)
aws_env_overrides=(
AWS_ACCESS_KEY_ID="$page_access_key_id"
AWS_SECRET_ACCESS_KEY="$page_secret_access_key"
)
if [[ -n "${PAPERCLIP_PAGE_AWS_SESSION_TOKEN:-}" ]]; then
aws_env_overrides+=(AWS_SESSION_TOKEN="$PAPERCLIP_PAGE_AWS_SESSION_TOKEN")
fi
elif [[ -n "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]]; then
aws_base_args+=(--profile "$PAPERCLIP_PAGE_AWS_PROFILE")
aws_env_unset=(AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE)
fi
fi

Expand Down
209 changes: 209 additions & 0 deletions .agents/skills/paperclip-page/scripts/publish.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,215 @@ test("rejects hidden files in the source tree", () => {
assert.match(result.output, /hidden files and dot paths are not allowed/);
});

test("namespaced page keys require both halves of the pair", () => {
const result = runPublish(
[createSite(), "--slug", "demo-page", "--dry-run"],
{ PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER" },
);

assert.notEqual(result.status, 0);
assert.match(result.output, /must be set together/);
});

test("namespaced session token requires the namespaced key pair", () => {
const result = runPublish(
[createSite(), "--slug", "demo-page", "--dry-run"],
{ PAPERCLIP_PAGE_AWS_SESSION_TOKEN: "page-session-token" },
);

assert.notEqual(result.status, 0);
assert.match(result.output, /requires the PAPERCLIP_PAGE_AWS_\* key pair/);
});

test("namespaced page keys conflict with PAPERCLIP_PAGE_AWS_PROFILE", () => {
const result = runPublish(
[createSite(), "--slug", "demo-page", "--dry-run"],
{
PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",
PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret",
PAPERCLIP_PAGE_AWS_PROFILE: "paperclip-page-uploader",
},
);

assert.notEqual(result.status, 0);
assert.match(result.output, /not both/);
});

test("namespaced page keys are scoped to the helper's aws calls", () => {
const siteDir = createSite();
const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
tempDirs.add(binDir);
const envDump = join(binDir, "aws-env.txt");

writeExecutable(
join(binDir, "aws"),
`#!/usr/bin/env bash
set -euo pipefail
{
echo "AWS_ACCESS_KEY_ID=\${AWS_ACCESS_KEY_ID:-<unset>}"
echo "AWS_SECRET_ACCESS_KEY=\${AWS_SECRET_ACCESS_KEY:-<unset>}"
echo "AWS_SESSION_TOKEN=\${AWS_SESSION_TOKEN:-<unset>}"
echo "AWS_PROFILE=\${AWS_PROFILE:-<unset>}"
} >"${envDump}"
while [[ "$1" == "--region" || "$1" == "--profile" ]]; do
shift 2
done
if [[ "$1" == "s3api" ]]; then
echo "None"
exit 0
fi
if [[ "$1" == "s3" && "$2" == "sync" ]]; then
exit 0
fi
echo "unexpected aws call: $*" >&2
exit 1
`,
);
writeExecutable(
join(binDir, "curl"),
`#!/usr/bin/env bash
exit 0
`,
);

const result = runPublish([siteDir, "--slug", "demo-page"], {
AWS_REGION: "us-east-1",
PATH: `${binDir}:${process.env.PATH}`,
AWS_ACCESS_KEY_ID: "AKIAAMBIENTIDENTITY",
AWS_SECRET_ACCESS_KEY: "ambient-secret",
AWS_SESSION_TOKEN: "ambient-session-token",
AWS_PROFILE: "ambient-profile",
PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",
PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret",
});

assert.equal(result.status, 0);

const seen = readFileSync(envDump, "utf8");
assert.match(seen, /^AWS_ACCESS_KEY_ID=AKIAPAGEUPLOADER$/m);
assert.match(seen, /^AWS_SECRET_ACCESS_KEY=page-secret$/m);
assert.match(seen, /^AWS_SESSION_TOKEN=<unset>$/m);
assert.match(seen, /^AWS_PROFILE=<unset>$/m);
});

test("credential values never pass through an external env command's argv", () => {
const siteDir = createSite();
const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
tempDirs.add(binDir);
const envArgvDump = join(binDir, "env-argv.txt");

writeExecutable(
join(binDir, "aws"),
`#!/usr/bin/env bash
set -euo pipefail
while [[ "$1" == "--region" || "$1" == "--profile" ]]; do
shift 2
done
if [[ "$1" == "s3api" ]]; then
echo "None"
exit 0
fi
if [[ "$1" == "s3" && "$2" == "sync" ]]; then
exit 0
fi
echo "unexpected aws call: $*" >&2
exit 1
`,
);
writeExecutable(
join(binDir, "curl"),
`#!/usr/bin/env bash
exit 0
`,
);
// Shim env: record every argv it is invoked with, then behave normally.
// Credentials in that argv would be world-readable via /proc/<pid>/cmdline.
writeExecutable(
join(binDir, "env"),
`#!/bin/bash
printf '%s\\n' "$@" >>"${envArgvDump}"
exec /usr/bin/env "$@"
`,
);

const result = runPublish([siteDir, "--slug", "demo-page"], {
AWS_REGION: "us-east-1",
PATH: `${binDir}:${process.env.PATH}`,
PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",
PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret-argv-canary",
PAPERCLIP_PAGE_AWS_SESSION_TOKEN: "page-session-argv-canary",
});

assert.equal(result.status, 0);

const argvSeen = existsSync(envArgvDump) ? readFileSync(envArgvDump, "utf8") : "";
assert.doesNotMatch(argvSeen, /page-secret-argv-canary/);
assert.doesNotMatch(argvSeen, /page-session-argv-canary/);
assert.doesNotMatch(argvSeen, /AKIAPAGEUPLOADER/);
});

test("PAPERCLIP_PAGE_AWS_PROFILE strips ambient static credentials", () => {
const siteDir = createSite();
const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
tempDirs.add(binDir);
const envDump = join(binDir, "aws-env.txt");

writeExecutable(
join(binDir, "aws"),
`#!/usr/bin/env bash
set -euo pipefail
profile="<none>"
while [[ "$1" == "--region" || "$1" == "--profile" ]]; do
if [[ "$1" == "--profile" ]]; then
profile="$2"
fi
shift 2
done
{
echo "PROFILE_ARG=$profile"
echo "AWS_ACCESS_KEY_ID=\${AWS_ACCESS_KEY_ID:-<unset>}"
echo "AWS_SECRET_ACCESS_KEY=\${AWS_SECRET_ACCESS_KEY:-<unset>}"
echo "AWS_SESSION_TOKEN=\${AWS_SESSION_TOKEN:-<unset>}"
echo "AWS_PROFILE=\${AWS_PROFILE:-<unset>}"
} >"${envDump}"
if [[ "$1" == "s3api" ]]; then
echo "None"
exit 0
fi
if [[ "$1" == "s3" && "$2" == "sync" ]]; then
exit 0
fi
echo "unexpected aws call: $*" >&2
exit 1
`,
);
writeExecutable(
join(binDir, "curl"),
`#!/usr/bin/env bash
exit 0
`,
);

const result = runPublish([siteDir, "--slug", "demo-page"], {
AWS_REGION: "us-east-1",
PATH: `${binDir}:${process.env.PATH}`,
AWS_ACCESS_KEY_ID: "AKIAAMBIENTIDENTITY",
AWS_SECRET_ACCESS_KEY: "ambient-secret",
AWS_SESSION_TOKEN: "ambient-session-token",
AWS_PROFILE: "ambient-profile",
PAPERCLIP_PAGE_AWS_PROFILE: "paperclip-page-uploader",
});

assert.equal(result.status, 0);

const seen = readFileSync(envDump, "utf8");
assert.match(seen, /^PROFILE_ARG=paperclip-page-uploader$/m);
assert.match(seen, /^AWS_ACCESS_KEY_ID=<unset>$/m);
assert.match(seen, /^AWS_SECRET_ACCESS_KEY=<unset>$/m);
assert.match(seen, /^AWS_SESSION_TOKEN=<unset>$/m);
assert.match(seen, /^AWS_PROFILE=<unset>$/m);
});

test("live publish writes state before URL verification", () => {
const siteDir = createSite();
const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
Expand Down
Loading
Loading