@jpcasabiancai/cayenne 0.3.1 → 0.3.2

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 (2) hide show
  1. package/dist/cli.js +319 -48
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -73,6 +73,38 @@ var init_constants = __esm({
73
73
  }
74
74
  });
75
75
 
76
+ // ../../packages/shared/src/protocol/attachments.ts
77
+ function sniffImage(bytes) {
78
+ if (startsWith(bytes, PNG)) return "image/png";
79
+ if (startsWith(bytes, JPEG)) return "image/jpeg";
80
+ if (startsWith(bytes, GIF87A) || startsWith(bytes, GIF89A)) return "image/gif";
81
+ if (startsWith(bytes, RIFF) && startsWith(bytes, WEBP, 8)) return "image/webp";
82
+ return null;
83
+ }
84
+ var ATTACHMENT_MAX_BYTES, ATTACHMENT_MAX_COUNT, ATTACHMENT_MEDIA_TYPES, ATTACHMENT_NAME, startsWith, ascii, PNG, JPEG, GIF87A, GIF89A, RIFF, WEBP;
85
+ var init_attachments = __esm({
86
+ "../../packages/shared/src/protocol/attachments.ts"() {
87
+ "use strict";
88
+ ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024;
89
+ ATTACHMENT_MAX_COUNT = 4;
90
+ ATTACHMENT_MEDIA_TYPES = {
91
+ "image/png": "png",
92
+ "image/jpeg": "jpg",
93
+ "image/webp": "webp",
94
+ "image/gif": "gif"
95
+ };
96
+ ATTACHMENT_NAME = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg|webp|gif)$/;
97
+ startsWith = (bytes, prefix, offset = 0) => bytes.length >= offset + prefix.length && prefix.every((b, i) => bytes[offset + i] === b);
98
+ ascii = (s) => Array.from(s, (c) => c.charCodeAt(0));
99
+ PNG = [137, 80, 78, 71, 13, 10, 26, 10];
100
+ JPEG = [255, 216, 255];
101
+ GIF87A = ascii("GIF87a");
102
+ GIF89A = ascii("GIF89a");
103
+ RIFF = ascii("RIFF");
104
+ WEBP = ascii("WEBP");
105
+ }
106
+ });
107
+
76
108
  // ../../packages/shared/src/protocol/git.ts
77
109
  import { z } from "zod";
78
110
  var WORKTREE_NAME, GIT_REF, REF_FORBIDDEN, isGitRef, ABSOLUTE, escapes, NUL, AbsoluteDir, RepoRelativePath, GitRef, WorktreeName, GitPayloads, GitFileStatus, GitStatusFile, GitStatus, GitDiffFile, GitDiff, GitBranch, GitBranches, GitWorktreeCreated, GitWorktreeRemoved;
