@canonmsg/codex-plugin 0.5.0 → 0.6.1

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/adapter.d.ts CHANGED
@@ -63,7 +63,7 @@ export declare class CodexConversationAdapter {
63
63
  setModel(model: string | null): void;
64
64
  isRunning(): boolean;
65
65
  interrupt(): Promise<void>;
66
- runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void): Promise<CodexTurnResult>;
66
+ runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void, imagePaths?: readonly string[]): Promise<CodexTurnResult>;
67
67
  private buildArgs;
68
68
  private clearActiveProcess;
69
69
  }
package/dist/adapter.js CHANGED
@@ -47,11 +47,11 @@ export class CodexConversationAdapter {
47
47
  this.child.kill('SIGKILL');
48
48
  }, 5_000);
49
49
  }
50
- async runTurn(prompt, onEvent, onLog) {
50
+ async runTurn(prompt, onEvent, onLog, imagePaths = []) {
51
51
  if (this.child) {
52
52
  throw new Error('A Codex turn is already in progress for this conversation');
53
53
  }
54
- const args = this.buildArgs(prompt);
54
+ const args = this.buildArgs(prompt, imagePaths);
55
55
  const child = spawn(this.codexBin, args, {
56
56
  cwd: this.cwd,
57
57
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -141,7 +141,7 @@ export class CodexConversationAdapter {
141
141
  });
142
142
  });
143
143
  }
