castle-web-cli 0.4.165 → 0.4.167

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 (126) hide show
  1. package/dist/agent-prompts.js +1 -1
  2. package/dist/agent.d.ts +1 -2
  3. package/dist/agent.js +22 -13
  4. package/dist/api.js +16 -1
  5. package/dist/imports.js +19 -4
  6. package/dist/localPaths.d.ts +2 -0
  7. package/dist/localPaths.js +6 -0
  8. package/dist/serve.js +5 -1
  9. package/dist/serveSecurity.d.ts +12 -0
  10. package/dist/serveSecurity.js +42 -0
  11. package/dist/shell/assets/{index-CG9TrvCm.js → index-C8M-p00y.js} +36 -36
  12. package/dist/shell/index.html +1 -1
  13. package/kits/base/castle.json +64 -2
  14. package/kits/base/editors/ImageViewer.js +259 -0
  15. package/kits/base/editors/MediaPlayer.js +90 -0
  16. package/kits/base/editors/imageViewer.css +95 -0
  17. package/kits/base/editors/mediaPlayer.css +58 -0
  18. package/kits/multiplayer-2d/castle.json +1 -1
  19. package/kits/multiplayer-2d/package-lock.json +4 -1
  20. package/kits/multiplayer-3d/castle.json +1 -1
  21. package/kits/multiplayer-3d/package-lock.json +1 -1
  22. package/kits/physics-2d/behaviors/Joints.jsx +11 -5
  23. package/kits/physics-2d/behaviors/Sprite.jsx +6 -21
  24. package/kits/physics-2d/castle.json +1 -61
  25. package/kits/physics-2d/editors/BlueprintLibrary.jsx +8 -68
  26. package/kits/physics-2d/editors/SingleEditor.jsx +3 -10
  27. package/kits/physics-2d/editors/editorRegistry.jsx +36 -2
  28. package/kits/physics-2d/engine/art.js +25 -1
  29. package/kits/physics-2d/engine/artThumbnail.jsx +92 -0
  30. package/kits/physics-2d/engine/files.js +4 -15
  31. package/kits/physics-2d/engine/spriteField.jsx +176 -0
  32. package/kits/physics-2d/engine/ui.module.css +136 -0
  33. package/kits/physics-2d/package-lock.json +5 -0
  34. package/kits/physics-2d/pnpm-lock.yaml +96 -3
  35. package/kits/physics-3d/castle.json +1 -1
  36. package/kits/physics-3d/package-lock.json +1 -1
  37. package/kits/physics-3d/pnpm-lock.yaml +88 -3
  38. package/kits/real-time/castle.json +1 -1
  39. package/kits/real-time/package.json +3 -0
  40. package/kits/turn-based/castle.json +1 -1
  41. package/kits/turn-based/package-lock.json +1 -1
  42. package/package.json +1 -1
  43. package/kits/base/node_modules/.package-lock.json +0 -28
  44. package/kits/base/node_modules/castle-web-fonts/README.md +0 -114
  45. package/kits/base/node_modules/castle-web-fonts/dist/catalog.d.ts +0 -18
  46. package/kits/base/node_modules/castle-web-fonts/dist/catalog.js +0 -142
  47. package/kits/base/node_modules/castle-web-fonts/dist/face.d.ts +0 -1
  48. package/kits/base/node_modules/castle-web-fonts/dist/face.js +0 -52
  49. package/kits/base/node_modules/castle-web-fonts/dist/faces/Bore.d.ts +0 -3
  50. package/kits/base/node_modules/castle-web-fonts/dist/faces/Bore.js +0 -10
  51. package/kits/base/node_modules/castle-web-fonts/dist/faces/CourierPrime.d.ts +0 -3
  52. package/kits/base/node_modules/castle-web-fonts/dist/faces/CourierPrime.js +0 -10
  53. package/kits/base/node_modules/castle-web-fonts/dist/faces/DMSans.d.ts +0 -3
  54. package/kits/base/node_modules/castle-web-fonts/dist/faces/DMSans.js +0 -10
  55. package/kits/base/node_modules/castle-web-fonts/dist/faces/Glacier.d.ts +0 -3
  56. package/kits/base/node_modules/castle-web-fonts/dist/faces/Glacier.js +0 -10
  57. package/kits/base/node_modules/castle-web-fonts/dist/faces/HelicoCentrica.d.ts +0 -3
  58. package/kits/base/node_modules/castle-web-fonts/dist/faces/HelicoCentrica.js +0 -10
  59. package/kits/base/node_modules/castle-web-fonts/dist/faces/Piazzolla.d.ts +0 -3
  60. package/kits/base/node_modules/castle-web-fonts/dist/faces/Piazzolla.js +0 -10
  61. package/kits/base/node_modules/castle-web-fonts/dist/faces/Synco.d.ts +0 -3
  62. package/kits/base/node_modules/castle-web-fonts/dist/faces/Synco.js +0 -10
  63. package/kits/base/node_modules/castle-web-fonts/dist/faces/Tektur.d.ts +0 -3
  64. package/kits/base/node_modules/castle-web-fonts/dist/faces/Tektur.js +0 -10
  65. package/kits/base/node_modules/castle-web-fonts/dist/faces/YatraOne.d.ts +0 -3
  66. package/kits/base/node_modules/castle-web-fonts/dist/faces/YatraOne.js +0 -10
  67. package/kits/base/node_modules/castle-web-fonts/dist/index.d.ts +0 -16
  68. package/kits/base/node_modules/castle-web-fonts/dist/index.js +0 -15
  69. package/kits/base/node_modules/castle-web-fonts/licenses/CC-BY-ND-4.0.txt +0 -393
  70. package/kits/base/node_modules/castle-web-fonts/licenses/FONTS.md +0 -64
  71. package/kits/base/node_modules/castle-web-fonts/licenses/OFL-1.1.txt +0 -95
  72. package/kits/base/node_modules/castle-web-fonts/package.json +0 -47
  73. package/kits/base/node_modules/castle-web-fonts/woff2/Bore.woff2 +0 -0
  74. package/kits/base/node_modules/castle-web-fonts/woff2/CourierPrime.woff2 +0 -0
  75. package/kits/base/node_modules/castle-web-fonts/woff2/DMSans.woff2 +0 -0
  76. package/kits/base/node_modules/castle-web-fonts/woff2/Glacier.woff2 +0 -0
  77. package/kits/base/node_modules/castle-web-fonts/woff2/HelicoCentrica.woff2 +0 -0
  78. package/kits/base/node_modules/castle-web-fonts/woff2/Piazzolla.woff2 +0 -0
  79. package/kits/base/node_modules/castle-web-fonts/woff2/SOURCE.txt +0 -2
  80. package/kits/base/node_modules/castle-web-fonts/woff2/Synco.woff2 +0 -0
  81. package/kits/base/node_modules/castle-web-fonts/woff2/Tektur.woff2 +0 -0
  82. package/kits/base/node_modules/castle-web-fonts/woff2/YatraOne.woff2 +0 -0
  83. package/kits/blank/node_modules/.package-lock.json +0 -13
  84. package/kits/blank/node_modules/castle-web-fonts/README.md +0 -114
  85. package/kits/blank/node_modules/castle-web-fonts/dist/catalog.d.ts +0 -18
  86. package/kits/blank/node_modules/castle-web-fonts/dist/catalog.js +0 -142
  87. package/kits/blank/node_modules/castle-web-fonts/dist/face.d.ts +0 -1
  88. package/kits/blank/node_modules/castle-web-fonts/dist/face.js +0 -52
  89. package/kits/blank/node_modules/castle-web-fonts/dist/faces/Bore.d.ts +0 -3
  90. package/kits/blank/node_modules/castle-web-fonts/dist/faces/Bore.js +0 -10
  91. package/kits/blank/node_modules/castle-web-fonts/dist/faces/CourierPrime.d.ts +0 -3
  92. package/kits/blank/node_modules/castle-web-fonts/dist/faces/CourierPrime.js +0 -10
  93. package/kits/blank/node_modules/castle-web-fonts/dist/faces/DMSans.d.ts +0 -3
  94. package/kits/blank/node_modules/castle-web-fonts/dist/faces/DMSans.js +0 -10
  95. package/kits/blank/node_modules/castle-web-fonts/dist/faces/Glacier.d.ts +0 -3
  96. package/kits/blank/node_modules/castle-web-fonts/dist/faces/Glacier.js +0 -10
  97. package/kits/blank/node_modules/castle-web-fonts/dist/faces/HelicoCentrica.d.ts +0 -3
  98. package/kits/blank/node_modules/castle-web-fonts/dist/faces/HelicoCentrica.js +0 -10
  99. package/kits/blank/node_modules/castle-web-fonts/dist/faces/Piazzolla.d.ts +0 -3
  100. package/kits/blank/node_modules/castle-web-fonts/dist/faces/Piazzolla.js +0 -10
  101. package/kits/blank/node_modules/castle-web-fonts/dist/faces/Synco.d.ts +0 -3
  102. package/kits/blank/node_modules/castle-web-fonts/dist/faces/Synco.js +0 -10
  103. package/kits/blank/node_modules/castle-web-fonts/dist/faces/Tektur.d.ts +0 -3
  104. package/kits/blank/node_modules/castle-web-fonts/dist/faces/Tektur.js +0 -10
  105. package/kits/blank/node_modules/castle-web-fonts/dist/faces/YatraOne.d.ts +0 -3
  106. package/kits/blank/node_modules/castle-web-fonts/dist/faces/YatraOne.js +0 -10
  107. package/kits/blank/node_modules/castle-web-fonts/dist/index.d.ts +0 -16
  108. package/kits/blank/node_modules/castle-web-fonts/dist/index.js +0 -15
  109. package/kits/blank/node_modules/castle-web-fonts/licenses/CC-BY-ND-4.0.txt +0 -393
  110. package/kits/blank/node_modules/castle-web-fonts/licenses/FONTS.md +0 -64
  111. package/kits/blank/node_modules/castle-web-fonts/licenses/OFL-1.1.txt +0 -95
  112. package/kits/blank/node_modules/castle-web-fonts/package.json +0 -47
  113. package/kits/blank/node_modules/castle-web-fonts/woff2/Bore.woff2 +0 -0
  114. package/kits/blank/node_modules/castle-web-fonts/woff2/CourierPrime.woff2 +0 -0
  115. package/kits/blank/node_modules/castle-web-fonts/woff2/DMSans.woff2 +0 -0
  116. package/kits/blank/node_modules/castle-web-fonts/woff2/Glacier.woff2 +0 -0
  117. package/kits/blank/node_modules/castle-web-fonts/woff2/HelicoCentrica.woff2 +0 -0
  118. package/kits/blank/node_modules/castle-web-fonts/woff2/Piazzolla.woff2 +0 -0
  119. package/kits/blank/node_modules/castle-web-fonts/woff2/SOURCE.txt +0 -2
  120. package/kits/blank/node_modules/castle-web-fonts/woff2/Synco.woff2 +0 -0
  121. package/kits/blank/node_modules/castle-web-fonts/woff2/Tektur.woff2 +0 -0
  122. package/kits/blank/node_modules/castle-web-fonts/woff2/YatraOne.woff2 +0 -0
  123. package/kits/physics-2d/editors/ImageViewer.jsx +0 -206
  124. package/kits/physics-2d/editors/MediaPlayer.jsx +0 -57
  125. package/kits/physics-2d/editors/mediaFile.js +0 -31
  126. package/kits/physics-2d/editors/mediaViewer.module.css +0 -128
