@canonmsg/codex-plugin 0.11.2 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,10 +71,12 @@ Advertise multiple project choices to the Canon app:
71
71
  canon-codex --cwd ~/dev --workspace-root ~/dev
72
72
  ```
73
73
 
74
- `--cwd` is the default workspace. Each `--workspace-root` value is an approved local root; the host discovers immediate child projects with common markers such as `.git`, `package.json`, `pyproject.toml`, `Cargo.toml`, or `go.mod` and publishes them as selectable projects during session creation. Use repeated `--workspace /path/to/project` entries to advertise specific projects outside those roots. Worktree mode creates a per-conversation git worktree under `~/.canon/conversation-worktrees`; shared-project mode runs directly in the selected directory.
74
+ `--cwd` is the default workspace. Each `--workspace-root` value is an approved local root; the host discovers immediate child projects with common markers such as `.git`, `package.json`, `pyproject.toml`, `Cargo.toml`, or `go.mod` and publishes them as selectable projects during session creation. Use repeated `--workspace /path/to/project` entries to advertise specific projects outside those roots. Worktree mode creates a best-effort per-conversation git worktree under `~/.canon/conversation-worktrees`; shared-project mode runs directly in the selected directory.
75
75
 
76
76
  If worktree isolation is requested for a project that cannot support it, Canon may fall back to shared-project execution and surface the fallback reason in session details instead of failing the session outright.
77
77
 
78
+ Worktree mode is project isolation, not an operating-system sandbox. The Codex CLI sandbox and approval policy enforce actual file and command behavior.
79
+
78
80
  Useful flags:
79
81
 
80
82
  ```bash
@@ -85,6 +87,8 @@ Codex also supports `--add-dir /extra/path` for additional writable directories
85
87
 
86
88
  Recent Codex CLI releases no longer accept `--ask-for-approval` with `codex exec`. If you previously launched Canon with `--sandbox workspace-write --ask-for-approval never`, switch to `--full-auto`.
87
89
 
90
+ Do not start Canon with `--sandbox danger-full-access` as an unlabeled default. Use `--dangerously-bypass-approvals-and-sandbox` only when you intentionally want Canon to advertise the owner-only Bypass policy.
91
+
88
92
  Local smoke test:
89
93
 
