@jpcasabiancai/cayenne 0.3.0 → 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.
- package/dist/cli.js +444 -80
- package/package.json +3 -3
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();
|
|
@@ -324,13 +357,42 @@ var init_command = __esm({
|
|
|
324
357
|
effort: Effort.optional(),
|
|
325
358
|
model: z4.string().optional(),
|
|
326
359
|
resumeProviderSessionId: z4.string().optional(),
|
|
360
|
+
// Duplicate a chat (D36): start from a fork of `resumeProviderSessionId` instead of appending
|
|
361
|
+
// to its transcript. Claude-only, no `worktree`. The runner additionally refuses a source that
|
|
362
|
+
// is not the caller's own Claude session on this runner, or a cwd other than the source's own directory.
|
|
363
|
+
forkSession: z4.boolean().optional(),
|
|
327
364
|
initialPrompt: z4.string().optional(),
|
|
328
365
|
title: z4.string().max(200).optional()
|
|
366
|
+
}).superRefine((p, ctx) => {
|
|
367
|
+
if (p.forkSession !== true) return;
|
|
368
|
+
if (p.resumeProviderSessionId === void 0) {
|
|
369
|
+
ctx.addIssue({
|
|
370
|
+
code: "custom",
|
|
371
|
+
message: "forkSession needs resumeProviderSessionId",
|
|
372
|
+
path: ["forkSession"]
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
if (p.worktree !== void 0) {
|
|
376
|
+
ctx.addIssue({
|
|
377
|
+
code: "custom",
|
|
378
|
+
message: "a forked session keeps its working directory",
|
|
379
|
+
path: ["worktree"]
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
if (p.provider === "codex") {
|
|
383
|
+
ctx.addIssue({
|
|
384
|
+
code: "custom",
|
|
385
|
+
message: "codex sessions cannot be forked",
|
|
386
|
+
path: ["provider"]
|
|
387
|
+
});
|
|
388
|
+
}
|
|
329
389
|
}),
|
|
330
390
|
"session.prompt": z4.object({
|
|
331
391
|
text: z4.string().min(1).max(2e5),
|
|
332
392
|
clientUuid: z4.uuid(),
|
|
333
|
-
attachments
|
|
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([]),
|
|
334
396
|
memoryContext: z4.string().max(2e4).optional()
|
|
335
397
|
}),
|
|
336
398
|
"session.permission.respond": z4.object({
|
|
@@ -401,6 +463,7 @@ var ContentBlock, MessageRole, ChatMessageRow, PermissionRequestRow, CodexQuesti
|
|
|
401
463
|
var init_content = __esm({
|
|
402
464
|
"../../packages/shared/src/protocol/content.ts"() {
|
|
403
465
|
"use strict";
|
|
466
|
+
init_attachments();
|
|
404
467
|
ContentBlock = z5.discriminatedUnion("type", [
|
|
405
468
|
z5.object({ type: z5.literal("text"), text: z5.string() }),
|
|
406
469
|
z5.object({ type: z5.literal("thinking"), text: z5.string() }),
|
|
@@ -417,7 +480,14 @@ var init_content = __esm({
|
|
|
417
480
|
isError: z5.boolean().default(false),
|
|
418
481
|
structured: z5.unknown().optional()
|
|
419
482
|
}),
|
|
420
|
-
|
|
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
|
+
}),
|
|
421
491
|
z5.object({
|
|
422
492
|
type: z5.literal("result"),
|
|
423
493
|
isError: z5.boolean(),
|
|
@@ -634,6 +704,18 @@ var init_runner = __esm({
|
|
|
634
704
|
* cannot grant a terminal. Optional so an older row still parses.
|
|
635
705
|
*/
|
|
636
706
|
memberTerminalIds: z8.array(z8.uuid()).max(64).optional(),
|
|
707
|
+
/**
|
|
708
|
+
* Whether this runner honours `session.start { forkSession: true }` (D36, protocol §9). An older
|
|
709
|
+
* runner strips the field and then refuses the resume under R30, so a client sends
|
|
710
|
+
* `forkSession` only when this is true. Absent reads as false; optional so an older row parses.
|
|
711
|
+
*/
|
|
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(),
|
|
637
719
|
/**
|
|
638
720
|
* The raw Ed25519 public key of the running process (32 bytes, unpadded base64url), which signs
|
|
639
721
|
* the local server's `hello` (protocol §6.2). New on every start, republished by every capabilities refresh that changes the row;
|
|
@@ -650,6 +732,21 @@ var init_runner = __esm({
|
|
|
650
732
|
}
|
|
651
733
|
});
|
|
652
734
|
|
|
735
|
+
// ../../packages/shared/src/protocol/session.ts
|
|
736
|
+
import { z as z9 } from "zod";
|
|
737
|
+
var SlashCommand, SessionCommandsListed;
|
|
738
|
+
var init_session = __esm({
|
|
739
|
+
"../../packages/shared/src/protocol/session.ts"() {
|
|
740
|
+
"use strict";
|
|
741
|
+
SlashCommand = z9.object({
|
|
742
|
+
name: z9.string(),
|
|
743
|
+
description: z9.string(),
|
|
744
|
+
argumentHint: z9.string()
|
|
745
|
+
});
|
|
746
|
+
SessionCommandsListed = z9.object({ slashCommands: z9.array(SlashCommand) });
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
|
|
653
750
|
// ../../packages/shared/src/protocol/transport.ts
|
|
654
751
|
var init_transport = __esm({
|
|
655
752
|
"../../packages/shared/src/protocol/transport.ts"() {
|
|
@@ -661,6 +758,7 @@ var init_transport = __esm({
|
|
|
661
758
|
var init_protocol = __esm({
|
|
662
759
|
"../../packages/shared/src/protocol/index.ts"() {
|
|
663
760
|
"use strict";
|
|
761
|
+
init_attachments();
|
|
664
762
|
init_command();
|
|
665
763
|
init_content();
|
|
666
764
|
init_events();
|
|
@@ -668,6 +766,7 @@ var init_protocol = __esm({
|
|
|
668
766
|
init_github();
|
|
669
767
|
init_local();
|
|
670
768
|
init_runner();
|
|
769
|
+
init_session();
|
|
671
770
|
init_terminal();
|
|
672
771
|
init_transport();
|
|
673
772
|
}
|
|
@@ -817,7 +916,7 @@ import {
|
|
|
817
916
|
} from "fs";
|
|
818
917
|
import { homedir as homedir2 } from "os";
|
|
819
918
|
import { dirname, isAbsolute as isAbsolute2, join as join3 } from "path";
|
|
820
|
-
import { z as
|
|
919
|
+
import { z as z10 } from "zod";
|
|
821
920
|
function setProfileOverride(name) {
|
|
822
921
|
profileOverride = name;
|
|
823
922
|
}
|
|
@@ -892,24 +991,24 @@ var init_config = __esm({
|
|
|
892
991
|
init_cloud();
|
|
893
992
|
init_profile();
|
|
894
993
|
MEMBER_TERMINALS_ERROR = "allowMemberTerminals must be false or a list of user ids";
|
|
895
|
-
RunnerConfig =
|
|
896
|
-
supabaseUrl:
|
|
897
|
-
supabaseAnonKey:
|
|
898
|
-
workspaceId:
|
|
899
|
-
runnerId:
|
|
900
|
-
name:
|
|
901
|
-
roots:
|
|
902
|
-
port:
|
|
994
|
+
RunnerConfig = z10.object({
|
|
995
|
+
supabaseUrl: z10.url(),
|
|
996
|
+
supabaseAnonKey: z10.string().min(1),
|
|
997
|
+
workspaceId: z10.uuid().optional(),
|
|
998
|
+
runnerId: z10.uuid().optional(),
|
|
999
|
+
name: z10.string().optional(),
|
|
1000
|
+
roots: z10.array(z10.string()).default([]),
|
|
1001
|
+
port: z10.number().int().default(LOCAL_PORT),
|
|
903
1002
|
/** Extra page origins the local server admits, beside the built-in list (`buildOrigins`). */
|
|
904
|
-
localOrigins:
|
|
905
|
-
allowBypassPermissions:
|
|
1003
|
+
localOrigins: z10.array(z10.string()).default([]),
|
|
1004
|
+
allowBypassPermissions: z10.boolean().default(false),
|
|
906
1005
|
/** Absolute path to the `codex` binary, when it is not on the daemon's PATH. */
|
|
907
|
-
codexPath:
|
|
1006
|
+
codexPath: z10.string().optional(),
|
|
908
1007
|
/**
|
|
909
1008
|
* The Claude Code config directory this profile's sessions run on (D33), handed to them as
|
|
910
1009
|
* `CLAUDE_CONFIG_DIR`. Unset means the CLI's own default, `~/.claude`.
|
|
911
1010
|
*/
|
|
912
|
-
claudeConfigDir:
|
|
1011
|
+
claudeConfigDir: z10.string().optional(),
|
|
913
1012
|
/**
|
|
914
1013
|
* Who besides the runner owner may hold a terminal here (D34, plan ruling 14): `false`, or the
|
|
915
1014
|
* user ids this machine's owner names. Machine-local like `allowBypassPermissions`: a workspace
|
|
@@ -917,15 +1016,15 @@ var init_config = __esm({
|
|
|
917
1016
|
* `true` is refused — there is no "every member" switch. A listed member's shell runs as this
|
|
918
1017
|
* machine's OS user; the env allowlist is not a boundary.
|
|
919
1018
|
*/
|
|
920
|
-
allowMemberTerminals:
|
|
1019
|
+
allowMemberTerminals: z10.union(
|
|
921
1020
|
[
|
|
922
|
-
|
|
923
|
-
|
|
1021
|
+
z10.literal(false),
|
|
1022
|
+
z10.array(z10.uuid({ error: MEMBER_TERMINALS_ERROR }), { error: MEMBER_TERMINALS_ERROR }).max(64, { error: MEMBER_TERMINALS_ERROR })
|
|
924
1023
|
],
|
|
925
1024
|
{ error: MEMBER_TERMINALS_ERROR }
|
|
926
1025
|
).default(false),
|
|
927
1026
|
/** Minutes a terminal nobody attends stays alive before the runner kills it (features §2). */
|
|
928
|
-
terminalIdleMinutes:
|
|
1027
|
+
terminalIdleMinutes: z10.number().int().min(1).max(1440).default(10)
|
|
929
1028
|
});
|
|
930
1029
|
memberTerminalIdsOf = (cfg) => cfg.allowMemberTerminals === false ? [] : [...cfg.allowMemberTerminals];
|
|
931
1030
|
configPath = (dir) => join3(dir, "config.json");
|
|
@@ -1170,7 +1269,7 @@ import {
|
|
|
1170
1269
|
} from "fs";
|
|
1171
1270
|
import { uptime } from "os";
|
|
1172
1271
|
import { join as join5 } from "path";
|
|
1173
|
-
import { z as
|
|
1272
|
+
import { z as z11 } from "zod";
|
|
1174
1273
|
function bootedAt(now = Date.now()) {
|
|
1175
1274
|
return new Date(Math.round((now - uptime() * 1e3) / 1e3) * 1e3).toISOString();
|
|
1176
1275
|
}
|
|
@@ -1313,13 +1412,13 @@ var LockRecord, BOOT_DRIFT_MS, ATTEMPTS, BACKOFF_MS, CLAIM_TTL_MS, errno, claimO
|
|
|
1313
1412
|
var init_lock = __esm({
|
|
1314
1413
|
"src/lock.ts"() {
|
|
1315
1414
|
"use strict";
|
|
1316
|
-
LockRecord =
|
|
1317
|
-
pid:
|
|
1318
|
-
profile:
|
|
1319
|
-
startedAt:
|
|
1415
|
+
LockRecord = z11.object({
|
|
1416
|
+
pid: z11.number().int().positive(),
|
|
1417
|
+
profile: z11.string(),
|
|
1418
|
+
startedAt: z11.string(),
|
|
1320
1419
|
/** `bootedAt()` when the lock was taken; a lock from another boot is stale whatever its pid. */
|
|
1321
|
-
bootedAt:
|
|
1322
|
-
version:
|
|
1420
|
+
bootedAt: z11.string(),
|
|
1421
|
+
version: z11.string()
|
|
1323
1422
|
});
|
|
1324
1423
|
BOOT_DRIFT_MS = 1e4;
|
|
1325
1424
|
ATTEMPTS = 8;
|
|
@@ -3062,7 +3161,7 @@ function createTokenVerifier({
|
|
|
3062
3161
|
}
|
|
3063
3162
|
return false;
|
|
3064
3163
|
};
|
|
3065
|
-
const
|
|
3164
|
+
const refuse2 = (k) => {
|
|
3066
3165
|
cache.set(k, { userId: null, exp: 0, until: now() + negativeTtlMs });
|
|
3067
3166
|
return { status: "refused" };
|
|
3068
3167
|
};
|
|
@@ -3073,19 +3172,19 @@ function createTokenVerifier({
|
|
|
3073
3172
|
const hit = cache.get(k);
|
|
3074
3173
|
if (hit && hit.until > now()) {
|
|
3075
3174
|
if (hit.userId === null) return { status: "refused" };
|
|
3076
|
-
if (hit.exp * 1e3 <= now()) return
|
|
3175
|
+
if (hit.exp * 1e3 <= now()) return refuse2(k);
|
|
3077
3176
|
return { status: "valid", userId: hit.userId, exp: hit.exp };
|
|
3078
3177
|
}
|
|
3079
3178
|
cache.delete(k);
|
|
3080
3179
|
const claims = claimsOf(token);
|
|
3081
|
-
if (!claims || claims.exp * 1e3 <= now() || !admit(claims.sub)) return
|
|
3180
|
+
if (!claims || claims.exp * 1e3 <= now() || !admit(claims.sub)) return refuse2(k);
|
|
3082
3181
|
if (!budgeted()) return { status: "unavailable" };
|
|
3083
3182
|
const user = await db.auth.getUser(token).then(
|
|
3084
3183
|
(r) => r.error ? retryable(r.error) ? void 0 : null : r.data.user,
|
|
3085
3184
|
() => void 0
|
|
3086
3185
|
);
|
|
3087
3186
|
if (user === void 0) return { status: "unavailable" };
|
|
3088
|
-
if (!user || user.id !== claims.sub) return
|
|
3187
|
+
if (!user || user.id !== claims.sub) return refuse2(k);
|
|
3089
3188
|
cache.set(k, { userId: user.id, exp: claims.exp, until: now() + ttlMs });
|
|
3090
3189
|
return { status: "valid", userId: user.id, exp: claims.exp };
|
|
3091
3190
|
}
|
|
@@ -3451,7 +3550,7 @@ function createLocalServer({
|
|
|
3451
3550
|
server.headersTimeout = LOCAL_HANDSHAKE_TIMEOUT_MS;
|
|
3452
3551
|
server.requestTimeout = LOCAL_HANDSHAKE_TIMEOUT_MS;
|
|
3453
3552
|
server.keepAliveTimeout = 1e3;
|
|
3454
|
-
const
|
|
3553
|
+
const refuse2 = (socket, status) => {
|
|
3455
3554
|
const text = status === 403 ? "Forbidden" : "Service Unavailable";
|
|
3456
3555
|
socket.write(`HTTP/1.1 ${status} ${text}\r
|
|
3457
3556
|
Connection: close\r
|
|
@@ -3463,11 +3562,11 @@ Content-Length: 0\r
|
|
|
3463
3562
|
server.on("upgrade", (req, socket, head) => {
|
|
3464
3563
|
const origin = req.headers.origin ?? "";
|
|
3465
3564
|
if (req.url !== LOCAL_WS_PATH || !hostAllowed(req.headers.host) || !originAllowed(origin, origins)) {
|
|
3466
|
-
|
|
3565
|
+
refuse2(socket, 403);
|
|
3467
3566
|
return;
|
|
3468
3567
|
}
|
|
3469
3568
|
if (pendingUpgrades >= LOCAL_MAX_PENDING_UPGRADES || conns.size >= maxSockets) {
|
|
3470
|
-
|
|
3569
|
+
refuse2(socket, 503);
|
|
3471
3570
|
return;
|
|
3472
3571
|
}
|
|
3473
3572
|
pendingUpgrades += 1;
|
|
@@ -3811,6 +3910,69 @@ var init_roster = __esm({
|
|
|
3811
3910
|
}
|
|
3812
3911
|
});
|
|
3813
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
|
+
|
|
3814
3976
|
// src/github/gh.ts
|
|
3815
3977
|
import { execFile as execFile4 } from "child_process";
|
|
3816
3978
|
import { promisify as promisify3 } from "util";
|
|
@@ -3962,11 +4124,12 @@ var init_refusals = __esm({
|
|
|
3962
4124
|
});
|
|
3963
4125
|
|
|
3964
4126
|
// src/sessions/handlers.ts
|
|
4127
|
+
import { ZodError } from "zod";
|
|
3965
4128
|
function parsePayload(row, kind) {
|
|
3966
4129
|
try {
|
|
3967
4130
|
return parseCommand(row, kind);
|
|
3968
4131
|
} catch (err) {
|
|
3969
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4132
|
+
const message = err instanceof ZodError ? err.issues.map((i) => i.message).join("; ") : err instanceof Error ? err.message : String(err);
|
|
3970
4133
|
throw new Error(message.startsWith("VALIDATION:") ? message : `VALIDATION: ${message}`);
|
|
3971
4134
|
}
|
|
3972
4135
|
}
|
|
@@ -4354,7 +4517,7 @@ import { randomUUID as randomUUID3 } from "crypto";
|
|
|
4354
4517
|
import { EventEmitter } from "events";
|
|
4355
4518
|
import { query as sdkQuery } from "@anthropic-ai/claude-agent-sdk";
|
|
4356
4519
|
var ClaudeSession;
|
|
4357
|
-
var
|
|
4520
|
+
var init_session2 = __esm({
|
|
4358
4521
|
"src/claude/session.ts"() {
|
|
4359
4522
|
"use strict";
|
|
4360
4523
|
init_src();
|
|
@@ -4364,7 +4527,7 @@ var init_session = __esm({
|
|
|
4364
4527
|
constructor(opts) {
|
|
4365
4528
|
super();
|
|
4366
4529
|
this.opts = opts;
|
|
4367
|
-
this.providerSessionId = opts.resume ?? null;
|
|
4530
|
+
this.providerSessionId = opts.forkSession ? null : opts.resume ?? null;
|
|
4368
4531
|
}
|
|
4369
4532
|
opts;
|
|
4370
4533
|
provider = "claude";
|
|
@@ -4397,6 +4560,7 @@ var init_session = __esm({
|
|
|
4397
4560
|
// Present only when asked for, so the default call stays the CLI's own behaviour.
|
|
4398
4561
|
...this.opts.strictMcpConfig === void 0 ? {} : { strictMcpConfig: this.opts.strictMcpConfig },
|
|
4399
4562
|
...this.opts.mcpServers === void 0 ? {} : { mcpServers: this.opts.mcpServers },
|
|
4563
|
+
...this.opts.forkSession ? { forkSession: true } : {},
|
|
4400
4564
|
canUseTool,
|
|
4401
4565
|
abortController: this.abort,
|
|
4402
4566
|
stderr: (line) => {
|
|
@@ -4408,10 +4572,21 @@ var init_session = __esm({
|
|
|
4408
4572
|
});
|
|
4409
4573
|
this.pumping = this.pump();
|
|
4410
4574
|
}
|
|
4411
|
-
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;
|
|
4412
4587
|
this.inbox.push({
|
|
4413
4588
|
type: "user",
|
|
4414
|
-
message: { role: "user", content
|
|
4589
|
+
message: { role: "user", content },
|
|
4415
4590
|
parent_tool_use_id: null,
|
|
4416
4591
|
uuid,
|
|
4417
4592
|
session_id: this.providerSessionId ?? void 0
|
|
@@ -4640,15 +4815,22 @@ var init_policy = __esm({
|
|
|
4640
4815
|
|
|
4641
4816
|
// src/codex/session.ts
|
|
4642
4817
|
import { spawn as spawn2 } from "child_process";
|
|
4818
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
4643
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
|
+
}
|
|
4644
4825
|
function scrubSecrets(text) {
|
|
4645
4826
|
return SECRET_PATTERNS.reduce((acc, [pattern, to]) => acc.replace(pattern, to), text);
|
|
4646
4827
|
}
|
|
4647
4828
|
var STDERR_TAIL_MAX, SIGKILL_AFTER_MS, SECRET_PATTERNS, APPROVAL_METHODS, CodexSession;
|
|
4648
|
-
var
|
|
4829
|
+
var init_session3 = __esm({
|
|
4649
4830
|
"src/codex/session.ts"() {
|
|
4650
4831
|
"use strict";
|
|
4651
4832
|
init_src();
|
|
4833
|
+
init_attachments2();
|
|
4652
4834
|
init_normalize2();
|
|
4653
4835
|
init_policy();
|
|
4654
4836
|
init_rpc();
|
|
@@ -4692,6 +4874,8 @@ var init_session2 = __esm({
|
|
|
4692
4874
|
turnId = null;
|
|
4693
4875
|
running = false;
|
|
4694
4876
|
queue = [];
|
|
4877
|
+
/** The image files of the turn in flight, removed when it ends. */
|
|
4878
|
+
turnFiles = [];
|
|
4695
4879
|
approvals = /* @__PURE__ */ new Map();
|
|
4696
4880
|
streaming = /* @__PURE__ */ new Set();
|
|
4697
4881
|
stderrTail = [];
|
|
@@ -4715,6 +4899,7 @@ var init_session2 = __esm({
|
|
|
4715
4899
|
const tail = this.stderrTail.length ? `
|
|
4716
4900
|
${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
|
|
4717
4901
|
const error = this.stopping || code === 0 ? null : `PROVIDER_ERROR: codex exited with ${code}${tail}`;
|
|
4902
|
+
this.removeAllFiles();
|
|
4718
4903
|
this.emit("exit", { error });
|
|
4719
4904
|
});
|
|
4720
4905
|
const rpc = new JsonRpc(child.stdin, child.stdout);
|
|
@@ -4754,19 +4939,88 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
|
|
|
4754
4939
|
});
|
|
4755
4940
|
this.flush();
|
|
4756
4941
|
}
|
|
4757
|
-
|
|
4758
|
-
|
|
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] });
|
|
4759
4951
|
this.flush();
|
|
4760
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
|
+
}
|
|
4761
5010
|
/** One turn at a time: Codex has no notion of a second concurrent turn on a thread. */
|
|
4762
5011
|
flush() {
|
|
4763
5012
|
if (this.running || !this.rpc || !this.providerSessionId) return;
|
|
4764
5013
|
const next = this.queue.shift();
|
|
4765
5014
|
if (next === void 0) return;
|
|
4766
5015
|
this.running = true;
|
|
5016
|
+
this.turnFiles = next.files;
|
|
4767
5017
|
void this.rpc.request("turn/start", {
|
|
4768
5018
|
threadId: this.providerSessionId,
|
|
4769
|
-
|
|
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
|
+
],
|
|
4770
5024
|
sandboxPolicy: this.sandbox,
|
|
4771
5025
|
...this.pendingModel ? { model: this.pendingModel } : {},
|
|
4772
5026
|
...this.pendingEffort ? { effort: this.pendingEffort } : {}
|
|
@@ -4774,6 +5028,7 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
|
|
|
4774
5028
|
this.turnId = res.turnId ?? null;
|
|
4775
5029
|
}).catch((err) => {
|
|
4776
5030
|
this.running = false;
|
|
5031
|
+
this.endTurnFiles();
|
|
4777
5032
|
this.emit("result", {
|
|
4778
5033
|
isError: true,
|
|
4779
5034
|
durationMs: 0,
|
|
@@ -4810,6 +5065,7 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
|
|
|
4810
5065
|
case "turn/failed": {
|
|
4811
5066
|
this.running = false;
|
|
4812
5067
|
this.turnId = null;
|
|
5068
|
+
this.endTurnFiles();
|
|
4813
5069
|
const status = params.status ?? (method === "turn/failed" ? "failed" : "completed");
|
|
4814
5070
|
this.emit("result", {
|
|
4815
5071
|
isError: status === "failed",
|
|
@@ -4964,6 +5220,7 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
|
|
|
4964
5220
|
this.emit("permissionTimeout", { id });
|
|
4965
5221
|
}
|
|
4966
5222
|
await this.interrupt();
|
|
5223
|
+
this.removeAllFiles();
|
|
4967
5224
|
this.rpc?.close();
|
|
4968
5225
|
this.child?.stdin?.end();
|
|
4969
5226
|
const child = this.child;
|
|
@@ -4991,13 +5248,27 @@ function createProviderSession(provider, opts) {
|
|
|
4991
5248
|
var init_create = __esm({
|
|
4992
5249
|
"src/providers/create.ts"() {
|
|
4993
5250
|
"use strict";
|
|
4994
|
-
init_session();
|
|
4995
5251
|
init_session2();
|
|
5252
|
+
init_session3();
|
|
5253
|
+
}
|
|
5254
|
+
});
|
|
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
|
+
});
|
|
4996
5267
|
}
|
|
4997
5268
|
});
|
|
4998
5269
|
|
|
4999
5270
|
// src/sessions/manager.ts
|
|
5000
|
-
import { randomUUID as
|
|
5271
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
5001
5272
|
import { realpathSync as realpathSync7 } from "fs";
|
|
5002
5273
|
import { isAbsolute as isAbsolute10, sep as sep5 } from "path";
|
|
5003
5274
|
import { isDeepStrictEqual } from "util";
|
|
@@ -5026,12 +5297,15 @@ var init_manager = __esm({
|
|
|
5026
5297
|
"src/sessions/manager.ts"() {
|
|
5027
5298
|
"use strict";
|
|
5028
5299
|
init_src();
|
|
5300
|
+
init_config();
|
|
5029
5301
|
init_introspect();
|
|
5030
5302
|
init_watch();
|
|
5031
5303
|
init_worktree();
|
|
5032
5304
|
init_worktree_owners();
|
|
5033
5305
|
init_paths();
|
|
5034
5306
|
init_create();
|
|
5307
|
+
init_types();
|
|
5308
|
+
init_attachments2();
|
|
5035
5309
|
isAtOrBelow = (p, dir) => p === dir || p.startsWith(dir.endsWith(sep5) ? dir : `${dir}${sep5}`);
|
|
5036
5310
|
DENY_MESSAGE_MAX = 2e3;
|
|
5037
5311
|
EXIT_ERROR_MAX = 8 * 1024;
|
|
@@ -5095,10 +5369,16 @@ var init_manager = __esm({
|
|
|
5095
5369
|
}
|
|
5096
5370
|
this.assertModeAllowed(p.permissionMode);
|
|
5097
5371
|
const provider = p.provider ?? "claude";
|
|
5372
|
+
if (p.forkSession && provider !== "claude") {
|
|
5373
|
+
throw new Error("VALIDATION: codex sessions cannot be forked");
|
|
5374
|
+
}
|
|
5098
5375
|
this.assertProviderAllowed(provider, createdBy);
|
|
5099
5376
|
const typed = resolveInsideRoots(p.cwd, this.deps.roots);
|
|
5100
5377
|
this.assertNotRemoving(typed.real);
|
|
5101
|
-
|
|
5378
|
+
const forking = p.forkSession === true && p.resumeProviderSessionId !== void 0;
|
|
5379
|
+
if (forking) {
|
|
5380
|
+
await this.assertForkable(p.resumeProviderSessionId, createdBy, typed.real);
|
|
5381
|
+
} else if (p.resumeProviderSessionId !== void 0) {
|
|
5102
5382
|
await this.assertResumable(sessionId, p.resumeProviderSessionId);
|
|
5103
5383
|
}
|
|
5104
5384
|
let { real: cwd, root } = typed;
|
|
@@ -5106,7 +5386,7 @@ var init_manager = __esm({
|
|
|
5106
5386
|
if (p.worktree) {
|
|
5107
5387
|
cwd = await this.startWorktree(typed.real, typed.root, p.worktree, createdBy);
|
|
5108
5388
|
inWorktree = true;
|
|
5109
|
-
} else if (p.resumeProviderSessionId !== void 0) {
|
|
5389
|
+
} else if (p.resumeProviderSessionId !== void 0 && !forking) {
|
|
5110
5390
|
const recorded = await this.recordedWorktree(sessionId);
|
|
5111
5391
|
if (recorded) ({ real: cwd, root } = recorded);
|
|
5112
5392
|
}
|
|
@@ -5127,9 +5407,16 @@ var init_manager = __esm({
|
|
|
5127
5407
|
permissionMode: p.permissionMode,
|
|
5128
5408
|
model: p.model,
|
|
5129
5409
|
resume: p.resumeProviderSessionId,
|
|
5410
|
+
...forking ? { forkSession: true } : {},
|
|
5130
5411
|
...provider === "codex" ? {
|
|
5131
5412
|
codexPath: this.deps.codexPath,
|
|
5132
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
|
+
),
|
|
5133
5420
|
...p.effort ? { effort: p.effort } : {}
|
|
5134
5421
|
} : this.isolation(createdBy)
|
|
5135
5422
|
});
|
|
@@ -5137,7 +5424,9 @@ var init_manager = __esm({
|
|
|
5137
5424
|
started();
|
|
5138
5425
|
}
|
|
5139
5426
|
if (inWorktree) await this.recordSessionWorktree(sessionId, cwd, root);
|
|
5140
|
-
else if (p.resumeProviderSessionId === void 0)
|
|
5427
|
+
else if (p.resumeProviderSessionId === void 0 || forking) {
|
|
5428
|
+
this.forgetSessionWorktree(sessionId);
|
|
5429
|
+
}
|
|
5141
5430
|
live.status = "idle";
|
|
5142
5431
|
await this.updateSession(sessionId, { status: "idle", model: p.model ?? null });
|
|
5143
5432
|
await this.broadcast(sessionId, {
|
|
@@ -5157,30 +5446,61 @@ var init_manager = __esm({
|
|
|
5157
5446
|
if (p.initialPrompt) {
|
|
5158
5447
|
await this.prompt(
|
|
5159
5448
|
sessionId,
|
|
5160
|
-
{ text: p.initialPrompt, clientUuid:
|
|
5449
|
+
{ text: p.initialPrompt, clientUuid: randomUUID5(), attachments: [] },
|
|
5161
5450
|
createdBy
|
|
5162
5451
|
);
|
|
5163
5452
|
}
|
|
5164
5453
|
return { providerSessionId: live.session.providerSessionId };
|
|
5165
5454
|
}
|
|
5166
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
|
+
) : [];
|
|
5167
5462
|
const live = await this.ensureLive(sessionId, createdBy);
|
|
5168
5463
|
this.assertCodexOwner(sessionId, createdBy);
|
|
5169
5464
|
const text = p.memoryContext ? wrapMemory(p.memoryContext, p.text) : p.text;
|
|
5170
|
-
live.
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
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
|
+
}
|
|
5182
5492
|
return { accepted: true };
|
|
5183
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
|
+
}
|
|
5184
5504
|
/**
|
|
5185
5505
|
* Refuse unless `requestId` is a request this session is still holding.
|
|
5186
5506
|
*
|
|
@@ -5584,6 +5904,37 @@ var init_manager = __esm({
|
|
|
5584
5904
|
throw new Error("VALIDATION: resumeProviderSessionId does not belong to this session");
|
|
5585
5905
|
}
|
|
5586
5906
|
}
|
|
5907
|
+
/**
|
|
5908
|
+
* D36, amending R30 for `forkSession: true`: the resume id may be held by another session, but
|
|
5909
|
+
* only one on this runner that `createdBy` owns — a member must not fork the runner owner's
|
|
5910
|
+
* conversation, or anyone else's. A command with no creator owns nothing. Only a Claude row with
|
|
5911
|
+
* a non-empty id can be a source. The new session must run where the source did, decided the way
|
|
5912
|
+
* a resume of the source would be: the worktree this runner recorded for it, else its canonical
|
|
5913
|
+
* `cwd`. Never `worktree_path`: the runner owner can rewrite that column on a member's chat, and
|
|
5914
|
+
* it must not steer where the member's fork runs. A source path that no longer resolves inside
|
|
5915
|
+
* the roots cannot be proved equal, so it is refused, not thrown through.
|
|
5916
|
+
*/
|
|
5917
|
+
async assertForkable(providerSessionId, createdBy, cwd) {
|
|
5918
|
+
if (providerSessionId === "") {
|
|
5919
|
+
throw new Error("VALIDATION: forkSession source is not a session you own on this runner");
|
|
5920
|
+
}
|
|
5921
|
+
const { data, error } = await this.deps.db.from("chat_sessions").select("id, owner_id, cwd").eq("provider_session_id", providerSessionId).eq("runner_id", this.deps.runnerId).eq("provider", "claude").maybeSingle();
|
|
5922
|
+
if (error) throw new Error(`PROVIDER_ERROR: could not read the session: ${error.message}`);
|
|
5923
|
+
if (!data || createdBy === null || data.owner_id !== createdBy) {
|
|
5924
|
+
throw new Error("VALIDATION: forkSession source is not a session you own on this runner");
|
|
5925
|
+
}
|
|
5926
|
+
let sourceCwd = (await this.recordedWorktree(data.id))?.real ?? null;
|
|
5927
|
+
if (sourceCwd === null) {
|
|
5928
|
+
try {
|
|
5929
|
+
sourceCwd = resolveInsideRoots(data.cwd, this.deps.roots).real;
|
|
5930
|
+
} catch {
|
|
5931
|
+
sourceCwd = null;
|
|
5932
|
+
}
|
|
5933
|
+
}
|
|
5934
|
+
if (sourceCwd !== cwd) {
|
|
5935
|
+
throw new Error("VALIDATION: a forked session keeps its working directory");
|
|
5936
|
+
}
|
|
5937
|
+
}
|
|
5587
5938
|
/**
|
|
5588
5939
|
* Whether this runner may open a session on `provider` for `createdBy`.
|
|
5589
5940
|
*
|
|
@@ -6259,9 +6610,9 @@ function createTerminalChannels({
|
|
|
6259
6610
|
const inflight = joining.get(terminalId);
|
|
6260
6611
|
if (inflight) return inflight;
|
|
6261
6612
|
if (joined.has(terminalId)) return;
|
|
6262
|
-
const
|
|
6263
|
-
joining.set(terminalId,
|
|
6264
|
-
return
|
|
6613
|
+
const join19 = attachOne(terminalId).finally(() => joining.delete(terminalId));
|
|
6614
|
+
joining.set(terminalId, join19);
|
|
6615
|
+
return join19;
|
|
6265
6616
|
},
|
|
6266
6617
|
flush,
|
|
6267
6618
|
detach,
|
|
@@ -6318,7 +6669,7 @@ var init_channels = __esm({
|
|
|
6318
6669
|
// src/terminal/env.ts
|
|
6319
6670
|
import { readFileSync as readFileSync7, realpathSync as realpathSync8 } from "fs";
|
|
6320
6671
|
import { lstat } from "fs/promises";
|
|
6321
|
-
import { dirname as
|
|
6672
|
+
import { dirname as dirname8, isAbsolute as isAbsolute11, relative as relative4 } from "path";
|
|
6322
6673
|
function terminalEnv(base, { runnerOwner, claudeConfigDir }) {
|
|
6323
6674
|
const env = {};
|
|
6324
6675
|
for (const [key, value] of Object.entries(base)) {
|
|
@@ -6374,7 +6725,7 @@ async function rootOwnedPath(fs, real) {
|
|
|
6374
6725
|
return false;
|
|
6375
6726
|
}
|
|
6376
6727
|
if (st.isSymbolicLink() || st.uid !== 0 || (st.mode & 18) !== 0) return false;
|
|
6377
|
-
const parent =
|
|
6728
|
+
const parent = dirname8(path);
|
|
6378
6729
|
if (parent === path) return true;
|
|
6379
6730
|
path = parent;
|
|
6380
6731
|
}
|
|
@@ -7358,7 +7709,7 @@ __export(start_exports, {
|
|
|
7358
7709
|
start: () => start
|
|
7359
7710
|
});
|
|
7360
7711
|
import { homedir as homedir3, hostname } from "os";
|
|
7361
|
-
import { join as
|
|
7712
|
+
import { join as join17 } from "path";
|
|
7362
7713
|
function protectedPaths(claudeConfigDir, home = homedir3()) {
|
|
7363
7714
|
const inHome = [
|
|
7364
7715
|
".claude",
|
|
@@ -7366,12 +7717,12 @@ function protectedPaths(claudeConfigDir, home = homedir3()) {
|
|
|
7366
7717
|
".codex",
|
|
7367
7718
|
".ssh",
|
|
7368
7719
|
".aws",
|
|
7369
|
-
|
|
7720
|
+
join17(".config", "gh"),
|
|
7370
7721
|
".gnupg",
|
|
7371
7722
|
".git-credentials",
|
|
7372
7723
|
".npmrc",
|
|
7373
7724
|
".netrc"
|
|
7374
|
-
].map((p) =>
|
|
7725
|
+
].map((p) => join17(home, p));
|
|
7375
7726
|
return [cayenneHome(), ...claudeConfigDir ? [claudeConfigDir] : [], ...inHome];
|
|
7376
7727
|
}
|
|
7377
7728
|
function logCodex(codex) {
|
|
@@ -7517,7 +7868,11 @@ async function compose(cfg, lock, host, preflight) {
|
|
|
7517
7868
|
claude: true,
|
|
7518
7869
|
providers,
|
|
7519
7870
|
// Who the runner's own config names (D34); every heartbeat refresh republishes it.
|
|
7520
|
-
memberTerminalIds: [...memberTerminalIds]
|
|
7871
|
+
memberTerminalIds: [...memberTerminalIds],
|
|
7872
|
+
// D36: this build forks a Claude session on `session.start { forkSession: true }`.
|
|
7873
|
+
sessionFork: true,
|
|
7874
|
+
// D37: this build loads image attachments on `session.prompt` under R31.
|
|
7875
|
+
promptImages: true
|
|
7521
7876
|
});
|
|
7522
7877
|
const registered = capabilities();
|
|
7523
7878
|
let loggedCodex = null;
|
|
@@ -7551,6 +7906,14 @@ async function compose(cfg, lock, host, preflight) {
|
|
|
7551
7906
|
await local.close();
|
|
7552
7907
|
throw err;
|
|
7553
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
|
+
}
|
|
7554
7917
|
let stopHeartbeat = null;
|
|
7555
7918
|
let roster = null;
|
|
7556
7919
|
try {
|
|
@@ -7576,9 +7939,9 @@ async function compose(cfg, lock, host, preflight) {
|
|
|
7576
7939
|
// The roster's own array, mutated in place: `serves` reads the live list with no restart.
|
|
7577
7940
|
allowedMemberIds: liveRoster.allowedMemberIds,
|
|
7578
7941
|
// Outside every profile and repository: the creator of a worktree is the machine's record.
|
|
7579
|
-
worktreeOwners: createWorktreeOwners(
|
|
7942
|
+
worktreeOwners: createWorktreeOwners(join17(cayenneHome(), "worktrees")),
|
|
7580
7943
|
// Beside it: where each session was spawned, the only source of a resumed directory.
|
|
7581
|
-
sessionWorktrees: createSessionWorktrees(
|
|
7944
|
+
sessionWorktrees: createSessionWorktrees(join17(cayenneHome(), "session-worktrees")),
|
|
7582
7945
|
roots,
|
|
7583
7946
|
allowBypass: cfg.allowBypassPermissions,
|
|
7584
7947
|
codexPath,
|
|
@@ -7838,6 +8201,7 @@ var init_start = __esm({
|
|
|
7838
8201
|
init_profile();
|
|
7839
8202
|
init_registry();
|
|
7840
8203
|
init_roster();
|
|
8204
|
+
init_attachments2();
|
|
7841
8205
|
init_handlers();
|
|
7842
8206
|
init_manager();
|
|
7843
8207
|
init_supabase();
|
|
@@ -7851,9 +8215,9 @@ var init_start = __esm({
|
|
|
7851
8215
|
});
|
|
7852
8216
|
|
|
7853
8217
|
// src/cli.ts
|
|
7854
|
-
import { existsSync as existsSync5, mkdirSync as
|
|
8218
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync7, readFileSync as readFileSync8, realpathSync as realpathSync9, rmSync as rmSync7, writeFileSync as writeFileSync6 } from "fs";
|
|
7855
8219
|
import { homedir as homedir4, hostname as hostname2, userInfo } from "os";
|
|
7856
|
-
import { dirname as
|
|
8220
|
+
import { dirname as dirname9, join as join18 } from "path";
|
|
7857
8221
|
import { Command } from "commander";
|
|
7858
8222
|
import prompts from "prompts";
|
|
7859
8223
|
|
|
@@ -8308,8 +8672,8 @@ var service = () => {
|
|
|
8308
8672
|
return { exec: exec4, launchd: makeLaunchd(exec4, userInfo().uid), label: plistLabel(currentProfile()) };
|
|
8309
8673
|
};
|
|
8310
8674
|
var writePlistFile = (path, xml2) => {
|
|
8311
|
-
|
|
8312
|
-
|
|
8675
|
+
mkdirSync7(dirname9(path), { recursive: true });
|
|
8676
|
+
writeFileSync6(path, xml2, { mode: 420 });
|
|
8313
8677
|
};
|
|
8314
8678
|
var requireMac = () => {
|
|
8315
8679
|
if (process.platform !== "darwin") {
|
|
@@ -8455,7 +8819,7 @@ program.command("doctor").description("Check the install and say how to fix what
|
|
|
8455
8819
|
if (checks.some((c) => !c.ok)) process.exitCode = 1;
|
|
8456
8820
|
});
|
|
8457
8821
|
program.command("logs").description("Show the service log").option("-f, --follow", "keep streaming").action(async (opts) => {
|
|
8458
|
-
const file =
|
|
8822
|
+
const file = join18(cayenneDir(), "logs", "runner.log");
|
|
8459
8823
|
if (!existsSync5(file)) throw new Error(`no log yet at ${file}. Run \`cayenne up\` first.`);
|
|
8460
8824
|
const print = (chunk) => {
|
|
8461
8825
|
for (const line of chunk.split("\n")) {
|
|
@@ -8501,7 +8865,7 @@ program.command("update").description("Re-pin the installed app and restart the
|
|
|
8501
8865
|
cliPath: record.cliPath,
|
|
8502
8866
|
profile: currentProfile(),
|
|
8503
8867
|
pathEnv: record.pathEnv,
|
|
8504
|
-
logDir:
|
|
8868
|
+
logDir: join18(dir, "logs")
|
|
8505
8869
|
})
|
|
8506
8870
|
);
|
|
8507
8871
|
await launchd.bootout(label);
|
|
@@ -8512,7 +8876,7 @@ program.command("uninstall").description("Stop the service and remove the launch
|
|
|
8512
8876
|
requireMac();
|
|
8513
8877
|
const { launchd, label } = service();
|
|
8514
8878
|
await launchd.bootout(label);
|
|
8515
|
-
|
|
8879
|
+
rmSync7(plistPath(label, homedir4()), { force: true });
|
|
8516
8880
|
if (!opts.purge) {
|
|
8517
8881
|
console.log(`removed ${label}`);
|
|
8518
8882
|
return;
|
|
@@ -8522,7 +8886,7 @@ program.command("uninstall").description("Stop the service and remove the launch
|
|
|
8522
8886
|
if (dir === "/" || dir === home || home.startsWith(`${dir}/`)) {
|
|
8523
8887
|
throw new Error(`refusing to purge ${dir}: it is the home directory or an ancestor of it`);
|
|
8524
8888
|
}
|
|
8525
|
-
if (!existsSync5(
|
|
8889
|
+
if (!existsSync5(join18(dir, "config.json")) && !existsSync5(join18(dir, "app", "install.json"))) {
|
|
8526
8890
|
throw new Error(`refusing to purge ${dir}: it does not look like a Cayenne profile`);
|
|
8527
8891
|
}
|
|
8528
8892
|
if (!opts.yes) {
|
|
@@ -8536,7 +8900,7 @@ program.command("uninstall").description("Stop the service and remove the launch
|
|
|
8536
8900
|
"could not revoke the stored session (offline?) \u2014 sign out from the web app to be sure"
|
|
8537
8901
|
);
|
|
8538
8902
|
}
|
|
8539
|
-
|
|
8903
|
+
rmSync7(dir, { recursive: true, force: true });
|
|
8540
8904
|
console.log(`removed ${label} and ${dir}`);
|
|
8541
8905
|
});
|
|
8542
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.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"files": [
|
|
5
5
|
"dist/cli.js"
|
|
6
6
|
],
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"tsx": "4.23.13",
|
|
43
43
|
"typescript": "~5.9.3",
|
|
44
44
|
"vitest": "5.0.0",
|
|
45
|
-
"@cayenne/
|
|
46
|
-
"@cayenne/
|
|
45
|
+
"@cayenne/db": "0.0.1",
|
|
46
|
+
"@cayenne/shared": "0.0.1"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
49
|
"cli": "tsx src/cli.ts",
|