@canonmsg/claude-code-plugin 0.14.3 → 0.14.5
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 +3 -2
- package/dist/approval-server.d.ts +10 -0
- package/dist/approval-server.js +78 -11
- package/dist/host.js +79 -15
- package/dist/mcp-args.d.ts +43 -0
- package/dist/mcp-args.js +151 -0
- package/dist/server.js +46 -35
- package/dist/session-state.d.ts +2 -0
- package/dist/session-state.js +3 -0
- package/dist/tool-policy.d.ts +30 -0
- package/dist/tool-policy.js +208 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ canon-register --name "My Claude" --description "My Claude Code agent" --phone "
|
|
|
15
15
|
canon-claude
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Send a message to your agent in the Canon app. Claude
|
|
18
|
+
Send a message to your agent in the Canon app. Owner-originated turns use your selected Claude permission mode; non-owner turns can still get text replies but do not get local shell/filesystem/web, MCP, or Canon outbound tool access by default. If you have a safe read-only tool for non-owner turns, allow exact tool names with `CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS=tool_a,tool_b`. `canon-claude` is the local agent process. Keep that terminal open while you want Canon to reach the agent. Closing it, logging out, rebooting, or sleeping long enough to stop the process takes this local agent offline until you revive it.
|
|
19
19
|
|
|
20
20
|
After a restart or closed terminal, use the shared local manager to list and revive all recorded local agents:
|
|
21
21
|
|
|
@@ -56,7 +56,8 @@ Current Canon truth for Claude host mode:
|
|
|
56
56
|
- thinking level / effort is live-editable when the host reports it
|
|
57
57
|
- workspace selection is setup-only
|
|
58
58
|
- execution mode selection is setup-only
|
|
59
|
-
- permission modes are the known Claude Code modes supported by this host; Claude Code applies and enforces the selected mode
|
|
59
|
+
- permission modes are the known Claude Code modes supported by this host; Claude Code applies and enforces the selected mode for owner-originated turns
|
|
60
|
+
- non-owner turns deny local filesystem, shell, web/network, task, MCP, and Canon outbound tools by default in first-party host mode; pure text replies still work, and owners can allow exact safe tool names with `CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS`
|
|
60
61
|
- if worktree creation is unavailable for the selected project, Canon may fall back to shared-project execution and surface the fallback reason in session details
|
|
61
62
|
|
|
62
63
|
## Multiple agents
|
|
@@ -10,13 +10,23 @@ export declare class ApprovalHttpServer {
|
|
|
10
10
|
private manager;
|
|
11
11
|
private port;
|
|
12
12
|
private getConversationId;
|
|
13
|
+
private getIsOwnerTurn;
|
|
14
|
+
private token;
|
|
15
|
+
private maxBodyBytes;
|
|
16
|
+
private requestTimeoutMs;
|
|
13
17
|
constructor(manager: ApprovalManager, getConversationId: () => string | null, opts?: {
|
|
14
18
|
port?: number;
|
|
19
|
+
token?: string;
|
|
20
|
+
maxBodyBytes?: number;
|
|
21
|
+
requestTimeoutMs?: number;
|
|
22
|
+
getIsOwnerTurn?: () => boolean;
|
|
15
23
|
});
|
|
16
24
|
private static readonly MAX_PORT_RETRIES;
|
|
25
|
+
get authToken(): string;
|
|
17
26
|
start(): Promise<number>;
|
|
18
27
|
stop(): Promise<void>;
|
|
19
28
|
private handleRequest;
|
|
29
|
+
private hasValidToken;
|
|
20
30
|
private handleApproval;
|
|
21
31
|
private sendHookResponse;
|
|
22
32
|
private classifyRisk;
|
package/dist/approval-server.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { buildClaudeApprovalDetails, classifyClaudeApprovalCategory, classifyClaudeApprovalRisk, } from './tool-policy.js';
|
|
2
4
|
/**
|
|
3
5
|
* HTTP server that bridges Claude Code's PermissionRequest hook
|
|
4
6
|
* to Canon's ApprovalManager.
|
|
@@ -10,12 +12,23 @@ export class ApprovalHttpServer {
|
|
|
10
12
|
manager;
|
|
11
13
|
port;
|
|
12
14
|
getConversationId;
|
|
15
|
+
getIsOwnerTurn;
|
|
16
|
+
token;
|
|
17
|
+
maxBodyBytes;
|
|
18
|
+
requestTimeoutMs;
|
|
13
19
|
constructor(manager, getConversationId, opts) {
|
|
14
20
|
this.manager = manager;
|
|
15
21
|
this.getConversationId = getConversationId;
|
|
22
|
+
this.getIsOwnerTurn = opts?.getIsOwnerTurn ?? (() => true);
|
|
16
23
|
this.port = opts?.port ?? 19420;
|
|
24
|
+
this.token = opts?.token ?? randomBytes(32).toString('base64url');
|
|
25
|
+
this.maxBodyBytes = opts?.maxBodyBytes ?? 64 * 1024;
|
|
26
|
+
this.requestTimeoutMs = opts?.requestTimeoutMs ?? 10_000;
|
|
17
27
|
}
|
|
18
28
|
static MAX_PORT_RETRIES = 10;
|
|
29
|
+
get authToken() {
|
|
30
|
+
return this.token;
|
|
31
|
+
}
|
|
19
32
|
async start() {
|
|
20
33
|
const startPort = this.port;
|
|
21
34
|
return new Promise((resolve, reject) => {
|
|
@@ -34,6 +47,10 @@ export class ApprovalHttpServer {
|
|
|
34
47
|
}
|
|
35
48
|
});
|
|
36
49
|
this.server.listen(this.port, '127.0.0.1', () => {
|
|
50
|
+
const address = this.server?.address();
|
|
51
|
+
if (address && typeof address === 'object') {
|
|
52
|
+
this.port = address.port;
|
|
53
|
+
}
|
|
37
54
|
console.error(`[canon] Approval server listening on 127.0.0.1:${this.port}`);
|
|
38
55
|
resolve(this.port);
|
|
39
56
|
});
|
|
@@ -55,18 +72,53 @@ export class ApprovalHttpServer {
|
|
|
55
72
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
56
73
|
return;
|
|
57
74
|
}
|
|
75
|
+
req.setTimeout(this.requestTimeoutMs, () => {
|
|
76
|
+
res.writeHead(408, { 'Content-Type': 'application/json' });
|
|
77
|
+
res.end(JSON.stringify({ error: 'Request timeout' }));
|
|
78
|
+
req.destroy();
|
|
79
|
+
});
|
|
80
|
+
if (!this.hasValidToken(req)) {
|
|
81
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
82
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
58
85
|
let body = '';
|
|
59
86
|
req.on('data', (chunk) => {
|
|
87
|
+
if (res.writableEnded)
|
|
88
|
+
return;
|
|
89
|
+
if (body.length + chunk.length > this.maxBodyBytes) {
|
|
90
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
91
|
+
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
92
|
+
req.destroy();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
60
95
|
body += chunk.toString();
|
|
61
96
|
});
|
|
62
97
|
req.on('end', () => {
|
|
98
|
+
if (res.writableEnded)
|
|
99
|
+
return;
|
|
63
100
|
this.handleApproval(body, res).catch((err) => {
|
|
64
101
|
console.error('[canon] Approval handler error:', err);
|
|
102
|
+
if (res.writableEnded)
|
|
103
|
+
return;
|
|
65
104
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
66
105
|
res.end(JSON.stringify({ error: 'Internal error' }));
|
|
67
106
|
});
|
|
68
107
|
});
|
|
69
108
|
}
|
|
109
|
+
hasValidToken(req) {
|
|
110
|
+
const authHeader = req.headers.authorization;
|
|
111
|
+
const bearer = typeof authHeader === 'string' && authHeader.toLowerCase().startsWith('bearer ')
|
|
112
|
+
? authHeader.slice('bearer '.length).trim()
|
|
113
|
+
: null;
|
|
114
|
+
const headerToken = req.headers['x-canon-approval-token'];
|
|
115
|
+
const supplied = bearer || (typeof headerToken === 'string' ? headerToken : null);
|
|
116
|
+
if (!supplied)
|
|
117
|
+
return false;
|
|
118
|
+
const expected = Buffer.from(this.token);
|
|
119
|
+
const received = Buffer.from(supplied);
|
|
120
|
+
return expected.length === received.length && timingSafeEqual(expected, received);
|
|
121
|
+
}
|
|
70
122
|
async handleApproval(body, res) {
|
|
71
123
|
let payload;
|
|
72
124
|
try {
|
|
@@ -81,23 +133,38 @@ export class ApprovalHttpServer {
|
|
|
81
133
|
const toolInput = payload.tool_input ?? {};
|
|
82
134
|
// Determine risk level
|
|
83
135
|
const riskLevel = this.classifyRisk(toolName, toolInput);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (existingRule) {
|
|
87
|
-
const decision = existingRule.type === 'deny-tool' ? 'deny' : 'allow';
|
|
88
|
-
this.sendHookResponse(res, decision);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
// Find the conversation to send the approval to
|
|
136
|
+
const isOwnerTurn = this.getIsOwnerTurn();
|
|
137
|
+
// Find the conversation to send the approval to.
|
|
92
138
|
const conversationId = this.getConversationId();
|
|
93
139
|
if (!conversationId) {
|
|
94
|
-
console.error('[canon] No active conversation for approval — falling back to
|
|
95
|
-
// Can't send approval message, fall through to terminal prompt
|
|
140
|
+
console.error('[canon] No active conversation for approval — falling back to ask');
|
|
141
|
+
// Can't send approval message, fall through to terminal prompt.
|
|
96
142
|
this.sendHookResponse(res, 'ask');
|
|
97
143
|
return;
|
|
98
144
|
}
|
|
145
|
+
// Check session rules first for owner-triggered turns. Non-owner-triggered
|
|
146
|
+
// tools require a fresh owner decision so an old "approve all" does not
|
|
147
|
+
// turn into remote control of the owner's local runtime.
|
|
148
|
+
const existingRule = isOwnerTurn ? this.manager.checkSessionRules(toolName, conversationId) : null;
|
|
149
|
+
if (existingRule) {
|
|
150
|
+
const decision = existingRule.type === 'deny-tool' ? 'deny' : 'allow';
|
|
151
|
+
this.sendHookResponse(res, decision);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
99
154
|
try {
|
|
100
|
-
const result = await this.manager.requestApproval(conversationId, toolName, toolInput, {
|
|
155
|
+
const result = await this.manager.requestApproval(conversationId, toolName, toolInput, {
|
|
156
|
+
riskLevel,
|
|
157
|
+
risk: classifyClaudeApprovalRisk(toolName, toolInput),
|
|
158
|
+
category: classifyClaudeApprovalCategory(toolName),
|
|
159
|
+
native: {
|
|
160
|
+
runtime: 'claude-code',
|
|
161
|
+
method: 'PermissionRequest',
|
|
162
|
+
...(payload.session_id ? { requestId: payload.session_id } : {}),
|
|
163
|
+
},
|
|
164
|
+
details: buildClaudeApprovalDetails(toolName, toolInput),
|
|
165
|
+
ignoreSessionRules: !isOwnerTurn,
|
|
166
|
+
allowSessionRule: isOwnerTurn,
|
|
167
|
+
});
|
|
101
168
|
this.sendHookResponse(res, result.decision);
|
|
102
169
|
}
|
|
103
170
|
catch (err) {
|
package/dist/host.js
CHANGED
|
@@ -26,10 +26,11 @@ import { execFileSync } from 'node:child_process';
|
|
|
26
26
|
import { existsSync } from 'node:fs';
|
|
27
27
|
import { query, } from '@anthropic-ai/claude-agent-sdk';
|
|
28
28
|
import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
|
|
29
|
-
import { CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPlanApprovalRequest, buildQuestionRequest, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, rtdbRead, rtdbWrite, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
29
|
+
import { CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPlanApprovalRequest, buildQuestionRequest, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, rtdbRead, rtdbWrite, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
30
30
|
import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
|
|
31
31
|
import { createStreamingBuffer } from './streaming-buffer.js';
|
|
32
32
|
import { runCli } from './cli-entry.js';
|
|
33
|
+
import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
|
|
33
34
|
import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
|
|
34
35
|
const HELP = `canon-claude — run a local Claude Code agent host for Canon
|
|
35
36
|
|
|
@@ -61,6 +62,7 @@ let workspaceRoots = [];
|
|
|
61
62
|
let workspaceRootMetadata = [];
|
|
62
63
|
let runtimeModels = [];
|
|
63
64
|
const CLAUDE_METADATA_TTL_MS = 5 * 60 * 1000;
|
|
65
|
+
const allowedNonOwnerClaudeTools = parseAllowedNonOwnerClaudeTools(process.env.CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS);
|
|
64
66
|
let runtimeMetadata = {
|
|
65
67
|
account: null,
|
|
66
68
|
mcpServers: [],
|
|
@@ -261,6 +263,17 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
261
263
|
],
|
|
262
264
|
});
|
|
263
265
|
}
|
|
266
|
+
function parseClaudePermissionMode(value) {
|
|
267
|
+
if (value === 'acceptEdits'
|
|
268
|
+
|| value === 'auto'
|
|
269
|
+
|| value === 'bypassPermissions'
|
|
270
|
+
|| value === 'default'
|
|
271
|
+
|| value === 'dontAsk'
|
|
272
|
+
|| value === 'plan') {
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
264
277
|
function normalizeClaudeEffortLevel(value) {
|
|
265
278
|
return value === 'low' || value === 'medium' || value === 'high'
|
|
266
279
|
? value
|
|
@@ -319,7 +332,9 @@ async function loadSessionConfig(conversationId, agentId) {
|
|
|
319
332
|
});
|
|
320
333
|
return {
|
|
321
334
|
...config,
|
|
322
|
-
permissionMode: config?.permissionMode
|
|
335
|
+
permissionMode: config?.permissionMode
|
|
336
|
+
? parseClaudePermissionMode(config.permissionMode) ?? undefined
|
|
337
|
+
: undefined,
|
|
323
338
|
effort: normalizeClaudeEffortLevel(config?.effort),
|
|
324
339
|
};
|
|
325
340
|
}
|
|
@@ -671,13 +686,14 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
|
|
|
671
686
|
messageQueue.push(input);
|
|
672
687
|
}
|
|
673
688
|
};
|
|
674
|
-
let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false) => {
|
|
689
|
+
let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false) => {
|
|
675
690
|
sendInput(createClaudeInputEnvelope({
|
|
676
691
|
kind: 'canon',
|
|
677
692
|
msg,
|
|
678
693
|
intent,
|
|
679
694
|
sourceMessageId,
|
|
680
695
|
markAccepted,
|
|
696
|
+
isOwnerTurn,
|
|
681
697
|
}));
|
|
682
698
|
};
|
|
683
699
|
const inputStream = {
|
|
@@ -732,7 +748,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
|
|
|
732
748
|
const queryOptions = {
|
|
733
749
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
734
750
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
735
|
-
permissionMode: config.permissionMode,
|
|
751
|
+
permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
|
|
736
752
|
allowDangerouslySkipPermissions: true,
|
|
737
753
|
cwd,
|
|
738
754
|
includePartialMessages: true,
|
|
@@ -824,8 +840,20 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
|
|
|
824
840
|
return { behavior: 'deny', message: 'No reply received (timed out)' };
|
|
825
841
|
}
|
|
826
842
|
}
|
|
827
|
-
|
|
828
|
-
|
|
843
|
+
return decideClaudeToolPermissionForMode({
|
|
844
|
+
permissionMode: session.state.permissionMode ?? config.permissionMode,
|
|
845
|
+
toolName,
|
|
846
|
+
toolInput: input,
|
|
847
|
+
conversationId,
|
|
848
|
+
runtimeId: config.runtimeId,
|
|
849
|
+
turnId: session.currentTurnId,
|
|
850
|
+
isOwnerTurn: session.activeInput?.kind === 'canon'
|
|
851
|
+
? session.activeInput.isOwnerTurn === true
|
|
852
|
+
: true,
|
|
853
|
+
approvalManager: config.approvalManager,
|
|
854
|
+
canRequestApproval: config.canRequestApproval,
|
|
855
|
+
allowedNonOwnerTools: allowedNonOwnerClaudeTools,
|
|
856
|
+
});
|
|
829
857
|
},
|
|
830
858
|
};
|
|
831
859
|
if (resumeSessionId) {
|
|
@@ -841,7 +869,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
|
|
|
841
869
|
environment,
|
|
842
870
|
query: q,
|
|
843
871
|
sendInput,
|
|
844
|
-
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted),
|
|
872
|
+
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn),
|
|
845
873
|
state: {
|
|
846
874
|
model: undefined,
|
|
847
875
|
permissionMode: config.permissionMode,
|
|
@@ -1134,7 +1162,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
|
|
|
1134
1162
|
void markQueuedMessageAccepted(next.sourceMessageId, next.markAccepted)
|
|
1135
1163
|
.finally(() => sendInput(next));
|
|
1136
1164
|
}
|
|
1137
|
-
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false) => {
|
|
1165
|
+
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false) => {
|
|
1138
1166
|
session.lastActivity = Date.now();
|
|
1139
1167
|
const input = createClaudeInputEnvelope({
|
|
1140
1168
|
kind: 'canon',
|
|
@@ -1142,6 +1170,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
|
|
|
1142
1170
|
intent,
|
|
1143
1171
|
sourceMessageId,
|
|
1144
1172
|
markAccepted,
|
|
1173
|
+
isOwnerTurn,
|
|
1145
1174
|
});
|
|
1146
1175
|
if (session.pendingFinalDelivery) {
|
|
1147
1176
|
session.pendingInputs.push(input);
|
|
@@ -1637,6 +1666,7 @@ export async function main() {
|
|
|
1637
1666
|
}
|
|
1638
1667
|
// ── Session manager ──
|
|
1639
1668
|
const sessions = new Map();
|
|
1669
|
+
const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
|
|
1640
1670
|
const pendingSessionCreations = new Map();
|
|
1641
1671
|
let controlStopped = false;
|
|
1642
1672
|
const lastSeenControl = new Map();
|
|
@@ -1662,6 +1692,13 @@ export async function main() {
|
|
|
1662
1692
|
}
|
|
1663
1693
|
lastKnownConversationRefreshAt = Date.now();
|
|
1664
1694
|
}
|
|
1695
|
+
async function canRequestCanonApproval(conversationId) {
|
|
1696
|
+
if (!ownerId)
|
|
1697
|
+
return false;
|
|
1698
|
+
const conversation = await getConversationMeta(conversationId)
|
|
1699
|
+
.catch(() => conversationCache.get(conversationId) ?? null);
|
|
1700
|
+
return Array.isArray(conversation?.memberIds) && conversation.memberIds.includes(ownerId);
|
|
1701
|
+
}
|
|
1665
1702
|
function handleConversationUpdated(payload) {
|
|
1666
1703
|
const rawMemberIds = payload.changes.memberIds;
|
|
1667
1704
|
if (!Array.isArray(rawMemberIds))
|
|
@@ -1710,10 +1747,12 @@ export async function main() {
|
|
|
1710
1747
|
]);
|
|
1711
1748
|
return buildHydratedInboundContext({
|
|
1712
1749
|
agentId,
|
|
1750
|
+
conversationId: input.conversationId,
|
|
1713
1751
|
conversation,
|
|
1714
1752
|
page,
|
|
1715
1753
|
activeSelfContextId: input.activeSelfContextId,
|
|
1716
1754
|
selfContexts: input.selfContexts,
|
|
1755
|
+
provenance: input.provenance,
|
|
1717
1756
|
message: input.message,
|
|
1718
1757
|
senderName: input.senderName,
|
|
1719
1758
|
isOwner: input.isOwner,
|
|
@@ -1888,6 +1927,9 @@ export async function main() {
|
|
|
1888
1927
|
effort: config?.effort,
|
|
1889
1928
|
availableModels: runtimeModels,
|
|
1890
1929
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
1930
|
+
approvalManager,
|
|
1931
|
+
canRequestApproval: () => canRequestCanonApproval(conversationId),
|
|
1932
|
+
runtimeId,
|
|
1891
1933
|
}, resumeId);
|
|
1892
1934
|
sessions.set(conversationId, session);
|
|
1893
1935
|
await Promise.all([
|
|
@@ -2016,7 +2058,7 @@ export async function main() {
|
|
|
2016
2058
|
content: messageContent,
|
|
2017
2059
|
},
|
|
2018
2060
|
parent_tool_use_id: null,
|
|
2019
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted);
|
|
2061
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner);
|
|
2020
2062
|
if (m.id) {
|
|
2021
2063
|
saveRuntimeSessionState(runtimeId, {
|
|
2022
2064
|
conversationId: input.conversationId,
|
|
@@ -2065,6 +2107,21 @@ export async function main() {
|
|
|
2065
2107
|
return;
|
|
2066
2108
|
const convoId = payload.conversationId;
|
|
2067
2109
|
knownConversationIds.add(convoId);
|
|
2110
|
+
if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
|
|
2111
|
+
const consumed = approvalManager?.handleMessage(convoId, {
|
|
2112
|
+
senderId: m.senderId,
|
|
2113
|
+
text: m.text ?? undefined,
|
|
2114
|
+
metadata: m.metadata,
|
|
2115
|
+
}) ?? false;
|
|
2116
|
+
if (m.metadata?.type === 'approval_reply') {
|
|
2117
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
if (consumed) {
|
|
2121
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2068
2125
|
// Check for metadata replies (question answers, plan approvals)
|
|
2069
2126
|
if (m.metadata?.type === 'question_reply' || m.metadata?.type === 'plan_approval_reply') {
|
|
2070
2127
|
const existingSession = sessions.get(convoId);
|
|
@@ -2117,6 +2174,7 @@ export async function main() {
|
|
|
2117
2174
|
isOwner,
|
|
2118
2175
|
activeSelfContextId: payload.activeSelfContextId,
|
|
2119
2176
|
selfContexts: payload.selfContexts,
|
|
2177
|
+
provenance: payload.provenance,
|
|
2120
2178
|
});
|
|
2121
2179
|
const behavior = payload.behavior ?? hydrated.behavior;
|
|
2122
2180
|
const activeSelfContextId = hydrated.activeSelfContextId;
|
|
@@ -2178,7 +2236,7 @@ export async function main() {
|
|
|
2178
2236
|
content: messageContent,
|
|
2179
2237
|
},
|
|
2180
2238
|
parent_tool_use_id: null,
|
|
2181
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted);
|
|
2239
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner);
|
|
2182
2240
|
if (m.id) {
|
|
2183
2241
|
saveRuntimeSessionState(runtimeId, {
|
|
2184
2242
|
conversationId: convoId,
|
|
@@ -2234,15 +2292,20 @@ export async function main() {
|
|
|
2234
2292
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
|
|
2235
2293
|
}
|
|
2236
2294
|
}
|
|
2237
|
-
|
|
2295
|
+
const nextPermissionMode = parseClaudePermissionMode(ctrl.permissionMode);
|
|
2296
|
+
if (ctrl.permissionMode && !nextPermissionMode) {
|
|
2297
|
+
session.setRuntimeControlError('permissionMode', ctrl.permissionMode, new Error(`Unsupported Claude permission mode: ${ctrl.permissionMode}`));
|
|
2298
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Unsupported mode: ${ctrl.permissionMode}`);
|
|
2299
|
+
}
|
|
2300
|
+
else if (nextPermissionMode && nextPermissionMode !== session.state.permissionMode) {
|
|
2238
2301
|
try {
|
|
2239
|
-
await session.query.setPermissionMode(
|
|
2240
|
-
session.state.permissionMode =
|
|
2302
|
+
await session.query.setPermissionMode(nextPermissionMode);
|
|
2303
|
+
session.state.permissionMode = nextPermissionMode;
|
|
2241
2304
|
session.clearRuntimeControlError('permissionMode');
|
|
2242
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${
|
|
2305
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${nextPermissionMode}`);
|
|
2243
2306
|
}
|
|
2244
2307
|
catch (err) {
|
|
2245
|
-
session.setRuntimeControlError('permissionMode',
|
|
2308
|
+
session.setRuntimeControlError('permissionMode', nextPermissionMode, err);
|
|
2246
2309
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
|
|
2247
2310
|
}
|
|
2248
2311
|
}
|
|
@@ -2364,6 +2427,7 @@ export async function main() {
|
|
|
2364
2427
|
clearInterval(heartbeat);
|
|
2365
2428
|
clearInterval(idleCheck);
|
|
2366
2429
|
stream.stop();
|
|
2430
|
+
approvalManager?.dispose();
|
|
2367
2431
|
await runtimeState.clearAgentRuntime().catch(() => { });
|
|
2368
2432
|
for (const convoId of [...sessions.keys()]) {
|
|
2369
2433
|
closeSession(convoId);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SendContextualMessageOptions } from '@canonmsg/core';
|
|
2
|
+
export type McpParseResult<T> = {
|
|
3
|
+
ok: true;
|
|
4
|
+
value: T;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
error: string;
|
|
8
|
+
};
|
|
9
|
+
type ContentType = 'text' | 'image' | 'audio' | 'file';
|
|
10
|
+
export interface ReplyArgs {
|
|
11
|
+
conversationId: string;
|
|
12
|
+
text: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function parseReplyArgs(value: unknown): McpParseResult<ReplyArgs>;
|
|
15
|
+
export interface SendMessageArgs {
|
|
16
|
+
conversationId: string;
|
|
17
|
+
text: string;
|
|
18
|
+
contentType: ContentType;
|
|
19
|
+
imageUrl?: string;
|
|
20
|
+
audioUrl?: string;
|
|
21
|
+
fileUrl?: string;
|
|
22
|
+
mediaPath?: string;
|
|
23
|
+
fileName?: string;
|
|
24
|
+
mimeType?: string;
|
|
25
|
+
durationMs?: number;
|
|
26
|
+
}
|
|
27
|
+
export declare function parseSendMessageArgs(value: unknown): McpParseResult<SendMessageArgs>;
|
|
28
|
+
export interface SendContextualMessageArgs {
|
|
29
|
+
sourceConversationId: string;
|
|
30
|
+
targetConversationId?: string;
|
|
31
|
+
targetUserId?: string;
|
|
32
|
+
text: string;
|
|
33
|
+
selfContext: SendContextualMessageOptions['selfContext'];
|
|
34
|
+
messageOptions?: SendContextualMessageOptions['messageOptions'];
|
|
35
|
+
}
|
|
36
|
+
export declare function parseSendContextualMessageArgs(value: unknown): McpParseResult<SendContextualMessageArgs>;
|
|
37
|
+
export interface SetTypingArgs {
|
|
38
|
+
conversationId: string;
|
|
39
|
+
typing: boolean;
|
|
40
|
+
status?: 'typing' | 'thinking';
|
|
41
|
+
}
|
|
42
|
+
export declare function parseSetTypingArgs(value: unknown): McpParseResult<SetTypingArgs>;
|
|
43
|
+
export {};
|
package/dist/mcp-args.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
3
|
+
}
|
|
4
|
+
function requiredString(record, key, maxLength = 4096) {
|
|
5
|
+
const value = record[key];
|
|
6
|
+
if (typeof value !== 'string') {
|
|
7
|
+
return { ok: false, error: `Missing ${key}` };
|
|
8
|
+
}
|
|
9
|
+
const trimmed = value.trim();
|
|
10
|
+
if (!trimmed) {
|
|
11
|
+
return { ok: false, error: `Missing ${key}` };
|
|
12
|
+
}
|
|
13
|
+
if (trimmed.length > maxLength) {
|
|
14
|
+
return { ok: false, error: `${key} is too long` };
|
|
15
|
+
}
|
|
16
|
+
return { ok: true, value: trimmed };
|
|
17
|
+
}
|
|
18
|
+
function optionalString(record, key, maxLength = 4096) {
|
|
19
|
+
const value = record[key];
|
|
20
|
+
if (typeof value !== 'string')
|
|
21
|
+
return undefined;
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
return trimmed && trimmed.length <= maxLength ? trimmed : undefined;
|
|
24
|
+
}
|
|
25
|
+
function optionalNumber(record, key) {
|
|
26
|
+
const value = record[key];
|
|
27
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
28
|
+
? value
|
|
29
|
+
: undefined;
|
|
30
|
+
}
|
|
31
|
+
function parseContentType(value) {
|
|
32
|
+
if (value == null)
|
|
33
|
+
return { ok: true, value: 'text' };
|
|
34
|
+
if (value === 'text' || value === 'image' || value === 'audio' || value === 'file') {
|
|
35
|
+
return { ok: true, value };
|
|
36
|
+
}
|
|
37
|
+
return { ok: false, error: 'Invalid content_type' };
|
|
38
|
+
}
|
|
39
|
+
export function parseReplyArgs(value) {
|
|
40
|
+
if (!isRecord(value))
|
|
41
|
+
return { ok: false, error: 'Arguments must be an object' };
|
|
42
|
+
const conversationId = requiredString(value, 'conversation_id', 256);
|
|
43
|
+
if (!conversationId.ok)
|
|
44
|
+
return conversationId;
|
|
45
|
+
const text = requiredString(value, 'text');
|
|
46
|
+
if (!text.ok)
|
|
47
|
+
return text;
|
|
48
|
+
return {
|
|
49
|
+
ok: true,
|
|
50
|
+
value: {
|
|
51
|
+
conversationId: conversationId.value,
|
|
52
|
+
text: text.value,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function parseSendMessageArgs(value) {
|
|
57
|
+
if (!isRecord(value))
|
|
58
|
+
return { ok: false, error: 'Arguments must be an object' };
|
|
59
|
+
const conversationId = requiredString(value, 'conversation_id', 256);
|
|
60
|
+
if (!conversationId.ok)
|
|
61
|
+
return conversationId;
|
|
62
|
+
const contentType = parseContentType(value.content_type);
|
|
63
|
+
if (!contentType.ok)
|
|
64
|
+
return contentType;
|
|
65
|
+
const imageUrl = optionalString(value, 'image_url', 4096);
|
|
66
|
+
const audioUrl = optionalString(value, 'audio_url', 4096);
|
|
67
|
+
const fileUrl = optionalString(value, 'file_url', 4096);
|
|
68
|
+
const mediaPath = optionalString(value, 'media_path', 4096);
|
|
69
|
+
const fileName = optionalString(value, 'file_name', 512);
|
|
70
|
+
const mimeType = optionalString(value, 'mime_type', 256);
|
|
71
|
+
const durationMs = optionalNumber(value, 'duration_ms');
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
value: {
|
|
75
|
+
conversationId: conversationId.value,
|
|
76
|
+
text: optionalString(value, 'text') ?? '',
|
|
77
|
+
contentType: contentType.value,
|
|
78
|
+
...(imageUrl ? { imageUrl } : {}),
|
|
79
|
+
...(audioUrl ? { audioUrl } : {}),
|
|
80
|
+
...(fileUrl ? { fileUrl } : {}),
|
|
81
|
+
...(mediaPath ? { mediaPath } : {}),
|
|
82
|
+
...(fileName ? { fileName } : {}),
|
|
83
|
+
...(mimeType ? { mimeType } : {}),
|
|
84
|
+
...(durationMs != null ? { durationMs } : {}),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function parseSendContextualMessageArgs(value) {
|
|
89
|
+
if (!isRecord(value))
|
|
90
|
+
return { ok: false, error: 'Arguments must be an object' };
|
|
91
|
+
const sourceConversationId = requiredString(value, 'source_conversation_id', 256);
|
|
92
|
+
if (!sourceConversationId.ok)
|
|
93
|
+
return sourceConversationId;
|
|
94
|
+
const text = requiredString(value, 'text');
|
|
95
|
+
if (!text.ok)
|
|
96
|
+
return text;
|
|
97
|
+
const targetConversationId = optionalString(value, 'target_conversation_id', 256);
|
|
98
|
+
const targetUserId = optionalString(value, 'target_user_id', 256);
|
|
99
|
+
if (Boolean(targetConversationId) === Boolean(targetUserId)) {
|
|
100
|
+
return { ok: false, error: 'Provide exactly one target_conversation_id or target_user_id' };
|
|
101
|
+
}
|
|
102
|
+
if (!isRecord(value.self_context)) {
|
|
103
|
+
return { ok: false, error: 'Missing self_context' };
|
|
104
|
+
}
|
|
105
|
+
const type = value.self_context.type;
|
|
106
|
+
const context = requiredString(value.self_context, 'context');
|
|
107
|
+
if (!context.ok)
|
|
108
|
+
return context;
|
|
109
|
+
if (type !== 'cross_session') {
|
|
110
|
+
return { ok: false, error: 'self_context.type must be cross_session' };
|
|
111
|
+
}
|
|
112
|
+
const messageOptions = isRecord(value.message_options)
|
|
113
|
+
? value.message_options
|
|
114
|
+
: undefined;
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
value: {
|
|
118
|
+
sourceConversationId: sourceConversationId.value,
|
|
119
|
+
...(targetConversationId ? { targetConversationId } : {}),
|
|
120
|
+
...(targetUserId ? { targetUserId } : {}),
|
|
121
|
+
text: text.value,
|
|
122
|
+
selfContext: {
|
|
123
|
+
type: 'cross_session',
|
|
124
|
+
context: context.value,
|
|
125
|
+
},
|
|
126
|
+
...(messageOptions ? { messageOptions } : {}),
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export function parseSetTypingArgs(value) {
|
|
131
|
+
if (!isRecord(value))
|
|
132
|
+
return { ok: false, error: 'Arguments must be an object' };
|
|
133
|
+
const conversationId = requiredString(value, 'conversation_id', 256);
|
|
134
|
+
if (!conversationId.ok)
|
|
135
|
+
return conversationId;
|
|
136
|
+
if (typeof value.typing !== 'boolean') {
|
|
137
|
+
return { ok: false, error: 'typing must be a boolean' };
|
|
138
|
+
}
|
|
139
|
+
const status = value.status;
|
|
140
|
+
if (status !== undefined && status !== 'typing' && status !== 'thinking') {
|
|
141
|
+
return { ok: false, error: 'Invalid status' };
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
value: {
|
|
146
|
+
conversationId: conversationId.value,
|
|
147
|
+
typing: value.typing,
|
|
148
|
+
...(status ? { status } : {}),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -14,6 +14,7 @@ import { CanonClient, CanonStream, ApprovalManager, buildLocalRuntimeId, getActi
|
|
|
14
14
|
import { materializeMessageMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
|
|
15
15
|
import { ApprovalHttpServer } from './approval-server.js';
|
|
16
16
|
import { runCli } from './cli-entry.js';
|
|
17
|
+
import { parseReplyArgs, parseSendContextualMessageArgs, parseSendMessageArgs, parseSetTypingArgs, } from './mcp-args.js';
|
|
17
18
|
const HELP = `canon-channel-server — Claude Code MCP channel server for Canon
|
|
18
19
|
|
|
19
20
|
USAGE
|
|
@@ -42,8 +43,10 @@ const TURN_COMPLETE_METADATA = {
|
|
|
42
43
|
turnSemantics: 'turn_complete',
|
|
43
44
|
turnComplete: true,
|
|
44
45
|
};
|
|
45
|
-
/**
|
|
46
|
-
let
|
|
46
|
+
/** Last owner-visible conversation to route approval cards into. */
|
|
47
|
+
let approvalConversationId = null;
|
|
48
|
+
/** Whether the message that most recently triggered work came from the owner. */
|
|
49
|
+
let triggerIsOwner = true;
|
|
47
50
|
function getPrimaryAttachment(message) {
|
|
48
51
|
const attachment = message.attachments?.[0];
|
|
49
52
|
if (attachment?.url)
|
|
@@ -53,6 +56,12 @@ function getPrimaryAttachment(message) {
|
|
|
53
56
|
function renderInboundContent(message, materialized) {
|
|
54
57
|
return renderCanonHostInboundContent(message, materialized);
|
|
55
58
|
}
|
|
59
|
+
function toolArgumentError(error) {
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: 'text', text: `Invalid arguments: ${error}` }],
|
|
62
|
+
isError: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
56
65
|
// ── Tool definitions ───────────────────────────────────────────────────
|
|
57
66
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
58
67
|
tools: [
|
|
@@ -216,8 +225,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
216
225
|
const args = request.params.arguments;
|
|
217
226
|
switch (request.params.name) {
|
|
218
227
|
case 'reply': {
|
|
219
|
-
const
|
|
220
|
-
|
|
228
|
+
const parsed = parseReplyArgs(args);
|
|
229
|
+
if (!parsed.ok)
|
|
230
|
+
return toolArgumentError(parsed.error);
|
|
231
|
+
const { conversationId, text } = parsed.value;
|
|
221
232
|
const result = await client.sendMessage(conversationId, text, {
|
|
222
233
|
metadata: TURN_COMPLETE_METADATA,
|
|
223
234
|
});
|
|
@@ -231,17 +242,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
231
242
|
};
|
|
232
243
|
}
|
|
233
244
|
case 'send_message': {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
245
|
+
const parsed = parseSendMessageArgs(args);
|
|
246
|
+
if (!parsed.ok)
|
|
247
|
+
return toolArgumentError(parsed.error);
|
|
248
|
+
const { conversationId, text, contentType, imageUrl, audioUrl, fileUrl, mediaPath, fileName, mimeType, durationMs, } = parsed.value;
|
|
237
249
|
const opts = {};
|
|
238
|
-
const imageUrl = typeof args.image_url === 'string' ? args.image_url : null;
|
|
239
|
-
const audioUrl = typeof args.audio_url === 'string' ? args.audio_url : null;
|
|
240
|
-
const fileUrl = typeof args.file_url === 'string' ? args.file_url : null;
|
|
241
|
-
const mediaPath = typeof args.media_path === 'string' ? args.media_path : null;
|
|
242
|
-
const fileName = typeof args.file_name === 'string' ? args.file_name : null;
|
|
243
|
-
const mimeType = typeof args.mime_type === 'string' ? args.mime_type : null;
|
|
244
|
-
const durationMs = typeof args.duration_ms === 'number' ? args.duration_ms : null;
|
|
245
250
|
const metadata = TURN_COMPLETE_METADATA;
|
|
246
251
|
if (mediaPath) {
|
|
247
252
|
const result = await sendMediaFileMessage(client, conversationId, mediaPath, text, {
|
|
@@ -301,25 +306,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
301
306
|
};
|
|
302
307
|
}
|
|
303
308
|
case 'send_contextual_message': {
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const targetUserId = typeof args.target_user_id === 'string' ? args.target_user_id : undefined;
|
|
309
|
-
const text = args.text;
|
|
310
|
-
const selfContext = typeof args.self_context === 'object' && args.self_context != null
|
|
311
|
-
? args.self_context
|
|
312
|
-
: undefined;
|
|
313
|
-
const messageOptions = typeof args.message_options === 'object' && args.message_options != null
|
|
314
|
-
? args.message_options
|
|
315
|
-
: undefined;
|
|
309
|
+
const parsed = parseSendContextualMessageArgs(args);
|
|
310
|
+
if (!parsed.ok)
|
|
311
|
+
return toolArgumentError(parsed.error);
|
|
312
|
+
const { sourceConversationId, targetConversationId, targetUserId, text, selfContext, messageOptions, } = parsed.value;
|
|
316
313
|
const contextualMessageOptions = messageOptions;
|
|
317
314
|
const result = await client.sendContextualMessage({
|
|
318
315
|
sourceConversationId,
|
|
319
316
|
...(targetConversationId ? { targetConversationId } : {}),
|
|
320
317
|
...(targetUserId ? { targetUserId } : {}),
|
|
321
318
|
text,
|
|
322
|
-
selfContext
|
|
319
|
+
selfContext,
|
|
323
320
|
...(contextualMessageOptions
|
|
324
321
|
? {
|
|
325
322
|
messageOptions: {
|
|
@@ -344,7 +341,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
344
341
|
};
|
|
345
342
|
}
|
|
346
343
|
case 'list_conversations': {
|
|
347
|
-
const
|
|
344
|
+
const rawArgs = args && typeof args === 'object' ? args : {};
|
|
345
|
+
const limit = typeof rawArgs.limit === 'number' && Number.isFinite(rawArgs.limit)
|
|
346
|
+
? Math.max(1, Math.min(100, Math.floor(rawArgs.limit)))
|
|
347
|
+
: 20;
|
|
348
348
|
const convos = await client.getConversations();
|
|
349
349
|
const slice = convos.slice(0, limit);
|
|
350
350
|
const summary = slice.map((c) => ({
|
|
@@ -381,9 +381,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
381
381
|
};
|
|
382
382
|
}
|
|
383
383
|
case 'set_typing': {
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
384
|
+
const parsed = parseSetTypingArgs(args);
|
|
385
|
+
if (!parsed.ok)
|
|
386
|
+
return toolArgumentError(parsed.error);
|
|
387
|
+
const { conversationId, typing, status } = parsed.value;
|
|
387
388
|
await client.setTyping(conversationId, typing, status);
|
|
388
389
|
return {
|
|
389
390
|
content: [
|
|
@@ -407,9 +408,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
407
408
|
async function handleInboundMessage(payload) {
|
|
408
409
|
const m = payload.message;
|
|
409
410
|
const convo = conversationCache.get(payload.conversationId);
|
|
410
|
-
// Track
|
|
411
|
-
|
|
412
|
-
|
|
411
|
+
// Track turn provenance separately from the owner-visible approval route.
|
|
412
|
+
// Non-owner direct chats do not include the owner, so approval cards still go
|
|
413
|
+
// to the last owner conversation while the hook bypasses cached session rules.
|
|
414
|
+
triggerIsOwner = m.isOwner === true;
|
|
415
|
+
if (triggerIsOwner) {
|
|
416
|
+
approvalConversationId = payload.conversationId;
|
|
413
417
|
}
|
|
414
418
|
// Route to approval manager first — if consumed, don't forward to Claude
|
|
415
419
|
if (approvalManager) {
|
|
@@ -620,11 +624,18 @@ async function startChannel() {
|
|
|
620
624
|
// Start approval system
|
|
621
625
|
if (agentContext?.ownerId) {
|
|
622
626
|
approvalManager = new ApprovalManager(client, agentId, agentContext.ownerId);
|
|
623
|
-
approvalServer = new ApprovalHttpServer(approvalManager, () =>
|
|
627
|
+
approvalServer = new ApprovalHttpServer(approvalManager, () => approvalConversationId, {
|
|
628
|
+
token: process.env.CANON_CLAUDE_APPROVAL_TOKEN,
|
|
629
|
+
getIsOwnerTurn: () => triggerIsOwner,
|
|
630
|
+
});
|
|
624
631
|
approvalServer.start().catch((err) => {
|
|
625
632
|
const msg = err instanceof Error ? err.message : String(err);
|
|
626
633
|
console.error(`[canon] Approval server error: ${msg}`);
|
|
627
634
|
});
|
|
635
|
+
console.error('[canon] Approval hooks must send Authorization: Bearer <token> or x-canon-approval-token.');
|
|
636
|
+
if (!process.env.CANON_CLAUDE_APPROVAL_TOKEN) {
|
|
637
|
+
console.error(`[canon] Generated local approval token: ${approvalServer.authToken}`);
|
|
638
|
+
}
|
|
628
639
|
}
|
|
629
640
|
}
|
|
630
641
|
// ── Main ───────────────────────────────────────────────────────────────
|
package/dist/session-state.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface ClaudeInputEnvelope {
|
|
|
8
8
|
sourceMessageId: string | null;
|
|
9
9
|
markAccepted: boolean;
|
|
10
10
|
turnKey: string;
|
|
11
|
+
isOwnerTurn: boolean;
|
|
11
12
|
}
|
|
12
13
|
export interface ClaudeRuntimeControlError {
|
|
13
14
|
value: string;
|
|
@@ -38,6 +39,7 @@ export declare function createClaudeInputEnvelope(input: {
|
|
|
38
39
|
intent?: DeliveryIntent;
|
|
39
40
|
sourceMessageId?: string | null;
|
|
40
41
|
markAccepted?: boolean;
|
|
42
|
+
isOwnerTurn?: boolean;
|
|
41
43
|
}): ClaudeInputEnvelope;
|
|
42
44
|
export declare function buildClaudeFinalMessageId(input: {
|
|
43
45
|
agentId: string;
|
package/dist/session-state.js
CHANGED
|
@@ -8,6 +8,9 @@ export function createClaudeInputEnvelope(input) {
|
|
|
8
8
|
intent: input.intent ?? 'queue',
|
|
9
9
|
sourceMessageId,
|
|
10
10
|
markAccepted: Boolean(input.markAccepted),
|
|
11
|
+
isOwnerTurn: input.kind === 'canon'
|
|
12
|
+
? input.isOwnerTurn === true
|
|
13
|
+
: input.isOwnerTurn !== false,
|
|
11
14
|
turnKey: input.kind === 'canon' && sourceMessageId
|
|
12
15
|
? `canon:${sourceMessageId}`
|
|
13
16
|
: `${input.kind}:${fallbackId}`,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ApprovalManager, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRisk } from '@canonmsg/core';
|
|
2
|
+
export type ClaudeToolPolicyDecision = {
|
|
3
|
+
behavior: 'allow';
|
|
4
|
+
} | {
|
|
5
|
+
behavior: 'deny';
|
|
6
|
+
message: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const CLAUDE_NATIVE_APPROVAL_PERMISSION_MODE = "default";
|
|
9
|
+
export declare function parseAllowedNonOwnerClaudeTools(value: string | null | undefined): string[];
|
|
10
|
+
export declare function isSensitiveNonOwnerClaudeTool(toolName: string, allowedNonOwnerTools?: Iterable<string>): boolean;
|
|
11
|
+
export declare function decideClaudeToolPermission(input: {
|
|
12
|
+
toolName: string;
|
|
13
|
+
isOwnerTurn: boolean;
|
|
14
|
+
allowedNonOwnerTools?: Iterable<string>;
|
|
15
|
+
}): ClaudeToolPolicyDecision;
|
|
16
|
+
export declare function classifyClaudeApprovalCategory(toolName: string): ApprovalRequestCategory;
|
|
17
|
+
export declare function classifyClaudeApprovalRisk(toolName: string, toolInput: Record<string, unknown>): ApprovalRisk;
|
|
18
|
+
export declare function buildClaudeApprovalDetails(toolName: string, toolInput: Record<string, unknown>): ApprovalRequestDetail[];
|
|
19
|
+
export declare function decideClaudeToolPermissionForMode(input: {
|
|
20
|
+
permissionMode: string;
|
|
21
|
+
toolName: string;
|
|
22
|
+
toolInput: Record<string, unknown>;
|
|
23
|
+
conversationId: string;
|
|
24
|
+
runtimeId?: string;
|
|
25
|
+
turnId?: string | null;
|
|
26
|
+
isOwnerTurn: boolean;
|
|
27
|
+
approvalManager?: ApprovalManager | null;
|
|
28
|
+
canRequestApproval?: () => Promise<boolean>;
|
|
29
|
+
allowedNonOwnerTools?: Iterable<string>;
|
|
30
|
+
}): Promise<ClaudeToolPolicyDecision>;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
const NON_OWNER_DENY_MESSAGE = 'Only the agent owner can authorize Claude Code to use runtime tools from Canon chat.';
|
|
2
|
+
const NATIVE_APPROVAL_UNAVAILABLE_MESSAGE = 'Runtime action approval is not available for this conversation.';
|
|
3
|
+
const NATIVE_APPROVAL_DENIED_MESSAGE = 'The agent owner denied this action in Canon.';
|
|
4
|
+
export const CLAUDE_NATIVE_APPROVAL_PERMISSION_MODE = 'default';
|
|
5
|
+
const SENSITIVE_CLAUDE_TOOLS = new Set([
|
|
6
|
+
'agent',
|
|
7
|
+
'bash',
|
|
8
|
+
'bashoutput',
|
|
9
|
+
'config',
|
|
10
|
+
'edit',
|
|
11
|
+
'enterworktree',
|
|
12
|
+
'exitworktree',
|
|
13
|
+
'glob',
|
|
14
|
+
'grep',
|
|
15
|
+
'killbash',
|
|
16
|
+
'listmcpresources',
|
|
17
|
+
'ls',
|
|
18
|
+
'multiedit',
|
|
19
|
+
'notebookedit',
|
|
20
|
+
'notebookread',
|
|
21
|
+
'powershell',
|
|
22
|
+
'read',
|
|
23
|
+
'readmcpresource',
|
|
24
|
+
'task',
|
|
25
|
+
'taskoutput',
|
|
26
|
+
'taskstop',
|
|
27
|
+
'todowrite',
|
|
28
|
+
'webfetch',
|
|
29
|
+
'websearch',
|
|
30
|
+
'write',
|
|
31
|
+
]);
|
|
32
|
+
function normalizeToolName(toolName) {
|
|
33
|
+
return toolName.trim().toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
function isCanonOutboundTool(normalizedToolName) {
|
|
36
|
+
return normalizedToolName.endsWith('__reply')
|
|
37
|
+
|| normalizedToolName.endsWith('__send_message')
|
|
38
|
+
|| normalizedToolName.endsWith('__send_contextual_message')
|
|
39
|
+
|| normalizedToolName === 'reply'
|
|
40
|
+
|| normalizedToolName === 'send_message'
|
|
41
|
+
|| normalizedToolName === 'send_contextual_message';
|
|
42
|
+
}
|
|
43
|
+
function isMcpTool(normalizedToolName) {
|
|
44
|
+
return normalizedToolName.startsWith('mcp__');
|
|
45
|
+
}
|
|
46
|
+
function normalizeAllowedToolNames(values) {
|
|
47
|
+
const normalized = new Set();
|
|
48
|
+
for (const value of values ?? []) {
|
|
49
|
+
const toolName = normalizeToolName(value);
|
|
50
|
+
if (toolName)
|
|
51
|
+
normalized.add(toolName);
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
export function parseAllowedNonOwnerClaudeTools(value) {
|
|
56
|
+
if (!value)
|
|
57
|
+
return [];
|
|
58
|
+
return value
|
|
59
|
+
.split(',')
|
|
60
|
+
.map((entry) => entry.trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
export function isSensitiveNonOwnerClaudeTool(toolName, allowedNonOwnerTools) {
|
|
64
|
+
const normalized = normalizeToolName(toolName);
|
|
65
|
+
if (normalizeAllowedToolNames(allowedNonOwnerTools).has(normalized))
|
|
66
|
+
return false;
|
|
67
|
+
if (SENSITIVE_CLAUDE_TOOLS.has(normalized))
|
|
68
|
+
return true;
|
|
69
|
+
if (isMcpTool(normalized))
|
|
70
|
+
return true;
|
|
71
|
+
return isCanonOutboundTool(normalized);
|
|
72
|
+
}
|
|
73
|
+
export function decideClaudeToolPermission(input) {
|
|
74
|
+
if (input.isOwnerTurn)
|
|
75
|
+
return { behavior: 'allow' };
|
|
76
|
+
if (!isSensitiveNonOwnerClaudeTool(input.toolName, input.allowedNonOwnerTools)) {
|
|
77
|
+
return { behavior: 'allow' };
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
behavior: 'deny',
|
|
81
|
+
message: NON_OWNER_DENY_MESSAGE,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function classifyClaudeApprovalCategory(toolName) {
|
|
85
|
+
const normalized = normalizeToolName(toolName);
|
|
86
|
+
if (normalized === 'bash' || normalized === 'bashoutput' || normalized === 'powershell' || normalized === 'killbash') {
|
|
87
|
+
return 'command';
|
|
88
|
+
}
|
|
89
|
+
if (normalized === 'edit'
|
|
90
|
+
|| normalized === 'multiedit'
|
|
91
|
+
|| normalized === 'write'
|
|
92
|
+
|| normalized === 'notebookedit'
|
|
93
|
+
|| normalized === 'read'
|
|
94
|
+
|| normalized === 'notebookread'
|
|
95
|
+
|| normalized === 'ls'
|
|
96
|
+
|| normalized === 'glob'
|
|
97
|
+
|| normalized === 'grep') {
|
|
98
|
+
return 'file';
|
|
99
|
+
}
|
|
100
|
+
if (normalized === 'webfetch' || normalized === 'websearch') {
|
|
101
|
+
return 'network';
|
|
102
|
+
}
|
|
103
|
+
if (isMcpTool(normalized) || normalized === 'listmcpresources' || normalized === 'readmcpresource') {
|
|
104
|
+
return 'mcp';
|
|
105
|
+
}
|
|
106
|
+
if (isCanonOutboundTool(normalized)) {
|
|
107
|
+
return 'canon';
|
|
108
|
+
}
|
|
109
|
+
return 'tool';
|
|
110
|
+
}
|
|
111
|
+
export function classifyClaudeApprovalRisk(toolName, toolInput) {
|
|
112
|
+
const normalized = normalizeToolName(toolName);
|
|
113
|
+
if (normalized === 'bash' || normalized === 'powershell') {
|
|
114
|
+
const command = typeof toolInput.command === 'string' ? toolInput.command : '';
|
|
115
|
+
const destructivePatterns = [
|
|
116
|
+
/\brm\s+(-rf?|--force)/,
|
|
117
|
+
/\bgit\s+push\s+--force/,
|
|
118
|
+
/\bgit\s+reset\s+--hard/,
|
|
119
|
+
/\bdrop\s+(table|database)/i,
|
|
120
|
+
/\bformat\b/i,
|
|
121
|
+
];
|
|
122
|
+
if (destructivePatterns.some((pattern) => pattern.test(command))) {
|
|
123
|
+
return 'destructive';
|
|
124
|
+
}
|
|
125
|
+
return 'high';
|
|
126
|
+
}
|
|
127
|
+
if (normalized === 'write' || normalized === 'edit' || normalized === 'multiedit' || normalized === 'notebookedit') {
|
|
128
|
+
return 'high';
|
|
129
|
+
}
|
|
130
|
+
if (normalized === 'webfetch' || normalized === 'websearch' || isMcpTool(normalized)) {
|
|
131
|
+
return 'high';
|
|
132
|
+
}
|
|
133
|
+
return 'normal';
|
|
134
|
+
}
|
|
135
|
+
export function buildClaudeApprovalDetails(toolName, toolInput) {
|
|
136
|
+
const details = [];
|
|
137
|
+
const normalized = normalizeToolName(toolName);
|
|
138
|
+
const command = typeof toolInput.command === 'string' ? toolInput.command : null;
|
|
139
|
+
const filePath = typeof toolInput.file_path === 'string'
|
|
140
|
+
? toolInput.file_path
|
|
141
|
+
: typeof toolInput.path === 'string'
|
|
142
|
+
? toolInput.path
|
|
143
|
+
: null;
|
|
144
|
+
if (command) {
|
|
145
|
+
details.push({ label: 'Command', value: command, monospace: true });
|
|
146
|
+
}
|
|
147
|
+
if (filePath) {
|
|
148
|
+
details.push({ label: 'Path', value: filePath, monospace: true });
|
|
149
|
+
}
|
|
150
|
+
if (normalized.startsWith('mcp__')) {
|
|
151
|
+
details.push({ label: 'MCP tool', value: toolName, monospace: true });
|
|
152
|
+
}
|
|
153
|
+
const json = JSON.stringify(toolInput);
|
|
154
|
+
if (json && json !== '{}' && details.length === 0) {
|
|
155
|
+
details.push({
|
|
156
|
+
label: 'Input',
|
|
157
|
+
value: json.length > 500 ? `${json.slice(0, 497)}...` : json,
|
|
158
|
+
monospace: true,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return details;
|
|
162
|
+
}
|
|
163
|
+
export async function decideClaudeToolPermissionForMode(input) {
|
|
164
|
+
if (input.permissionMode !== CLAUDE_NATIVE_APPROVAL_PERMISSION_MODE) {
|
|
165
|
+
return decideClaudeToolPermission({
|
|
166
|
+
toolName: input.toolName,
|
|
167
|
+
isOwnerTurn: input.isOwnerTurn,
|
|
168
|
+
allowedNonOwnerTools: input.allowedNonOwnerTools,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (!input.approvalManager || !(await input.canRequestApproval?.())) {
|
|
172
|
+
return {
|
|
173
|
+
behavior: 'deny',
|
|
174
|
+
message: NATIVE_APPROVAL_UNAVAILABLE_MESSAGE,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
let result;
|
|
178
|
+
try {
|
|
179
|
+
const risk = classifyClaudeApprovalRisk(input.toolName, input.toolInput);
|
|
180
|
+
result = await input.approvalManager.requestApproval(input.conversationId, input.toolName, input.toolInput, {
|
|
181
|
+
category: classifyClaudeApprovalCategory(input.toolName),
|
|
182
|
+
risk,
|
|
183
|
+
riskLevel: risk === 'destructive' ? 'destructive' : 'normal',
|
|
184
|
+
runtimeId: input.runtimeId,
|
|
185
|
+
turnId: input.turnId ?? undefined,
|
|
186
|
+
native: {
|
|
187
|
+
runtime: 'claude-code',
|
|
188
|
+
method: 'canUseTool',
|
|
189
|
+
},
|
|
190
|
+
details: buildClaudeApprovalDetails(input.toolName, input.toolInput),
|
|
191
|
+
ignoreSessionRules: !input.isOwnerTurn,
|
|
192
|
+
allowSessionRule: input.isOwnerTurn,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return {
|
|
197
|
+
behavior: 'deny',
|
|
198
|
+
message: NATIVE_APPROVAL_UNAVAILABLE_MESSAGE,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (result.decision === 'allow') {
|
|
202
|
+
return { behavior: 'allow' };
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
behavior: 'deny',
|
|
206
|
+
message: NATIVE_APPROVAL_DENIED_MESSAGE,
|
|
207
|
+
};
|
|
208
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/claude-code-plugin",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.5",
|
|
4
4
|
"description": "Canon channel plugin for Claude Code — messaging where AI agents are first-class citizens",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@anthropic-ai/claude-agent-sdk": "^0.2.101",
|
|
34
|
-
"@canonmsg/agent-sdk": "^1.
|
|
35
|
-
"@canonmsg/core": "^0.
|
|
34
|
+
"@canonmsg/agent-sdk": "^1.5.0",
|
|
35
|
+
"@canonmsg/core": "^0.19.1",
|
|
36
36
|
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|