@ingram-cloud/pulumi 1.0.0 → 1.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 CHANGED
@@ -88,6 +88,35 @@ const curator = new ic.IcAgent("curator", {
88
88
  export const curatorAgentId = curator.agentId;
89
89
  ```
90
90
 
91
+ **MCP Apps UI templates.** Pass `uiTemplates` to attach interactive HTML bundles
92
+ (MCP Apps, SEP-1865). Each template's bytes upload when they (or the metadata)
93
+ change, and its name + content hash + `tool`/`csp`/`permissions` fold into the
94
+ content signature — so a template edit publishes and rolls out a new version like
95
+ an instruction change. Provide the HTML as an absolute `htmlPath` or inline
96
+ `html`; a `tool` binds it as a typed app-tool. Dropping a template from the array
97
+ deletes it from the draft.
98
+
99
+ ```ts
100
+ import { join } from "node:path";
101
+
102
+ const bookkeeper = new ic.IcAgent("bookkeeper", {
103
+ ...conn,
104
+ instructions: BOOKKEEPER_INSTRUCTIONS,
105
+ uiTemplates: [
106
+ {
107
+ name: "cash_chart",
108
+ htmlPath: join(__dirname, "ui/cash-chart.html"),
109
+ tool: {
110
+ description: "Show an interactive cash-flow chart.",
111
+ input_schema: { type: "object", properties: {}, required: [] },
112
+ instruction: "Call get_cash_flow_history, then render_app with template cash_chart.",
113
+ mutating: false,
114
+ },
115
+ },
116
+ ],
117
+ });
118
+ ```
119
+
91
120
  > Attaching **existing** smiths to a agent is a one-time fleet backfill —
92
121
  > keep it as the app's migration script. New smiths attach at birth in app code.
93
122
  > Pulumi does not own per-smith runtime state.
@@ -37,6 +37,38 @@ export interface AgentVariable {
37
37
  description?: string | null;
38
38
  required?: boolean;
39
39
  }
40
+ /** CSP domain allowlists for the sandboxed iframe (the `_meta.ui.csp` shape). */
41
+ export interface UiCsp {
42
+ connectDomains?: string[];
43
+ resourceDomains?: string[];
44
+ frameDomains?: string[];
45
+ baseUriDomains?: string[];
46
+ }
47
+ /** A typed app-tool bound to a template (Rung 2): the host's model calls it and
48
+ * the template renders the result. */
49
+ export interface UiResourceTool {
50
+ description: string;
51
+ /** JSON Schema for the tool's arguments. */
52
+ input_schema?: Record<string, unknown>;
53
+ /** Scoped instruction the smith runs with when this tool is called. */
54
+ instruction?: string | null;
55
+ /** Writes gate through approval; reads flow freely. */
56
+ mutating?: boolean;
57
+ }
58
+ /** One UI template declared on an `IcAgent`. Provide the HTML via `htmlPath`
59
+ * (read at `pulumi up`, so use an absolute path) or inline `html`. */
60
+ export interface UiTemplate {
61
+ /** Template name (`[a-z0-9][a-z0-9_-]*`, ≤63) — the `ui://…/{name}` handle. */
62
+ name: string;
63
+ /** Absolute path to the HTML bundle. One of `htmlPath` / `html` is required. */
64
+ htmlPath?: string;
65
+ /** Inline HTML, as an alternative to `htmlPath`. */
66
+ html?: string;
67
+ csp?: UiCsp;
68
+ /** Permissions-Policy-style host permissions the template requests. */
69
+ permissions?: Record<string, unknown>;
70
+ tool?: UiResourceTool;
71
+ }
40
72
  export interface IcAgentArgs {
41
73
  baseUrl: pulumi.Input<string>;
42
74
  token: pulumi.Input<string>;
@@ -57,6 +89,14 @@ export interface IcAgentArgs {
57
89
  /** Opt into observational-memory consolidation (background, billed). Default off. */
58
90
  memoryConsolidation?: pulumi.Input<boolean>;
59
91
  variables?: pulumi.Input<AgentVariable[]>;
92
+ /**
93
+ * MCP Apps UI templates attached to the agent. Each template's HTML bundle is
94
+ * uploaded when its bytes or metadata change, and its name + content hash +
95
+ * metadata fold into the content signature — so a template edit publishes and
96
+ * rolls out a new version like an instruction change. Provide the HTML via an
97
+ * absolute `htmlPath` or inline `html`.
98
+ */
99
+ uiTemplates?: pulumi.Input<UiTemplate[]>;
60
100
  /** Release note recorded when a new version is published. */
61
101
  publishNote?: pulumi.Input<string>;
62
102
  /** Rollout percentage for the published version (default 100). */
package/dist/resources.js CHANGED
@@ -145,6 +145,80 @@ function makePutResource(cfg) {
145
145
  },
146
146
  };
147
147
  }
148
+ // Read each template's HTML bundle (from `htmlPath` or inline `html`), hash the
149
+ // bytes with the same sha256-hex the API stores as `content_hash`, and carry the
150
+ // metadata through. Module-scope + node built-ins required inside so Pulumi's
151
+ // closure serialization captures it (the providers otherwise assume only global
152
+ // `fetch`).
153
+ function resolveUiTemplates(templates) {
154
+ if (!templates?.length)
155
+ return [];
156
+ const { createHash } = require("node:crypto");
157
+ return templates.map((t) => {
158
+ let bytes;
159
+ if (t.html !== undefined)
160
+ bytes = Buffer.from(t.html, "utf8");
161
+ else if (t.htmlPath)
162
+ bytes = require("node:fs").readFileSync(t.htmlPath);
163
+ else
164
+ throw new Error(`uiTemplate "${t.name}": set htmlPath or html`);
165
+ return {
166
+ name: t.name,
167
+ bytes,
168
+ contentHash: createHash("sha256").update(bytes).digest("hex"),
169
+ csp: t.csp,
170
+ permissions: t.permissions,
171
+ tool: t.tool,
172
+ };
173
+ });
174
+ }
175
+ // Multipart upload of one template to POST /v1/agents/{aid}/ui — the HTML `file`
176
+ // part plus a JSON `metadata` part. Not `icRequest` (that JSON-encodes the body);
177
+ // uses global FormData/File (Node ≥18).
178
+ async function uploadUiTemplate(baseUrl, token, aid, t) {
179
+ const form = new FormData();
180
+ form.set("file", new File([new Uint8Array(t.bytes)], `${t.name}.html`, { type: "text/html" }));
181
+ const meta = { name: t.name };
182
+ if (t.csp)
183
+ meta.csp = t.csp;
184
+ if (t.permissions)
185
+ meta.permissions = t.permissions;
186
+ if (t.tool)
187
+ meta.tool = t.tool;
188
+ form.set("metadata", JSON.stringify(meta));
189
+ const res = await fetch(`${baseUrl}/v1/agents/${aid}/ui`, {
190
+ method: "POST",
191
+ headers: { Authorization: `Bearer ${token}`, "IC-Api-Version": exports.IC_API_VERSION },
192
+ body: form,
193
+ });
194
+ if (!res.ok) {
195
+ const err = new Error(`IC POST /v1/agents/${aid}/ui → ${res.status}: ${(await res.text()).slice(0, 400)}`);
196
+ err.status = res.status;
197
+ throw err;
198
+ }
199
+ }
200
+ // Reconcile the agent's UI templates against its draft `current` state: upload any
201
+ // whose bytes or metadata drifted (or are new), delete any the resource no longer
202
+ // declares. The draft this leaves behind is what the publish step snapshots.
203
+ async function syncUiTemplates(i, id, current) {
204
+ const desired = resolveUiTemplates(i.uiTemplates);
205
+ const eq = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
206
+ for (const t of desired) {
207
+ const cur = current.find((e) => e?.name === t.name);
208
+ const unchanged = cur &&
209
+ cur.content_hash === t.contentHash &&
210
+ eq(cur.tool, t.tool) &&
211
+ eq(cur.csp, t.csp) &&
212
+ eq(cur.permissions, t.permissions);
213
+ if (!unchanged)
214
+ await uploadUiTemplate(i.baseUrl, i.token, id, t);
215
+ }
216
+ for (const e of current) {
217
+ const name = e?.name;
218
+ if (typeof name === "string" && !desired.some((t) => t.name === name))
219
+ await icRequest(i.baseUrl, i.token, "DELETE", `/v1/agents/${id}/ui/${encodeURIComponent(name)}`);
220
+ }
221
+ }
148
222
  // Lower-case, hyphenate, trim — matches the API's slug derivation so a
149
223
  // logical-name-derived slug lines up with what the server would compute.
150
224
  function slugify(s) {
@@ -169,17 +243,40 @@ function agentBody(i) {
169
243
  variables: i.variables ?? [],
170
244
  };
171
245
  }
246
+ // Canonical, name-sorted signature of an agent's frozen UI templates: each
247
+ // entry's name + content hash + tool/csp/permissions metadata. Both the resource
248
+ // inputs (hash computed from local bytes) and a published version's snapshot
249
+ // (hash already stored) map onto this shape, so a template edit moves the
250
+ // signature exactly one way.
251
+ function uiResourcesSig(list) {
252
+ return (list ?? [])
253
+ .map((e) => ({
254
+ name: e.name,
255
+ content_hash: e.content_hash,
256
+ tool: e.tool ?? null,
257
+ csp: e.csp ?? null,
258
+ permissions: e.permissions ?? null,
259
+ }))
260
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
261
+ }
172
262
  // Stable signature of the *content* that warrants a new published version
173
263
  // (name and rollout% deliberately excluded — a rename/restage is not new content).
174
264
  function agentContentSig(s) {
175
- return JSON.stringify({
265
+ const base = {
176
266
  instructions: s.instructions ?? null,
177
267
  model: s.model ?? null,
178
268
  tools: [...(s.enabled_hosted_tools ?? [])].sort(),
179
269
  auto_memory: s.auto_memory ?? null,
180
270
  memory_consolidation: s.memory_consolidation ?? null,
181
271
  variables: s.variables ?? [],
182
- });
272
+ };
273
+ // Only add the key when templates exist, so agents without any keep a
274
+ // byte-identical signature to pre-uiTemplates releases (no spurious republish
275
+ // on the first `up` after upgrading the library).
276
+ const ui = uiResourcesSig(s.ui_resources);
277
+ if (ui.length)
278
+ base.ui_resources = ui;
279
+ return JSON.stringify(base);
183
280
  }
184
281
  // Map the camelCase resource inputs onto the canonical snapshot shape.
185
282
  function inputsToSnapshot(i) {
@@ -190,6 +287,15 @@ function inputsToSnapshot(i) {
190
287
  auto_memory: i.autoMemory,
191
288
  memory_consolidation: i.memoryConsolidation,
192
289
  variables: i.variables,
290
+ // Resolve each template's bytes and hash so a HTML edit shows up in the
291
+ // signature (the API's `content_hash` uses the same sha256-hex).
292
+ ui_resources: resolveUiTemplates(i.uiTemplates).map((t) => ({
293
+ name: t.name,
294
+ content_hash: t.contentHash,
295
+ tool: t.tool,
296
+ csp: t.csp,
297
+ permissions: t.permissions,
298
+ })),
193
299
  };
194
300
  }
195
301
  async function findAgentBySlug(baseUrl, token, slug) {
@@ -234,6 +340,9 @@ async function reconcileAgent(i, knownId, priorSig) {
234
340
  await icRequest(i.baseUrl, i.token, "PATCH", `/v1/agents/${id}`, agentBody(i));
235
341
  }
236
342
  const bp = await icRequest(i.baseUrl, i.token, "GET", `/v1/agents/${id}`);
343
+ // Reconcile UI templates against the draft before publishing so a template
344
+ // change rides the same publish + rollout as an instruction change.
345
+ await syncUiTemplates(i, id, (bp.draft?.ui_resources ?? []));
237
346
  const desiredSig = agentContentSig(inputsToSnapshot(i));
238
347
  // On the create path we don't know the prior sig — compare to what's actually
239
348
  // published so adopting a script-made agent doesn't churn a needless version.
@@ -295,8 +404,13 @@ const agentProvider = {
295
404
  // update (the old and new slugs differ, so create-before-delete is safe).
296
405
  if (olds.slug !== news.slug)
297
406
  return { changes: true, replaces: ["slug"] };
298
- const contentChanged = agentContentSig(inputsToSnapshot(olds)) !==
299
- agentContentSig(inputsToSnapshot(news));
407
+ // Compare the freshly-resolved desired signature to the one STORED at the
408
+ // last apply — not a re-resolve of `olds`. A file-backed template edits the
409
+ // same `htmlPath`, so re-reading `olds` would hash the new bytes and hide
410
+ // the change; the stored `contentSig` froze the prior hash.
411
+ const oldSig = olds.contentSig ??
412
+ agentContentSig(inputsToSnapshot(olds));
413
+ const contentChanged = oldSig !== agentContentSig(inputsToSnapshot(news));
300
414
  const nameChanged = (olds.name ?? olds.slug) !== (news.name ?? news.slug);
301
415
  const rolloutChanged = (olds.rolloutPercent ?? 100) !== (news.rolloutPercent ?? 100);
302
416
  return { changes: contentChanged || nameChanged || rolloutChanged };
@@ -333,6 +447,7 @@ class IcAgent extends pulumi.dynamic.Resource {
333
447
  autoMemory: undefined,
334
448
  memoryConsolidation: undefined,
335
449
  variables: undefined,
450
+ uiTemplates: undefined,
336
451
  publishNote: undefined,
337
452
  rolloutPercent: undefined,
338
453
  agentId: undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-cloud/pulumi",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Pulumi dynamic resources for the Ingram Cloud /v1 API — projects, project tokens, agents, MCP servers, channels, webhooks, model keys.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://cloud.ingram.tech",