@@ -964,7 +964,7 @@ phase: wiring the paddle to touch
964
964
 
965
965
  - \`avatar\` -- what you're doing RIGHT NOW, exactly one of: \`thinking\` (figuring out the plan), \`reading\` (looking through files), \`building\` (writing game logic), \`painting\` (making or editing art/visuals), \`playing\` (trying it in the game).
966
966
  - \`phase\` -- a SHORT, plain-language line for this moment ("wiring the paddle to touch", "drawing the flag"). No file or code names. Update \`avatar\` and \`phase\` together each time your stage changes -- a few times across the task, not every turn. (You may also include \`progress: NN\` here, but the progress file above is the primary progress channel.)
967
- - Before finishing, write ${opts.notesPath}: a tiny test guide for the PLAYER -- AT MOST 2-3 bullets (markdown \`- \` lines), each ONE short phrase: what to try and what should happen. NO code, NO API or "integration contract" detail, NO file/behavior names or implementation notes -- those are for you, not the player. Occasionally one bullet may run a little longer, but default to terse. Mention a blocker only if you hit one. The user reads this verbatim to check your work.${wrapUp}
967
+ - Before finishing, write ${opts.notesPath}: a tiny test guide for the PLAYER -- AT MOST 2-3 bullets (markdown \`- \` lines), each ONE short phrase: what to try and what should happen. NO code, NO API or "integration contract" detail, and no tour of how you built it -- that part is for you, not them. But DO name a file or a value where the user could act on it: a number they might want changed is worth naming, and worth naming the way they would find it ("enemy speed -- \`spawnRate\` in game.js"). Occasionally one bullet may run a little longer, but default to terse. Mention a blocker only if you hit one. The user reads this verbatim to check your work.${wrapUp}
968
968
  - If you are truly blocked, write the blocker to the notes file and stop rather than guessing wildly.
969
969
  - Never touch files under .castle/ other than ${opts.plan ? "those three paths" : "those two paths"}. (The version store lives there too, but you never edit it by hand -- \`castle-web save-version\` writes it for you.)`;
