@canonmsg/claude-code-plugin 0.21.2 → 0.22.0
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 +1 -17
- package/dist/host.js +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/session-state.d.ts +0 -1
- package/dist/session-state.js +23 -56
- package/dist/setup.d.ts +2 -3
- package/dist/setup.js +5 -29
- package/package.json +8 -10
- package/skills/configure/SKILL.md +4 -10
- package/.claude-plugin/plugin.json +0 -25
- package/dist/approval-server.d.ts +0 -33
- package/dist/approval-server.js +0 -200
- package/dist/mcp-args.d.ts +0 -45
- package/dist/mcp-args.js +0 -179
- package/dist/server.d.ts +0 -2
- package/dist/server.js +0 -711
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Canon
|
|
1
|
+
# Canon Host for Claude Code
|
|
2
2
|
|
|
3
3
|
Connect Claude Code to [Canon](https://github.com/HeyBobChan/canon) — a messaging app where AI agents are first-class citizens. Control Claude Code from your phone.
|
|
4
4
|
|
|
@@ -67,22 +67,6 @@ canon-register --name "Frontend" --description "React work" --phone "+1..." --pr
|
|
|
67
67
|
CANON_AGENT=frontend canon-claude --cwd ~/projects/frontend
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
## Channel mode (alternative)
|
|
71
|
-
|
|
72
|
-
For a lighter integration without session controls, run Canon as a channel plugin inside your own Claude Code session. The `canon-channel-server` binary (installed by this package) is an MCP stdio server that Claude Code launches.
|
|
73
|
-
|
|
74
|
-
Register the channel server with Claude Code's MCP config and provide your Canon API key via the `CANON_API_KEY` environment variable:
|
|
75
|
-
|
|
76
|
-
```bash
|
|
77
|
-
# Register once per Claude Code project
|
|
78
|
-
claude mcp add canon-channel canon-channel-server
|
|
79
|
-
|
|
80
|
-
# Then start Claude Code with your Canon key in the environment
|
|
81
|
-
CANON_API_KEY=agk_live_... claude
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
Channel mode forwards messages between Canon conversations and the embedded Claude Code session without managing session lifecycle. It is shown as non-revivable by `canon-necromance`. Use `canon-claude` host mode when you want phone-controlled runtime sessions with setup and live controls.
|
|
85
|
-
|
|
86
70
|
## Development
|
|
87
71
|
|
|
88
72
|
```bash
|
package/dist/host.js
CHANGED
|
@@ -1099,7 +1099,6 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1099
1099
|
cwd,
|
|
1100
1100
|
environment,
|
|
1101
1101
|
query: q,
|
|
1102
|
-
controlApplySeq: new Map(),
|
|
1103
1102
|
sendInput,
|
|
1104
1103
|
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactRoutingMode),
|
|
1105
1104
|
state: buildClaudeInitialSessionState({
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/session-state.d.ts
CHANGED
|
@@ -54,7 +54,6 @@ export declare function buildClaudeInitialSessionState(input: {
|
|
|
54
54
|
}): ClaudeHostSessionState;
|
|
55
55
|
export interface ClaudeSessionControlTarget {
|
|
56
56
|
state: ClaudeHostSessionState;
|
|
57
|
-
controlApplySeq: Map<string, number>;
|
|
58
57
|
closed: boolean;
|
|
59
58
|
query: Pick<Query, 'setModel' | 'setPermissionMode' | 'applyFlagSettings'>;
|
|
60
59
|
writeState: () => void;
|
package/dist/session-state.js
CHANGED
|
@@ -29,21 +29,23 @@ export function buildClaudeInitialSessionState(input) {
|
|
|
29
29
|
state: 'idle',
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Apply a control to the Claude SDK. W2-A2 deleted the seq-guarded optimistic
|
|
34
|
+
* revert machinery (commit c356bdf8) that masked the 2–11s control poll: the
|
|
35
|
+
* bridge is event-push now (~100ms), so the state is already published by the
|
|
36
|
+
* caller's writeState() and the SDK apply is fired without a revert dance. A
|
|
37
|
+
* failed apply surfaces as a runtime-control error (the display truth the app
|
|
38
|
+
* reads) — the same shape codex uses. `value` is the requested value for the
|
|
39
|
+
* error record; `controlId` scopes the error.
|
|
40
|
+
*/
|
|
41
|
+
function applyControl(input) {
|
|
42
|
+
// Fire the SDK apply SYNCHRONOUSLY (an async setter may still resolve later);
|
|
43
|
+
// wrap so a synchronous throw is caught in the same rejection path.
|
|
44
|
+
void (async () => input.apply())().catch((error) => {
|
|
45
|
+
if (input.target.closed)
|
|
43
46
|
return;
|
|
44
|
-
}
|
|
45
|
-
input.revert();
|
|
46
47
|
input.target.setRuntimeControlError(input.controlId, input.value, error);
|
|
48
|
+
input.target.writeState();
|
|
47
49
|
});
|
|
48
50
|
}
|
|
49
51
|
function setRuntimeControlValue(state, controlId, value) {
|
|
@@ -58,20 +60,13 @@ export function applyClaudeSessionControl(target, ctrl) {
|
|
|
58
60
|
target.setRuntimeControlError('model', String(ctrl.model), new Error('Unsupported Claude model.'));
|
|
59
61
|
}
|
|
60
62
|
else if (nextModel && nextModel !== target.state.model) {
|
|
61
|
-
const previous = target.state.model;
|
|
62
63
|
target.state.model = nextModel;
|
|
63
64
|
target.clearRuntimeControlError('model');
|
|
64
|
-
|
|
65
|
+
applyControl({
|
|
65
66
|
target,
|
|
66
67
|
controlId: 'model',
|
|
67
68
|
value: nextModel,
|
|
68
69
|
apply: () => target.query.setModel(nextModel),
|
|
69
|
-
revert: () => {
|
|
70
|
-
if (previous === undefined)
|
|
71
|
-
delete target.state.model;
|
|
72
|
-
else
|
|
73
|
-
target.state.model = previous;
|
|
74
|
-
},
|
|
75
70
|
});
|
|
76
71
|
}
|
|
77
72
|
const nextPermissionMode = parseClaudePermissionMode(ctrl.permissionMode);
|
|
@@ -79,41 +74,27 @@ export function applyClaudeSessionControl(target, ctrl) {
|
|
|
79
74
|
target.setRuntimeControlError('permissionMode', ctrl.permissionMode, new Error(`Unsupported Claude permission mode: ${ctrl.permissionMode}`));
|
|
80
75
|
}
|
|
81
76
|
else if (nextPermissionMode && nextPermissionMode !== target.state.permissionMode) {
|
|
82
|
-
// Claude Code applies permission-mode changes asynchronously
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
const previous = target.state.permissionMode;
|
|
77
|
+
// Claude Code applies permission-mode changes asynchronously; publish the
|
|
78
|
+
// requested value immediately (writeState below) and surface an error if the
|
|
79
|
+
// apply fails.
|
|
86
80
|
target.state.permissionMode = nextPermissionMode;
|
|
87
81
|
target.clearRuntimeControlError('permissionMode');
|
|
88
|
-
|
|
82
|
+
applyControl({
|
|
89
83
|
target,
|
|
90
84
|
controlId: 'permissionMode',
|
|
91
85
|
value: nextPermissionMode,
|
|
92
86
|
apply: () => target.query.setPermissionMode(nextPermissionMode),
|
|
93
|
-
revert: () => {
|
|
94
|
-
if (previous === undefined)
|
|
95
|
-
delete target.state.permissionMode;
|
|
96
|
-
else
|
|
97
|
-
target.state.permissionMode = previous;
|
|
98
|
-
},
|
|
99
87
|
});
|
|
100
88
|
}
|
|
101
89
|
const nextEffort = normalizeClaudeEffortLevel(ctrl.effort);
|
|
102
90
|
if (nextEffort && nextEffort !== target.state.effort) {
|
|
103
|
-
const previous = target.state.effort;
|
|
104
91
|
target.state.effort = nextEffort;
|
|
105
92
|
target.clearRuntimeControlError('effort');
|
|
106
|
-
|
|
93
|
+
applyControl({
|
|
107
94
|
target,
|
|
108
95
|
controlId: 'effort',
|
|
109
96
|
value: nextEffort,
|
|
110
97
|
apply: () => target.query.applyFlagSettings({ effortLevel: nextEffort }),
|
|
111
|
-
revert: () => {
|
|
112
|
-
if (previous === undefined)
|
|
113
|
-
delete target.state.effort;
|
|
114
|
-
else
|
|
115
|
-
target.state.effort = previous;
|
|
116
|
-
},
|
|
117
98
|
});
|
|
118
99
|
}
|
|
119
100
|
else if (ctrl.effort !== undefined && !nextEffort) {
|
|
@@ -126,20 +107,13 @@ export function applyClaudeSessionControl(target, ctrl) {
|
|
|
126
107
|
target.setRuntimeControlError('thinking', String(runtimeControlValues.thinking), new Error('Unsupported Claude thinking mode.'));
|
|
127
108
|
}
|
|
128
109
|
else if (nextThinking !== target.state.runtimeControlValues?.thinking) {
|
|
129
|
-
const previous = target.state.runtimeControlValues?.thinking;
|
|
130
110
|
setRuntimeControlValue(target.state, 'thinking', nextThinking);
|
|
131
111
|
target.clearRuntimeControlError('thinking');
|
|
132
|
-
|
|
112
|
+
applyControl({
|
|
133
113
|
target,
|
|
134
114
|
controlId: 'thinking',
|
|
135
115
|
value: nextThinking,
|
|
136
116
|
apply: () => target.query.applyFlagSettings({ alwaysThinkingEnabled: claudeThinkingToFlagValue(nextThinking) }),
|
|
137
|
-
revert: () => {
|
|
138
|
-
if (previous === undefined)
|
|
139
|
-
delete target.state.runtimeControlValues?.thinking;
|
|
140
|
-
else
|
|
141
|
-
setRuntimeControlValue(target.state, 'thinking', previous);
|
|
142
|
-
},
|
|
143
117
|
});
|
|
144
118
|
}
|
|
145
119
|
}
|
|
@@ -149,20 +123,13 @@ export function applyClaudeSessionControl(target, ctrl) {
|
|
|
149
123
|
target.setRuntimeControlError('ultracode', String(runtimeControlValues.ultracode), new Error('Unsupported Claude ultracode mode.'));
|
|
150
124
|
}
|
|
151
125
|
else if (nextUltracode !== target.state.runtimeControlValues?.ultracode) {
|
|
152
|
-
const previous = target.state.runtimeControlValues?.ultracode;
|
|
153
126
|
setRuntimeControlValue(target.state, 'ultracode', nextUltracode);
|
|
154
127
|
target.clearRuntimeControlError('ultracode');
|
|
155
|
-
|
|
128
|
+
applyControl({
|
|
156
129
|
target,
|
|
157
130
|
controlId: 'ultracode',
|
|
158
131
|
value: nextUltracode,
|
|
159
132
|
apply: () => target.query.applyFlagSettings({ ultracode: nextUltracode === 'on' }),
|
|
160
|
-
revert: () => {
|
|
161
|
-
if (previous === undefined)
|
|
162
|
-
delete target.state.runtimeControlValues?.ultracode;
|
|
163
|
-
else
|
|
164
|
-
setRuntimeControlValue(target.state, 'ultracode', previous);
|
|
165
|
-
},
|
|
166
133
|
});
|
|
167
134
|
}
|
|
168
135
|
}
|
package/dist/setup.d.ts
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Setup script for the Canon Claude Code plugin.
|
|
4
4
|
*
|
|
5
|
-
* Run after `npm install -g @canonmsg/claude-code-plugin` to
|
|
6
|
-
*
|
|
7
|
-
* 2. Show the user how to add the MCP server to their .mcp.json
|
|
5
|
+
* Run after `npm install -g @canonmsg/claude-code-plugin` to install the
|
|
6
|
+
* /canon-register and /canon-configure skills.
|
|
8
7
|
*/
|
|
9
8
|
export declare function main(): void;
|
package/dist/setup.js
CHANGED
|
@@ -2,15 +2,14 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Setup script for the Canon Claude Code plugin.
|
|
4
4
|
*
|
|
5
|
-
* Run after `npm install -g @canonmsg/claude-code-plugin` to
|
|
6
|
-
*
|
|
7
|
-
* 2. Show the user how to add the MCP server to their .mcp.json
|
|
5
|
+
* Run after `npm install -g @canonmsg/claude-code-plugin` to install the
|
|
6
|
+
* /canon-register and /canon-configure skills.
|
|
8
7
|
*/
|
|
9
8
|
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
|
10
9
|
import { join, dirname } from 'node:path';
|
|
11
10
|
import { fileURLToPath } from 'node:url';
|
|
12
11
|
import { runCli } from './cli-entry.js';
|
|
13
|
-
const HELP = `canon-setup — install Claude Code Canon skills
|
|
12
|
+
const HELP = `canon-setup — install Claude Code Canon skills
|
|
14
13
|
|
|
15
14
|
USAGE
|
|
16
15
|
canon-setup
|
|
@@ -19,8 +18,7 @@ FLAGS
|
|
|
19
18
|
--help, -h Show this help
|
|
20
19
|
--version, -V Show package version
|
|
21
20
|
|
|
22
|
-
This command writes Canon Claude Code skill files under ~/.claude/skills
|
|
23
|
-
prints the MCP server configuration for Claude Code channel mode.`;
|
|
21
|
+
This command writes Canon Claude Code skill files under ~/.claude/skills.`;
|
|
24
22
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
23
|
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
26
24
|
const skillsDir = join(home, '.claude', 'skills');
|
|
@@ -45,27 +43,6 @@ function installSkills() {
|
|
|
45
43
|
console.log(` Installed /canon-${skill}`);
|
|
46
44
|
}
|
|
47
45
|
}
|
|
48
|
-
// ── Show MCP config instructions ───────────────────────────────────────
|
|
49
|
-
function showMcpInstructions() {
|
|
50
|
-
// Find the installed server.js path
|
|
51
|
-
const serverPath = join(__dirname, 'server.js');
|
|
52
|
-
console.log(`
|
|
53
|
-
Add this to your project's .mcp.json (or ~/.mcp.json for global):
|
|
54
|
-
|
|
55
|
-
{
|
|
56
|
-
"mcpServers": {
|
|
57
|
-
"canon-channel": {
|
|
58
|
-
"command": "node",
|
|
59
|
-
"args": ["${serverPath}"]
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
Then start Claude Code with:
|
|
65
|
-
|
|
66
|
-
claude --dangerously-load-development-channels server:canon-channel
|
|
67
|
-
`);
|
|
68
|
-
}
|
|
69
46
|
// ── Main ───────────────────────────────────────────────────────────────
|
|
70
47
|
export function main() {
|
|
71
48
|
console.log('Canon Claude Code Plugin Setup');
|
|
@@ -75,8 +52,7 @@ export function main() {
|
|
|
75
52
|
console.log('\nRegistering your agent...');
|
|
76
53
|
console.log(' Run /canon-register in Claude Code to register a new agent');
|
|
77
54
|
console.log(' Run /canon-configure if you already have an API key\n');
|
|
78
|
-
console.log('
|
|
79
|
-
showMcpInstructions();
|
|
55
|
+
console.log('Run `canon-claude` to connect this agent to Canon.');
|
|
80
56
|
}
|
|
81
57
|
runCli(import.meta.url, main, (error) => {
|
|
82
58
|
console.error('[canon-setup] Fatal error:', error);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/claude-code-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Canon
|
|
3
|
+
"version": "0.22.0",
|
|
4
|
+
"description": "Canon host for Claude Code — messaging where AI agents are first-class citizens",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -12,15 +12,13 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
|
-
"canon-channel-server": "dist/server.js",
|
|
16
15
|
"canon-claude": "dist/host.js",
|
|
17
16
|
"canon-register": "dist/register.js",
|
|
18
17
|
"canon-setup": "dist/setup.js"
|
|
19
18
|
},
|
|
20
19
|
"files": [
|
|
21
20
|
"dist",
|
|
22
|
-
"skills"
|
|
23
|
-
".claude-plugin"
|
|
21
|
+
"skills"
|
|
24
22
|
],
|
|
25
23
|
"scripts": {
|
|
26
24
|
"prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../../packages/core ../../packages/agent-sdk ../../packages/framework ../../packages/bridge ../../packages/agent-host",
|
|
@@ -31,11 +29,11 @@
|
|
|
31
29
|
},
|
|
32
30
|
"dependencies": {
|
|
33
31
|
"@anthropic-ai/claude-agent-sdk": "^0.3.200",
|
|
34
|
-
"@canonmsg/agent-host": "^0.
|
|
35
|
-
"@canonmsg/agent-sdk": "^
|
|
36
|
-
"@canonmsg/bridge": "^0.
|
|
37
|
-
"@canonmsg/core": "^3.
|
|
38
|
-
"@canonmsg/framework": "^0.
|
|
32
|
+
"@canonmsg/agent-host": "^0.5.0",
|
|
33
|
+
"@canonmsg/agent-sdk": "^4.0.0",
|
|
34
|
+
"@canonmsg/bridge": "^0.3.0",
|
|
35
|
+
"@canonmsg/core": "^3.3.0",
|
|
36
|
+
"@canonmsg/framework": "^0.3.0",
|
|
39
37
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
40
38
|
},
|
|
41
39
|
"engines": {
|
|
@@ -36,15 +36,9 @@ Add an existing Canon agent API key as a named profile.
|
|
|
36
36
|
|
|
37
37
|
5. Write the updated profiles to `~/.canon/agents.json`.
|
|
38
38
|
|
|
39
|
-
6. Tell the user: **"API key saved as profile '<profile>'.
|
|
39
|
+
6. Tell the user: **"API key saved as profile '<profile>'. Run `canon-claude` to connect."**
|
|
40
40
|
|
|
41
|
-
If they have multiple agents and want to pin one,
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
"mcpServers": {
|
|
45
|
-
"canon-channel": {
|
|
46
|
-
"env": { "CANON_AGENT": "<profile>" }
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
41
|
+
If they have multiple agents and want to pin one, start the host with the profile name:
|
|
42
|
+
```bash
|
|
43
|
+
CANON_AGENT=<profile> canon-claude
|
|
50
44
|
```
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "Canon",
|
|
3
|
-
"description": "Connect Claude Code to Canon — messaging where AI agents are first-class citizens",
|
|
4
|
-
"version": "0.1.0",
|
|
5
|
-
"channels": [
|
|
6
|
-
{
|
|
7
|
-
"server": "canon-channel",
|
|
8
|
-
"userConfig": {
|
|
9
|
-
"api_key": {
|
|
10
|
-
"description": "Canon agent API key (agk_live_...)",
|
|
11
|
-
"sensitive": true
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
],
|
|
16
|
-
"mcpServers": {
|
|
17
|
-
"canon-channel": {
|
|
18
|
-
"command": "node",
|
|
19
|
-
"args": ["${CLAUDE_PLUGIN_ROOT}/dist/server.js"],
|
|
20
|
-
"env": {
|
|
21
|
-
"CANON_PLUGIN_API_KEY": "${user_config.api_key}"
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import type { ApprovalManager } from '@canonmsg/core/host';
|
|
2
|
-
/**
|
|
3
|
-
* HTTP server that bridges Claude Code's PermissionRequest hook
|
|
4
|
-
* to Canon's ApprovalManager.
|
|
5
|
-
*
|
|
6
|
-
* Listens on localhost for POST /approve from Claude Code hooks.
|
|
7
|
-
*/
|
|
8
|
-
export declare class ApprovalHttpServer {
|
|
9
|
-
private server;
|
|
10
|
-
private manager;
|
|
11
|
-
private port;
|
|
12
|
-
private getConversationId;
|
|
13
|
-
private getIsOwnerTurn;
|
|
14
|
-
private token;
|
|
15
|
-
private maxBodyBytes;
|
|
16
|
-
private requestTimeoutMs;
|
|
17
|
-
constructor(manager: ApprovalManager, getConversationId: () => string | null, opts?: {
|
|
18
|
-
port?: number;
|
|
19
|
-
token?: string;
|
|
20
|
-
maxBodyBytes?: number;
|
|
21
|
-
requestTimeoutMs?: number;
|
|
22
|
-
getIsOwnerTurn?: () => boolean;
|
|
23
|
-
});
|
|
24
|
-
private static readonly MAX_PORT_RETRIES;
|
|
25
|
-
get authToken(): string;
|
|
26
|
-
start(): Promise<number>;
|
|
27
|
-
stop(): Promise<void>;
|
|
28
|
-
private handleRequest;
|
|
29
|
-
private hasValidToken;
|
|
30
|
-
private handleApproval;
|
|
31
|
-
private sendHookResponse;
|
|
32
|
-
private classifyRisk;
|
|
33
|
-
}
|
package/dist/approval-server.js
DELETED
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import { createServer } from 'node:http';
|
|
2
|
-
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
-
import { buildClaudeApprovalDetails, classifyClaudeApprovalCategory, classifyClaudeApprovalRisk, } from './tool-policy.js';
|
|
4
|
-
/**
|
|
5
|
-
* HTTP server that bridges Claude Code's PermissionRequest hook
|
|
6
|
-
* to Canon's ApprovalManager.
|
|
7
|
-
*
|
|
8
|
-
* Listens on localhost for POST /approve from Claude Code hooks.
|
|
9
|
-
*/
|
|
10
|
-
export class ApprovalHttpServer {
|
|
11
|
-
server = null;
|
|
12
|
-
manager;
|
|
13
|
-
port;
|
|
14
|
-
getConversationId;
|
|
15
|
-
getIsOwnerTurn;
|
|
16
|
-
token;
|
|
17
|
-
maxBodyBytes;
|
|
18
|
-
requestTimeoutMs;
|
|
19
|
-
constructor(manager, getConversationId, opts) {
|
|
20
|
-
this.manager = manager;
|
|
21
|
-
this.getConversationId = getConversationId;
|
|
22
|
-
this.getIsOwnerTurn = opts?.getIsOwnerTurn ?? (() => true);
|
|
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;
|
|
27
|
-
}
|
|
28
|
-
static MAX_PORT_RETRIES = 10;
|
|
29
|
-
get authToken() {
|
|
30
|
-
return this.token;
|
|
31
|
-
}
|
|
32
|
-
async start() {
|
|
33
|
-
const startPort = this.port;
|
|
34
|
-
return new Promise((resolve, reject) => {
|
|
35
|
-
this.server = createServer((req, res) => this.handleRequest(req, res));
|
|
36
|
-
this.server.on('error', (err) => {
|
|
37
|
-
if (err.code === 'EADDRINUSE') {
|
|
38
|
-
this.port++;
|
|
39
|
-
if (this.port - startPort >= ApprovalHttpServer.MAX_PORT_RETRIES) {
|
|
40
|
-
reject(new Error(`No available port found in range ${startPort}–${this.port}`));
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
this.server.listen(this.port, '127.0.0.1');
|
|
44
|
-
}
|
|
45
|
-
else {
|
|
46
|
-
reject(err);
|
|
47
|
-
}
|
|
48
|
-
});
|
|
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
|
-
}
|
|
54
|
-
console.error(`[canon] Approval server listening on 127.0.0.1:${this.port}`);
|
|
55
|
-
resolve(this.port);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
async stop() {
|
|
60
|
-
return new Promise((resolve) => {
|
|
61
|
-
if (this.server) {
|
|
62
|
-
this.server.close(() => resolve());
|
|
63
|
-
}
|
|
64
|
-
else {
|
|
65
|
-
resolve();
|
|
66
|
-
}
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
handleRequest(req, res) {
|
|
70
|
-
if (req.method !== 'POST' || req.url !== '/approve') {
|
|
71
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
72
|
-
res.end(JSON.stringify({ error: 'Not found' }));
|
|
73
|
-
return;
|
|
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
|
-
}
|
|
85
|
-
let body = '';
|
|
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
|
-
}
|
|
95
|
-
body += chunk.toString();
|
|
96
|
-
});
|
|
97
|
-
req.on('end', () => {
|
|
98
|
-
if (res.writableEnded)
|
|
99
|
-
return;
|
|
100
|
-
this.handleApproval(body, res).catch((err) => {
|
|
101
|
-
console.error('[canon] Approval handler error:', err);
|
|
102
|
-
if (res.writableEnded)
|
|
103
|
-
return;
|
|
104
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
105
|
-
res.end(JSON.stringify({ error: 'Internal error' }));
|
|
106
|
-
});
|
|
107
|
-
});
|
|
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
|
-
}
|
|
122
|
-
async handleApproval(body, res) {
|
|
123
|
-
let payload;
|
|
124
|
-
try {
|
|
125
|
-
payload = JSON.parse(body);
|
|
126
|
-
}
|
|
127
|
-
catch {
|
|
128
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
129
|
-
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
const toolName = payload.tool_name ?? 'Unknown';
|
|
133
|
-
const toolInput = payload.tool_input ?? {};
|
|
134
|
-
// Determine risk level
|
|
135
|
-
const riskLevel = this.classifyRisk(toolName, toolInput);
|
|
136
|
-
const isOwnerTurn = this.getIsOwnerTurn();
|
|
137
|
-
// Find the conversation to send the approval to.
|
|
138
|
-
const conversationId = this.getConversationId();
|
|
139
|
-
if (!conversationId) {
|
|
140
|
-
console.error('[canon] No active conversation for approval — falling back to ask');
|
|
141
|
-
// Can't send approval message, fall through to terminal prompt.
|
|
142
|
-
this.sendHookResponse(res, 'ask');
|
|
143
|
-
return;
|
|
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
|
-
}
|
|
154
|
-
try {
|
|
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
|
-
});
|
|
168
|
-
this.sendHookResponse(res, result.decision);
|
|
169
|
-
}
|
|
170
|
-
catch (err) {
|
|
171
|
-
console.error('[canon] Approval request failed:', err);
|
|
172
|
-
// On error, fall back to terminal prompt
|
|
173
|
-
this.sendHookResponse(res, 'ask');
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
sendHookResponse(res, behavior) {
|
|
177
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
178
|
-
res.end(JSON.stringify({
|
|
179
|
-
hookSpecificOutput: {
|
|
180
|
-
hookEventName: 'PermissionRequest',
|
|
181
|
-
decision: { behavior },
|
|
182
|
-
},
|
|
183
|
-
}));
|
|
184
|
-
}
|
|
185
|
-
classifyRisk(toolName, toolInput) {
|
|
186
|
-
if (toolName !== 'Bash')
|
|
187
|
-
return 'normal';
|
|
188
|
-
const cmd = typeof toolInput.command === 'string' ? toolInput.command : '';
|
|
189
|
-
const destructivePatterns = [
|
|
190
|
-
/\brm\s+(-rf?|--force)/,
|
|
191
|
-
/\bgit\s+push\s+--force/,
|
|
192
|
-
/\bgit\s+reset\s+--hard/,
|
|
193
|
-
/\bdrop\s+(table|database)/i,
|
|
194
|
-
/\bformat\b/,
|
|
195
|
-
];
|
|
196
|
-
return destructivePatterns.some((p) => p.test(cmd))
|
|
197
|
-
? 'destructive'
|
|
198
|
-
: 'normal';
|
|
199
|
-
}
|
|
200
|
-
}
|
package/dist/mcp-args.d.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import type { DirectSessionSelection, SendContextualMessageOptions } from '@canonmsg/core/contract';
|
|
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' | 'video' | '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
|
-
videoUrl?: string;
|
|
22
|
-
fileUrl?: string;
|
|
23
|
-
mediaPath?: string;
|
|
24
|
-
fileName?: string;
|
|
25
|
-
mimeType?: string;
|
|
26
|
-
durationMs?: number;
|
|
27
|
-
}
|
|
28
|
-
export declare function parseSendMessageArgs(value: unknown): McpParseResult<SendMessageArgs>;
|
|
29
|
-
export interface SendContextualMessageArgs {
|
|
30
|
-
sourceConversationId: string;
|
|
31
|
-
targetConversationId?: string;
|
|
32
|
-
targetUserId?: string;
|
|
33
|
-
text: string;
|
|
34
|
-
selfContext: SendContextualMessageOptions['selfContext'];
|
|
35
|
-
sessionSelection?: DirectSessionSelection;
|
|
36
|
-
messageOptions?: SendContextualMessageOptions['messageOptions'];
|
|
37
|
-
}
|
|
38
|
-
export declare function parseSendContextualMessageArgs(value: unknown): McpParseResult<SendContextualMessageArgs>;
|
|
39
|
-
export interface SetTypingArgs {
|
|
40
|
-
conversationId: string;
|
|
41
|
-
typing: boolean;
|
|
42
|
-
status?: 'typing' | 'thinking';
|
|
43
|
-
}
|
|
44
|
-
export declare function parseSetTypingArgs(value: unknown): McpParseResult<SetTypingArgs>;
|
|
45
|
-
export {};
|