@@ -284,6 +316,7 @@ var PermissionMode, PermissionDecision, Provider, Effort, CommandPayloads, kinds
284
316
  var init_command = __esm({
285
317
  "../../packages/shared/src/protocol/command.ts"() {
286
318
  "use strict";
319
+ init_attachments();
287
320
  init_git();
288
321
  init_github();
289
322
  init_terminal();
@@ -357,7 +390,9 @@ var init_command = __esm({
357
390
  "session.prompt": z4.object({
358
391
  text: z4.string().min(1).max(2e5),
359
392
  clientUuid: z4.uuid(),
360
- attachments: z4.array(z4.object({ kind: z4.literal("image"), storagePath: z4.string() })).default([]),
393
+ // Images uploaded to the private `attachments` bucket (D37). No mediaType: the runner sniffs
394
+ // the bytes. The runner still checks each path against the session's own row (R31).
395
+ attachments: z4.array(z4.object({ kind: z4.literal("image"), storagePath: z4.string().regex(ATTACHMENT_NAME) })).max(ATTACHMENT_MAX_COUNT).default([]),
361
396
  memoryContext: z4.string().max(2e4).optional()
362
397
  }),
363
398
  "session.permission.respond": z4.object({
@@ -428,6 +463,7 @@ var ContentBlock, MessageRole, ChatMessageRow, PermissionRequestRow, CodexQuesti
428
463
  var init_content = __esm({
429
464
  "../../packages/shared/src/protocol/content.ts"() {
430
465
  "use strict";
466
+ init_attachments();
431
467
  ContentBlock = z5.discriminatedUnion("type", [
432
468
  z5.object({ type: z5.literal("text"), text: z5.string() }),
433
469
  z5.object({ type: z5.literal("thinking"), text: z5.string() }),
@@ -444,7 +480,14 @@ var init_content = __esm({
444
480
  isError: z5.boolean().default(false),
445
481
  structured: z5.unknown().optional()
446
482
  }),
447
- z5.object({ type: z5.literal("image"), storagePath: z5.string(), mediaType: z5.string() }),
483
+ // The path is checked here, not only where it is used: the session owner may insert any
484
+ // `content` (0003 `messages_insert`), and a reader's browser signs whatever path a block names.
485
+ // A block that is not a canonical attachment name drops the row rather than steering that request.
486
+ z5.object({
487
+ type: z5.literal("image"),
488
+ storagePath: z5.string().regex(ATTACHMENT_NAME),
489
+ mediaType: z5.enum(Object.keys(ATTACHMENT_MEDIA_TYPES))
490
+ }),
448
491
  z5.object({
449
492
  type: z5.literal("result"),
450
493
  isError: z5.boolean(),
@@ -667,6 +710,12 @@ var init_runner = __esm({
667
710
  * `forkSession` only when this is true. Absent reads as false; optional so an older row parses.
668
711
  */
669
712
  sessionFork: z8.boolean().optional(),
713
+ /**
714
+ * Whether this runner sends `session.prompt` attachments to the provider (D37, R31, protocol §9).
715
+ * An older runner parses `attachments` and ignores them, so a client offers images only when
716
+ * this is true. Absent reads as false; optional so an older row parses.
717
+ */
718
+ promptImages: z8.boolean().optional(),
670
719
  /**
671
720
  * The raw Ed25519 public key of the running process (32 bytes, unpadded base64url), which signs
672
721
  * the local server's `hello` (protocol §6.2). New on every start, republished by every capabilities refresh that changes the row;
@@ -709,6 +758,7 @@ var init_transport = __esm({
709
758
  var init_protocol = __esm({
710
759
  "../../packages/shared/src/protocol/index.ts"() {
711
760
  "use strict";
761
+ init_attachments();
712
762
  init_command();
713
763
  init_content();
714
764
  init_events();
@@ -3111,7 +3161,7 @@ function createTokenVerifier({
3111
3161
  }
3112
3162
  return false;
3113
3163
  };
3114
- const refuse = (k) => {
3164
+ const refuse2 = (k) => {
3115
3165
  cache.set(k, { userId: null, exp: 0, until: now() + negativeTtlMs });
3116
3166
  return { status: "refused" };
3117
3167
  };
@@ -3122,19 +3172,19 @@ function createTokenVerifier({
3122
3172
  const hit = cache.get(k);
3123
3173
  if (hit && hit.until > now()) {
3124
3174
  if (hit.userId === null) return { status: "refused" };
3125
- if (hit.exp * 1e3 <= now()) return refuse(k);
3175
+ if (hit.exp * 1e3 <= now()) return refuse2(k);
3126
3176
  return { status: "valid", userId: hit.userId, exp: hit.exp };
3127
3177
  }
3128
3178
  cache.delete(k);
3129
3179
  const claims = claimsOf(token);
3130
- if (!claims || claims.exp * 1e3 <= now() || !admit(claims.sub)) return refuse(k);
3180
+ if (!claims || claims.exp * 1e3 <= now() || !admit(claims.sub)) return refuse2(k);
3131
3181
  if (!budgeted()) return { status: "unavailable" };
3132
3182
  const user = await db.auth.getUser(token).then(
3133
3183
  (r) => r.error ? retryable(r.error) ? void 0 : null : r.data.user,
3134
3184
  () => void 0
3135
3185
  );
3136
3186
  if (user === void 0) return { status: "unavailable" };
3137
- if (!user || user.id !== claims.sub) return refuse(k);
3187
+ if (!user || user.id !== claims.sub) return refuse2(k);
3138
3188
  cache.set(k, { userId: user.id, exp: claims.exp, until: now() + ttlMs });
3139
3189
  return { status: "valid", userId: user.id, exp: claims.exp };
3140
3190
  }
@@ -3500,7 +3550,7 @@ function createLocalServer({
3500
3550
  server.headersTimeout = LOCAL_HANDSHAKE_TIMEOUT_MS;
3501
3551
  server.requestTimeout = LOCAL_HANDSHAKE_TIMEOUT_MS;
3502
3552
  server.keepAliveTimeout = 1e3;
3503
- const refuse = (socket, status) => {
3553
+ const refuse2 = (socket, status) => {
3504
3554
  const text = status === 403 ? "Forbidden" : "Service Unavailable";
3505
3555
  socket.write(`HTTP/1.1 ${status} ${text}\r
3506
3556
  Connection: close\r
@@ -3512,11 +3562,11 @@ Content-Length: 0\r
3512
3562
  server.on("upgrade", (req, socket, head) => {
3513
3563
  const origin = req.headers.origin ?? "";
3514
3564
  if (req.url !== LOCAL_WS_PATH || !hostAllowed(req.headers.host) || !originAllowed(origin, origins)) {
3515
- refuse(socket, 403);
3565
+ refuse2(socket, 403);
3516
3566
  return;
3517
3567
  }
3518
3568
  if (pendingUpgrades >= LOCAL_MAX_PENDING_UPGRADES || conns.size >= maxSockets) {
3519
- refuse(socket, 503);
3569
+ refuse2(socket, 503);
3520
3570
  return;
3521
3571
  }
3522
3572
  pendingUpgrades += 1;
@@ -3860,6 +3910,69 @@ var init_roster = __esm({
3860
3910
  }
3861
3911
  });
3862
3912
 
3913
+ // src/sessions/attachments.ts
3914
+ import { rmSync as rmSync5 } from "fs";
3915
+ import { join as join15 } from "path";
3916
+ async function loadAttachments(db, scope, attachments) {
3917
+ const prefix = `${scope.workspaceId.toLowerCase()}/${scope.sessionId.toLowerCase()}/`;
3918
+ const named = attachments.map(({ storagePath }, i) => {
3919
+ const match = ATTACHMENT_NAME.exec(storagePath);
3920
+ const declared = match?.[3] === void 0 ? void 0 : TYPE_OF_EXT.get(match[3]);
3921
+ if (!match || !storagePath.startsWith(prefix) || !declared)
3922
+ throw refuse(i + 1, "not in this chat");
3923
+ return { storagePath, declared };
3924
+ });
3925
+ const loaded = [];
3926
+ for (const [i, { storagePath, declared }] of named.entries()) {
3927
+ const n = i + 1;
3928
+ let blob;
3929
+ try {
3930
+ const { data: data2, error } = await db.storage.from(ATTACHMENTS_BUCKET).download(storagePath);
3931
+ if (error || !data2) throw refuse(n, "could not be read");
3932
+ blob = data2;
3933
+ } catch {
3934
+ throw refuse(n, "could not be read");
3935
+ }
3936
+ if (blob.size > ATTACHMENT_MAX_BYTES) throw refuse(n, "too large");
3937
+ let data;
3938
+ try {
3939
+ data = Buffer.from(await blob.arrayBuffer());
3940
+ } catch {
3941
+ throw refuse(n, "could not be read");
3942
+ }
3943
+ if (data.byteLength > ATTACHMENT_MAX_BYTES) throw refuse(n, "too large");
3944
+ const sniffed = sniffImage(data);
3945
+ if (!sniffed) throw refuse(n, "not a supported image");
3946
+ if (sniffed !== declared) throw refuse(n, "not the image type its name says");
3947
+ loaded.push({ storagePath, mediaType: sniffed, data });
3948
+ }
3949
+ return loaded;
3950
+ }
3951
+ function attachmentsTempDir(home, runnerId, sessionId) {
3952
+ return join15(home, "tmp", "attachments", runnerId, sessionId);
3953
+ }
3954
+ function assertStagingInsideHome(home, dir) {
3955
+ return assertInsideRootsOrAncestor(dir, [home]);
3956
+ }
3957
+ function clearAttachmentsTemp(home, runnerId) {
3958
+ const dir = join15(home, "tmp", "attachments", runnerId);
3959
+ assertStagingInsideHome(home, dir);
3960
+ rmSync5(dir, { recursive: true, force: true });
3961
+ }
3962
+ var ATTACHMENTS_BUCKET, TYPE_OF_EXT, refuse;
3963
+ var init_attachments2 = __esm({
3964
+ "src/sessions/attachments.ts"() {
3965
+ "use strict";
3966
+ init_src();
3967
+ init_paths();
3968
+ ATTACHMENTS_BUCKET = "attachments";
3969
+ TYPE_OF_EXT = new Map(
3970
+ Object.entries(ATTACHMENT_MEDIA_TYPES).map(([type, ext]) => [ext, type])
3971
+ );
3972
+ refuse = (n, reason) => new Error(`VALIDATION: attachment ${n}: ${reason}`);
3973
+ }
3974
+ });
3975
+
3863
3976
  // src/github/gh.ts
3864
3977
  import { execFile as execFile4 } from "child_process";
3865
3978
  import { promisify as promisify3 } from "util";
@@ -4459,10 +4572,21 @@ var init_session2 = __esm({
4459
4572
  });
4460
4573
  this.pumping = this.pump();
4461
4574
  }
4462
- prompt(text, uuid) {
4575
+ prompt(text, uuid, { images }) {
4576
+ const content = images.length ? [
4577
+ ...images.map((image) => ({
4578
+ type: "image",
4579
+ source: {
4580
+ type: "base64",
4581
+ media_type: image.mediaType,
4582
+ data: image.data.toString("base64")
4583
+ }
4584
+ })),
4585
+ { type: "text", text }
4586
+ ] : text;
4463
4587
  this.inbox.push({
4464
4588
  type: "user",
4465
- message: { role: "user", content: text },
4589
+ message: { role: "user", content },
4466
4590
  parent_tool_use_id: null,
4467
4591
  uuid,
4468
4592
  session_id: this.providerSessionId ?? void 0
@@ -4691,7 +4815,13 @@ var init_policy = __esm({
4691
4815
 
4692
4816
  // src/codex/session.ts
4693
4817
  import { spawn as spawn2 } from "child_process";
4818
+ import { randomUUID as randomUUID4 } from "crypto";
4694
4819
  import { EventEmitter as EventEmitter2 } from "events";
4820
+ import { chmodSync as chmodSync4, lstatSync as lstatSync4, mkdirSync as mkdirSync6, rmdirSync, rmSync as rmSync6, writeFileSync as writeFileSync5 } from "fs";
4821
+ import { dirname as dirname7, join as join16 } from "path";
4822
+ function removeFiles(files) {
4823
+ for (const file of files) rmSync6(file, { force: true });
4824
+ }
4695
4825
  function scrubSecrets(text) {
4696
4826
  return SECRET_PATTERNS.reduce((acc, [pattern, to]) => acc.replace(pattern, to), text);
4697
4827
  }
@@ -4700,6 +4830,7 @@ var init_session3 = __esm({
4700
4830
  "src/codex/session.ts"() {
4701
4831
  "use strict";
4702
4832
  init_src();
4833
+ init_attachments2();
4703
4834
  init_normalize2();
4704
4835
  init_policy();
4705
4836
  init_rpc();
@@ -4743,6 +4874,8 @@ var init_session3 = __esm({
4743
4874
  turnId = null;
4744
4875
  running = false;
4745
4876
  queue = [];
4877
+ /** The image files of the turn in flight, removed when it ends. */
4878
+ turnFiles = [];
4746
4879
  approvals = /* @__PURE__ */ new Map();
4747
4880
  streaming = /* @__PURE__ */ new Set();
4748
4881
  stderrTail = [];
@@ -4766,6 +4899,7 @@ var init_session3 = __esm({
4766
4899
  const tail = this.stderrTail.length ? `
4767
4900
  ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
4768
4901
  const error = this.stopping || code === 0 ? null : `PROVIDER_ERROR: codex exited with ${code}${tail}`;
4902
+ this.removeAllFiles();
4769
4903
  this.emit("exit", { error });
4770
4904
  });
4771
4905
  const rpc = new JsonRpc(child.stdin, child.stdout);
@@ -4805,19 +4939,88 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
4805
4939
  });
4806
4940
  this.flush();
4807
4941
  }
4808
- prompt(text, _uuid) {
4809
- this.queue.push(text);
4942
+ /**
4943
+ * Queue one turn with images `stageImages` already wrote. From here the session owns the files
4944
+ * and removes them when the turn ends, fails, or the session stops.
4945
+ */
4946
+ prompt(text, _uuid, staged) {
4947
+ if (staged.files.length !== staged.images.length) {
4948
+ throw new Error("PROVIDER_ERROR: codex images must be staged before the prompt");
4949
+ }
4950
+ this.queue.push({ text, files: [...staged.files] });
4810
4951
  this.flush();
4811
4952
  }
4953
+ /**
4954
+ * Write the images Codex will read, synchronously, before the manager persists anything. A
4955
+ * failure removes whatever was written and throws, so a refused prompt leaves no file behind.
4956
+ */
4957
+ stageImages(images) {
4958
+ const files = this.writeImages(images);
4959
+ return { images, files, discard: () => removeFiles(files) };
4960
+ }
4961
+ writeImages(images) {
4962
+ if (images.length === 0) return [];
4963
+ const dir = this.opts.attachmentsDir;
4964
+ const home = this.opts.attachmentsHome;
4965
+ if (!dir || !home) {
4966
+ throw new Error("PROVIDER_ERROR: this codex session has no attachments directory");
4967
+ }
4968
+ const written = [];
4969
+ try {
4970
+ assertStagingInsideHome(home, dir);
4971
+ mkdirSync6(dir, { recursive: true, mode: 448 });
4972
+ assertStagingInsideHome(home, dir);
4973
+ for (const path of [dirname7(dir), dir]) {
4974
+ const st = lstatSync4(path);
4975
+ if (st.isSymbolicLink() || !st.isDirectory()) {
4976
+ throw new Error(`${path} is not a plain directory`);
4977
+ }
4978
+ }
4979
+ chmodSync4(dir, 448);
4980
+ for (const image of images) {
4981
+ const path = join16(dir, `${randomUUID4()}.${ATTACHMENT_MEDIA_TYPES[image.mediaType]}`);
4982
+ writeFileSync5(path, image.data, { mode: 384, flag: "wx" });
4983
+ written.push(path);
4984
+ }
4985
+ } catch (err) {
4986
+ removeFiles(written);
4987
+ const detail = err instanceof Error ? err.message : String(err);
4988
+ throw new Error(`PROVIDER_ERROR: could not write an image for codex: ${detail}`);
4989
+ }
4990
+ return written;
4991
+ }
4992
+ /** The turn in flight is over: its images have been read, or never will be. */
4993
+ endTurnFiles() {
4994
+ removeFiles(this.turnFiles);
4995
+ this.turnFiles = [];
4996
+ }
4997
+ removeAllFiles() {
4998
+ this.endTurnFiles();
4999
+ for (const turn of this.queue) {
5000
+ removeFiles(turn.files);
5001
+ turn.files = [];
5002
+ }
5003
+ if (this.opts.attachmentsDir) {
5004
+ try {
5005
+ rmdirSync(this.opts.attachmentsDir);
5006
+ } catch {
5007
+ }
5008
+ }
5009
+ }
4812
5010
  /** One turn at a time: Codex has no notion of a second concurrent turn on a thread. */
4813
5011
  flush() {
4814
5012
  if (this.running || !this.rpc || !this.providerSessionId) return;
4815
5013
  const next = this.queue.shift();
4816
5014
  if (next === void 0) return;
4817
5015
  this.running = true;
5016
+ this.turnFiles = next.files;
4818
5017
  void this.rpc.request("turn/start", {
4819
5018
  threadId: this.providerSessionId,
4820
- input: [{ type: "text", text: next }],
5019
+ // app-server v2 `UserInput`: images as `localImage` paths, then the text.
5020
+ input: [
5021
+ ...next.files.map((path) => ({ type: "localImage", path })),
5022
+ { type: "text", text: next.text }
5023
+ ],
4821
5024
  sandboxPolicy: this.sandbox,
4822
5025
  ...this.pendingModel ? { model: this.pendingModel } : {},
4823
5026
  ...this.pendingEffort ? { effort: this.pendingEffort } : {}
@@ -4825,6 +5028,7 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
4825
5028
  this.turnId = res.turnId ?? null;
4826
5029
  }).catch((err) => {
4827
5030
  this.running = false;
5031
+ this.endTurnFiles();
4828
5032
  this.emit("result", {
4829
5033
  isError: true,
4830
5034
  durationMs: 0,
@@ -4861,6 +5065,7 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
4861
5065
  case "turn/failed": {
4862
5066
  this.running = false;
4863
5067
  this.turnId = null;
5068
+ this.endTurnFiles();
4864
5069
  const status = params.status ?? (method === "turn/failed" ? "failed" : "completed");
4865
5070
  this.emit("result", {
4866
5071
  isError: status === "failed",
@@ -5015,6 +5220,7 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
5015
5220
  this.emit("permissionTimeout", { id });
5016
5221
  }
5017
5222
  await this.interrupt();
5223
+ this.removeAllFiles();
5018
5224
  this.rpc?.close();
5019
5225
  this.child?.stdin?.end();
5020
5226
  const child = this.child;
@@ -5047,8 +5253,22 @@ var init_create = __esm({
5047
5253
  }
5048
5254
  });
5049
5255
 
5256
+ // src/providers/types.ts
5257
+ var inMemoryImages;
5258
+ var init_types = __esm({
5259
+ "src/providers/types.ts"() {
5260
+ "use strict";
5261
+ inMemoryImages = (images) => ({
5262
+ images,
5263
+ files: [],
5264
+ discard: () => {
5265
+ }
5266
+ });
5267
+ }
5268
+ });
5269
+
5050
5270
  // src/sessions/manager.ts
5051
- import { randomUUID as randomUUID4 } from "crypto";
5271
+ import { randomUUID as randomUUID5 } from "crypto";
5052
5272
  import { realpathSync as realpathSync7 } from "fs";
5053
5273
  import { isAbsolute as isAbsolute10, sep as sep5 } from "path";
5054
5274
  import { isDeepStrictEqual } from "util";
@@ -5077,12 +5297,15 @@ var init_manager = __esm({
5077
5297
  "src/sessions/manager.ts"() {
5078
5298
  "use strict";
5079
5299
  init_src();
5300
+ init_config();
5080
5301
  init_introspect();
5081
5302
  init_watch();
5082
5303
  init_worktree();
5083
5304
  init_worktree_owners();
5084
5305
  init_paths();
5085
5306
  init_create();
5307
+ init_types();
5308
+ init_attachments2();
5086
5309
  isAtOrBelow = (p, dir) => p === dir || p.startsWith(dir.endsWith(sep5) ? dir : `${dir}${sep5}`);
5087
5310
  DENY_MESSAGE_MAX = 2e3;
5088
5311
  EXIT_ERROR_MAX = 8 * 1024;
@@ -5188,6 +5411,12 @@ var init_manager = __esm({
5188
5411
  ...provider === "codex" ? {
5189
5412
  codexPath: this.deps.codexPath,
5190
5413
  checkInput: this.codexGate(sessionId, cwd),
5414
+ attachmentsHome: this.deps.cayenneHome ?? cayenneHome(),
5415
+ attachmentsDir: attachmentsTempDir(
5416
+ this.deps.cayenneHome ?? cayenneHome(),
5417
+ this.deps.runnerId,
5418
+ sessionId
5419
+ ),
5191
5420
  ...p.effort ? { effort: p.effort } : {}
5192
5421
  } : this.isolation(createdBy)
5193
5422
  });
@@ -5217,30 +5446,61 @@ var init_manager = __esm({
5217
5446
  if (p.initialPrompt) {
5218
5447
  await this.prompt(
5219
5448
  sessionId,
5220
- { text: p.initialPrompt, clientUuid: randomUUID4(), attachments: [] },
5449
+ { text: p.initialPrompt, clientUuid: randomUUID5(), attachments: [] },
5221
5450
  createdBy
5222
5451
  );
5223
5452
  }
5224
5453
  return { providerSessionId: live.session.providerSessionId };
5225
5454
  }
5226
5455
  async prompt(sessionId, p, createdBy) {
5456
+ this.assertCodexOwner(sessionId, createdBy);
5457
+ const images = p.attachments.length ? await loadAttachments(
5458
+ this.deps.db,
5459
+ { workspaceId: await this.sessionWorkspace(sessionId), sessionId },
5460
+ p.attachments
5461
+ ) : [];
5227
5462
  const live = await this.ensureLive(sessionId, createdBy);
5228
5463
  this.assertCodexOwner(sessionId, createdBy);
5229
5464
  const text = p.memoryContext ? wrapMemory(p.memoryContext, p.text) : p.text;
5230
- live.echoed.add(p.clientUuid);
5231
- await this.persist(
5232
- sessionId,
5233
- live,
5234
- "user",
5235
- [{ type: "text", text: p.text }],
5236
- p.clientUuid,
5237
- null,
5238
- null
5239
- );
5240
- await this.setStatus(sessionId, "running");
5241
- live.session.prompt(text, p.clientUuid);
5465
+ const staged = images.length ? live.session.stageImages?.(images) ?? inMemoryImages(images) : inMemoryImages([]);
5466
+ try {
5467
+ live.echoed.add(p.clientUuid);
5468
+ await this.persist(
5469
+ sessionId,
5470
+ live,
5471
+ "user",
5472
+ [
5473
+ { type: "text", text: p.text },
5474
+ ...images.map(
5475
+ (image) => ({
5476
+ type: "image",
5477
+ storagePath: image.storagePath,
5478
+ mediaType: image.mediaType
5479
+ })
5480
+ )
5481
+ ],
5482
+ p.clientUuid,
5483
+ null,
5484
+ null
5485
+ );
5486
+ await this.setStatus(sessionId, "running");
5487
+ live.session.prompt(text, p.clientUuid, staged);
5488
+ } catch (err) {
5489
+ staged.discard();
5490
+ throw err;
5491
+ }
5242
5492
  return { accepted: true };
5243
5493
  }
5494
+ /**
5495
+ * The session row's own workspace, which R31 checks attachment paths against. Read, never taken
5496
+ * from the payload or assumed to be the runner's: the path prefix is the whole of the check.
5497
+ */
5498
+ async sessionWorkspace(sessionId) {
5499
+ const { data, error } = await this.deps.db.from("chat_sessions").select("workspace_id").eq("id", sessionId).eq("runner_id", this.deps.runnerId).maybeSingle();
5500
+ if (error) this.deps.log.warn({ err: error.message, sessionId }, "session read failed");
5501
+ if (!data?.workspace_id) throw new Error("SESSION_NOT_FOUND");
5502
+ return data.workspace_id;
5503
+ }
5244
5504
  /**
5245
5505
  * Refuse unless `requestId` is a request this session is still holding.
5246
5506
  *
@@ -6350,9 +6610,9 @@ function createTerminalChannels({
6350
6610
  const inflight = joining.get(terminalId);
6351
6611
  if (inflight) return inflight;
6352
6612
  if (joined.has(terminalId)) return;
6353
- const join17 = attachOne(terminalId).finally(() => joining.delete(terminalId));
6354
- joining.set(terminalId, join17);
6355
- return join17;
6613
+ const join19 = attachOne(terminalId).finally(() => joining.delete(terminalId));
6614
+ joining.set(terminalId, join19);
6615
+ return join19;
6356
6616
  },
6357
6617
  flush,
6358
6618
  detach,
@@ -6409,7 +6669,7 @@ var init_channels = __esm({
6409
6669
  // src/terminal/env.ts
6410
6670
  import { readFileSync as readFileSync7, realpathSync as realpathSync8 } from "fs";
6411
6671
  import { lstat } from "fs/promises";
6412
- import { dirname as dirname7, isAbsolute as isAbsolute11, relative as relative4 } from "path";
6672
+ import { dirname as dirname8, isAbsolute as isAbsolute11, relative as relative4 } from "path";
6413
6673
  function terminalEnv(base, { runnerOwner, claudeConfigDir }) {
6414
6674
  const env = {};
6415
6675
  for (const [key, value] of Object.entries(base)) {
@@ -6465,7 +6725,7 @@ async function rootOwnedPath(fs, real) {
6465
6725
  return false;
6466
6726
  }
6467
6727
  if (st.isSymbolicLink() || st.uid !== 0 || (st.mode & 18) !== 0) return false;
6468
- const parent = dirname7(path);
6728
+ const parent = dirname8(path);
6469
6729
  if (parent === path) return true;
6470
6730
  path = parent;
6471
6731
  }
@@ -7449,7 +7709,7 @@ __export(start_exports, {
7449
7709
  start: () => start
7450
7710
  });
7451
7711
  import { homedir as homedir3, hostname } from "os";
7452
- import { join as join15 } from "path";
7712
+ import { join as join17 } from "path";
7453
7713
  function protectedPaths(claudeConfigDir, home = homedir3()) {
7454
7714
  const inHome = [
7455
7715
  ".claude",
@@ -7457,12 +7717,12 @@ function protectedPaths(claudeConfigDir, home = homedir3()) {
7457
7717
  ".codex",
7458
7718
  ".ssh",
7459
7719
  ".aws",
7460
- join15(".config", "gh"),
7720
+ join17(".config", "gh"),
7461
7721
  ".gnupg",
7462
7722
  ".git-credentials",
7463
7723
  ".npmrc",
7464
7724
  ".netrc"
7465
- ].map((p) => join15(home, p));
7725
+ ].map((p) => join17(home, p));
7466
7726
  return [cayenneHome(), ...claudeConfigDir ? [claudeConfigDir] : [], ...inHome];
7467
7727
  }
7468
7728
  function logCodex(codex) {
@@ -7610,7 +7870,9 @@ async function compose(cfg, lock, host, preflight) {
7610
7870
  // Who the runner's own config names (D34); every heartbeat refresh republishes it.
7611
7871
  memberTerminalIds: [...memberTerminalIds],
7612
7872
  // D36: this build forks a Claude session on `session.start { forkSession: true }`.
7613
- sessionFork: true
7873
+ sessionFork: true,
7874
+ // D37: this build loads image attachments on `session.prompt` under R31.
7875
+ promptImages: true
7614
7876
  });
7615
7877
  const registered = capabilities();
7616
7878
  let loggedCodex = null;
@@ -7644,6 +7906,14 @@ async function compose(cfg, lock, host, preflight) {
7644
7906
  await local.close();
7645
7907
  throw err;
7646
7908
  }
7909
+ try {
7910
+ clearAttachmentsTemp(cayenneHome(), identity.runnerId);
7911
+ } catch (err) {
7912
+ logger.warn(
7913
+ { err: err instanceof Error ? err.message : String(err) },
7914
+ "could not clear staged attachment images"
7915
+ );
7916
+ }
7647
7917
  let stopHeartbeat = null;
7648
7918
  let roster = null;
7649
7919
  try {
@@ -7669,9 +7939,9 @@ async function compose(cfg, lock, host, preflight) {
7669
7939
  // The roster's own array, mutated in place: `serves` reads the live list with no restart.
7670
7940
  allowedMemberIds: liveRoster.allowedMemberIds,
7671
7941
  // Outside every profile and repository: the creator of a worktree is the machine's record.
7672
- worktreeOwners: createWorktreeOwners(join15(cayenneHome(), "worktrees")),
7942
+ worktreeOwners: createWorktreeOwners(join17(cayenneHome(), "worktrees")),
7673
7943
  // Beside it: where each session was spawned, the only source of a resumed directory.
7674
- sessionWorktrees: createSessionWorktrees(join15(cayenneHome(), "session-worktrees")),
7944
+ sessionWorktrees: createSessionWorktrees(join17(cayenneHome(), "session-worktrees")),
7675
7945
  roots,
7676
7946
  allowBypass: cfg.allowBypassPermissions,
7677
7947
  codexPath,
@@ -7931,6 +8201,7 @@ var init_start = __esm({
7931
8201
  init_profile();
7932
8202
  init_registry();
7933
8203
  init_roster();
8204
+ init_attachments2();
7934
8205
  init_handlers();
7935
8206
  init_manager();
7936
8207
  init_supabase();
@@ -7944,9 +8215,9 @@ var init_start = __esm({
7944
8215
  });
7945
8216
 
7946
8217
  // src/cli.ts
7947
- import { existsSync as existsSync5, mkdirSync as mkdirSync6, readFileSync as readFileSync8, realpathSync as realpathSync9, rmSync as rmSync5, writeFileSync as writeFileSync5 } from "fs";
8218
+ import { existsSync as existsSync5, mkdirSync as mkdirSync7, readFileSync as readFileSync8, realpathSync as realpathSync9, rmSync as rmSync7, writeFileSync as writeFileSync6 } from "fs";
7948
8219
  import { homedir as homedir4, hostname as hostname2, userInfo } from "os";
7949
- import { dirname as dirname8, join as join16 } from "path";
8220
+ import { dirname as dirname9, join as join18 } from "path";
7950
8221
  import { Command } from "commander";
7951
8222
  import prompts from "prompts";
7952
8223
 
@@ -8401,8 +8672,8 @@ var service = () => {
8401
8672
  return { exec: exec4, launchd: makeLaunchd(exec4, userInfo().uid), label: plistLabel(currentProfile()) };
8402
8673
  };
8403
8674
  var writePlistFile = (path, xml2) => {
8404
- mkdirSync6(dirname8(path), { recursive: true });
8405
- writeFileSync5(path, xml2, { mode: 420 });
8675
+ mkdirSync7(dirname9(path), { recursive: true });
8676
+ writeFileSync6(path, xml2, { mode: 420 });
8406
8677
  };
8407
8678
  var requireMac = () => {
8408
8679
  if (process.platform !== "darwin") {
@@ -8548,7 +8819,7 @@ program.command("doctor").description("Check the install and say how to fix what
8548
8819
  if (checks.some((c) => !c.ok)) process.exitCode = 1;
8549
8820
  });
8550
8821
  program.command("logs").description("Show the service log").option("-f, --follow", "keep streaming").action(async (opts) => {
8551
- const file = join16(cayenneDir(), "logs", "runner.log");
8822
+ const file = join18(cayenneDir(), "logs", "runner.log");
8552
8823
  if (!existsSync5(file)) throw new Error(`no log yet at ${file}. Run \`cayenne up\` first.`);
8553
8824
  const print = (chunk) => {
8554
8825
  for (const line of chunk.split("\n")) {
@@ -8594,7 +8865,7 @@ program.command("update").description("Re-pin the installed app and restart the
8594
8865
  cliPath: record.cliPath,
8595
8866
  profile: currentProfile(),
8596
8867
  pathEnv: record.pathEnv,
8597
- logDir: join16(dir, "logs")
8868
+ logDir: join18(dir, "logs")
8598
8869
  })
8599
8870
  );
8600
8871
  await launchd.bootout(label);
@@ -8605,7 +8876,7 @@ program.command("uninstall").description("Stop the service and remove the launch
8605
8876
  requireMac();
8606
8877
  const { launchd, label } = service();
8607
8878
  await launchd.bootout(label);
8608
- rmSync5(plistPath(label, homedir4()), { force: true });
8879
+ rmSync7(plistPath(label, homedir4()), { force: true });
8609
8880
  if (!opts.purge) {
8610
8881
  console.log(`removed ${label}`);
8611
8882
  return;
@@ -8615,7 +8886,7 @@ program.command("uninstall").description("Stop the service and remove the launch
8615
8886
  if (dir === "/" || dir === home || home.startsWith(`${dir}/`)) {
8616
8887
  throw new Error(`refusing to purge ${dir}: it is the home directory or an ancestor of it`);
8617
8888
  }
8618
- if (!existsSync5(join16(dir, "config.json")) && !existsSync5(join16(dir, "app", "install.json"))) {
8889
+ if (!existsSync5(join18(dir, "config.json")) && !existsSync5(join18(dir, "app", "install.json"))) {
8619
8890
  throw new Error(`refusing to purge ${dir}: it does not look like a Cayenne profile`);
8620
8891
  }
8621
8892
  if (!opts.yes) {
@@ -8629,7 +8900,7 @@ program.command("uninstall").description("Stop the service and remove the launch
8629
8900
  "could not revoke the stored session (offline?) \u2014 sign out from the web app to be sure"
8630
8901
  );
8631
8902
  }
8632
- rmSync5(dir, { recursive: true, force: true });
8903
+ rmSync7(dir, { recursive: true, force: true });
8633
8904
  console.log(`removed ${label} and ${dir}`);
8634
8905
  });
8635
8906
  program.parseAsync(process.argv).catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpcasabiancai/cayenne",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "files": [
5
5
  "dist/cli.js"
6
6
  ],