970
970
  }
package/dist/agent.d.ts CHANGED
@@ -3,8 +3,6 @@ import { Duplex } from 'stream';
3
3
  import type { ORReasoningEffort, ORRoutingMode } from './native/openrouter.js';
4
4
  import { type PlaytestToolResult } from './native/playtest.js';
5
5
  export declare const AGENT_WS_PATH = "/__castle/agent";
6
- export declare const AGENT_ATTACHMENT_PREFIX = "/__castle/agent/attachments/";
7
- export declare const AGENT_PLAYTEST_PREFIX = "/__castle/agent/playtest/";
8
6
  export declare const AGENT_MODEL_CAPS_PREFIX = "/__castle/agent/model-caps";
9
7
  export type AgentBackend = 'cursor' | 'claude' | 'smith';
10
8
  export type ClaudeModel = 'sonnet' | 'opus' | 'fable' | 'openrouter';
@@ -90,6 +88,7 @@ export declare function createAgentServer(opts: {
90
88
  port?: number;
91
89
  playtestServeUrl?: string;
92
90
  browserContentUrl?: () => string | null;
91
+ signPath?: (reqPath: string) => string;
93
92
  restart?: () => void;
94
93
  authorizeUpgrade?: (req: http.IncomingMessage, endpoint: 'agent') => boolean;
95
94
  }): AgentServer;