90
94
  ```bash
@@ -0,0 +1,32 @@
1
+ import type { ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalResult, ApprovalRisk } from '@canonmsg/core';
2
+ /**
3
+ * Mapping for Codex app-server native approval requests.
4
+ *
5
+ * The current Canon Codex host still uses `codex exec --json`, which cannot
6
+ * block on these methods. Keep this as metadata/decision plumbing for the
7
+ * future app-server transport, not as an advertised `exec --json` UI mode.
8
+ */
9
+ export type CodexAppServerApprovalMethod = 'item/commandExecution/requestApproval' | 'item/fileChange/requestApproval' | 'item/permissions/requestApproval' | 'execCommandApproval' | 'applyPatchApproval';
10
+ export interface CodexNativeApprovalRequest {
11
+ method: CodexAppServerApprovalMethod;
12
+ params: Record<string, unknown>;
13
+ }
14
+ export interface CanonCodexApprovalRequest {
15
+ toolName: string;
16
+ toolInput: Record<string, unknown>;
17
+ toolSummary: string;
18
+ category: ApprovalRequestCategory;
19
+ risk: ApprovalRisk;
20
+ riskLevel: 'normal' | 'destructive';
21
+ native: ApprovalNativeRequestMetadata;
22
+ details: ApprovalRequestDetail[];
23
+ }
24
+ export type CodexApprovalDecision = {
25
+ decision: 'accept';
26
+ } | {
27
+ decision: 'acceptForSession';
28
+ } | {
29
+ decision: 'decline';
30
+ };
31
+ export declare function mapCodexAppServerApprovalRequest(request: CodexNativeApprovalRequest): CanonCodexApprovalRequest | null;
32
+ export declare function mapCanonApprovalResultToCodexDecision(result: ApprovalResult): CodexApprovalDecision;
@@ -0,0 +1,168 @@
1
+ function isRecord(value) {
2
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
3
+ }
4
+ function readString(record, keys) {
5
+ for (const key of keys) {
6
+ const value = record[key];
7
+ if (typeof value === 'string' && value.trim())
8
+ return value.trim();
9
+ }
10
+ return undefined;
11
+ }
12
+ function firstString(value) {
13
+ if (typeof value === 'string' && value.trim())
14
+ return value.trim();
15
+ if (Array.isArray(value)) {
16
+ return value.map((entry) => (typeof entry === 'string' ? entry.trim() : '')).find(Boolean);
17
+ }
18
+ return undefined;
19
+ }
20
+ function stringifyPreview(value, maxLength = 500) {
21
+ if (typeof value === 'string')
22
+ return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
23
+ const json = JSON.stringify(value ?? {});
24
+ return json.length > maxLength ? `${json.slice(0, maxLength - 3)}...` : json;
25
+ }
26
+ function nativeHandle(method, params) {
27
+ const native = {
28
+ runtime: 'codex',
29
+ method,
30
+ };
31
+ const requestId = readString(params, ['requestId', 'id', 'callId']);
32
+ const provider = readString(params, ['provider']);
33
+ const origin = readString(params, ['origin']);
34
+ const surface = readString(params, ['surface']);
35
+ const threadId = readString(params, ['threadId', 'conversationId']);
36
+ const turnId = readString(params, ['turnId']);
37
+ const runId = readString(params, ['runId']);
38
+ const itemId = readString(params, ['itemId']);
39
+ const toolCallId = readString(params, ['toolCallId', 'toolUseId']);
40
+ const approvalId = readString(params, ['approvalId']);
41
+ const pluginId = readString(params, ['pluginId']);
42
+ const sessionKey = readString(params, ['sessionKey', 'sessionId']);
43
+ const cwd = readString(params, ['cwd', 'workingDirectory']);
44
+ const model = readString(params, ['model']);
45
+ if (requestId)
46
+ native.requestId = requestId;
47
+ if (provider)
48
+ native.provider = provider;
49
+ if (origin)
50
+ native.origin = origin;
51
+ if (surface)
52
+ native.surface = surface;
53
+ if (threadId)
54
+ native.threadId = threadId;
55
+ if (turnId)
56
+ native.turnId = turnId;
57
+ if (runId)
58
+ native.runId = runId;
59
+ if (itemId)
60
+ native.itemId = itemId;
61
+ if (toolCallId)
62
+ native.toolCallId = toolCallId;
63
+ if (approvalId)
64
+ native.approvalId = approvalId;
65
+ if (pluginId)
66
+ native.pluginId = pluginId;
67
+ if (sessionKey)
68
+ native.sessionKey = sessionKey;
69
+ if (cwd)
70
+ native.cwd = cwd;
71
+ if (model)
72
+ native.model = model;
73
+ return native;
74
+ }
75
+ function commandRisk(command) {
76
+ if (/\brm\s+(-rf?|--force)|\bgit\s+reset\s+--hard|\bgit\s+push\s+--force|\bdrop\s+(table|database)\b/i.test(command)) {
77
+ return 'destructive';
78
+ }
79
+ return 'high';
80
+ }
81
+ function detail(label, value, monospace = true) {
82
+ return value ? [{ label, value, monospace }] : [];
83
+ }
84
+ function mapCommandRequest(method, params) {
85
+ const command = readString(params, ['command', 'cmd'])
86
+ ?? firstString(params.argv)
87
+ ?? firstString(params.args)
88
+ ?? stringifyPreview(params);
89
+ const cwd = readString(params, ['cwd', 'workingDirectory']);
90
+ const risk = commandRisk(command);
91
+ return {
92
+ toolName: 'codex.command',
93
+ toolInput: {
94
+ command,
95
+ ...(cwd ? { cwd } : {}),
96
+ },
97
+ toolSummary: command,
98
+ category: 'command',
99
+ risk,
100
+ riskLevel: risk === 'destructive' ? 'destructive' : 'normal',
101
+ native: nativeHandle(method, params),
102
+ details: [
103
+ ...detail('Command', command),
104
+ ...detail('Working directory', cwd),
105
+ ],
106
+ };
107
+ }
108
+ function mapFileRequest(method, params) {
109
+ const path = readString(params, ['path', 'filePath', 'targetPath']);
110
+ const summary = path
111
+ ?? firstString(params.paths)
112
+ ?? stringifyPreview(params.changes ?? params.patch ?? params);
113
+ const patch = readString(params, ['patch', 'diff']);
114
+ const destructive = /\b(delete|remove|unlink)\b/i.test(stringifyPreview(params, 1000));
115
+ return {
116
+ toolName: method === 'applyPatchApproval' ? 'codex.applyPatch' : 'codex.fileChange',
117
+ toolInput: {
118
+ ...(path ? { path } : {}),
119
+ ...(patch ? { patch } : {}),
120
+ },
121
+ toolSummary: summary,
122
+ category: 'file',
123
+ risk: destructive ? 'destructive' : 'high',
124
+ riskLevel: destructive ? 'destructive' : 'normal',
125
+ native: nativeHandle(method, params),
126
+ details: [
127
+ ...detail('Path', path),
128
+ ...detail('Patch', patch),
129
+ ],
130
+ };
131
+ }
132
+ function mapPermissionRequest(method, params) {
133
+ const action = readString(params, ['action', 'permission', 'toolName', 'tool'])
134
+ ?? stringifyPreview(params);
135
+ return {
136
+ toolName: 'codex.permission',
137
+ toolInput: { action },
138
+ toolSummary: action,
139
+ category: 'tool',
140
+ risk: 'normal',
141
+ riskLevel: 'normal',
142
+ native: nativeHandle(method, params),
143
+ details: detail('Permission', action, false),
144
+ };
145
+ }
146
+ export function mapCodexAppServerApprovalRequest(request) {
147
+ if (!isRecord(request.params))
148
+ return null;
149
+ if (request.method === 'item/commandExecution/requestApproval'
150
+ || request.method === 'execCommandApproval') {
151
+ return mapCommandRequest(request.method, request.params);
152
+ }
153
+ if (request.method === 'item/fileChange/requestApproval'
154
+ || request.method === 'applyPatchApproval') {
155
+ return mapFileRequest(request.method, request.params);
156
+ }
157
+ if (request.method === 'item/permissions/requestApproval') {
158
+ return mapPermissionRequest(request.method, request.params);
159
+ }
160
+ return null;
161
+ }
162
+ export function mapCanonApprovalResultToCodexDecision(result) {
163
+ if (result.decision === 'deny')
164
+ return { decision: 'decline' };
165
+ if (result.sessionRule)
166
+ return { decision: 'acceptForSession' };
167
+ return { decision: 'accept' };
168
+ }
package/dist/host.js CHANGED
@@ -3,7 +3,7 @@ import { setDefaultResultOrder } from 'node:dns';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { dirname } from 'node:path';
5
5
  import { parseArgs } from 'node:util';
6
- import { getCodexImagePath, materializeMessageMedia, } from '@canonmsg/agent-sdk';
6
+ import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, } from '@canonmsg/agent-sdk';
7
7
  import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
8
8
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
9
9
  import { CodexConversationAdapter, } from './adapter.js';
@@ -202,6 +202,21 @@ function buildCanonPrompt(input) {
202
202
  function renderInboundContent(message, materialized) {
203
203
  return renderCanonHostInboundContent(message, materialized);
204
204
  }
205
+ async function materializePromptReplyContext(input) {
206
+ if (!input.replyContext?.found || !input.replyContext.attachments?.length) {
207
+ return { replyContext: input.replyContext, materialized: [] };
208
+ }
209
+ try {
210
+ return await materializeReplyContextMedia(input.replyContext, {
211
+ agentId: input.agentId,
212
+ conversationId: input.conversationId,
213
+ });
214
+ }
215
+ catch (error) {
216
+ console.error(`${input.logPrefix} Failed to materialize replied-to media:`, error instanceof Error ? error.message : error);
217
+ return { replyContext: input.replyContext, materialized: [] };
218
+ }
219
+ }
205
220
  function summarizeCommand(command) {
206
221
  const trimmed = command.trim();
207
222
  if (!trimmed)
@@ -398,10 +413,12 @@ export async function main() {
398
413
  ]);
399
414
  return buildHydratedInboundContext({
400
415
  agentId,
416
+ conversationId: input.conversationId,
401
417
  conversation,
402
418
  page,
403
419
  activeSelfContextId: input.activeSelfContextId,
404
420
  selfContexts: input.selfContexts,
421
+ provenance: input.provenance,
405
422
  message: input.message,
406
423
  senderName: input.senderName,
407
424
  isOwner: input.isOwner,
@@ -664,10 +681,6 @@ export async function main() {
664
681
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
665
682
  }
666
683
  }
667
- const imagePaths = materialized
668
- .map((attachment) => getCodexImagePath(attachment))
669
- .filter((path) => path !== null);
670
- const mediaAddDirs = uniqueStrings(materialized.map((attachment) => dirname(attachment.path)));
671
684
  const content = renderInboundContent(input.message, materialized);
672
685
  const hydrated = await loadHydratedInboundContext({
673
686
  conversationId: input.conversationId,
@@ -676,11 +689,24 @@ export async function main() {
676
689
  isOwner: input.isOwner,
677
690
  activeSelfContextId: input.activeSelfContextId,
678
691
  selfContexts: input.selfContexts,
692
+ provenance: input.provenance,
679
693
  hydratedPage: input.hydratedPage,
680
694
  });
681
695
  const behavior = input.behavior ?? hydrated.behavior;
682
696
  const activeSelfContextId = hydrated.activeSelfContextId;
683
697
  const selfContexts = hydrated.selfContexts;
698
+ const replyMedia = await materializePromptReplyContext({
699
+ replyContext: hydrated.replyContext,
700
+ agentId,
701
+ conversationId: input.conversationId,
702
+ logPrefix: `[canon-codex] [${input.conversationId.slice(0, 8)}]`,
703
+ });
704
+ const replyContext = replyMedia.replyContext;
705
+ const promptMaterialized = [...replyMedia.materialized, ...materialized];
706
+ const imagePaths = promptMaterialized
707
+ .map((attachment) => getCodexImagePath(attachment))
708
+ .filter((path) => path !== null);
709
+ const mediaAddDirs = uniqueStrings(promptMaterialized.map((attachment) => dirname(attachment.path)));
684
710
  const participantContext = hydrated.participantContext;
685
711
  const autoReply = decideAutoReply(participantContext, behavior);
686
712
  if (!autoReply.allow) {
@@ -717,6 +743,7 @@ export async function main() {
717
743
  participantContext,
718
744
  behavior,
719
745
  selfContexts,
746
+ replyContext,
720
747
  });
721
748
  if (session.running && deliveryIntent === 'interrupt') {
722
749
  enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs);
@@ -1072,6 +1099,7 @@ export async function main() {
1072
1099
  behavior: payload.behavior,
1073
1100
  activeSelfContextId: payload.activeSelfContextId,
1074
1101
  selfContexts: payload.selfContexts,
1102
+ provenance: payload.provenance,
1075
1103
  });
1076
1104
  if (message.id) {
1077
1105
  saveRuntimeSessionState(runtimeId, {
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { main as hostMain } from './host.js';
2
2
  export { main as registerMain } from './register.js';
3
3
  export { main as setupMain } from './setup.js';
4
+ export { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
5
+ export type { CanonCodexApprovalRequest, CodexAppServerApprovalMethod, CodexApprovalDecision, CodexNativeApprovalRequest, } from './app-server-approval.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { main as hostMain } from './host.js';
2
2
  export { main as registerMain } from './register.js';
3
3
  export { main as setupMain } from './setup.js';
4
+ export { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
@@ -2,15 +2,19 @@ import type { CodexSandboxMode } from './adapter.js';
2
2
  export declare const CODEX_PERMISSION_OPTIONS: readonly [{
3
3
  readonly value: "readonly";
4
4
  readonly label: "Read-only";
5
+ readonly description: "Inspect files and project state without making changes.";
5
6
  }, {
6
7
  readonly value: "workspace";
7
8
  readonly label: "Workspace-write";
9
+ readonly description: "Edit inside the selected workspace using Codex CLI sandboxing.";
8
10
  }, {
9
11
  readonly value: "full-auto";
10
12
  readonly label: "Full auto";
13
+ readonly description: "Automatically run workspace-write actions with lower-friction approvals.";
11
14
  }, {
12
15
  readonly value: "bypass";
13
16
  readonly label: "Bypass (dangerous)";
17
+ readonly description: "Skip Codex approval and sandbox protection. Use only in an externally sandboxed environment.";
14
18
  }];
15
19
  export type CodexPermissionMode = (typeof CODEX_PERMISSION_OPTIONS)[number]['value'];
16
20
  export type CodexApprovalShape = {
@@ -1,8 +1,8 @@
1
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)' },
2
+ { value: 'readonly', label: 'Read-only', description: 'Inspect files and project state without making changes.' },
3
+ { value: 'workspace', label: 'Workspace-write', description: 'Edit inside the selected workspace using Codex CLI sandboxing.' },
4
+ { value: 'full-auto', label: 'Full auto', description: 'Automatically run workspace-write actions with lower-friction approvals.' },
5
+ { value: 'bypass', label: 'Bypass (dangerous)', description: 'Skip Codex approval and sandbox protection. Use only in an externally sandboxed environment.' },
6
6
  ];
7
7
  export function mapCanonPermissionToCodex(mode) {
8
8
  switch (mode) {
@@ -29,12 +29,8 @@ export function deriveCodexPermissionEnvelope(args) {
29
29
  availablePermissionModes: codexOptionsThrough('readonly'),
30
30
  };
31
31
  }
32
- if (args.sandbox === 'danger-full-access') {
33
- return {
34
- availablePermissionModes: args['dangerously-bypass-approvals-and-sandbox']
35
- ? [...CODEX_PERMISSION_OPTIONS]
36
- : codexOptionsThrough('full-auto'),
37
- };
32
+ if (args.sandbox === 'danger-full-access' && !args['dangerously-bypass-approvals-and-sandbox']) {
33
+ throw new Error('Codex sandbox danger-full-access cannot be used as an unlabeled Canon default. Use --dangerously-bypass-approvals-and-sandbox if you intentionally want the Bypass policy.');
38
34
  }
39
35
  if (args['dangerously-bypass-approvals-and-sandbox']) {
40
36
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,8 +29,8 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@canonmsg/agent-sdk": "^1.4.0",
33
- "@canonmsg/core": "^0.18.0"
32
+ "@canonmsg/agent-sdk": "^1.5.0",
33
+ "@canonmsg/core": "^0.19.0"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=18.0.0"