@deployfoundation/foundation-deploy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +174 -0
  2. package/agent-image/Dockerfile +254 -0
  3. package/agent-image/bin/aws +36 -0
  4. package/agent-image/bin/gh +193 -0
  5. package/agent-image/bin/git-credential-sky +89 -0
  6. package/agent-image/security-overlay.yml +176 -0
  7. package/cdk.json +6 -0
  8. package/dist/bin/app.js +112 -0
  9. package/dist/bin/foundation-deploy.js +1906 -0
  10. package/dist/bin/release-account.js +154 -0
  11. package/dist/chunk-4aye5cee.js +2416 -0
  12. package/dist/chunk-9ddxyvq2.js +1455 -0
  13. package/dist/chunk-v7tz8g50.js +428 -0
  14. package/dist/src/index.js +88 -0
  15. package/package.json +38 -0
  16. package/pipeline/buildspec.yml +34 -0
  17. package/src/artifacts.ts +318 -0
  18. package/src/deploy/assets/github-app-manifest.yml +29 -0
  19. package/src/deploy/assets/slack-app-manifest.yml +95 -0
  20. package/src/deploy/aws.ts +265 -0
  21. package/src/deploy/cli.ts +212 -0
  22. package/src/deploy/config-sync.ts +93 -0
  23. package/src/deploy/config.ts +29 -0
  24. package/src/deploy/deploy.ts +566 -0
  25. package/src/deploy/endpoint.ts +242 -0
  26. package/src/deploy/github-app-create.ts +154 -0
  27. package/src/deploy/github-app-manifest.ts +53 -0
  28. package/src/deploy/image.ts +80 -0
  29. package/src/deploy/instance.ts +87 -0
  30. package/src/deploy/license-cache.ts +47 -0
  31. package/src/deploy/license.ts +272 -0
  32. package/src/deploy/paths.ts +65 -0
  33. package/src/deploy/post-deploy.ts +97 -0
  34. package/src/deploy/release.ts +282 -0
  35. package/src/deploy/runtime-secret.ts +241 -0
  36. package/src/deploy/setup.ts +393 -0
  37. package/src/deploy/sh.ts +74 -0
  38. package/src/deploy/slack-manifest.ts +112 -0
  39. package/src/deploy/stage-customization.ts +224 -0
  40. package/src/deploy/tracing.ts +243 -0
  41. package/src/deploy-permissions.ts +165 -0
  42. package/src/index.ts +60 -0
  43. package/src/lambda-bundle-context.ts +64 -0
  44. package/src/names.ts +170 -0
  45. package/src/release/kms.ts +86 -0
  46. package/src/release/manifest.ts +265 -0
  47. package/src/stacks/agent-stack.ts +938 -0
  48. package/src/stacks/api-stack.ts +1005 -0
  49. package/src/stacks/ci-stack.ts +96 -0
  50. package/src/stacks/data-stack.ts +446 -0
  51. package/src/stacks/network-stack.ts +282 -0
  52. package/src/stacks/newsletter-stack.ts +572 -0
  53. package/src/stacks/pipeline-stack.ts +242 -0
  54. package/src/stacks/release-account-stack.ts +229 -0