144
- buildArgs(prompt) {
144
+ buildArgs(prompt, imagePaths = []) {
145
145
  if (this.threadId) {
146
146
  const args = ['exec', 'resume', '--json', '--skip-git-repo-check'];
147
147
  if (this.model) {
@@ -159,6 +159,9 @@ export class CodexConversationAdapter {
159
159
  if (this.bypassApprovalsAndSandbox) {
160
160
  args.push('--dangerously-bypass-approvals-and-sandbox');
161
161
  }
162
+ for (const imagePath of imagePaths) {
163
+ args.push('-i', imagePath);
164
+ }
162
165
  args.push(this.threadId, prompt);
163
166
  return args;
164
167
  }
@@ -190,6 +193,9 @@ export class CodexConversationAdapter {
190
193
  if (execMode.bypassApprovalsAndSandbox) {
191
194
  args.push('--dangerously-bypass-approvals-and-sandbox');
192
195
  }
196
+ for (const imagePath of imagePaths) {
197
+ args.push('-i', imagePath);
198
+ }
193
199
  args.push(prompt);
194
200
  return args;
195
201
  }
@@ -28,9 +28,6 @@ export interface HostInboundParticipantContext {
28
28
  type HostInboundMessage = {
29
29
  text?: string | null;
30
30
  contentType?: CanonMessage['contentType'] | null;
31
- audioUrl?: string | null;
32
- audioDurationMs?: number | null;
33
- imageUrl?: string | null;
34
31
  attachments?: CanonMessage['attachments'];
35
32
  senderType?: CanonMessage['senderType'];
36
33
  mentions?: string[] | null;
@@ -49,7 +46,26 @@ export declare function buildCanonHostPrompt(input: {
49
46
  workSessions?: MessageCreatedPayload['workSessions'];
50
47
  buildInboundContextLines: (context: HostInboundParticipantContext) => string[];
51
48
  }): string;
52
- export declare function renderCanonHostInboundContent(message: HostInboundMessage): string;
49
+ /**
50
+ * Render the **text portion** of an inbound Canon message. Images are
51
+ * referenced by short placeholders — their actual bytes are delivered to the
52
+ * host as native vision/media inputs (Codex `-i <file>`, Anthropic image
53
+ * blocks). URLs are intentionally *not* inlined, since the harness never
54
+ * needs to refetch and earlier `[Image: <url>]` inlining caused vision
55
+ * models to see a string about an image instead of the image itself.
56
+ *
57
+ * `materialized` may be passed so non-image attachments can reference a
58
+ * local path the agent can Read. Without it we fall back to an unadorned
59
+ * placeholder; the vision path still works because image args carry the
60
+ * file path directly.
61
+ */
62
+ export declare function renderCanonHostInboundContent(message: HostInboundMessage, materialized?: ReadonlyArray<{
63
+ kind: 'image' | 'audio' | 'file';
64
+ path: string;
65
+ fileName?: string;
66
+ durationMs?: number;
67
+ index: number;
68
+ }>): string;
53
69
  export declare function buildHydratedInboundContext(input: {
54
70
  agentId: string;
55
71
  conversation: CanonConversation | null;
@@ -32,38 +32,45 @@ export function buildCanonHostPrompt(input) {
32
32
  input.content,
33
33
  ].join('\n');
34
34
  }
35
- export function renderCanonHostInboundContent(message) {
36
- let content = message.text || '';
37
- const attachment = message.attachments?.[0];
38
- if (attachment?.kind === 'audio' && attachment.url) {
39
- const duration = attachment.durationMs ? ` (${Math.round(attachment.durationMs / 1000)}s)` : '';
40
- content = content
41
- ? `[Voice message${duration}: ${attachment.url}]\n${content}`
42
- : `[Voice message${duration}: ${attachment.url}]`;
43
- }
44
- else if (attachment?.kind === 'image' && attachment.url) {
45
- content = content
46
- ? `[Image: ${attachment.url}]\n${content}`
47
- : `[Image: ${attachment.url}]`;
48
- }
49
- else if (attachment?.kind === 'file' && attachment.url) {
50
- const label = attachment.fileName || 'File';
51
- content = content
52
- ? `[File: ${label} ${attachment.url}]\n${content}`
53
- : `[File: ${label} ${attachment.url}]`;
35
+ /**
36
+ * Render the **text portion** of an inbound Canon message. Images are
37
+ * referenced by short placeholders — their actual bytes are delivered to the
38
+ * host as native vision/media inputs (Codex `-i <file>`, Anthropic image
39
+ * blocks). URLs are intentionally *not* inlined, since the harness never
40
+ * needs to refetch and earlier `[Image: <url>]` inlining caused vision
41
+ * models to see a string about an image instead of the image itself.
42
+ *
43
+ * `materialized` may be passed so non-image attachments can reference a
44
+ * local path the agent can Read. Without it we fall back to an unadorned
45
+ * placeholder; the vision path still works because image args carry the
46
+ * file path directly.
47
+ */
48
+ export function renderCanonHostInboundContent(message, materialized) {
49
+ const body = message.text || '';
50
+ const placeholders = [];
51
+ const attachments = message.attachments ?? [];
52
+ for (let i = 0; i < attachments.length; i += 1) {
53
+ const att = attachments[i];
54
+ const mat = materialized?.find((m) => m.index === i) ?? null;
55
+ placeholders.push(describeAttachment(att, mat));
54
56
  }
55
- else if (message.contentType === 'audio' && message.audioUrl) {
56
- const duration = message.audioDurationMs ? ` (${Math.round(message.audioDurationMs / 1000)}s)` : '';
57
- content = content
58
- ? `[Voice message${duration}: ${message.audioUrl}]\n${content}`
59
- : `[Voice message${duration}: ${message.audioUrl}]`;
57
+ const rendered = [...placeholders, body].filter(Boolean).join('\n');
58
+ return rendered || '[Empty message]';
59
+ }
60
+ function describeAttachment(attachment, materialized) {
61
+ if (attachment.kind === 'image') {
62
+ return '[Image attached]';
60
63
  }
61
- else if (message.contentType === 'image' && message.imageUrl) {
62
- content = content
63
- ? `[Image: ${message.imageUrl}]\n${content}`
64
- : `[Image: ${message.imageUrl}]`;
64
+ if (attachment.kind === 'audio') {
65
+ const durationMs = materialized?.durationMs ?? attachment.durationMs;
66
+ const duration = durationMs ? ` (${Math.round(durationMs / 1000)}s)` : '';
67
+ const ref = materialized?.path ? ` ${materialized.path}` : '';
68
+ return `[Voice message${duration}${ref}]`;
65
69
  }
66
- return content || '[Empty message]';
70
+ // file
71
+ const label = materialized?.fileName ?? attachment.fileName ?? 'File';
72
+ const ref = materialized?.path ? ` ${materialized.path}` : '';
73
+ return `[File: ${label}${ref}]`;
67
74
  }
68
75
  export function buildHydratedInboundContext(input) {
69
76
  const history = buildParticipationHistorySnapshot(input.page?.messages ?? [], input.agentId);
package/dist/host.js CHANGED
@@ -3,11 +3,13 @@ import { setDefaultResultOrder } from 'node:dns';
3
3
  setDefaultResultOrder('ipv4first');
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { parseArgs } from 'node:util';
6
- import { buildConfiguredWorkspaceOptions, buildPublicWorkspaceOptions, ExecutionEnvironmentError, isEnabledFlag, CanonClient, CanonStream, clearSessionState, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfile, initRTDBAuth, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, releaseLock, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, writeSessionState, writeTurnState, } from '@canonmsg/core';
6
+ import { getCodexImagePath, materializeMessageMedia, } from '@canonmsg/agent-sdk';
7
+ import { buildConfiguredWorkspaceOptions, buildPublicWorkspaceOptions, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, isEnabledFlag, CanonClient, CanonStream, clearSessionState, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfile, initRTDBAuth, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, releaseLock, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, writeSessionState, writeTurnState, } from '@canonmsg/core';
7
8
  import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
8
9
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
9
10
  import { CodexConversationAdapter, } from './adapter.js';
10
11
  import { clearStoredThreadId, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
12
+ import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
11
13
  const MAX_SESSIONS = 12;
12
14
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
13
15
  const HEARTBEAT_MS = 30_000;
@@ -41,7 +43,19 @@ async function publishAgentRuntime(agentId, runtime) {
41
43
  await publishHostAgentRuntime(agentId, 'codex', runtime);
42
44
  }
43
45
  async function loadSessionConfig(conversationId, agentId) {
44
- return loadHostSessionConfig({ conversationId, agentId });
46
+ return loadHostSessionConfig({
47
+ conversationId,
48
+ agentId,
49
+ extraStringFields: ['permissionMode'],
50
+ });
51
+ }
52
+ const SESSION_EXECUTION_MODE_REQUIRED = 'Session execution mode required; please select a mode before starting the session.';
53
+ function requireSessionExecutionMode(config) {
54
+ const mode = config?.executionMode;
55
+ if (!mode) {
56
+ throw new ExecutionEnvironmentError(SESSION_EXECUTION_MODE_REQUIRED, SESSION_EXECUTION_MODE_REQUIRED);
57
+ }
58
+ return mode;
45
59
  }
46
60
  function resolveWorkspaceCwd(config) {
47
61
  return resolveHostWorkspaceCwd({
@@ -57,8 +71,8 @@ function buildCanonPrompt(input) {
57
71
  ...input,
58
72
  });
59
73
  }
60
- function renderInboundContent(message) {
61
- return renderCanonHostInboundContent(message);
74
+ function renderInboundContent(message, materialized) {
75
+ return renderCanonHostInboundContent(message, materialized);
62
76
  }
63
77
  function summarizeCommand(command) {
64
78
  const trimmed = command.trim();
@@ -241,17 +255,29 @@ async function main() {
241
255
  }
242
256
  const creation = (async () => {
243
257
  const config = await loadSessionConfig(conversationId, agentId);
258
+ const sessionExecutionMode = requireSessionExecutionMode(config);
259
+ if (sessionExecutionMode === 'worktree' && !allowWorktrees) {
260
+ throw new ExecutionEnvironmentError('This host does not allow worktree sessions (launched without --enable-worktrees).', 'This Canon host was started without worktree isolation enabled. Choose "Lock the workspace" or restart the host with --enable-worktrees.');
261
+ }
244
262
  const workspaceCwd = resolveWorkspaceCwd(config);
245
263
  const environment = prepareConversationEnvironment({
246
264
  agentId,
247
265
  conversationId,
248
266
  workspaceCwd,
249
- allowWorktrees,
267
+ allowWorktrees: sessionExecutionMode === 'worktree',
250
268
  });
251
269
  try {
252
270
  const sessionCwd = environment.cwd;
253
271
  const sessionModel = config?.model ?? (typeof args.model === 'string' ? args.model : undefined);
254
272
  const storedThreadId = loadStoredThreadId(agentId, conversationId, sessionCwd);
273
+ if (config?.permissionMode
274
+ && !codexPermissionEnvelope.availablePermissionModes.some((option) => option.value === config.permissionMode)) {
275
+ throw new ExecutionEnvironmentError(`Permission mode "${config.permissionMode}" is not supported by this Codex host.`, 'This Canon host was started with stricter approval settings. Choose one of the advertised permission modes or restart the host with more permissive flags.');
276
+ }
277
+ const approvalOverride = mapCanonPermissionToCodex(config?.permissionMode);
278
+ const defaultSandbox = (typeof args.sandbox === 'string' ? args.sandbox : null);
279
+ const defaultFullAuto = Boolean(args['full-auto']);
280
+ const defaultBypass = Boolean(args['dangerously-bypass-approvals-and-sandbox']);
255
281
  const session = {
256
282
  conversationId,
257
283
  cwd: sessionCwd,
@@ -261,15 +287,17 @@ async function main() {
261
287
  threadId: storedThreadId,
262
288
  codexBin: typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex',
263
289
  model: sessionModel ?? null,
264
- sandbox: (typeof args.sandbox === 'string' ? args.sandbox : null),
290
+ sandbox: approvalOverride ? approvalOverride.sandbox : defaultSandbox,
265
291
  approvalPolicy: (typeof args['ask-for-approval'] === 'string'
266
292
  ? args['ask-for-approval']
267
293
  : null),
268
294
  codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
269
295
  addDirs: args['add-dir'] ?? [],
270
296
  configOverrides: args.config ?? [],
271
- fullAuto: Boolean(args['full-auto']),
272
- bypassApprovalsAndSandbox: Boolean(args['dangerously-bypass-approvals-and-sandbox']),
297
+ fullAuto: approvalOverride ? approvalOverride.fullAuto : defaultFullAuto,
298
+ bypassApprovalsAndSandbox: approvalOverride
299
+ ? approvalOverride.bypassApprovalsAndSandbox
300
+ : defaultBypass,
273
301
  }),
274
302
  queue: [],
275
303
  running: false,
@@ -303,8 +331,8 @@ async function main() {
303
331
  pendingSessionCreations.delete(conversationId);
304
332
  }
305
333
  }
306
- function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false) {
307
- const nextPrompt = { prompt, intent, sourceMessageId, markAccepted };
334
+ function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = []) {
335
+ const nextPrompt = { prompt, intent, sourceMessageId, markAccepted, imagePaths };
308
336
  if (toFront) {
309
337
  session.queue.unshift(nextPrompt);
310
338
  }
@@ -316,7 +344,22 @@ async function main() {
316
344
  void runNextTurn(session);
317
345
  }
318
346
  async function enqueueInboundMessage(input) {
319
- const content = renderInboundContent(input.message);
347
+ let materialized = [];
348
+ if (input.message.id) {
349
+ try {
350
+ materialized = await materializeMessageMedia({
351
+ id: input.message.id,
352
+ attachments: input.message.attachments ?? [],
353
+ }, { agentId, conversationId: input.conversationId });
354
+ }
355
+ catch (error) {
356
+ console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
357
+ }
358
+ }
359
+ const imagePaths = materialized
360
+ .map((attachment) => getCodexImagePath(attachment))
361
+ .filter((path) => path !== null);
362
+ const content = renderInboundContent(input.message, materialized);
320
363
  const hydrated = await loadHydratedInboundContext({
321
364
  conversationId: input.conversationId,
322
365
  message: input.message,
@@ -360,14 +403,14 @@ async function main() {
360
403
  workSessions,
361
404
  });
362
405
  if (session.running && deliveryIntent === 'interrupt') {
363
- enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted);
406
+ enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths);
364
407
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
365
408
  await session.adapter.interrupt().catch(() => { });
366
409
  clearStreaming(input.conversationId);
367
410
  client.setTyping(input.conversationId, false).catch(() => { });
368
411
  return;
369
412
  }
370
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted);
413
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths);
371
414
  }
372
415
  async function runNextTurn(session) {
373
416
  if (session.running || session.closed)
@@ -393,6 +436,7 @@ async function main() {
393
436
  updatedAt: { '.sv': 'timestamp' },
394
437
  }).catch(() => { });
395
438
  try {
439
+ const turnImagePaths = nextTurn.imagePaths ?? [];
396
440
  const result = await session.adapter.runTurn(nextTurn.prompt, (event) => {
397
441
  session.lastActivity = Date.now();
398
442
  if (event.type === 'thread.started') {
@@ -426,7 +470,7 @@ async function main() {
426
470
  }
427
471
  }, (line) => {
428
472
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`);
429
- });
473
+ }, turnImagePaths);
430
474
  if (result.threadId) {
431
475
  saveStoredThreadId(agentId, session.conversationId, session.cwd, result.threadId);
432
476
  }
@@ -503,10 +547,19 @@ async function main() {
503
547
  }
504
548
  let controlStopped = false;
505
549
  let streamConnected = false;
550
+ const hostAvailableExecutionModes = allowWorktrees
551
+ ? [...EXECUTION_ENVIRONMENT_MODES]
552
+ : ['locked'];
553
+ const codexPermissionEnvelope = deriveCodexPermissionEnvelope(args);
506
554
  let runtimeDescriptor = {
507
555
  defaultWorkspaceId: workspaceOptions[0]?.id,
508
556
  ...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
509
557
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
558
+ availableExecutionModes: hostAvailableExecutionModes,
559
+ availablePermissionModes: [...codexPermissionEnvelope.availablePermissionModes],
560
+ ...(codexPermissionEnvelope.defaultPermissionMode
561
+ ? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
562
+ : {}),
510
563
  };
511
564
  const publishRuntimeHeartbeat = async () => {
512
565
  if (!streamConnected)
@@ -550,12 +603,22 @@ async function main() {
550
603
  defaultWorkspaceId: workspaceOptions[0]?.id,
551
604
  ...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
552
605
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
606
+ availableExecutionModes: hostAvailableExecutionModes,
607
+ availablePermissionModes: [...codexPermissionEnvelope.availablePermissionModes],
608
+ ...(codexPermissionEnvelope.defaultPermissionMode
609
+ ? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
610
+ : {}),
553
611
  };
554
612
  }
555
613
  catch {
556
614
  runtimeDescriptor = {
557
615
  defaultWorkspaceId: workspaceOptions[0]?.id,
558
616
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
617
+ availableExecutionModes: hostAvailableExecutionModes,
618
+ availablePermissionModes: [...codexPermissionEnvelope.availablePermissionModes],
619
+ ...(codexPermissionEnvelope.defaultPermissionMode
620
+ ? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
621
+ : {}),
559
622
  };
560
623
  }
561
624
  try {
@@ -623,7 +686,7 @@ async function main() {
623
686
  writeState(session);
624
687
  }
625
688
  if (control.permissionMode) {
626
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] permissionMode control is not mapped yet (${control.permissionMode})`);
689
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
627
690
  }
628
691
  if (control.effort) {
629
692
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] effort control is not mapped yet (${control.effort})`);
@@ -0,0 +1,31 @@
1
+ import type { CodexSandboxMode } from './adapter.js';
2
+ export declare const CODEX_PERMISSION_OPTIONS: readonly [{
3
+ readonly value: "readonly";
4
+ readonly label: "Read-only";
5
+ }, {
6
+ readonly value: "workspace";
7
+ readonly label: "Workspace-write";
8
+ }, {
9
+ readonly value: "full-auto";
10
+ readonly label: "Full auto";
11
+ }, {
12
+ readonly value: "bypass";
13
+ readonly label: "Bypass (dangerous)";
14
+ }];
15
+ export type CodexPermissionMode = (typeof CODEX_PERMISSION_OPTIONS)[number]['value'];
16
+ export type CodexApprovalShape = {
17
+ sandbox: CodexSandboxMode | null;
18
+ fullAuto: boolean;
19
+ bypassApprovalsAndSandbox: boolean;
20
+ };
21
+ export type CodexPermissionEnvelope = {
22
+ defaultPermissionMode?: CodexPermissionMode;
23
+ availablePermissionModes: ReadonlyArray<(typeof CODEX_PERMISSION_OPTIONS)[number]>;
24
+ };
25
+ export declare function mapCanonPermissionToCodex(mode: string | null | undefined): CodexApprovalShape | null;
26
+ export declare function deriveCodexPermissionEnvelope(args: {
27
+ 'full-auto'?: unknown;
28
+ 'dangerously-bypass-approvals-and-sandbox'?: unknown;
29
+ 'ask-for-approval'?: unknown;
30
+ sandbox?: unknown;
31
+ }): CodexPermissionEnvelope;
@@ -0,0 +1,59 @@
1
+ export const CODEX_PERMISSION_OPTIONS = [
2
+ { value: 'readonly', label: 'Read-only' },
3
+ { value: 'workspace', label: 'Workspace-write' },
4
+ { value: 'full-auto', label: 'Full auto' },
5
+ { value: 'bypass', label: 'Bypass (dangerous)' },
6
+ ];
7
+ export function mapCanonPermissionToCodex(mode) {
8
+ switch (mode) {
9
+ case 'readonly':
10
+ return { sandbox: 'read-only', fullAuto: false, bypassApprovalsAndSandbox: false };
11
+ case 'workspace':
12
+ return { sandbox: 'workspace-write', fullAuto: false, bypassApprovalsAndSandbox: false };
13
+ case 'full-auto':
14
+ return { sandbox: 'workspace-write', fullAuto: true, bypassApprovalsAndSandbox: false };
15
+ case 'bypass':
16
+ return { sandbox: null, fullAuto: false, bypassApprovalsAndSandbox: true };
17
+ default:
18
+ return null;
19
+ }
20
+ }
21
+ function codexOptionsThrough(mode) {
22
+ const index = CODEX_PERMISSION_OPTIONS.findIndex((option) => option.value === mode);
23
+ return index >= 0 ? CODEX_PERMISSION_OPTIONS.slice(0, index + 1) : [];
24
+ }
25
+ function isLegacyFullAuto(args) {
26
+ return args['ask-for-approval'] === 'never'
27
+ && (args.sandbox === 'workspace-write' || args.sandbox == null);
28
+ }
29
+ export function deriveCodexPermissionEnvelope(args) {
30
+ if (args.sandbox === 'read-only') {
31
+ return {
32
+ defaultPermissionMode: 'readonly',
33
+ availablePermissionModes: codexOptionsThrough('readonly'),
34
+ };
35
+ }
36
+ if (args.sandbox === 'danger-full-access') {
37
+ return {
38
+ availablePermissionModes: args['dangerously-bypass-approvals-and-sandbox']
39
+ ? [...CODEX_PERMISSION_OPTIONS]
40
+ : codexOptionsThrough('full-auto'),
41
+ };
42
+ }
43
+ if (args['dangerously-bypass-approvals-and-sandbox']) {
44
+ return {
45
+ defaultPermissionMode: 'bypass',
46
+ availablePermissionModes: [...CODEX_PERMISSION_OPTIONS],
47
+ };
48
+ }
49
+ if (args['full-auto'] || isLegacyFullAuto(args)) {
50
+ return {
51
+ defaultPermissionMode: 'full-auto',
52
+ availablePermissionModes: codexOptionsThrough('full-auto'),
53
+ };
54
+ }
55
+ return {
56
+ defaultPermissionMode: 'workspace',
57
+ availablePermissionModes: codexOptionsThrough('workspace'),
58
+ };
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/host.js",
@@ -22,7 +22,8 @@
22
22
  "prepack": "npm run build"
23
23
  },
24
24
  "dependencies": {
25
- "@canonmsg/core": "^0.7.0"
25
+ "@canonmsg/agent-sdk": "^0.8.1",
26
+ "@canonmsg/core": "^0.7.1"
26
27
  },
27
28
  "engines": {
28
29
  "node": ">=18.0.0"