1import * as control from "@distilled.cloud/aws/bedrock-agentcore-control";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import {
11 AgentCoreProvisioningFailed,
12 createAgentCoreName,
13 readAgentCoreTags,
14 retryWhileConflict,
15 retryWhileValidation,
16 syncAgentCoreTags,
17} from "./internal.ts";
18
19export interface BrowserCustomProps {
20 /**
21 * Name of the browser. Must match `[a-zA-Z][a-zA-Z0-9_]{0,47}`
22 * (underscores, no hyphens). If omitted, a deterministic physical name is
23 * generated from the app, stage, and logical ID. Changing the name triggers
24 * a replacement.
25 */
26 name?: string;
27 /**
28 * A description of the browser. Changing it triggers a replacement (the
29 * API has no update operation).
30 */
31 description?: string;
32 /**
33 * The ARN of an IAM role browser sessions assume (required for session
34 * recording to S3). Changing it triggers a replacement.
35 */
36 executionRoleArn?: string;
37 /**
38 * Network access for browser sessions: `PUBLIC` (internet egress) or
39 * `VPC`. Changing it triggers a replacement.
40 * @default { networkMode: "PUBLIC" }
41 */
42 networkConfiguration?: control.BrowserNetworkConfiguration;
43 /**
44 * Session recording configuration (S3 destination). Changing it triggers a
45 * replacement.
46 */
47 recording?: control.RecordingConfig;
48 /**
49 * Tags to apply to the browser. Merged with internal Alchemy tags.
50 */
51 tags?: Record<string, string>;
52}
53
54export interface BrowserCustom extends Resource<
55 "AWS.BedrockAgentCore.BrowserCustom",
56 BrowserCustomProps,
57 {
58 /**
59 * The unique identifier of the browser.
60 */
61 browserId: string;
62 /**
63 * The ARN of the browser.
64 */
65 browserArn: string;
66 /**
67 * Name of the browser.
68 */
69 name: string;
70 /**
71 * Current status of the browser (e.g. `READY`).
72 */
73 status: string;
74 }
75> {}
76
77/**
78 * A custom Amazon Bedrock AgentCore Browser — a managed, isolated cloud
79 * browser that agents drive to interact with websites.
80 *
81 * A custom browser controls the sandbox's network mode, execution role, and
82 * session recording. All configuration is create-only (the API has no update
83 * operation); property changes trigger a replacement.
84 *
85 * ### Creating Browsers
86 * **Example:** Public-Egress Browser
87 * ```typescript
88 * import * as AgentCore from "alchemy/AWS/BedrockAgentCore";
89 *
90 * const browser = yield* AgentCore.BrowserCustom("AgentBrowser", {});
91 * ```
92 *
93 * **Example:** Browser with Session Recording
94 * ```typescript
95 * const browser = yield* AgentCore.BrowserCustom("RecordedBrowser", {
96 * executionRoleArn: role.roleArn,
97 * recording: {
98 * enabled: true,
99 * s3Location: { bucket: bucket.bucketName, prefix: "sessions/" },
100 * },
101 * });
102 * ```
103 *
104 * @resource
105 */
106export const BrowserCustom = Resource<BrowserCustom>(
107 "AWS.BedrockAgentCore.BrowserCustom",
108);
109
110export const BrowserCustomProvider = () =>
111 Provider.effect(
112 BrowserCustom,
113 Effect.gen(function* () {
114 const createName = Effect.fn(function* (
115 id: string,
116 props: Pick<BrowserCustomProps, "name">,
117 ) {
118 return props.name ?? (yield* createAgentCoreName(id));
119 });
120
121 // getBrowser still resolves soft-deleted browsers — DELETED/DELETING
122 // count as gone.
123 const getLiveOrUndefined = Effect.fn(function* (browserId: string) {
124 const found = yield* control
125 .getBrowser({ browserId })
126 .pipe(
127 Effect.catchTag("ResourceNotFoundException", () =>
128 Effect.succeed(undefined),
129 ),
130 );
131 return found === undefined ||
132 found.status === "DELETED" ||
133 found.status === "DELETING"
134 ? undefined
135 : found;
136 });
137
138 const findByName = Effect.fn(function* (name: string) {
139 const pages = yield* control.listBrowsers
140 .pages({})
141 .pipe(Stream.runCollect);
142 const summary = Array.from(pages)
143 .flatMap((page) => page.browserSummaries ?? [])
144 .find(
145 (s) =>
146 s.name === name &&
147 s.status !== "DELETED" &&
148 s.status !== "DELETING",
149 );
150 return summary === undefined
151 ? undefined
152 : yield* getLiveOrUndefined(summary.browserId);
153 });
154
155 const toAttributes = (browser: control.GetBrowserResponse) => ({
156 browserId: browser.browserId,
157 browserArn: browser.browserArn,
158 name: browser.name,
159 status: browser.status,
160 });
161
162 return BrowserCustom.Provider.of({
163 stables: ["browserId", "browserArn", "name"],
164
165 list: () =>
166 Effect.gen(function* () {
167 const pages = yield* control.listBrowsers
168 .pages({})
169 .pipe(Stream.runCollect);
170 const summaries = Array.from(pages)
171 .flatMap((page) => page.browserSummaries ?? [])
172 .filter((s) => s.status !== "DELETED" && s.status !== "DELETING");
173 const hydrated = yield* Effect.forEach(
174 summaries,
175 (s) => getLiveOrUndefined(s.browserId),
176 { concurrency: 5 },
177 );
178 return hydrated.filter((b) => b !== undefined).map(toAttributes);
179 }),
180
181 read: Effect.fn(function* ({ id, olds, output }) {
182 const browser = output?.browserId
183 ? yield* getLiveOrUndefined(output.browserId)
184 : yield* findByName(
185 yield* createName(id, olds ?? ({} as BrowserCustomProps)),
186 );
187 if (browser === undefined) return undefined;
188 const attrs = toAttributes(browser);
189 const tags = yield* readAgentCoreTags(browser.browserArn);
190 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
191 }),
192
193 // Everything except tags is create-only — any change replaces.
194 diff: Effect.fn(function* ({ id, news, olds }) {
195 if (!isResolved(news)) return undefined;
196 const oldProps = olds ?? ({} as BrowserCustomProps);
197 const newProps = news ?? ({} as BrowserCustomProps);
198 const oldName = yield* createName(id, oldProps);
199 const newName = yield* createName(id, newProps);
200 if (
201 oldName !== newName ||
202 (oldProps.description ?? undefined) !==
203 (newProps.description ?? undefined) ||
204 (oldProps.executionRoleArn ?? undefined) !==
205 (newProps.executionRoleArn ?? undefined) ||
206 JSON.stringify(
207 oldProps.networkConfiguration ?? { networkMode: "PUBLIC" },
208 ) !==
209 JSON.stringify(
210 newProps.networkConfiguration ?? { networkMode: "PUBLIC" },
211 ) ||
212 JSON.stringify(oldProps.recording ?? null) !==
213 JSON.stringify(newProps.recording ?? null)
214 ) {
215 return { action: "replace" } as const;
216 }
217 }),
218
219 reconcile: Effect.fn(function* ({ id, news, output, session }) {
220 const props = news ?? ({} as BrowserCustomProps);
221 const name = output?.name ?? (yield* createName(id, props));
222 const internalTags = yield* createInternalTags(id);
223 const desiredTags = { ...props.tags, ...internalTags };
224
225 // 1. OBSERVE
226 let browser = output?.browserId
227 ? yield* getLiveOrUndefined(output.browserId)
228 : undefined;
229 if (browser === undefined) {
230 browser = yield* findByName(name);
231 }
232
233 // 2. ENSURE — creation is synchronous (READY on return); tolerate
234 // the name-exists race and transient IAM propagation.
235 if (browser === undefined) {
236 const created = yield* control
237 .createBrowser({
238 name,
239 description: props.description,
240 executionRoleArn: props.executionRoleArn,
241 networkConfiguration: props.networkConfiguration ?? {
242 networkMode: "PUBLIC",
243 },
244 recording: props.recording,
245 tags: desiredTags,
246 })
247 .pipe(
248 retryWhileValidation,
249 Effect.catchTag("ConflictException", () =>
250 Effect.succeed(undefined),
251 ),
252 );
253 browser =
254 created === undefined
255 ? yield* findByName(name)
256 : yield* getLiveOrUndefined(created.browserId);
257 }
258 if (browser === undefined) {
259 return yield* new AgentCoreProvisioningFailed({
260 message: `browser '${name}' was neither created nor found`,
261 });
262 }
263 if (browser.status === "CREATE_FAILED") {
264 return yield* new AgentCoreProvisioningFailed({
265 message: `browser '${name}' failed: ${browser.failureReason ?? "unknown"}`,
266 });
267 }
268
269 // 3. SYNC — only tags are mutable.
270 yield* syncAgentCoreTags(browser.browserArn, desiredTags);
271
272 // 4. RETURN fresh attributes.
273 yield* session.note(browser.browserId);
274 return toAttributes(browser);
275 }),
276
277 delete: Effect.fn(function* ({ output }) {
278 yield* control.deleteBrowser({ browserId: output.browserId }).pipe(
279 retryWhileConflict,
280 Effect.catchTag("ResourceNotFoundException", () =>
281 Effect.succeed(undefined),
282 ),
283 );
284 yield* control.getBrowser({ browserId: output.browserId }).pipe(
285 Effect.map((b) => b.status as string),
286 Effect.catchTag("ResourceNotFoundException", () =>
287 Effect.succeed("DELETED" as string),
288 ),
289 Effect.repeat({
290 schedule: Schedule.fixed("3 seconds"),
291 until: (status) =>
292 status === "DELETED" || status === "DELETE_FAILED",
293 times: 20,
294 }),
295 );
296 }),
297 });
298 }),
299 );