@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.
- package/README.md +174 -0
- package/agent-image/Dockerfile +254 -0
- package/agent-image/bin/aws +36 -0
- package/agent-image/bin/gh +193 -0
- package/agent-image/bin/git-credential-sky +89 -0
- package/agent-image/security-overlay.yml +176 -0
- package/cdk.json +6 -0
- package/dist/bin/app.js +112 -0
- package/dist/bin/foundation-deploy.js +1906 -0
- package/dist/bin/release-account.js +154 -0
- package/dist/chunk-4aye5cee.js +2416 -0
- package/dist/chunk-9ddxyvq2.js +1455 -0
- package/dist/chunk-v7tz8g50.js +428 -0
- package/dist/src/index.js +88 -0
- package/package.json +38 -0
- package/pipeline/buildspec.yml +34 -0
- package/src/artifacts.ts +318 -0
- package/src/deploy/assets/github-app-manifest.yml +29 -0
- package/src/deploy/assets/slack-app-manifest.yml +95 -0
- package/src/deploy/aws.ts +265 -0
- package/src/deploy/cli.ts +212 -0
- package/src/deploy/config-sync.ts +93 -0
- package/src/deploy/config.ts +29 -0
- package/src/deploy/deploy.ts +566 -0
- package/src/deploy/endpoint.ts +242 -0
- package/src/deploy/github-app-create.ts +154 -0
- package/src/deploy/github-app-manifest.ts +53 -0
- package/src/deploy/image.ts +80 -0
- package/src/deploy/instance.ts +87 -0
- package/src/deploy/license-cache.ts +47 -0
- package/src/deploy/license.ts +272 -0
- package/src/deploy/paths.ts +65 -0
- package/src/deploy/post-deploy.ts +97 -0
- package/src/deploy/release.ts +282 -0
- package/src/deploy/runtime-secret.ts +241 -0
- package/src/deploy/setup.ts +393 -0
- package/src/deploy/sh.ts +74 -0
- package/src/deploy/slack-manifest.ts +112 -0
- package/src/deploy/stage-customization.ts +224 -0
- package/src/deploy/tracing.ts +243 -0
- package/src/deploy-permissions.ts +165 -0
- package/src/index.ts +60 -0
- package/src/lambda-bundle-context.ts +64 -0
- package/src/names.ts +170 -0
- package/src/release/kms.ts +86 -0
- package/src/release/manifest.ts +265 -0
- package/src/stacks/agent-stack.ts +938 -0
- package/src/stacks/api-stack.ts +1005 -0
- package/src/stacks/ci-stack.ts +96 -0
- package/src/stacks/data-stack.ts +446 -0
- package/src/stacks/network-stack.ts +282 -0
- package/src/stacks/newsletter-stack.ts +572 -0
- package/src/stacks/pipeline-stack.ts +242 -0
- package/src/stacks/release-account-stack.ts +229 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploying a published release rather than this checkout.
|
|
3
|
+
*
|
|
4
|
+
* A customer's pipeline runs one line — `npx @deployfoundation/foundation-deploy@X.Y.Z
|
|
5
|
+
* deploy --instance .foundation/instance.yaml` — and that pinned version *is*
|
|
6
|
+
* the release. Nothing is built: the manifest is fetched from Foundation's
|
|
7
|
+
* release bucket, its KMS signature and every artifact digest are checked, and
|
|
8
|
+
* only then is the CDK app invoked with `-c releaseManifest=…`.
|
|
9
|
+
*
|
|
10
|
+
* Which mode a run is in is not a flag a pipeline has to remember:
|
|
11
|
+
*
|
|
12
|
+
* - `--release <version>` or `--manifest <path|s3://…>` — explicit.
|
|
13
|
+
* - neither, and this install has no Foundation workspace to build from
|
|
14
|
+
* (the published npm package) — the release matching the tool's own
|
|
15
|
+
* version.
|
|
16
|
+
* - neither, in a Foundation checkout — the local build, unchanged.
|
|
17
|
+
*
|
|
18
|
+
* The verification lives in `src/release/manifest.ts` and is pure; this module
|
|
19
|
+
* is the part that talks to S3 and KMS.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
22
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
23
|
+
import { RELEASE_BUCKET_ENV, releaseCacheDir } from "../artifacts.ts";
|
|
24
|
+
import { kmsVerifier } from "../release/kms.ts";
|
|
25
|
+
import {
|
|
26
|
+
type KmsVerifier,
|
|
27
|
+
RELEASE_VERSION_RE,
|
|
28
|
+
type ReleaseManifest,
|
|
29
|
+
manifestKey,
|
|
30
|
+
verifyManifest,
|
|
31
|
+
} from "../release/manifest.ts";
|
|
32
|
+
import { type AwsCallContext, aws } from "./aws.ts";
|
|
33
|
+
import { FOUNDATION_ROOT, INFRA_ROOT } from "./paths.ts";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The bucket a release is fetched from when nothing says otherwise.
|
|
37
|
+
*
|
|
38
|
+
* One name, owned by Foundry 41, created by `release-account-stack.ts`. A
|
|
39
|
+
* mirror is `--release-bucket` or `FOUNDATION_RELEASE_BUCKET`, and the
|
|
40
|
+
* signature is what makes a mirror safe to use.
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_RELEASE_BUCKET = "foundry41-foundation-releases";
|
|
43
|
+
|
|
44
|
+
/** The env var that names the key a deployer will accept a manifest from. */
|
|
45
|
+
export const RELEASE_KMS_KEY_ARN_ENV = "FOUNDATION_RELEASE_KMS_KEY_ARN";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The Foundation release signing key, in the Foundry 41 release account
|
|
49
|
+
* (793593623536), created by `ReleaseAccountStack` on 2026-09-20.
|
|
50
|
+
*
|
|
51
|
+
* This constant is what makes signature verification mean anything: a
|
|
52
|
+
* deployer refuses a manifest signed by any other key. Changing it rotates
|
|
53
|
+
* the root of trust for every customer, so it changes only with a key
|
|
54
|
+
* rotation and a release that says so. {@link RELEASE_KMS_KEY_ARN_ENV}
|
|
55
|
+
* overrides it, for a private mirror or a test key.
|
|
56
|
+
*/
|
|
57
|
+
export const RELEASE_KMS_KEY_ARN: string | undefined =
|
|
58
|
+
"arn:aws:kms:us-east-1:793593623536:key/fba7d9f3-e9b6-49ae-b4a5-c8c5f1429231";
|
|
59
|
+
|
|
60
|
+
/** What the caller asked for, before anything has been fetched. */
|
|
61
|
+
export interface ReleaseRequest {
|
|
62
|
+
/** `vX.Y.Z`, when the caller named a version or one was implied. */
|
|
63
|
+
version?: string;
|
|
64
|
+
/** `--manifest`: a local path or an `s3://bucket/key` URI. */
|
|
65
|
+
manifestRef?: string;
|
|
66
|
+
bucket: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A release that has been fetched and verified (or, in a dry run, planned). */
|
|
70
|
+
export interface ResolvedRelease {
|
|
71
|
+
version: string;
|
|
72
|
+
bucket: string;
|
|
73
|
+
/** The local manifest file the CDK app is pointed at. */
|
|
74
|
+
manifestPath: string;
|
|
75
|
+
/** Present once verified; absent in a dry run, which fetches nothing. */
|
|
76
|
+
manifest?: ReleaseManifest;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function flag(args: readonly string[], name: string): string | undefined {
|
|
80
|
+
const index = args.indexOf(name);
|
|
81
|
+
const value = index === -1 ? undefined : args[index + 1];
|
|
82
|
+
if (value?.startsWith("-") === true)
|
|
83
|
+
throw new Error(`${name} needs a value, e.g. ${name} ${name === "--release" ? "v0.1.0" : "…"}`);
|
|
84
|
+
const inline = args.find((arg) => arg.startsWith(`${name}=`));
|
|
85
|
+
return value ?? (inline === undefined ? undefined : inline.slice(name.length + 1));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** This tool's own version, which is the release a published copy deploys. */
|
|
89
|
+
export function toolVersion(infraRoot: string = INFRA_ROOT): string {
|
|
90
|
+
const { version } = JSON.parse(readFileSync(join(infraRoot, "package.json"), "utf8")) as {
|
|
91
|
+
version: string;
|
|
92
|
+
};
|
|
93
|
+
return version;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Is there a Foundation workspace here to build Lambda bundles from?
|
|
98
|
+
*
|
|
99
|
+
* The published package carries the CDK app and the deploy tool, not the
|
|
100
|
+
* product's source. Its absence is what makes a bare `deploy` a release
|
|
101
|
+
* deploy, so no pipeline has to pass a flag that duplicates the version it
|
|
102
|
+
* already pinned in `npx`.
|
|
103
|
+
*/
|
|
104
|
+
export function hasFoundationWorkspace(foundationRoot: string = FOUNDATION_ROOT): boolean {
|
|
105
|
+
return existsSync(join(foundationRoot, "packages", "gateway", "package.json"));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ReleaseRequestOptions {
|
|
109
|
+
env?: Record<string, string | undefined>;
|
|
110
|
+
/** Overridable for tests. */
|
|
111
|
+
version?: string;
|
|
112
|
+
workspace?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* What release this command should deploy, or `undefined` for a local build.
|
|
117
|
+
*/
|
|
118
|
+
export function releaseRequest(
|
|
119
|
+
args: readonly string[],
|
|
120
|
+
options: ReleaseRequestOptions = {},
|
|
121
|
+
): ReleaseRequest | undefined {
|
|
122
|
+
const env = options.env ?? process.env;
|
|
123
|
+
const bucket =
|
|
124
|
+
flag(args, "--release-bucket") ?? env[RELEASE_BUCKET_ENV] ?? DEFAULT_RELEASE_BUCKET;
|
|
125
|
+
const manifestRef = flag(args, "--manifest");
|
|
126
|
+
const explicit = flag(args, "--release");
|
|
127
|
+
if (explicit !== undefined) {
|
|
128
|
+
if (!RELEASE_VERSION_RE.test(explicit))
|
|
129
|
+
throw new Error(`--release wants a version like v0.1.0, not "${explicit}"`);
|
|
130
|
+
return { version: explicit, bucket, ...(manifestRef === undefined ? {} : { manifestRef }) };
|
|
131
|
+
}
|
|
132
|
+
if (manifestRef !== undefined) return { manifestRef, bucket };
|
|
133
|
+
|
|
134
|
+
const workspace = options.workspace ?? hasFoundationWorkspace();
|
|
135
|
+
if (workspace) return undefined;
|
|
136
|
+
const version = `v${options.version ?? toolVersion()}`;
|
|
137
|
+
if (!RELEASE_VERSION_RE.test(version))
|
|
138
|
+
throw new Error(
|
|
139
|
+
`this copy of foundation-deploy is version ${version.slice(1)}, which is not a release; pass --release <version>`,
|
|
140
|
+
);
|
|
141
|
+
return { version, bucket };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The key ARN a deployer will accept a manifest from. */
|
|
145
|
+
export function expectedKeyArn(env: Record<string, string | undefined> = process.env): string {
|
|
146
|
+
const configured = env[RELEASE_KMS_KEY_ARN_ENV] ?? RELEASE_KMS_KEY_ARN;
|
|
147
|
+
if (configured === undefined || configured === "")
|
|
148
|
+
throw new Error(
|
|
149
|
+
`no Foundation release signing key is configured; set ${RELEASE_KMS_KEY_ARN_ENV} to the key ARN published with the release`,
|
|
150
|
+
);
|
|
151
|
+
return configured;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface ResolveReleaseDeps {
|
|
155
|
+
/** Defaults to the SDK-backed verifier for the expected key. */
|
|
156
|
+
kms?: KmsVerifier;
|
|
157
|
+
kmsKeyArn?: string;
|
|
158
|
+
/** Copies `s3://<bucket>/<key>` to a local path. Defaults to `aws s3 cp`. */
|
|
159
|
+
download?: (ctx: AwsCallContext, bucket: string, key: string, dest: string) => Promise<void>;
|
|
160
|
+
/** Where the verified manifest is cached for the CDK app. */
|
|
161
|
+
cacheDir?: string;
|
|
162
|
+
/**
|
|
163
|
+
* Check every artifact's digest, not only the signature. On by default:
|
|
164
|
+
* the signature says the list is Foundation's, the digests say the bytes
|
|
165
|
+
* the deploy will hand CloudFormation are the ones on the list.
|
|
166
|
+
*/
|
|
167
|
+
verifyArtifacts?: boolean;
|
|
168
|
+
env?: Record<string, string | undefined>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** `aws s3 cp` — the same CLI every other read in the deploy tool uses. */
|
|
172
|
+
export async function s3Download(
|
|
173
|
+
ctx: AwsCallContext,
|
|
174
|
+
bucket: string,
|
|
175
|
+
key: string,
|
|
176
|
+
dest: string,
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
179
|
+
await aws(ctx, ["s3", "cp", `s3://${bucket}/${key}`, dest]);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Fetch and verify the release a request names. Throws unless the manifest is
|
|
184
|
+
* signed by the expected key and every artifact hashes to what it claims.
|
|
185
|
+
*
|
|
186
|
+
* Nothing else in the deploy may run before this returns — that ordering is
|
|
187
|
+
* the whole point of a signed manifest, and `deploy.ts` is written so the
|
|
188
|
+
* image step, the CDK call and the config sync all sit after it.
|
|
189
|
+
*/
|
|
190
|
+
export async function verifyRelease(
|
|
191
|
+
ctx: AwsCallContext,
|
|
192
|
+
request: ReleaseRequest,
|
|
193
|
+
deps: ResolveReleaseDeps = {},
|
|
194
|
+
): Promise<ResolvedRelease> {
|
|
195
|
+
const env = deps.env ?? process.env;
|
|
196
|
+
const kmsKeyArn = deps.kmsKeyArn ?? expectedKeyArn(env);
|
|
197
|
+
const download = deps.download ?? s3Download;
|
|
198
|
+
const local = localManifestRef(request);
|
|
199
|
+
const version = request.version;
|
|
200
|
+
const cacheDir =
|
|
201
|
+
deps.cacheDir ?? releaseCacheDir(version ?? "unversioned", env as NodeJS.ProcessEnv);
|
|
202
|
+
const manifestPath = local ?? join(cacheDir, "manifest.json");
|
|
203
|
+
|
|
204
|
+
if (local === undefined) {
|
|
205
|
+
const source = s3ManifestRef(request);
|
|
206
|
+
await download(ctx, source.bucket, source.key, manifestPath);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const manifest = await verifyManifest(JSON.parse(readFileSync(manifestPath, "utf8")), {
|
|
210
|
+
kmsKeyArn,
|
|
211
|
+
kms: deps.kms ?? kmsVerifier(kmsKeyArn, ctx.profile === "" ? {} : { profile: ctx.profile }),
|
|
212
|
+
...(version === undefined ? {} : { expectVersion: version }),
|
|
213
|
+
...(deps.verifyArtifacts === false
|
|
214
|
+
? {}
|
|
215
|
+
: {
|
|
216
|
+
readArtifact: async (key: string) => {
|
|
217
|
+
const dest = join(cacheDir, "artifacts", key.split("/").slice(-1)[0] ?? "artifact");
|
|
218
|
+
await download(ctx, request.bucket, key, dest);
|
|
219
|
+
return readFileSync(dest);
|
|
220
|
+
},
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
return { version: manifest.version, bucket: request.bucket, manifestPath, manifest };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The release a command will deploy: verified for real, or — in a dry run,
|
|
228
|
+
* which has neither credentials nor any business downloading megabytes —
|
|
229
|
+
* planned.
|
|
230
|
+
*/
|
|
231
|
+
export async function resolveRelease(
|
|
232
|
+
ctx: AwsCallContext,
|
|
233
|
+
request: ReleaseRequest,
|
|
234
|
+
deps: ResolveReleaseDeps = {},
|
|
235
|
+
): Promise<ResolvedRelease> {
|
|
236
|
+
return ctx.dryRun === true ? plannedRelease(request) : verifyRelease(ctx, request, deps);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** What a dry run reports: the release it would verify, fetching nothing. */
|
|
240
|
+
export function plannedRelease(request: ReleaseRequest): ResolvedRelease {
|
|
241
|
+
const version = request.version ?? "<from the manifest>";
|
|
242
|
+
const local = localManifestRef(request);
|
|
243
|
+
return {
|
|
244
|
+
version,
|
|
245
|
+
bucket: request.bucket,
|
|
246
|
+
manifestPath: local ?? join(releaseCacheDir(version), "manifest.json"),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The manifest as a local path, when the caller gave one. */
|
|
251
|
+
function localManifestRef(request: ReleaseRequest): string | undefined {
|
|
252
|
+
const ref = request.manifestRef;
|
|
253
|
+
if (ref === undefined || ref.startsWith("s3://")) return undefined;
|
|
254
|
+
return isAbsolute(ref) ? ref : resolve(process.cwd(), ref);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Where the manifest is fetched from: an explicit `s3://…`, else the version's key. */
|
|
258
|
+
export function s3ManifestRef(request: ReleaseRequest): { bucket: string; key: string } {
|
|
259
|
+
const ref = request.manifestRef;
|
|
260
|
+
if (ref?.startsWith("s3://") === true) {
|
|
261
|
+
const [bucket, ...rest] = ref.slice("s3://".length).split("/");
|
|
262
|
+
const key = rest.join("/");
|
|
263
|
+
if (bucket === undefined || bucket === "" || key === "")
|
|
264
|
+
throw new Error(`--manifest ${ref} is not an s3://bucket/key URI`);
|
|
265
|
+
return { bucket, key };
|
|
266
|
+
}
|
|
267
|
+
if (request.version === undefined)
|
|
268
|
+
throw new Error("a release needs a version or a manifest to fetch");
|
|
269
|
+
return { bucket: request.bucket, key: manifestKey(request.version) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The `-c` context a release deploy adds to the CDK invocation. */
|
|
273
|
+
export function releaseContext(release: ResolvedRelease): string[] {
|
|
274
|
+
return [
|
|
275
|
+
"-c",
|
|
276
|
+
`release=${release.version}`,
|
|
277
|
+
"-c",
|
|
278
|
+
`releaseManifest=${release.manifestPath}`,
|
|
279
|
+
"-c",
|
|
280
|
+
`releaseBucket=${release.bucket}`,
|
|
281
|
+
];
|
|
282
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition of `<secretPrefix>/agent/runtime` — the one secret the container is told
|
|
3
|
+
* about (RUNTIME_SECRET_ID). server.ts fetches it at boot and setdefaults each
|
|
4
|
+
* key into the environment, so this object IS the runtime's env contract.
|
|
5
|
+
*
|
|
6
|
+
* The bot token lives here rather than being fetched separately so the agent
|
|
7
|
+
* needs exactly one Secrets Manager read at start.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
type Instance,
|
|
11
|
+
type InstanceNames,
|
|
12
|
+
instanceNames as namesFor,
|
|
13
|
+
slackCommandPrefix,
|
|
14
|
+
} from "@deployfoundation/foundation-core/instance";
|
|
15
|
+
|
|
16
|
+
/** The pinned AgentCore endpoint a deploy promotes and the invoker calls. */
|
|
17
|
+
export const LIVE_ENDPOINT = "live";
|
|
18
|
+
|
|
19
|
+
export interface SlackAppSecret {
|
|
20
|
+
bot_token: string;
|
|
21
|
+
bot_user_id: string;
|
|
22
|
+
team_id?: string;
|
|
23
|
+
api_app_id?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RuntimeSecretInputs {
|
|
27
|
+
/** The instance being deployed: every secret id and default below is its own. */
|
|
28
|
+
instance: Instance;
|
|
29
|
+
slackApp: SlackAppSecret;
|
|
30
|
+
tableName: string;
|
|
31
|
+
/** `FoundationData.ItemsTableName` — the pk+sk table holding todos and routines. */
|
|
32
|
+
itemsTableName?: string;
|
|
33
|
+
bucketName: string;
|
|
34
|
+
/**
|
|
35
|
+
* `FoundationData.DocumentsBucketName` — absent before the dedicated bucket has
|
|
36
|
+
* been deployed, in which case the documents capability has no binding.
|
|
37
|
+
*/
|
|
38
|
+
documentsBucketName?: string;
|
|
39
|
+
/** `FoundationAgent.WebSearchGatewayUrl` — omitted before the gateway exists. */
|
|
40
|
+
webSearchUrl?: string;
|
|
41
|
+
/** `FoundationApi.InvokeQueueUrl` — where a fired routine re-enqueues its turn. */
|
|
42
|
+
invokeQueueUrl?: string;
|
|
43
|
+
/** `FoundationApi.RoutineSchedulerRoleArn` — passed to EventBridge Scheduler. */
|
|
44
|
+
routineSchedulerRoleArn?: string;
|
|
45
|
+
/** `FoundationAgent.AgentRuntimeArn` — named by every schedule's target input. */
|
|
46
|
+
agentRuntimeArn?: string;
|
|
47
|
+
/**
|
|
48
|
+
* The pinned endpoint humans talk to. The container cannot learn it from
|
|
49
|
+
* inside AgentCore, and it is half the name of its own span log group
|
|
50
|
+
* (`<runtime id>-<endpoint>`), so the deploy tells it.
|
|
51
|
+
*/
|
|
52
|
+
agentEndpoint?: string;
|
|
53
|
+
/**
|
|
54
|
+
* `FoundationAgent.ReadOnlyRoleArn` — the instance's OWN account, expressed as a
|
|
55
|
+
* role the agent assumes. Absent on a stack deployed before it existed;
|
|
56
|
+
* `aws-readonly` then offers nothing unless `config.yaml` names another role.
|
|
57
|
+
*/
|
|
58
|
+
readOnlyRoleArn?: string;
|
|
59
|
+
/**
|
|
60
|
+
* `FoundationApi.EmailProxyFunctionArn` — the ONLY thing the container is told
|
|
61
|
+
* about email. No mailbox credential is ever in this secret, or reachable
|
|
62
|
+
* from the execution role: the agent can invoke the proxy and nothing more.
|
|
63
|
+
* Absent on a stack deployed before the proxy existed; the registry then
|
|
64
|
+
* offers no email tools.
|
|
65
|
+
*/
|
|
66
|
+
emailProxyFunctionArn?: string;
|
|
67
|
+
/**
|
|
68
|
+
* `FoundationApi.BrowserProxyFunctionArn`; absent when managed browser is disabled.
|
|
69
|
+
* The runtime receives no Browser API grant or signing secret.
|
|
70
|
+
*/
|
|
71
|
+
browserProxyFunctionArn?: string;
|
|
72
|
+
/**
|
|
73
|
+
* `FoundationApi.CrmProxyFunctionArn` — the sole CRM binding supplied to the
|
|
74
|
+
* shell-capable container. Storage remains reachable only through the proxy.
|
|
75
|
+
*/
|
|
76
|
+
crmProxyFunctionArn?: string;
|
|
77
|
+
/** Hash of the CRM policy baked into the deployed proxy; never a credential. */
|
|
78
|
+
crmPolicyFingerprint?: string;
|
|
79
|
+
/**
|
|
80
|
+
* `FoundationApi.OtterProxyFunctionArn` — the ONLY Otter binding given to the
|
|
81
|
+
* shell-capable container. The API credential remains readable solely by
|
|
82
|
+
* the proxy role. Absent deployments offer no Otter tools.
|
|
83
|
+
*/
|
|
84
|
+
otterProxyFunctionArn?: string;
|
|
85
|
+
/**
|
|
86
|
+
* `FoundationApi.KnockProxyFunctionArn` — the agent's sole Knock binding. OAuth
|
|
87
|
+
* client and credential secret ids never enter this runtime secret.
|
|
88
|
+
*/
|
|
89
|
+
knockProxyFunctionArn?: string;
|
|
90
|
+
/**
|
|
91
|
+
* `FoundationApi.UpworkProxyFunctionArn` — the sole Upwork binding supplied to the
|
|
92
|
+
* shell-capable container. OAuth client and token state stay in the proxy.
|
|
93
|
+
*/
|
|
94
|
+
upworkProxyFunctionArn?: string;
|
|
95
|
+
/** Trusted gateway ingress used instead of granting the agent normal-queue writes. */
|
|
96
|
+
routineIngressFunctionArn?: string;
|
|
97
|
+
defaultRepo?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function composeRuntimeSecret(inputs: RuntimeSecretInputs): Record<string, string> {
|
|
101
|
+
const { instance, slackApp, tableName, bucketName } = inputs;
|
|
102
|
+
const names: InstanceNames = namesFor(instance);
|
|
103
|
+
for (const [key, value] of [
|
|
104
|
+
["bot_token", slackApp.bot_token],
|
|
105
|
+
["bot_user_id", slackApp.bot_user_id],
|
|
106
|
+
["table name", tableName],
|
|
107
|
+
["bucket name", bucketName],
|
|
108
|
+
] as const) {
|
|
109
|
+
if (typeof value !== "string" || value === "")
|
|
110
|
+
throw new Error(`runtime secret: missing ${key}`);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
// Which deployment this container is. Logs and the message footer say so;
|
|
114
|
+
// nothing else branches on it.
|
|
115
|
+
FOUNDATION_INSTANCE: instance.name,
|
|
116
|
+
// The prefix this deployment's slash commands carry. A tool that tells
|
|
117
|
+
// someone to run a connect command has to name the command their own
|
|
118
|
+
// workspace installed, not the reference instance's `/sky-*` set.
|
|
119
|
+
FOUNDATION_COMMAND_PREFIX: slackCommandPrefix(instance),
|
|
120
|
+
FOUNDATION_SLACK_BOT_TOKEN: slackApp.bot_token,
|
|
121
|
+
FOUNDATION_SLACK_BOT_USER_ID: slackApp.bot_user_id,
|
|
122
|
+
FOUNDATION_GITHUB_APP_SECRET_ID: names.secretGithubApp,
|
|
123
|
+
FOUNDATION_CODEX_SECRET_ID: names.secretCodex,
|
|
124
|
+
// The image key. Always named: server.ts reads it lazily, so a secret
|
|
125
|
+
// still holding the CDK placeholder costs nothing until an image is asked for.
|
|
126
|
+
FOUNDATION_GOOGLE_AI_STUDIO_SECRET_ID: names.secretGoogleAiStudio,
|
|
127
|
+
// Drive service account JSON. Its `client_email` is the address users
|
|
128
|
+
// share folders/files with; the private key is fetched only on tool use.
|
|
129
|
+
FOUNDATION_GOOGLE_DRIVE_SECRET_ID: names.secretGoogleDrive,
|
|
130
|
+
// Calendar OAuth *client*. Per-person refresh tokens live in FoundationItems,
|
|
131
|
+
// never in a secret and never in the runtime environment.
|
|
132
|
+
FOUNDATION_GOOGLE_CALENDAR_SECRET_ID: names.secretGoogleCalendar,
|
|
133
|
+
// The shared OAuth client. Preferred over the one Calendar historically
|
|
134
|
+
// kept inside its own secret, which stays a fallback.
|
|
135
|
+
FOUNDATION_GOOGLE_OAUTH_SECRET_ID: names.secretGoogleOauth,
|
|
136
|
+
FOUNDATION_TABLE_NAME: tableName,
|
|
137
|
+
// Absent on a stack deployed before the table existed; server.ts then
|
|
138
|
+
// offers neither the todo nor the routine tools.
|
|
139
|
+
...(inputs.itemsTableName !== undefined && inputs.itemsTableName !== ""
|
|
140
|
+
? { FOUNDATION_ITEMS_TABLE_NAME: inputs.itemsTableName }
|
|
141
|
+
: {}),
|
|
142
|
+
FOUNDATION_BUCKET_NAME: bucketName,
|
|
143
|
+
// Deliberately absent until FoundationData has exported the dedicated bucket:
|
|
144
|
+
// configuration alone must not expose document tools without storage.
|
|
145
|
+
...(inputs.documentsBucketName !== undefined && inputs.documentsBucketName !== ""
|
|
146
|
+
? { FOUNDATION_DOCUMENTS_BUCKET_NAME: inputs.documentsBucketName }
|
|
147
|
+
: {}),
|
|
148
|
+
FOUNDATION_CONFIG_KEY: names.configKey,
|
|
149
|
+
FOUNDATION_DEFAULT_REPO: inputs.defaultRepo ?? names.defaultRepo,
|
|
150
|
+
// The org the instance's GitHub App is installed on. The prompt names it
|
|
151
|
+
// and `gh repo list` is scoped by it; nothing in the image hard-codes one.
|
|
152
|
+
FOUNDATION_GITHUB_ORG: instance.github.org,
|
|
153
|
+
// Workflow-file changes are executable code, so pass the narrow repo
|
|
154
|
+
// allowlist only when this instance explicitly opts in.
|
|
155
|
+
...(instance.github.workflowWriteRepos !== undefined &&
|
|
156
|
+
instance.github.workflowWriteRepos.length > 0
|
|
157
|
+
? {
|
|
158
|
+
FOUNDATION_GITHUB_WORKFLOW_WRITE_REPOS: JSON.stringify(
|
|
159
|
+
instance.github.workflowWriteRepos,
|
|
160
|
+
),
|
|
161
|
+
}
|
|
162
|
+
: {}),
|
|
163
|
+
// This instance's AgentCore runtime name. The commit author falls back to
|
|
164
|
+
// it while the GitHub App has no slug yet.
|
|
165
|
+
FOUNDATION_RUNTIME_NAME: names.runtimeName,
|
|
166
|
+
// The App's slug: the agent authors commits as `<slug>[bot]` so GitHub
|
|
167
|
+
// attributes them to the App. Absent until bootstrap has created the App.
|
|
168
|
+
...(instance.github.appSlug !== "TBD"
|
|
169
|
+
? { FOUNDATION_GITHUB_APP_SLUG: instance.github.appSlug }
|
|
170
|
+
: {}),
|
|
171
|
+
// Absent (rather than empty) when there is no gateway yet: server.ts
|
|
172
|
+
// treats a missing url as "no search_web tool".
|
|
173
|
+
...(inputs.webSearchUrl !== undefined && inputs.webSearchUrl !== ""
|
|
174
|
+
? { FOUNDATION_WEB_SEARCH_URL: inputs.webSearchUrl }
|
|
175
|
+
: {}),
|
|
176
|
+
// The routine trio: all three or none, and server.ts offers no
|
|
177
|
+
// `routine_*` tools until every one of them is present.
|
|
178
|
+
...(inputs.invokeQueueUrl !== undefined && inputs.invokeQueueUrl !== ""
|
|
179
|
+
? { FOUNDATION_INVOKE_QUEUE_URL: inputs.invokeQueueUrl }
|
|
180
|
+
: {}),
|
|
181
|
+
...(inputs.routineSchedulerRoleArn !== undefined && inputs.routineSchedulerRoleArn !== ""
|
|
182
|
+
? { FOUNDATION_ROUTINE_SCHEDULER_ROLE_ARN: inputs.routineSchedulerRoleArn }
|
|
183
|
+
: {}),
|
|
184
|
+
...(inputs.agentRuntimeArn !== undefined && inputs.agentRuntimeArn !== ""
|
|
185
|
+
? { FOUNDATION_AGENT_RUNTIME_ARN: inputs.agentRuntimeArn }
|
|
186
|
+
: {}),
|
|
187
|
+
// The EventBridge Scheduler group the runtime role is allowed to write
|
|
188
|
+
// (`<prefix>-routines`, api-stack RoutineGroup). Derived, never optional:
|
|
189
|
+
// without it the agent fell back to a hard-coded group name, which only
|
|
190
|
+
// one instance's role may touch, and every other instance logged
|
|
191
|
+
// AccessDenied on CreateSchedule (2026-09-07).
|
|
192
|
+
FOUNDATION_ROUTINE_GROUP: names.routineGroup,
|
|
193
|
+
// Always `live` today: every deploy promotes that one endpoint, and it is
|
|
194
|
+
// what the span log group is named after.
|
|
195
|
+
FOUNDATION_AGENT_ENDPOINT:
|
|
196
|
+
inputs.agentEndpoint !== undefined && inputs.agentEndpoint !== ""
|
|
197
|
+
? inputs.agentEndpoint
|
|
198
|
+
: LIVE_ENDPOINT,
|
|
199
|
+
...(inputs.readOnlyRoleArn !== undefined && inputs.readOnlyRoleArn !== ""
|
|
200
|
+
? { FOUNDATION_AWS_READONLY_ROLE_ARN: inputs.readOnlyRoleArn }
|
|
201
|
+
: {}),
|
|
202
|
+
...(inputs.emailProxyFunctionArn !== undefined && inputs.emailProxyFunctionArn !== ""
|
|
203
|
+
? { FOUNDATION_EMAIL_PROXY_FUNCTION_ARN: inputs.emailProxyFunctionArn }
|
|
204
|
+
: {}),
|
|
205
|
+
...(inputs.browserProxyFunctionArn !== undefined && inputs.browserProxyFunctionArn !== ""
|
|
206
|
+
? { FOUNDATION_BROWSER_PROXY_FUNCTION_ARN: inputs.browserProxyFunctionArn }
|
|
207
|
+
: {}),
|
|
208
|
+
...(inputs.crmProxyFunctionArn !== undefined && inputs.crmProxyFunctionArn !== ""
|
|
209
|
+
? { FOUNDATION_CRM_PROXY_FUNCTION_ARN: inputs.crmProxyFunctionArn }
|
|
210
|
+
: {}),
|
|
211
|
+
...(inputs.crmPolicyFingerprint !== undefined && inputs.crmPolicyFingerprint !== ""
|
|
212
|
+
? { FOUNDATION_CRM_POLICY_FINGERPRINT: inputs.crmPolicyFingerprint }
|
|
213
|
+
: {}),
|
|
214
|
+
...(inputs.otterProxyFunctionArn !== undefined && inputs.otterProxyFunctionArn !== ""
|
|
215
|
+
? { FOUNDATION_OTTER_PROXY_FUNCTION_ARN: inputs.otterProxyFunctionArn }
|
|
216
|
+
: {}),
|
|
217
|
+
...(inputs.knockProxyFunctionArn !== undefined && inputs.knockProxyFunctionArn !== ""
|
|
218
|
+
? { FOUNDATION_KNOCK_PROXY_FUNCTION_ARN: inputs.knockProxyFunctionArn }
|
|
219
|
+
: {}),
|
|
220
|
+
...(inputs.upworkProxyFunctionArn !== undefined && inputs.upworkProxyFunctionArn !== ""
|
|
221
|
+
? { FOUNDATION_UPWORK_PROXY_ARN: inputs.upworkProxyFunctionArn }
|
|
222
|
+
: {}),
|
|
223
|
+
...(inputs.routineIngressFunctionArn !== undefined && inputs.routineIngressFunctionArn !== ""
|
|
224
|
+
? { FOUNDATION_ROUTINE_INGRESS_FUNCTION_ARN: inputs.routineIngressFunctionArn }
|
|
225
|
+
: {}),
|
|
226
|
+
// The read-only Atlas credential. Always named: server.ts reads it
|
|
227
|
+
// LAZILY, so a secret still holding the CDK placeholder costs nothing
|
|
228
|
+
// until someone asks a MongoDB question.
|
|
229
|
+
FOUNDATION_MONGODB_SECRET_ID: names.secretMongodbReadonly,
|
|
230
|
+
// CRM storage, the Otter credential, the Knock OAuth secrets, and the Upwork
|
|
231
|
+
// OAuth secret are intentionally absent here: the shell-capable agent receives
|
|
232
|
+
// only their fixed proxy ARNs.
|
|
233
|
+
// A routine fire builds its own session key, so the container needs the
|
|
234
|
+
// team id the gateway takes from the event. The app secret carries it
|
|
235
|
+
// when setup wrote one; the instance file is the fallback.
|
|
236
|
+
FOUNDATION_SLACK_TEAM_ID:
|
|
237
|
+
slackApp.team_id !== undefined && slackApp.team_id !== ""
|
|
238
|
+
? slackApp.team_id
|
|
239
|
+
: instance.slack.teamId,
|
|
240
|
+
};
|
|
241
|
+
}
|