package/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # `@deployfoundation/foundation-deploy`
2
+
3
+ The CDK app that deploys one Foundation instance, and `foundation-deploy`, the
4
+ tool an instance's pipeline runs.
5
+
6
+ Foundation ships **no instance file and no company configuration**. Everything
7
+ that identifies a deployment — account, region, Slack workspace, GitHub org,
8
+ naming prefixes, which integrations are provisioned — lives in an instance YAML
9
+ the deployment's own repository owns. Both halves of this package take the path
10
+ to that file and derive the rest.
11
+
12
+ ## The CDK app
13
+
14
+ ```sh
15
+ cd packages/infra
16
+ bunx cdk synth --all -c instanceFile=../../../my-instance/instance.yaml
17
+ bunx cdk deploy --all -c instanceFile=/abs/path/instance.yaml -c agentImageTag=abc1234
18
+ ```
19
+
20
+ Context keys:
21
+
22
+ | Key | Meaning |
23
+ | --- | --- |
24
+ | `instanceFile` | **Required.** Path to the instance YAML, absolute or relative to the working directory. |
25
+ | `admins` | CSV of Slack admin ids. Defaults to `admins` in the instance's runtime config. |
26
+ | `alarmEmail` | DLQ alarm subscriber. Defaults to none. |
27
+ | `agentImageTag` | Required when deploying the AgentCore runtime from a local build; a release names the image instead. |
28
+ | `release` | Deploy a published release: `vX.Y.Z`. The manifest is read from `$FOUNDATION_RELEASE_DIR` or the per-version cache directory `foundation-deploy` writes. |
29
+ | `releaseManifest` | Path to a verified release manifest, which is what `foundation-deploy --release` passes. |
30
+ | `releaseBucket` | The Foundation release bucket the manifest's keys live in. Required in release mode (or `FOUNDATION_RELEASE_BUCKET`). |
31
+ | `deployRuntime=false` | Bootstrap phase: every stack except the runtime. |
32
+ | `pipelineInstanceFile` | Where the instance file sits inside the repo a CodePipeline sources, when that differs from the path given here. |
33
+ | `vpcId`, `createVpcEndpoints`, `skipEndpoints`, `agentSubnetIds` | Network escape hatches, unchanged. |
34
+
35
+ `instance.config` is resolved **relative to the instance file's own directory**,
36
+ so an instance repository whose layout is `.foundation/{instance.yaml,config.yaml}`
37
+ writes `config: config.yaml` and nothing else.
38
+
39
+ `FOUNDATION_SKIP_BUNDLE=1` swaps every Lambda bundle for an inline stub. It is what the
40
+ tests use and what a synth check on a host without Docker or Bun should use.
41
+
42
+ ## The deploy tool
43
+
44
+ ```sh
45
+ bun run packages/infra/bin/foundation-deploy.ts <command> --instance <path>
46
+ ```
47
+
48
+ | Command | What it does |
49
+ | --- | --- |
50
+ | `deploy` | Build and push the agent image, `cdk deploy --all`, refresh the runtime secret, sync config. |
51
+ | `post-deploy` | Smoke DEFAULT, probe the persistent mount, promote `live`, smoke `live`. |
52
+ | `config:sync` | Upload the runtime config and Foundation's product skills to S3. |
53
+ | `setup` | First-deploy orchestration, idempotent. |
54
+ | `github-app-create` | Create the instance's GitHub App from the shipped manifest template. |
55
+ | `slack-manifest` | Print the instance's Slack app manifest as JSON (the Slack CLI's `get-manifest` hook contract). |
56
+ | `stage-customization` | Validate and stage a tenant-owned runtime config. |
57
+
58
+ `--instance <path>` is required for every command (or `FOUNDATION_INSTANCE_FILE`
59
+ in a pipeline). There is no instance *name*, no `instances/` directory and no
60
+ default: a deploy that guessed its target would be a deploy into the wrong
61
+ account.
62
+
63
+ ### Deploying a release
64
+
65
+ ```sh
66
+ # an instance repo's whole build step
67
+ npx @deployfoundation/foundation-deploy@$(cat foundation.version) deploy \
68
+ --instance .foundation/instance.yaml
69
+ ```
70
+
71
+ A copy of the tool installed from npm has no Foundation workspace to build
72
+ from, so it deploys the release matching **its own version**: it fetches
73
+ `releases/vX.Y.Z/manifest.json` from the Foundation release bucket, verifies
74
+ the KMS signature and every artifact's sha256, and only then deploys. In a
75
+ Foundation checkout the same command builds locally, as it always did.
76
+ `--release <vX.Y.Z>`, `--manifest <path|s3://…>` and `--release-bucket <name>`
77
+ override the choice.
78
+
79
+ Until the release account exists, set `FOUNDATION_RELEASE_KMS_KEY_ARN` to the
80
+ published signing key ARN: the tool ships no default, because trusting the
81
+ wrong key is worse than checking nothing.
82
+
83
+ ### The license check
84
+
85
+ `deploy` verifies the instance's `license.key` against
86
+ `https://license.foundry41.com/v1/verify` (override with
87
+ `FOUNDATION_LICENSE_ENDPOINT`) before it deploys, sending the key, the
88
+ instance id and the version — nothing else. An instance with no key is
89
+ skipped with a warning. A definite refusal stops the deploy; an unreachable
90
+ endpoint does not, for 30 days, on the verification cached in the instance's
91
+ own `<prefix>/license/last-verified` secret.
92
+
93
+ The tool never imports the agent package. It runs in a pipeline, not in the
94
+ agent container.
95
+
96
+ ## Artifacts
97
+
98
+ `src/artifacts.ts` is the single place that decides where the deployed bytes
99
+ come from, and it has two modes:
100
+
101
+ - **local build** (the default). Every Lambda bundle is built from this
102
+ checkout at synth with the pinned Bun, and `deploy` builds and pushes the
103
+ agent image to the instance's own ECR first. A contributor deploys their
104
+ working tree this way.
105
+ - **release**. `-c release=vX.Y.Z` (or `-c releaseManifest=<path>`) plus
106
+ `-c releaseBucket=<name>`: each Lambda's code is `Code.fromBucket(...)` on
107
+ the key the signed manifest names, and the agent image is Foundation's own
108
+ repository pinned by digest. Nothing is built, so the pipeline needs no
109
+ Docker, no Bun and no Foundation checkout.
110
+
111
+ `src/release/manifest.ts` is the manifest itself — schema, builder, the
112
+ canonical bytes a signature covers, and `verifyManifest`, which checks the KMS
113
+ signature **before** any artifact is read and then every artifact's sha256.
114
+ `src/release/kms.ts` is the AWS-facing half. A manifest reaching a synth has
115
+ already been verified by `foundation-deploy`.
116
+
117
+ ## The release account (Foundry 41's own)
118
+
119
+ `src/stacks/release-account-stack.ts` is the only stack here that is not an
120
+ instance's. It holds what a release is published *from*: the release bucket,
121
+ the `foundation/agent` image repository, the KMS signing key, and the OIDC
122
+ role `.github/workflows/release.yml` assumes on a tag push. Customers appear
123
+ in exactly one place — the account-id list that opens the bucket, the
124
+ repository and the key.
125
+
126
+ ```sh
127
+ cd packages/infra
128
+ bunx cdk --app 'bun run bin/release-account.ts' diff FoundationRelease \
129
+ -c customerAccountIds=111122223333,444455556666
130
+ bunx cdk --app 'bun run bin/release-account.ts' deploy FoundationRelease \
131
+ -c customerAccountIds=111122223333,444455556666
132
+ ```
133
+
134
+ Deployed once, by hand, into the release account. Its outputs are the four
135
+ repository variables the release workflow needs (`FOUNDATION_RELEASE_BUCKET`,
136
+ `FOUNDATION_RELEASE_KMS_KEY_ARN`, `FOUNDATION_RELEASE_ROLE_ARN`, and the image
137
+ repository), plus the signing key ARN customers verify against. Adding a
138
+ customer is a redeploy with a longer `customerAccountIds`.
139
+
140
+ ## Publishing
141
+
142
+ ```sh
143
+ bun run --cwd packages/infra build # dist/, Node target, ESM
144
+ cd packages/infra && npm pack --dry-run
145
+ ```
146
+
147
+ The build bundles the three entry points — the CLI, the CDK app and the
148
+ release-account app — into `dist/`, inlining the workspace packages (which are
149
+ not published) and leaving every declared dependency external, so npm installs
150
+ one `aws-cdk-lib` and both halves of the tool see it. `aws-cdk` itself is a
151
+ dependency, not a devDependency: the tool shells out to the CDK CLI and
152
+ resolves it through this package rather than through PATH.
153
+
154
+ The tarball carries `dist/`, `src/` (the manifest templates are read from
155
+ there at runtime), the agent image's Dockerfile, the reference buildspec and
156
+ `cdk.json`. It does NOT carry `bin/`: those TypeScript entry points need Bun
157
+ and the workspace, and shipping an app entry that cannot run is how a deploy
158
+ picks the wrong one. `node dist/bin/foundation-deploy.js --help` works from an
159
+ extracted tarball with only the runtime dependencies installed — no Bun, no
160
+ Foundation checkout — which is exactly what an instance's pipeline has.
161
+
162
+ ## Tests
163
+
164
+ ```sh
165
+ bun test packages/infra
166
+ ```
167
+
168
+ They run under `bun test` with the rest of the repo — no jest, no ts-node. The
169
+ snapshot test shells out to `cdk synth` for both fixtures, so it needs the
170
+ workspace installed but no AWS credentials and no Docker.
171
+
172
+ Fixtures are synthetic (`test/fixtures/`): `acme` turns every integration on,
173
+ `beta` is a minimal instance deployed by CodePipeline. No real company appears
174
+ anywhere in this package.
@@ -0,0 +1,254 @@
1
+ # Foundation agent container for Bedrock AgentCore Runtime.
2
+ #
3
+ # AgentCore contract: an HTTP server on :8080 exposing POST /invocations and
4
+ # GET /ping. Runs as a non-root user; scratch space is the session microVM's
5
+ # ephemeral filesystem (dies with the session).
6
+ #
7
+ # Build from the FOUNDATION ROOT:
8
+ # docker build -f packages/infra/agent-image/Dockerfile .
9
+ #
10
+ # Pins (binding):
11
+ # - Bun 1.4.0 (omp's engines floor is `>=1.3.14`; pinned exact to the
12
+ # version that WRITES this repo's bun.lock — 1.3.14 cannot read a
13
+ # lockfileVersion 2 lockfile and fails the install below outright)
14
+ # - omp @oh-my-pi/pi-coding-agent@18.1.14 (all @oh-my-pi/pi-*@18.1.14 in
15
+ # lockstep; the EXACT-pin shape is enforced below against
16
+ # packages/agent/package.json, and the frozen lockfile pins the version itself)
17
+ # - Python 3.10+ for the omp `eval` tool (the bun base is trixie: 3.13)
18
+ # - gh 2.97.0, built from source with Go (see the gh-build stage)
19
+ #
20
+ # Secrets bootstrap: the container receives ONE pointer env var,
21
+ # RUNTIME_SECRET_ID. At start, server.ts fetches that JSON secret from Secrets
22
+ # Manager, setdefaults each key into process env (explicit env wins), and logs
23
+ # ONLY the number of keys loaded — never key names or values.
24
+
25
+ # gh version is shared by the builder stage and the runtime stage.
26
+ # 2.71.0+ required: older gh hard-fails `gh pr view/create` under App
27
+ # installation tokens after GitHub removed classic-Projects projectCards
28
+ # (cli/cli#10714) — the error text mimics a permissions failure.
29
+ ARG GH_VERSION=2.97.0
30
+
31
+ # GLOBAL build args: an ARG consumed by a `FROM` must be declared BEFORE the
32
+ # first stage. Declared after one, it belongs to that stage's scope and
33
+ # resolves to the empty string ("base name should not be blank").
34
+ #
35
+ # BUN_IMAGE defaults to Docker Hub so a plain local `docker build` works with
36
+ # no ECR access; a deploy pipeline can override it with a mirrored tag to stay
37
+ # clear of Docker Hub's anonymous rate limit.
38
+ ARG BUN_IMAGE=oven/bun:1.4.0-slim
39
+ # GH_IMAGE: where the gh BINARY comes from. The default names the `gh-source`
40
+ # stage below, so a local build compiles it; an override points at a prebuilt
41
+ # image, and under BuildKit an unreferenced stage never runs — which takes the
42
+ # clone and the Go module download out of the build entirely.
43
+ ARG GH_IMAGE=gh-source
44
+
45
+ # --- gh builder -------------------------------------------------------------
46
+ # gh is BUILT, not downloaded: image scanners read the Go toolchain and module
47
+ # versions embedded in the binary, and upstream's release artifact carries
48
+ # whatever versions it was built with — only a rebuild can move those.
49
+ # CGO is off and GOARCH comes from TARGETARCH, so the output is a static
50
+ # binary for the target arch. Pulled from AWS's mirror of the Docker official
51
+ # images rather than Docker Hub (no anonymous rate limit, no credentials).
52
+ #
53
+ # `--platform=$BUILDPLATFORM` pins the toolchain to the *builder's* own
54
+ # architecture so the Go compile runs natively and cross-compiles via GOARCH,
55
+ # instead of running under emulation on an x86 host. BUILDPLATFORM (like
56
+ # TARGETARCH below) requires BuildKit, which Docker Desktop and the GitHub
57
+ # runners use by default.
58
+ FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26.6-trixie AS gh-build
59
+ ARG GH_VERSION
60
+ # Auto-populated by buildx; the default keeps a plain `docker build` working.
61
+ ARG TARGETARCH=arm64
62
+ RUN git clone --depth=1 --branch "v${GH_VERSION}" https://github.com/cli/cli.git /src
63
+ WORKDIR /src
64
+ # x/mod v0.40.0 is the fix line; it also carries x/crypto, x/net, x/text and
65
+ # x/tools forward. grpc v1.83.2 clears CVE-2026-84304 (unauthenticated remote
66
+ # OOM via fragmented HTTP/2 DATA frames). `tidy` reconciles go.sum.
67
+ RUN go get golang.org/x/mod@v0.40.0 google.golang.org/grpc@v1.83.2 \
68
+ && go mod tidy
69
+ # VCS stamping stays ON: editing go.mod makes the tree dirty, so the module
70
+ # records as `v2.97.0+dirty` — mildly ugly, but it keeps the version in the
71
+ # binary's build info. `-buildvcs=false` would record `(devel)` and hide any
72
+ # future cli/cli module CVE from scanners entirely.
73
+ RUN CGO_ENABLED=0 GOOS=linux GOARCH="${TARGETARCH}" go build -trimpath \
74
+ -ldflags "-s -w -X github.com/cli/cli/v2/internal/build.Version=v${GH_VERSION}" \
75
+ -o /out/gh ./cmd/gh
76
+
77
+ # The publishable shape of the above: JUST the static binary, on scratch, so a
78
+ # mirror image and the local fallback are the same artifact built by the same
79
+ # instructions.
80
+ FROM scratch AS gh-source
81
+ COPY --from=gh-build /out/gh /out/gh
82
+
83
+ # --- AWS CLI builder --------------------------------------------------------
84
+ # AWS CLI v2 from Amazon's official installer, in its own stage so the zip,
85
+ # unzip and the installer's scratch files never reach the runtime layer.
86
+ # v2 (not Debian's `awscli`, which is the Python v1 line) because the profile
87
+ # below uses `credential_process`, and because v2 is the CLI every AWS doc and
88
+ # every skill will assume. Installed size is ~230 MB; see
89
+ # docs/capabilities/aws-readonly.md for the trade-off.
90
+ FROM ${BUN_IMAGE} AS awscli-build
91
+ ARG TARGETARCH=arm64
92
+ RUN export DEBIAN_FRONTEND=noninteractive \
93
+ && apt-get update \
94
+ && apt-get install -y --no-install-recommends curl unzip ca-certificates \
95
+ && case "${TARGETARCH}" in \
96
+ arm64) AWS_ARCH=aarch64 ;; \
97
+ amd64) AWS_ARCH=x86_64 ;; \
98
+ *) echo "unsupported TARGETARCH ${TARGETARCH}" && exit 1 ;; \
99
+ esac \
100
+ && curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${AWS_ARCH}.zip" -o /tmp/awscliv2.zip \
101
+ && unzip -q /tmp/awscliv2.zip -d /tmp \
102
+ && /tmp/aws/install --install-dir /opt/aws-cli --bin-dir /opt/aws-cli/bin \
103
+ && rm -rf /tmp/aws /tmp/awscliv2.zip /var/lib/apt/lists/*
104
+
105
+ # Resolve whichever of the two GH_IMAGE names. `COPY --from=gh-provider`
106
+ # below is what makes the choice load-bearing.
107
+ FROM ${GH_IMAGE} AS gh-provider
108
+
109
+ # --- runtime ----------------------------------------------------------------
110
+ FROM ${BUN_IMAGE}
111
+
112
+ ARG FOUNDATION_GITHUB_APP_ID=0
113
+
114
+ # NOTE: AWS_EC2_METADATA_DISABLED must NOT be set container-wide: AgentCore
115
+ # vends the execution role's credentials via an IMDS-style metadata endpoint,
116
+ # and disabling it globally leaves the container with no credentials at all.
117
+ # AWS_EC2_METADATA_SERVICE_ENDPOINT: AgentCore serves those credentials on an
118
+ # IMDS-compatible endpoint but exposes none of the env signals omp's
119
+ # credential probe checks — without this pointer the omp Bedrock provider
120
+ # refuses with "No API key found". The real IMDS control is the tool policy
121
+ # denying 169.254.169.254, not an env var a raw curl could ignore.
122
+ ENV AWS_EC2_METADATA_SERVICE_ENDPOINT=http://169.254.169.254 \
123
+ NODE_ENV=production \
124
+ PYTHONDONTWRITEBYTECODE=1 \
125
+ PYTHONUNBUFFERED=1 \
126
+ # omp env sheet: relocate the agent base to the baked container dir
127
+ # (models.yml/materialized config land where omp's registry reads them;
128
+ # ambient files like history.db stay off ~) and pin the fail-hard
129
+ # security overlay.
130
+ PI_CODING_AGENT_DIR=/opt/sky/omp-agent \
131
+ PI_CONFIG_FILES=/opt/sky/omp-agent/security-overlay.yml \
132
+ PI_NO_TITLE=1 \
133
+ PI_NO_PTY=1
134
+
135
+ # Python for the omp eval tool (kernelMode "per-call"). The bun base is trixie
136
+ # (Debian 13), which ships 3.13 — over the 3.10+ floor. ca-certificates for
137
+ # TLS to Slack. curl: the git credential helper and the gh wrapper talk to the
138
+ # loopback broker with it. poppler-utils supplies `pdftotext`, the ONLY way a
139
+ # PDF dropped in a thread becomes readable text (files.ts shells out to it
140
+ # rather than adding a PDF parser dependency); missing here, every PDF
141
+ # silently degrades to a path the model cannot open.
142
+ #
143
+ # `upgrade` (not just `install`) is what picks up Debian security patches for
144
+ # packages the base image baked in — without it, glibc/systemd/libcap2/liblzma5
145
+ # stay at whatever oven/bun published. Naming the --only-upgrade set also
146
+ # changes this layer's hash, so a rebuild re-fetches patches rather than
147
+ # reusing a stale cached layer.
148
+ RUN export DEBIAN_FRONTEND=noninteractive \
149
+ && apt-get update \
150
+ && apt-get upgrade -y \
151
+ && apt-get install -y --only-upgrade \
152
+ libc6 libc-bin libsystemd0 libudev1 libcap2 liblzma5 sed \
153
+ && apt-get install -y --no-install-recommends \
154
+ python3 python3-venv python3-pip ca-certificates git curl \
155
+ poppler-utils \
156
+ && ln -sf /usr/bin/python3 /usr/local/bin/python \
157
+ && rm -rf /var/lib/apt/lists/*
158
+
159
+ # --- Ambient GitHub (github-broker.ts) --------------------------------------
160
+ # gh CLI as /opt/sky/bin/gh-real (from the gh-provider stage above), fronted
161
+ # by a wrapper that injects the broker's short-lived token as GH_TOKEN. git
162
+ # authenticates through the `sky` credential helper wired in /etc/gitconfig,
163
+ # so plain `git clone` and `gh pr create` work in the sandbox with no
164
+ # model-facing credential tools.
165
+ RUN mkdir -p /opt/sky/bin
166
+ COPY --from=gh-provider /out/gh /opt/sky/bin/gh-real
167
+ RUN /opt/sky/bin/gh-real --version
168
+ COPY packages/infra/agent-image/bin/git-credential-sky \
169
+ packages/infra/agent-image/bin/gh \
170
+ packages/infra/agent-image/bin/aws \
171
+ /opt/sky/bin/
172
+ # System-level gitconfig: github.com credentials come from the helper (git
173
+ # resolves `helper = sky` to git-credential-sky on PATH). useHttpPath makes
174
+ # git send the repo path in the credential request, so the helper sees which
175
+ # repo is being authenticated. Commits attribute to the App bot identity:
176
+ # <app-id>+<app-slug>[bot] is what links a commit to the GitHub App.
177
+ RUN chmod 0755 /opt/sky/bin/git-credential-sky /opt/sky/bin/gh /opt/sky/bin/aws /opt/sky/bin/gh-real \
178
+ && printf '[credential "https://github.com"]\n\thelper = sky\n\tuseHttpPath = true\n[user]\n\tname = sky[bot]\n\temail = %s+sky[bot]@users.noreply.github.com\n' "${FOUNDATION_GITHUB_APP_ID}" > /etc/gitconfig
179
+ # --- Ambient AWS (aws-broker.ts) --------------------------------------------
180
+ # AWS CLI v2 as /opt/sky/bin/aws-real, fronted by the wrapper copied above.
181
+ # /opt/sky/aws is where server.ts WRITES the generated profile file at boot,
182
+ # one profile per configured read-only role; it is created here (owned by the
183
+ # runtime user) because the container cannot mkdir under a root-owned /opt.
184
+ COPY --from=awscli-build /opt/aws-cli /opt/aws-cli
185
+ RUN ln -s /opt/aws-cli/v2/current/bin/aws /opt/sky/bin/aws-real \
186
+ && /opt/sky/bin/aws-real --version \
187
+ && mkdir -p /opt/sky/aws
188
+
189
+ # /opt/sky/bin FIRST so `gh` and `aws` resolve to the wrappers, never the real
190
+ # binaries.
191
+ ENV PATH="/opt/sky/bin:${PATH}"
192
+
193
+ # Non-root runtime user.
194
+ RUN groupadd --gid 10001 sky \
195
+ && useradd --uid 10001 --gid 10001 --create-home --shell /usr/sbin/nologin sky
196
+
197
+ WORKDIR /app
198
+
199
+ # Workspace install with the frozen lockfile (the build context is the repo
200
+ # root). Workspaces the agent does not run are copied as their MANIFEST ONLY:
201
+ # `bun install --frozen-lockfile` fails with `Workspace not found` if the
202
+ # directory is absent, and this satisfies resolution without shipping source.
203
+ COPY package.json bun.lock bunfig.toml tsconfig.json ./
204
+ # Every workspace the agent actually runs, in full. `bun install
205
+ # --frozen-lockfile` fails with `Workspace not found` if a workspace the
206
+ # lockfile names is absent, so the ones this image does NOT run are copied as
207
+ # their MANIFEST ONLY: enough to satisfy resolution, without shipping source.
208
+ COPY packages/core/ ./packages/core/
209
+ COPY packages/connectors/ ./packages/connectors/
210
+ COPY packages/runtime/ ./packages/runtime/
211
+ COPY packages/capability-otter/ ./packages/capability-otter/
212
+ COPY packages/capability-knock/ ./packages/capability-knock/
213
+ COPY packages/capability-browser/ ./packages/capability-browser/
214
+ COPY packages/capability-email/ ./packages/capability-email/
215
+ COPY packages/capability-crm/ ./packages/capability-crm/
216
+ COPY packages/capability-upwork/ ./packages/capability-upwork/
217
+ COPY packages/agent/ ./packages/agent/
218
+ # Manifest only: the gateway and every proxy are Lambdas, the newsletter is a
219
+ # Lambda service, and the infra package is the CDK app a pipeline runs. None of
220
+ # them executes in this container.
221
+ COPY packages/testing/package.json ./packages/testing/package.json
222
+ COPY packages/gateway/package.json ./packages/gateway/package.json
223
+ COPY packages/newsletter/package.json ./packages/newsletter/package.json
224
+ COPY packages/infra/package.json ./packages/infra/package.json
225
+
226
+ # Enforce the pin's SHAPE before installing — an EXACT version, no ^/~ range.
227
+ # Deliberately not a literal version: asserting one breaks the image build the
228
+ # first time the pin is legitimately bumped, and package.json plus
229
+ # --frozen-lockfile already pin the version itself.
230
+ RUN grep -qE '"@oh-my-pi/pi-coding-agent": "[0-9]+\.[0-9]+\.[0-9]+"' packages/agent/package.json \
231
+ || (echo "FATAL: packages/agent must pin @oh-my-pi/pi-coding-agent to an EXACT version (no ^/~)" && exit 1)
232
+ RUN bun install --frozen-lockfile --production
233
+
234
+ # The microVM's writable workspace (repo clones, and the memory/skills
235
+ # materialization target /workspace/.omp/).
236
+ RUN mkdir -p /workspace && chown sky:sky /workspace
237
+
238
+ # Baked omp agent base: the fail-hard security overlay, an INTENTIONAL empty
239
+ # AGENTS.md (shadows any ambient user-level context file), and ownership for
240
+ # the runtime user — the materializer writes models.yml here at session build
241
+ # and would EACCES on an un-created, root-owned /opt path.
242
+ COPY packages/infra/agent-image/security-overlay.yml /opt/sky/omp-agent/security-overlay.yml
243
+ RUN touch /opt/sky/omp-agent/AGENTS.md \
244
+ && chown -R sky:sky /opt/sky/omp-agent /opt/sky/aws
245
+
246
+ USER sky:sky
247
+
248
+ EXPOSE 8080
249
+
250
+ # Entrypoint contract (packages/agent): server.ts starts the :8080
251
+ # /invocations + /ping server, runs the RUNTIME_SECRET_ID bootstrap (key COUNT
252
+ # logged only), and dispatches handleAgentInvocation — including
253
+ # {"_smoke_test": true}.
254
+ CMD ["bun", "run", "packages/agent/src/server.ts"]
@@ -0,0 +1,36 @@
1
+ #!/bin/sh
2
+ # aws wrapper for the ambient AWS path (aws-broker.ts).
3
+ #
4
+ # Defaults AWS_PROFILE to `self` for THIS CHILD ONLY and execs the real CLI.
5
+ # The profile lives in the generated /opt/sky/aws/config, where its
6
+ # `credential_process` fetches short-lived credentials for an assumed
7
+ # READ-ONLY role from the loopback broker. `aws --profile <label>` reaches
8
+ # any other account the instance has been given a role in.
9
+ #
10
+ # WHY A WRAPPER, and not `ENV AWS_PROFILE=self` in the Dockerfile: the agent
11
+ # process shares its environment with every subprocess, and its OWN AWS SDK
12
+ # clients (DynamoDB, S3, Secrets Manager, SQS) resolve credentials from that
13
+ # same environment. A global AWS_PROFILE would route those WRITES through a
14
+ # read-only role — and route the broker's own AssumeRole call back into the
15
+ # broker. AWS_CONFIG_FILE is exported globally instead, which is safe because
16
+ # the generated file declares no `[default]` profile: the SDK's ini step
17
+ # finds nothing there and falls through to the execution role, as before.
18
+ #
19
+ # An explicit AWS_PROFILE in the environment wins (never overridden), so
20
+ # `AWS_PROFILE=client-acme aws s3 ls` works too.
21
+ #
22
+ # This instance has no read-only role at all: the `aws-readonly` capability is off,
23
+ # or no role is configured. Say so once, plainly, instead of letting every
24
+ # command fail as an obscure credential error.
25
+ if [ "${FOUNDATION_AWS_DISABLED:-}" = "1" ]; then
26
+ echo "AWS is not connected on this instance." >&2
27
+ exit 1
28
+ fi
29
+
30
+ # Overridable ONLY so the wrapper can be tested against a stub CLI;
31
+ # production always uses the image's real binary.
32
+ AWS_REAL="${FOUNDATION_AWS_REAL:-/opt/sky/bin/aws-real}"
33
+
34
+ AWS_PROFILE="${AWS_PROFILE:-self}" \
35
+ AWS_CONFIG_FILE="${AWS_CONFIG_FILE:-/opt/sky/aws/config}" \
36
+ exec "$AWS_REAL" "$@"
@@ -0,0 +1,193 @@
1
+ #!/bin/sh
2
+ # gh wrapper for the ambient GitHub path (github-broker.ts).
3
+ #
4
+ # Fetches a fresh short-lived installation token from the loopback broker
5
+ # and execs the real gh with GH_TOKEN set, so `gh pr create`, `gh pr checks`,
6
+ # `gh run view`, `gh api`, etc. authenticate as the channel's GitHub App.
7
+ # The broker responds with two lines (token, then UNIX-epoch expiry) — the
8
+ # wrapper takes line 1 only; per-invocation fetch means expiry never
9
+ # matters here. GH_TOKEN lives ONLY in the child's env (the server scrubs
10
+ # any inherited GitHub tokens at start). Without a broker (channel without
11
+ # the github connector) gh runs unauthenticated — public reads still work,
12
+ # writes fail loudly.
13
+ #
14
+ # REPO SCOPING: when the repository in play is knowable — an explicit GH_REPO,
15
+ # else the checkout's github.com origin, else (fallbacks, 2026-08-30) a
16
+ # `-R`/`--repo` flag or the `repos/<owner>/<name>/...` prefix of a `gh api`
17
+ # endpoint — it is passed to the broker as `repo=owner/name` so the minted
18
+ # token covers that repo ONLY, not every repo the channel can reach. A
19
+ # repo-less READ (`gh api /orgs/...`, `gh auth status`) sends nothing and
20
+ # gets the channel-wide token, as before.
21
+ #
22
+ # REPO-LESS WRITES are REFUSED (2026-08-30): a write served the channel-wide
23
+ # token could touch every granted repo, not just the one in play. Only this
24
+ # wrapper sees the verb — the broker sees a credential request — so the
25
+ # wrapper classifies the invocation (`gh api` with a mutating -X/--method,
26
+ # or body fields which imply POST; or a mutating subcommand) and, when no
27
+ # repository could be resolved, declares it to the broker (`write=1`, which
28
+ # refuses and logs it) and exits with a "set GH_REPO" error instead of
29
+ # running the command. NOTE: `gh api graphql` counts as a write (it is a
30
+ # POST and can mutate) — set GH_REPO to use it.
31
+ #
32
+ # An explicit GH_TOKEN in the environment wins (never overridden).
33
+
34
+ # Overridable ONLY so the refusal/scoping paths can be tested against a stub
35
+ # broker and a stub gh binary (dev-runner has the same seam); production
36
+ # always uses the loopback default and the image's real gh.
37
+ # This instance has no GitHub at all: the `github` capability is off, or no GitHub
38
+ # App secret is configured. Say so once, plainly, instead of letting every
39
+ # command fail as an obscure auth error.
40
+ if [ "${FOUNDATION_GITHUB_DISABLED:-}" = "1" ]; then
41
+ echo "GitHub is not connected on this instance." >&2
42
+ exit 1
43
+ fi
44
+
45
+ BROKER_URL="${FOUNDATION_GH_BROKER_URL:-http://127.0.0.1:7791/git-credential}"
46
+ GH_REAL="${FOUNDATION_GH_REAL:-/opt/sky/bin/gh-real}"
47
+
48
+ # First positional token of a `gh api` invocation ($1 is "api"): the
49
+ # endpoint. Flags that take a value are skipped WITH their value so an
50
+ # argument like `-f state=closed` is never mistaken for the endpoint.
51
+ api_endpoint() {
52
+ shift # the leading "api"
53
+ while [ $# -gt 0 ]; do
54
+ case "$1" in
55
+ -X|--method|-f|-F|--field|--raw-field|-H|--header|-q|--jq|-t|--template|--input|--cache|--hostname|-p|--preview)
56
+ shift
57
+ [ $# -gt 0 ] && shift
58
+ ;;
59
+ -*) shift ;;
60
+ *) printf '%s' "$1"; return 0 ;;
61
+ esac
62
+ done
63
+ return 1
64
+ }
65
+
66
+ if [ -z "$GH_TOKEN" ] && [ -z "$GITHUB_TOKEN" ]; then
67
+ cmd="$1"
68
+
69
+ # --- classify write intent (only the wrapper can: the broker never sees
70
+ # the verb). Unknown/new subcommands classify as reads — the broker's
71
+ # grant intersection and GitHub's own permission checks still bound them.
72
+ is_write=""
73
+ if [ "$cmd" = "api" ]; then
74
+ method=""
75
+ has_body=""
76
+ want_method=""
77
+ for arg in "$@"; do
78
+ if [ -n "$want_method" ]; then method="$arg"; want_method=""; continue; fi
79
+ case "$arg" in
80
+ -X|--method) want_method=1 ;;
81
+ --method=*) method="${arg#--method=}" ;;
82
+ -X?*) method="${arg#-X}"; method="${method#=}" ;;
83
+ -f|-F|--field|--raw-field|--input|--field=*|--raw-field=*|--input=*|-f?*|-F?*) has_body=1 ;;
84
+ esac
85
+ done
86
+ if [ -n "$method" ]; then
87
+ case "$method" in
88
+ [Gg][Ee][Tt]|[Hh][Ee][Aa][Dd]) ;;
89
+ *) is_write=1 ;;
90
+ esac
91
+ elif [ -n "$has_body" ]; then
92
+ is_write=1 # gh api implies POST when body fields are supplied
93
+ fi
94
+ else
95
+ case "$cmd" in
96
+ ''|-*|auth|browse|search|status|config|alias|completion|help|version) ;;
97
+ *)
98
+ case "$2" in
99
+ create|delete|edit|merge|close|reopen|comment|review|ready|lock|unlock|transfer|develop|pin|unpin|upload|delete-asset|set|remove|rename|archive|unarchive|sync|enable|disable|cancel|rerun|run|dismiss|approve|update-branch|fork|link|unlink|mark-template|deploy) is_write=1 ;;
100
+ esac
101
+ ;;
102
+ esac
103
+ fi
104
+
105
+ # --- resolve the repository in play. GH_REPO, then the checkout's origin
106
+ # (both as before — a repo resolved yesterday is scoped identically today),
107
+ # then the fallbacks: -R/--repo, and a `repos/<owner>/<name>` api endpoint.
108
+ repo="$GH_REPO"
109
+ if [ -z "$repo" ]; then
110
+ origin="$(git config --get remote.origin.url 2>/dev/null)"
111
+ case "$origin" in
112
+ https://github.com/*) repo="${origin#https://github.com/}" ;;
113
+ git@github.com:*) repo="${origin#git@github.com:}" ;;
114
+ ssh://git@github.com/*) repo="${origin#ssh://git@github.com/}" ;;
115
+ esac
116
+ repo="${repo%.git}"
117
+ fi
118
+ if [ -z "$repo" ]; then
119
+ flag_repo=""
120
+ want_repo=""
121
+ for arg in "$@"; do
122
+ if [ -n "$want_repo" ]; then flag_repo="$arg"; want_repo=""; continue; fi
123
+ case "$arg" in
124
+ -R|--repo) want_repo=1 ;;
125
+ --repo=*) flag_repo="${arg#--repo=}" ;;
126
+ -R?*) flag_repo="${arg#-R}"; flag_repo="${flag_repo#=}" ;;
127
+ esac
128
+ done
129
+ case "$flag_repo" in
130
+ https://github.com/*) flag_repo="${flag_repo#https://github.com/}" ;;
131
+ github.com/*) flag_repo="${flag_repo#github.com/}" ;;
132
+ esac
133
+ flag_repo="${flag_repo%.git}"
134
+ case "$flag_repo" in
135
+ */*/*) ;; # HOST/OWNER/NAME on some other host — not brokered
136
+ */*) repo="$flag_repo" ;;
137
+ esac
138
+ fi
139
+ if [ -z "$repo" ] && [ "$cmd" = "api" ]; then
140
+ endpoint="$(api_endpoint "$@")"
141
+ endpoint="${endpoint%%\?*}"
142
+ endpoint="${endpoint#/}"
143
+ case "$endpoint" in
144
+ repos/*/*)
145
+ rest="${endpoint#repos/}"
146
+ owner="${rest%%/*}"
147
+ rest="${rest#*/}"
148
+ name="${rest%%/*}"
149
+ repo="$owner/$name"
150
+ ;;
151
+ esac
152
+ fi
153
+
154
+ query=""
155
+ case "$repo" in
156
+ '' | *[!A-Za-z0-9._/-]*) ;;
157
+ */*) query="?repo=$repo" ;;
158
+ esac
159
+
160
+ # FAIL CLOSED on a repo-less write: the channel-wide token must never back
161
+ # a mutation. The probe (`write=1`) lets the broker log and refuse it
162
+ # server-side; only connection-refused (curl rc=7: no broker = channel
163
+ # without the github connector) falls through to anonymous gh, whose
164
+ # writes fail at GitHub exactly as before.
165
+ if [ -n "$is_write" ] && [ -z "$query" ]; then
166
+ curl -fsS --max-time 25 --retry 2 --retry-connrefused "$BROKER_URL?write=1" >/dev/null 2>&1
167
+ if [ $? -ne 7 ]; then
168
+ echo "gh: refusing a GitHub write with no resolved repository — set GH_REPO=owner/name (or pass -R owner/name, or run inside the repository checkout) so the credential can be scoped to that one repo" >&2
169
+ exit 1
170
+ fi
171
+ exec "$GH_REAL" "$@"
172
+ fi
173
+
174
+ # FAIL CLOSED when the broker exists but misbehaves (2026-08-16): an
175
+ # unauthenticated fallthrough turns a broker hiccup into a misleading
176
+ # GitHub 404 ("PR not found") that models mistake for a missing PR or a
177
+ # sandbox limitation. Only connection-refused (curl rc=7: no broker =
178
+ # channel without the github connector) falls through to anonymous.
179
+ response="$(curl -fsS --max-time 25 --retry 2 --retry-connrefused "$BROKER_URL$query" 2>/dev/null)"
180
+ rc=$?
181
+ if [ $rc -eq 0 ]; then
182
+ token="$(printf '%s\n' "$response" | head -n 1)"
183
+ if [ -n "$token" ]; then
184
+ GH_TOKEN="$token" exec "$GH_REAL" "$@"
185
+ fi
186
+ echo "gh: credential broker returned an empty token — transient; retry shortly" >&2
187
+ exit 1
188
+ elif [ $rc -ne 7 ]; then
189
+ echo "gh: could not obtain GitHub credentials from broker (curl exit $rc) — transient; retry shortly" >&2
190
+ exit 1
191
+ fi
192
+ fi
193
+ exec "$GH_REAL" "$@"