package/dist/agent.js CHANGED
@@ -24,7 +24,7 @@ import * as path from 'path';
24
24
  import { nanoid } from 'nanoid';
25
25
  import { WebSocketServer } from 'ws';
26
26
  import { rawDataToString } from './rawData.js';
27
- import { PLAN_FILE } from './localPaths.js';
27
+ import { AGENT_ATTACHMENT_PREFIX, AGENT_PLAYTEST_PREFIX, PLAN_FILE } from './localPaths.js';
28
28
  import { applyPlanOps, buildRouterPrompt, buildTaskPrompt, parsePlanOps, planOpenQuestionLines, truncateToBytes, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
29
29
  import { readCastleJson } from './castleJson.js';
30
30
  import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from './openrouter-catalog.js';
@@ -39,10 +39,6 @@ import { createPlaywrightPlaytestExecutor } from './native/playtest-executor.js'
39
39
  import { createRemotePlaytestExecutor } from './native/playtest-remote.js';
40
40
  import { runPlaytest, } from './native/playtest.js';
41
41
  export const AGENT_WS_PATH = '/__castle/agent';
42
- export const AGENT_ATTACHMENT_PREFIX = '/__castle/agent/attachments/';
43
- // Playtest frame PNGs (tasks/<id>/playtest/<file>.png), served for the
44
- // finished-task card's thumbnails -- see makePlaytestFrameHandler.
45
- export const AGENT_PLAYTEST_PREFIX = '/__castle/agent/playtest/';
46
42
  // Same-origin proxy for OpenRouter model capabilities (avoids browser CORS
47
43
  // against openrouter.ai). GET ?model=<slug> -> ModelCaps JSON. Powers the
48
44
  // settings popover's dynamic reasoning-effort / provider-tier pickers.
@@ -3077,7 +3073,7 @@ function asPromptTask(task) {
3077
3073
  blockedBy: task.status === 'blocked' ? task.blockedBy : undefined,
3078
3074
  };
3079
3075
  }
