@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
@@ -0,0 +1,96 @@
1
+ import * as cdk from "aws-cdk-lib";
2
+ import * as iam from "aws-cdk-lib/aws-iam";
3
+ import type * as kms from "aws-cdk-lib/aws-kms";
4
+ import type * as s3 from "aws-cdk-lib/aws-s3";
5
+ import type { Construct } from "constructs";
6
+ import { deployStatements } from "../deploy-permissions.ts";
7
+ import { type Instance, namesFor } from "../names.ts";
8
+
9
+ export interface FoundationCiProps extends cdk.StackProps {
10
+ /** The deployment this stack belongs to; every name below comes from it. */
11
+ instance: Instance;
12
+ /** The data stack's bucket — config and skills are synced into it by the deploy. */
13
+ bucket: s3.IBucket;
14
+ /** The CMK that bucket is encrypted with. */
15
+ dataKey: kms.IKey;
16
+ /** `owner/repo` allowed to assume the deploy role. */
17
+ repository?: string;
18
+ /**
19
+ * GitHub's numeric owner and repository ids. GitHub now issues the
20
+ * rename-proof subject `repo:<owner>@<ownerId>/<repo>@<repoId>:ref:…` by
21
+ * default; without these the trust policy never matches.
22
+ */
23
+ repositoryIds?: { owner: number; repo: number };
24
+ }
25
+
26
+ /**
27
+ * FoundationCi — the GitHub OIDC trust for the deploy job in the instance
28
+ * repository's own workflow. One per instance: each has its own repo, its own
29
+ * account and its own deploy role.
30
+ *
31
+ * No long-lived AWS keys live in GitHub: Actions presents its short-lived
32
+ * OIDC token and assumes the instance's deploy role, whose trust policy pins both the
33
+ * audience and the exact `sub` — `repo:<owner>/<repo>:ref:refs/heads/main`.
34
+ * Pull requests carry a different `sub` (`…:pull_request`) and so cannot
35
+ * assume it; that is what keeps a fork's PR from deploying.
36
+ *
37
+ * The permissions live in `deploy-permissions.ts`, shared with the CodeBuild
38
+ * role in `pipeline-stack.ts`: both run the same deploy scripts, and two
39
+ * copies of that policy would drift. They are admin-equivalent in this account
40
+ * by design (`sts:AssumeRole` on the CDK bootstrap roles), which is why the
41
+ * trust policy above, not the permission policy, is the security boundary.
42
+ * Keep `main` protected.
43
+ */
44
+ export class FoundationCi extends cdk.Stack {
45
+ public readonly deployRole: iam.Role;
46
+
47
+ constructor(scope: Construct, id: string, props: FoundationCiProps) {
48
+ super(scope, id, props);
49
+ const names = namesFor(props.instance);
50
+
51
+ const repository = props.repository ?? props.instance.github.repo;
52
+ const ids = props.repositoryIds ?? props.instance.github.repoIds;
53
+ const [owner, repo] = repository.split("/");
54
+ // Both subject shapes GitHub can issue for a push to main: the plain
55
+ // `owner/repo` form and the immutable-id form seen in CloudTrail
56
+ // (`repo:<owner>@<ownerId>/<repo>@<repoId>:ref:refs/heads/main`).
57
+ const mainSubjects = [
58
+ `repo:${repository}:ref:refs/heads/main`,
59
+ `repo:${owner}@${ids.owner}/${repo}@${ids.repo}:ref:refs/heads/main`,
60
+ ];
61
+
62
+ // The provider is account-wide and already exists (an account can hold
63
+ // only one per url), so it is imported, never created: CDK would fail on
64
+ // the duplicate and a `cdk destroy` here would take it from every other
65
+ // repo that trusts it.
66
+ const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
67
+ this,
68
+ "GitHubOidc",
69
+ `arn:${this.partition}:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`,
70
+ );
71
+
72
+ this.deployRole = new iam.Role(this, "FoundationDeployRole", {
73
+ roleName: names.deployRole,
74
+ description: `GitHub Actions deploy role for ${repository} (main only)`,
75
+ maxSessionDuration: cdk.Duration.hours(1),
76
+ assumedBy: new iam.WebIdentityPrincipal(provider.openIdConnectProviderArn, {
77
+ StringEquals: { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
78
+ StringLike: {
79
+ "token.actions.githubusercontent.com:sub": mainSubjects,
80
+ },
81
+ }),
82
+ inlinePolicies: {
83
+ FoundationDeploy: new iam.PolicyDocument({
84
+ statements: deployStatements(
85
+ this,
86
+ names,
87
+ { bucket: props.bucket, dataKey: props.dataKey },
88
+ props.instance,
89
+ ),
90
+ }),
91
+ },
92
+ });
93
+
94
+ new cdk.CfnOutput(this, "DeployRoleArn", { value: this.deployRole.roleArn });
95
+ }
96
+ }
@@ -0,0 +1,446 @@
1
+ import * as cdk from "aws-cdk-lib";
2
+ import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
3
+ import * as iam from "aws-cdk-lib/aws-iam";
4
+ import * as kms from "aws-cdk-lib/aws-kms";
5
+ import * as s3 from "aws-cdk-lib/aws-s3";
6
+ import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
7
+ import type { Construct } from "constructs";
8
+ import { type Instance, namesFor, provisionsIntegration } from "../names.ts";
9
+
10
+ export interface FoundationDataProps extends cdk.StackProps {
11
+ /** The deployment this stack belongs to; every name below comes from it. */
12
+ instance: Instance;
13
+ }
14
+
15
+ /**
16
+ * FoundationData — the durable layer: one CMK, two S3 buckets (config +
17
+ * skills, and channel documents), two always-present DynamoDB tables, an
18
+ * optional CRM table, an optional proxy-only Upwork approval table, an optional
19
+ * retained Upwork OAuth secret, and the Secrets Manager entries.
20
+ * The two always-present DynamoDB tables use
21
+ * different access patterns: `FoundationTable` is `pk` (S) only:
22
+ * dedupe rows, the Codex credential lease and the thread→session index, every
23
+ * one of them a single-item get/put under a prefixed key
24
+ * (`dedupe#…`, `lease#…`, `thread#…`) — matching `DynamoLeaseBackend` and
25
+ * `ts/gateway/src/lambda.ts` from Plan 2. `FoundationItems` is `pk` + `sk`, for the
26
+ * collections the agent reads back whole: a channel's todos, a channel's
27
+ * routines. Those need a Query over a partition, which a table without a sort
28
+ * key cannot answer.
29
+ *
30
+ * Secrets: the signing, app and GitHub App entries already exist in the
31
+ * account (created out of band before the first deploy), so they are
32
+ * *imported* by name and never managed here — CDK would otherwise try to
33
+ * create and then own them. The remaining managed entries are created as
34
+ * empty shells (the generateSecretString placeholder trick) and filled in by
35
+ * `scripts/setup.ts`. Durable data and credential shells use RETAIN: a
36
+ * `cdk destroy` must never take the credentials or the table with
37
+ * it.
38
+ */
39
+ export class FoundationData extends cdk.Stack {
40
+ public readonly dataKey: kms.Key;
41
+ public readonly bucket: s3.Bucket;
42
+ /** Dedicated encrypted, versioned store for durable per-channel documents. */
43
+ public readonly documentBucket: s3.Bucket;
44
+ /** S3 Files file system over the bucket's `fs/` prefix — the persistent mount. */
45
+ public readonly fileSystem: cdk.CfnResource;
46
+ /** Its single access point (POSIX 10001:10001 = the container's `sky` user). */
47
+ public readonly table: dynamodb.Table;
48
+ /** `pk` + `sk`: the agent's queryable collections (todos, routines). */
49
+ public readonly itemsTable: dynamodb.Table;
50
+ /** Dedicated profile/activity table for an enabled self-hosted CRM integration. */
51
+ public readonly crmTable?: dynamodb.Table;
52
+ /** Ephemeral proposal drafts; accessible only to the credential-isolated Upwork proxy. */
53
+ public readonly upworkApprovalTable?: dynamodb.Table;
54
+ public readonly secrets: {
55
+ signing: secretsmanager.ISecret;
56
+ slackApp: secretsmanager.ISecret;
57
+ githubApp: secretsmanager.ISecret;
58
+ codex: secretsmanager.ISecret;
59
+ googleAiStudio: secretsmanager.ISecret;
60
+ googleDrive: secretsmanager.ISecret;
61
+ googleCalendar: secretsmanager.ISecret;
62
+ googleOauth: secretsmanager.ISecret;
63
+ googleEmail: secretsmanager.ISecret;
64
+ mongodbReadonly: secretsmanager.ISecret;
65
+ otterApi?: secretsmanager.ISecret;
66
+ /** Dynamic public Knock OAuth client; no client secret is stored. */
67
+ knockOauthClient?: secretsmanager.ISecret;
68
+ /** Connected Knock OAuth credential, readable only by its fixed proxy. */
69
+ knockCredential?: secretsmanager.ISecret;
70
+ /** Combined Upwork OAuth client and token secret, provisioned only when opted in. */
71
+ upwork?: secretsmanager.ISecret;
72
+ runtime: secretsmanager.ISecret;
73
+ };
74
+
75
+ constructor(scope: Construct, id: string, props: FoundationDataProps) {
76
+ super(scope, id, props);
77
+ const names = namesFor(props.instance);
78
+ const display = props.instance.displayName;
79
+ // `crmStorage` is the one integration switch that is deliberately NOT the
80
+ // registry's `crm` flag: storage outlives the proxy so pausing the tools
81
+ // cannot orphan a retained table CloudFormation could not re-adopt.
82
+ const crmStorage = props.instance.integrations.crmStorage;
83
+
84
+ this.dataKey = new kms.Key(this, "FoundationDataKey", {
85
+ description: `${display} CMK for the S3 buckets, DynamoDB table and SQS queues`,
86
+ enableKeyRotation: true,
87
+ alias: names.keyAlias,
88
+ });
89
+
90
+ this.bucket = new s3.Bucket(this, "FoundationBucket", {
91
+ versioned: true,
92
+ encryption: s3.BucketEncryption.KMS,
93
+ encryptionKey: this.dataKey,
94
+ bucketKeyEnabled: true,
95
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
96
+ enforceSSL: true,
97
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
98
+ });
99
+
100
+ this.documentBucket = new s3.Bucket(this, "DocumentsBucket", {
101
+ versioned: true,
102
+ encryption: s3.BucketEncryption.KMS,
103
+ encryptionKey: this.dataKey,
104
+ bucketKeyEnabled: true,
105
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
106
+ enforceSSL: true,
107
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
108
+ });
109
+
110
+ this.table = new dynamodb.Table(this, "FoundationTable", {
111
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
112
+ timeToLiveAttribute: "ttl",
113
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
114
+ encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
115
+ encryptionKey: this.dataKey,
116
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
117
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
118
+ });
119
+
120
+ this.itemsTable = new dynamodb.Table(this, "FoundationItems", {
121
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
122
+ sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
123
+ timeToLiveAttribute: "ttl",
124
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
125
+ encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
126
+ encryptionKey: this.dataKey,
127
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
128
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
129
+ });
130
+
131
+ this.crmTable = crmStorage
132
+ ? new dynamodb.Table(this, "CrmTable", {
133
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
134
+ sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
135
+ timeToLiveAttribute: "ttl",
136
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
137
+ encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
138
+ encryptionKey: this.dataKey,
139
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
140
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
141
+ })
142
+ : undefined;
143
+ this.crmTable?.addGlobalSecondaryIndex({
144
+ indexName: "RecordsIndex",
145
+ partitionKey: { name: "gsi1pk", type: dynamodb.AttributeType.STRING },
146
+ sortKey: { name: "gsi1sk", type: dynamodb.AttributeType.STRING },
147
+ projectionType: dynamodb.ProjectionType.INCLUDE,
148
+ nonKeyAttributes: ["recordType", "recordId"],
149
+ });
150
+ this.upworkApprovalTable = provisionsIntegration(props.instance, "upwork")
151
+ ? new dynamodb.Table(this, "UpworkApprovalTable", {
152
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
153
+ timeToLiveAttribute: "ttl",
154
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
155
+ encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
156
+ encryptionKey: this.dataKey,
157
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
158
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
159
+ })
160
+ : undefined;
161
+
162
+ // An empty shell CDK is willing to synth without a real value; the real
163
+ // value is written out of band and survives every later deploy because
164
+ // CDK only manages the resource, not its current version.
165
+ const secretShell = (constructId: string, name: string, description: string) =>
166
+ new secretsmanager.Secret(this, constructId, {
167
+ secretName: name,
168
+ description,
169
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
170
+ generateSecretString: {
171
+ secretStringTemplate: "{}",
172
+ generateStringKey: "placeholder",
173
+ },
174
+ });
175
+ // Unlike long-lived auth stores, this integration credential is destroyed
176
+ // with the opt-in resources. That avoids stranding a retained fixed-name
177
+ // secret which CloudFormation could not adopt if the integration is later
178
+ // re-enabled. Operators must repopulate the key after a disable/re-enable.
179
+ const otterApi = provisionsIntegration(props.instance, "otter")
180
+ ? new secretsmanager.Secret(this, "OtterApiSecret", {
181
+ secretName: names.secretOtterApi,
182
+ description: `Otter Enterprise Public API key for ${display} ({ api_key })`,
183
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
184
+ generateSecretString: {
185
+ secretStringTemplate: "{}",
186
+ generateStringKey: "placeholder",
187
+ },
188
+ })
189
+ : undefined;
190
+ // Knock's public-client registration and connected credential are distinct:
191
+ // the deployer writes only the former, while the callback writes only the
192
+ // latter. Both are destroyed with the opt-in integration so a later
193
+ // re-enable must deliberately reconnect a tenant.
194
+ const knockOauthClient = provisionsIntegration(props.instance, "knock")
195
+ ? new secretsmanager.Secret(this, "KnockOauthClientSecret", {
196
+ secretName: names.secretKnockOauthClient,
197
+ description: `Knock public OAuth client for ${display} ({ client_id, redirect_uri })`,
198
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
199
+ generateSecretString: {
200
+ secretStringTemplate: "{}",
201
+ generateStringKey: "placeholder",
202
+ },
203
+ })
204
+ : undefined;
205
+ const knockCredential = provisionsIntegration(props.instance, "knock")
206
+ ? new secretsmanager.Secret(this, "KnockCredentialSecret", {
207
+ secretName: names.secretKnockCredential,
208
+ description: `Knock OAuth credential for ${display}; callback writes and proxy alone reads`,
209
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
210
+ generateSecretString: {
211
+ secretStringTemplate: "{}",
212
+ generateStringKey: "placeholder",
213
+ },
214
+ })
215
+ : undefined;
216
+
217
+ // Unlike the disposable Otter API key, the Upwork OAuth secret carries
218
+ // client credentials plus refresh state. Retaining it makes a deploy safe
219
+ // and leaves manually entered client credentials intact.
220
+ const upwork = provisionsIntegration(props.instance, "upwork")
221
+ ? secretShell(
222
+ "UpworkSecret",
223
+ names.secretUpwork,
224
+ `Upwork OAuth client and token store for ${display} ({ client_id, client_secret, access_token?, refresh_token?, expires_at?, tenant_id? })`,
225
+ )
226
+ : undefined;
227
+
228
+ this.secrets = {
229
+ // Already created out of band — import, never manage.
230
+ signing: secretsmanager.Secret.fromSecretNameV2(
231
+ this,
232
+ "SlackSigningSecret",
233
+ names.secretSlackSigning,
234
+ ),
235
+ slackApp: secretsmanager.Secret.fromSecretNameV2(
236
+ this,
237
+ "SlackAppSecret",
238
+ names.secretSlackApp,
239
+ ),
240
+ githubApp: secretsmanager.Secret.fromSecretNameV2(
241
+ this,
242
+ "GithubAppSecret",
243
+ names.secretGithubApp,
244
+ ),
245
+ codex: secretShell(
246
+ "CodexSecret",
247
+ names.secretCodex,
248
+ "OpenAI Codex credential store (rotated tokens are written back here)",
249
+ ),
250
+ // The image-generation key. A placeholder is fine: the container reads
251
+ // it lazily, so `generate_image` reports "not configured" until the
252
+ // real value is written out of band.
253
+ googleAiStudio: new secretsmanager.Secret(this, "GoogleAiStudioSecret", {
254
+ secretName: names.secretGoogleAiStudio,
255
+ encryptionKey: this.dataKey,
256
+ description: "Google AI Studio API key for image generation ({ api_key })",
257
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
258
+ generateSecretString: {
259
+ secretStringTemplate: "{}",
260
+ generateStringKey: "placeholder",
261
+ },
262
+ }),
263
+ googleDrive: new secretsmanager.Secret(this, "GoogleDriveSecret", {
264
+ secretName: names.secretGoogleDrive,
265
+ encryptionKey: this.dataKey,
266
+ description: `Google Drive service identity: the company-owned Drive account ${display} reads as. JSON { client_email, private_key, token_uri? }, optionally nested under identities.default. Access is granted by sharing files/folders with client_email; scope is read-only.`,
267
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
268
+ generateSecretString: {
269
+ secretStringTemplate: "{}",
270
+ generateStringKey: "placeholder",
271
+ },
272
+ }),
273
+ // The Google Calendar OAuth *client* — never a person's refresh token,
274
+ // which lives per-user in `FoundationItems`. A placeholder is fine: both the
275
+ // agent and the gateway treat an unparseable value as "capability not
276
+ // configured" rather than a boot failure.
277
+ googleCalendar: secretShell(
278
+ "GoogleCalendarSecret",
279
+ names.secretGoogleCalendar,
280
+ `Google Calendar OAuth client for ${display}'s per-person calendar connections`,
281
+ ),
282
+ // The connected mailbox. Written by the gateway's OAuth callback and
283
+ // read by the EMAIL PROXY LAMBDA'S ROLE ALONE — the agent execution role
284
+ // carries an explicit Deny on it, which is what makes it true that no
285
+ // credential able to send mail exists inside the container.
286
+ googleEmail: secretShell(
287
+ "GoogleEmailSecret",
288
+ names.secretGoogleEmail,
289
+ `Gmail identity for ${display}, connected from Slack — written by the gateway, read only by the email proxy`,
290
+ ),
291
+ // The read-only Atlas connection string, `{ uri, databases? }`. A
292
+ // placeholder is fine and is what ships: the agent reads it on first
293
+ // tool use, so an unfilled secret means "no MongoDB", not a boot error.
294
+ mongodbReadonly: new secretsmanager.Secret(this, "MongodbReadonlySecret", {
295
+ secretName: names.secretMongodbReadonly,
296
+ encryptionKey: this.dataKey,
297
+ description: `Read-only MongoDB Atlas credential for ${display}. JSON { uri, databases? } where uri authenticates an Atlas user provisioned with the \`read\` role, and databases (optional) narrows which databases the agent may name.`,
298
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
299
+ generateSecretString: {
300
+ secretStringTemplate: "{}",
301
+ generateStringKey: "placeholder",
302
+ },
303
+ }),
304
+ ...(otterApi === undefined ? {} : { otterApi }),
305
+ ...(knockOauthClient === undefined ? {} : { knockOauthClient }),
306
+ ...(knockCredential === undefined ? {} : { knockCredential }),
307
+ ...(upwork === undefined ? {} : { upwork }),
308
+ // The OAuth client every Google capability shares: Calendar builds a
309
+ // per-person consent link with it, Drive connects the company identity
310
+ // with it. Never a refresh token. A placeholder is fine — an unparseable
311
+ // value reads as "not configured", not as a boot failure.
312
+ googleOauth: secretShell(
313
+ "GoogleOauthSecret",
314
+ names.secretGoogleOauth,
315
+ `Google OAuth client shared by ${display}'s Google capabilities ({ client_id, client_secret, redirect_uri })`,
316
+ ),
317
+ runtime: secretShell(
318
+ "RuntimeSecret",
319
+ names.secretRuntime,
320
+ "Agent runtime env map, written by scripts/setup.ts after deploy",
321
+ ),
322
+ };
323
+
324
+ this.fileSystem = this.buildFileSystem(display);
325
+
326
+ new cdk.CfnOutput(this, "FoundationFileSystemId", {
327
+ value: this.fileSystem.getAtt("FileSystemId").toString(),
328
+ });
329
+ new cdk.CfnOutput(this, "BucketName", { value: this.bucket.bucketName });
330
+ new cdk.CfnOutput(this, "DocumentsBucketName", { value: this.documentBucket.bucketName });
331
+ new cdk.CfnOutput(this, "TableName", { value: this.table.tableName });
332
+ new cdk.CfnOutput(this, "ItemsTableName", { value: this.itemsTable.tableName });
333
+ }
334
+
335
+ /**
336
+ * The S3 Files file system that backs `/mnt/sky` in the agent container.
337
+ *
338
+ * It is a *view* of this bucket under the `fs/` prefix, so learned skills
339
+ * and caches are ordinary S3 objects an operator can list and read — that
340
+ * visibility is the whole reason S3 Files was chosen over EFS. The service
341
+ * reaches the bucket as its own role, not as the caller's, hence the
342
+ * service role below; `AcceptBucketWarning` is the acknowledgement that
343
+ * other writers to the same prefix could confuse the file-system view
344
+ * (nothing else writes under `fs/`).
345
+ */
346
+ private buildFileSystem(display: string): cdk.CfnResource {
347
+ const role = new iam.Role(this, "S3FilesServiceRole", {
348
+ description: `S3 Files service role for the ${display} persistent mount`,
349
+ // S3 Files runs on the EFS control plane: the documented trust principal is
350
+ // elasticfilesystem.amazonaws.com, scoped to this account's s3files file systems
351
+ // (docs: AmazonS3/latest/userguide/s3-files-prereq-policies.html).
352
+ assumedBy: new iam.ServicePrincipal("elasticfilesystem.amazonaws.com", {
353
+ conditions: {
354
+ StringEquals: { "aws:SourceAccount": this.account },
355
+ ArnLike: {
356
+ "aws:SourceArn": `arn:${this.partition}:s3files:${this.region}:${this.account}:file-system/*`,
357
+ },
358
+ },
359
+ }),
360
+ });
361
+ role.addToPolicy(
362
+ new iam.PolicyStatement({
363
+ sid: "MountObjects",
364
+ actions: [
365
+ "s3:AbortMultipartUpload",
366
+ "s3:DeleteObject*",
367
+ "s3:GetObject*",
368
+ "s3:List*",
369
+ "s3:PutObject*",
370
+ ],
371
+ resources: [this.bucket.arnForObjects("fs/*")],
372
+ }),
373
+ );
374
+ role.addToPolicy(
375
+ new iam.PolicyStatement({
376
+ sid: "MountBucket",
377
+ actions: [
378
+ "s3:ListBucket",
379
+ "s3:ListBucketVersions",
380
+ "s3:GetBucketLocation",
381
+ "s3:ListBucketMultipartUploads",
382
+ ],
383
+ resources: [this.bucket.bucketArn],
384
+ }),
385
+ );
386
+ role.addToPolicy(
387
+ new iam.PolicyStatement({
388
+ sid: "MountKeyUsage",
389
+ actions: [
390
+ "kms:Decrypt",
391
+ "kms:Encrypt",
392
+ "kms:GenerateDataKey*",
393
+ "kms:ReEncryptFrom",
394
+ "kms:ReEncryptTo",
395
+ "kms:DescribeKey",
396
+ ],
397
+ resources: [this.dataKey.keyArn],
398
+ }),
399
+ );
400
+ // S3 Files manages EventBridge rules for bucket→file-system synchronization.
401
+ role.addToPolicy(
402
+ new iam.PolicyStatement({
403
+ sid: "EventBridgeManage",
404
+ actions: [
405
+ "events:DeleteRule",
406
+ "events:DisableRule",
407
+ "events:EnableRule",
408
+ "events:PutRule",
409
+ "events:PutTargets",
410
+ "events:RemoveTargets",
411
+ ],
412
+ resources: [`arn:${this.partition}:events:*:*:rule/DO-NOT-DELETE-S3-Files*`],
413
+ conditions: { StringEquals: { "events:ManagedBy": "elasticfilesystem.amazonaws.com" } },
414
+ }),
415
+ );
416
+ role.addToPolicy(
417
+ new iam.PolicyStatement({
418
+ sid: "EventBridgeRead",
419
+ actions: [
420
+ "events:DescribeRule",
421
+ "events:ListRuleNamesByTarget",
422
+ "events:ListRules",
423
+ "events:ListTargetsByRule",
424
+ ],
425
+ resources: [`arn:${this.partition}:events:*:*:rule/*`],
426
+ }),
427
+ );
428
+
429
+ const fileSystem = new cdk.CfnResource(this, "FoundationFileSystem", {
430
+ type: "AWS::S3Files::FileSystem",
431
+ properties: {
432
+ Bucket: this.bucket.bucketArn,
433
+ Prefix: "fs/",
434
+ KmsKeyId: this.dataKey.keyArn,
435
+ RoleArn: role.roleArn,
436
+ AcceptBucketWarning: true,
437
+ },
438
+ });
439
+ fileSystem.node.addDependency(role);
440
+ // Same reasoning as the bucket: a `cdk destroy` must not take the
441
+ // learned skills with it. The objects survive regardless, but the
442
+ // file-system view is what makes them a mount.
443
+ fileSystem.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);
444
+ return fileSystem;
445
+ }
446
+ }