3080
- function asClientTask(task) {
3076
+ function asClientTask(task, signPath) {
3081
3077
  return {
3082
3078
  id: task.id,
3083
3079
  title: task.title,
@@ -3094,7 +3090,20 @@ function asClientTask(task) {
3094
3090
  rejected: task.rejected,
3095
3091
  errorCopy: task.errorCopy,
3096
3092
  errorDetail: task.errorDetail,
3097
- playtestFrames: (task.playtestFrames ?? []).map((rel) => `${AGENT_PLAYTEST_PREFIX}${task.id}/${path.basename(rel)}`),
3093
+ playtestFrames: (task.playtestFrames ?? []).map((rel) => clientUrl(`${AGENT_PLAYTEST_PREFIX}${task.id}/${path.basename(rel)}`, signPath)),
3094
+ };
3095
+ }
3096
+ function clientUrl(reqPath, signPath) {
3097
+ return signPath ? `${reqPath}?t=${signPath(reqPath)}` : reqPath;
3098
+ }
3099
+ // A sent message renders its pasted images as `<img src>`, which cannot carry
3100
+ // the serve token any more than a playtest frame can -- see ServeSecurity.signPath.
3101
+ function asClientMessage(message, signPath) {
3102
+ if (!message.attachments?.length)
3103
+ return message;
3104
+ return {
3105
+ ...message,
3106
+ attachments: message.attachments.map((name) => clientUrl(`${AGENT_ATTACHMENT_PREFIX}${name}`, signPath)),
3098
3107
  };
3099
3108
  }
3100
3109
  function createTaskFeeds(broadcast) {
@@ -3133,7 +3142,7 @@ function createMessageThinking(broadcast) {
3133
3142
  }
3134
3143
  return { append, snapshot };
3135
3144
  }
3136
- function createMessageLog(messagesPath, broadcast, welcomeMessage) {
3145
+ function createMessageLog(messagesPath, broadcast, welcomeMessage, forClient) {
3137
3146
  const loaded = readJsonFile(messagesPath) ?? [];
3138
3147
  const messages = loaded
3139
3148
  .filter((m) => m.text.trim() !== '' || m.role === 'user')
@@ -3154,7 +3163,7 @@ function createMessageLog(messagesPath, broadcast, welcomeMessage) {
3154
3163
  function add(message) {
3155
3164
  messages.push(message);
3156
3165
  persist();
3157
- broadcast({ type: 'message-add', message });
3166
+ broadcast({ type: 'message-add', message: forClient(message) });
3158
3167
  }
3159
3168
  function addLog(text) {
3160
3169
  add({ id: nanoid(8), role: 'log', text, at: nowIso(), status: 'done' });
@@ -4215,7 +4224,7 @@ export function createAgentServer(opts) {
4215
4224
  socket.send(payload);
4216
4225
  }
4217
4226
  }
4218
- const log = createMessageLog(messagesPath, broadcast, welcomeMessage);
4227
+ const log = createMessageLog(messagesPath, broadcast, welcomeMessage, (message) => asClientMessage(message, opts.signPath));
4219
4228
  const messages = log.messages;
4220
4229
  const addLog = (text) => log.addLog(text);
4221
4230
  // Which CLI backs the router and the task agents -- independently
@@ -4265,7 +4274,7 @@ export function createAgentServer(opts) {
4265
4274
  playtest,
4266
4275
  restart: opts.restart,
4267
4276
  // Task lifecycle stays on the board only -- log lines for it were spam.
4268
- onUpdate: (task) => broadcast({ type: 'task-update', task: asClientTask(task) }),
4277
+ onUpdate: (task) => broadcast({ type: 'task-update', task: asClientTask(task, opts.signPath) }),
4269
4278
  onStarted: () => undefined,
4270
4279
  onRetry: (task, attempt) => addLog(`agent died, retrying (${attempt}/${MAX_TASK_ATTEMPTS}): ${task.title}`),
4271
4280
  onFinished: (task) => taskFeeds.map.delete(task.id),
@@ -4345,8 +4354,8 @@ export function createAgentServer(opts) {
4345
4354
  clients.add(socket);
4346
4355
  const hello = {
4347
4356
  type: 'hello',
4348
- messages,
4349
- tasks: taskStore.sorted().map(asClientTask),
4357
+ messages: messages.map((message) => asClientMessage(message, opts.signPath)),
4358
+ tasks: taskStore.sorted().map((task) => asClientTask(task, opts.signPath)),
4350
4359
  settings,
4351
4360
  feeds: Object.fromEntries(taskFeeds.map),
4352
4361
  thinking: messageThinking.snapshot(),
package/dist/api.js CHANGED
@@ -23,7 +23,22 @@ export async function graphql(query, variables, options = {}) {
23
23
  body: JSON.stringify(variables ? { query, variables } : { query }),
24
24
  signal,
25
25
  });
26
- return res.json();
26
+ const body = await res.text();
27
+ try {
28
+ return JSON.parse(body);
29
+ }
30
+ catch {
31
+ // A body that isn't JSON never reached the GraphQL layer at all -- a sandbox
32
+ // proxy refusing the operation, a gateway error page. Parsing it blind reports
33
+ // that as `Unexpected token 'G'`, which hid a broken publish for days.
34
+ throw new Error(`Castle API ${res.status}: ${firstLine(body)}`);
35
+ }
36
+ }
37
+ function firstLine(body) {
38
+ const line = body.split('\n', 1)[0].trim();
39
+ if (!line)
40
+ return '(empty response body)';
41
+ return line.length > 200 ? `${line.slice(0, 200)}...` : line;
27
42
  }
28
43
  export async function runBrowserJob(contentGrant, operation, signal) {
29
44
  const data = await graphql(`mutation($contentGrant: String!, $operation: Json!) {
package/dist/imports.js CHANGED
@@ -664,11 +664,22 @@ function importOptsIntoAutoUpdate(deckDir, alias) {
664
664
  // publish (the framework kits -- the same account `init` qualifies their aliases
665
665
  // with).
666
666
  //
667
- // Deliberately not a setting: nothing a deck or a person can flip, since that
668
- // would be the leash undoing itself. The env var is a test seam -- the automated
669
- // check can't publish a deck to the castle account (nothing should), so it
670
- // substitutes which account counts and exercises both branches for real.
667
+ // Deliberately not a deck setting: nothing in castle.json a person can flip,
668
+ // since that would be the leash undoing itself. Process env is the escape
669
+ // hatch for local kit work and tests:
670
+ // - CASTLE_NO_AUTO_UPDATE=1 -- skip serve-start auto-update entirely (keep
671
+ // whatever is already under imports/, including hand-synced kits from this
672
+ // checkout).
673
+ // - CASTLE_AUTO_UPDATE_TRUSTED_ACCOUNT -- which username counts as trusted
674
+ // (default `castle`). Tests set it to exercise both branches; setting it to
675
+ // a non-matching name also disables updates as a side effect.
671
676
  const AUTO_UPDATE_ACCOUNT = process.env.CASTLE_AUTO_UPDATE_TRUSTED_ACCOUNT ?? 'castle';
677
+ function autoUpdateDisabled() {
678
+ const raw = process.env.CASTLE_NO_AUTO_UPDATE;
679
+ if (raw == null || raw === '')
680
+ return false;
681
+ return raw !== '0' && raw.toLowerCase() !== 'false' && raw.toLowerCase() !== 'no';
682
+ }
672
683
  // Who published a deck. Readable without auth for the same decks webDeckSource
673
684
  // is, so this answers in a sandbox with no login. A lookup that fails answers
674
685
  // "not trusted": the failure direction has to be the one that leaves an import
@@ -689,6 +700,10 @@ async function importIsOnTrustedAccount(deckId) {
689
700
  // checked (offline, no source on the server, a bad archive) stays exactly as it
690
701
  // is pinned, and the serve starts anyway.
691
702
  export async function autoUpdateImports(deckDir) {
703
+ if (autoUpdateDisabled()) {
704
+ console.log('auto-update: skipped (CASTLE_NO_AUTO_UPDATE)');
705
+ return [];
706
+ }
692
707
  const pins = tryReadCastleJson(deckDir)?.imports ?? {};
693
708
  const updated = [];
694
709
  for (const [alias, pin] of Object.entries(pins)) {
@@ -5,4 +5,6 @@ export declare function getSdkPackagePath(): string;
5
5
  export declare function getKitsDir(): string;
6
6
  export declare const COVER_FILE = "preview.png";
7
7
  export declare const PLAN_FILE = "plan.md";
8
+ export declare const AGENT_PLAYTEST_PREFIX = "/__castle/agent/playtest/";
9
+ export declare const AGENT_ATTACHMENT_PREFIX = "/__castle/agent/attachments/";
8
10
  export declare function toPosixPath(filepath: string): string;
@@ -41,6 +41,12 @@ export const COVER_FILE = 'preview.png';
41
41
  // instead. Named here because the serve's file listing has to know it too, to
42
42
  // keep it out of a deck's path curation.
43
43
  export const PLAN_FILE = 'plan.md';
44
+ // Request-path prefixes the agent server serves a task's playtest frame PNGs
45
+ // and a chat message's pasted images under. Named here because ServeSecurity
46
+ // has to know them too -- they are the shell prefixes a signed path may open --
47
+ // and agent.ts already imports that.
48
+ export const AGENT_PLAYTEST_PREFIX = '/__castle/agent/playtest/';
49
+ export const AGENT_ATTACHMENT_PREFIX = '/__castle/agent/attachments/';
44
50
  export function toPosixPath(filepath) {
45
51
  return filepath.split(path.sep).join('/');
46
52
  }
package/dist/serve.js CHANGED
@@ -404,7 +404,10 @@ window.__castleShellOrigin=(function(){
404
404
  const publicShellPath = reqPath === '/' ||
405
405
  reqPath.startsWith('/__castle/ide/') ||
406
406
  FAVICON_FILES.some((name) => reqPath === `/${name}`);
407
- if (reqPath.startsWith('/__castle/') && !publicShellPath && !security.authorizeHttp(req)) {
407
+ if (reqPath.startsWith('/__castle/') &&
408
+ !publicShellPath &&
409
+ !security.authorizeHttp(req) &&
410
+ !security.authorizeSignedPath(req, reqPath)) {
408
411
  security.rejectHttpAuth(res);
409
412
  return;
410
413
  }
@@ -778,6 +781,7 @@ export async function serve(dir, options = {}) {
778
781
  deckLabel: path.basename(projectDir),
779
782
  port,
780
783
  playtestServeUrl: security.localContentOrigin('play'),
784
+ signPath: (reqPath) => security.signPath(reqPath),
781
785
  browserContentUrl: process.env.CASTLE_SANDBOX_ID
782
786
  ? () => security.browserContentOrigin('play')
783
787
  : undefined,
@@ -18,6 +18,7 @@ export declare class ServeSecurity {
18
18
  private readonly contentProxyToken;
19
19
  private readonly signingKey;
20
20
  private readonly serveNonce;
21
+ private readonly pathKey;
21
22
  private readonly tickets;
22
23
  private localContentPorts;
23
24
  private localContentHost;
@@ -26,6 +27,17 @@ export declare class ServeSecurity {
26
27
  setLocalContentHost(host: string): void;
27
28
  authorizeHttp(req: http.IncomingMessage): boolean;
28
29
  rejectHttpAuth(res: http.ServerResponse): void;
30
+ /**
31
+ * Sign one shell path so a browser can load it WITHOUT the serve token. The
32
+ * bootstrap supplies that token by patching `window.fetch`, and an `<img
33
+ * src>` or a `target="_blank"` link never goes through fetch -- so a URL the
34
+ * page hands to the browser instead of fetching itself has to carry its own
35
+ * capability. Bound to the exact path, and only a SIGNABLE_PATH_PREFIXES
36
+ * path: a signature minted for one frame opens nothing else under
37
+ * `/__castle/`, and a path outside that set cannot be signed at all.
38
+ */
39
+ signPath(reqPath: string): string;
40
+ authorizeSignedPath(req: http.IncomingMessage, reqPath: string): boolean;
29
41
  issueTicket(endpoint: SocketEndpoint): string;
30
42
  /** One-use admission for an SDK that predates the shell's MessagePort bridge. */
31
43
  issueLegacyContentTicket(role: ContentRole): string;
@@ -1,4 +1,5 @@
1
1
  import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
2
+ import { AGENT_ATTACHMENT_PREFIX, AGENT_PLAYTEST_PREFIX } from './localPaths.js';
2
3
  export const SERVE_AUTH_FAILURE_HEADER = 'x-castle-serve-auth';
3
4
  export const SERVE_AUTH_FAILURE_VALUE = 'invalid';
4
5
  const CROCKFORD = '0123456789abcdefghjkmnpqrstvwxyz';
@@ -7,6 +8,13 @@ const MAX_GRANT_SECONDS = 12 * 60 * 60;
7
8
  const GRANT_CLOCK_SKEW_SECONDS = 60;
8
9
  const BROWSER_GRANT_SECONDS = 3 * 60;
9
10
  export const MOBILE_HOST_PROTOCOL = '2';
11
+ // A signature covers the request path and nothing else, so every path it can
12
+ // open must be a static file the path alone names. A query-driven endpoint
13
+ // here (`/__castle/files/read?path=`) would be opened wholesale by one signature.
14
+ const SIGNABLE_PATH_PREFIXES = [AGENT_PLAYTEST_PREFIX, AGENT_ATTACHMENT_PREFIX];
15
+ function isSignablePath(reqPath) {
16
+ return SIGNABLE_PATH_PREFIXES.some((prefix) => reqPath.startsWith(prefix));
17
+ }
10
18
  function base32Lower(bytes) {
11
19
  let bits = 0;
12
20
  let value = 0;
@@ -57,6 +65,7 @@ export class ServeSecurity {
57
65
  contentProxyToken = randomBytes(32).toString('base64url');
58
66
  signingKey = process.env.CASTLE_AGENT_TOKEN || randomBytes(32).toString('base64url');
59
67
  serveNonce = base32Lower(randomBytes(8));
68
+ pathKey = randomBytes(32).toString('base64url');
60
69
  tickets = new Map();
61
70
  localContentPorts;
62
71
  localContentHost = '127.0.0.1';
@@ -87,6 +96,39 @@ export class ServeSecurity {
87
96
  })
88
97
  .end();
89
98
  }
99
+ /**
100
+ * Sign one shell path so a browser can load it WITHOUT the serve token. The
101
+ * bootstrap supplies that token by patching `window.fetch`, and an `<img
102
+ * src>` or a `target="_blank"` link never goes through fetch -- so a URL the
103
+ * page hands to the browser instead of fetching itself has to carry its own
104
+ * capability. Bound to the exact path, and only a SIGNABLE_PATH_PREFIXES
105
+ * path: a signature minted for one frame opens nothing else under
106
+ * `/__castle/`, and a path outside that set cannot be signed at all.
107
+ */
108
+ signPath(reqPath) {
109
+ if (!isSignablePath(reqPath)) {
110
+ throw new Error(`refusing to sign a shell path outside the signable set: ${reqPath}`);
111
+ }
112
+ return base32Lower(createHmac('sha256', this.pathKey)
113
+ .update(`castle-path-v1\n${reqPath}`)
114
+ .digest()
115
+ .subarray(0, 12));
116
+ }
117
+ authorizeSignedPath(req, reqPath) {
118
+ const method = req.method?.toUpperCase();
119
+ if (method !== 'GET' && method !== 'HEAD')
120
+ return false;
121
+ if (!isSignablePath(reqPath))
122
+ return false;
123
+ let signature = '';
124
+ try {
125
+ signature = new URL(req.url || '/', 'http://localhost').searchParams.get('t') || '';
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ return signature !== '' && safeEqual(this.signPath(reqPath), signature);
131
+ }
90
132
  issueTicket(endpoint) {
91
133
  this.pruneTickets();
92
134
  const ticket = randomBytes(16).toString('base64url');