kenaz 0.3.19 → 0.3.20
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/core/.formation/tools/__tests__/arms.test.js +41 -0
- package/core/.formation/tools/__tests__/diagnose-bug.test.js +10 -0
- package/core/.formation/tools/arms.js +48 -0
- package/core/.formation/tools/dispatch.js +23 -14
- package/core/.formation/tools/kenaz-stats.js +32 -8
- package/core/.formation/tools/kenaz-stats.test.js +15 -2
- package/core/.formation/tools/prompt.js +16 -1
- package/core/.formation/tools/prompt.test.js +20 -0
- package/core/.specialist/tools/ansuz-boot-fast.js +7 -4
- package/core/.specialist/tools/ansuz-boot-fast.test.js +10 -3
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/dev/scripts/peer-identity-e2e.js +402 -0
- package/core/dev/scripts/peer-identity-e2e.md +47 -0
- package/core/dev/scripts/relay-admin.js +94 -0
- package/core/dev/scripts/relay-admin.test.js +131 -0
- package/core/plugins/kenaz/README.md +10 -0
- package/core/plugins/kenaz/hooks/kenaz-hook.js +2 -1
- package/core/plugins/kenaz/hooks/sdk-gate.js +9 -0
- package/core/plugins/kenaz/scripts/kenaz.test.js +15 -3
- package/core/plugins/kenaz/scripts/ygg.js +8 -0
- package/core/plugins/kenaz/scripts/ygg.test.js +72 -0
- package/core/plugins/kenaz-peer/hooks/register.test.ts +32 -0
- package/core/plugins/kenaz-peer/hooks/register.ts +9 -0
- package/package.json +6 -6
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// TASK_2451: relay-admin.js against a local fixture that mimics the relay admin endpoints
|
|
3
|
+
// (same routes, X-Admin-Key check, approve-by-fingerprint semantics as node_admin.rs). Every
|
|
4
|
+
// command in HELP is executed through the real CLI process.
|
|
5
|
+
const test = require('node:test');
|
|
6
|
+
const assert = require('node:assert');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { spawn, spawnSync } = require('child_process');
|
|
12
|
+
const { HELP } = require('./relay-admin');
|
|
13
|
+
|
|
14
|
+
const KEY = 'sekret-admin-key';
|
|
15
|
+
const CLI = path.join(__dirname, 'relay-admin.js');
|
|
16
|
+
let server, url, tokens, blocks = [], protect = false;
|
|
17
|
+
|
|
18
|
+
test.before(async () => {
|
|
19
|
+
tokens = [
|
|
20
|
+
{ team_id: 't1', node_name: 'n1', fingerprint: 'AAAA-1111', status: 'pending', created_at: '2026-10-13T00:00:00Z' },
|
|
21
|
+
{ team_id: 't1', node_name: 'n1', fingerprint: 'BBBB-2222', status: 'active', created_at: '2026-10-12T00:00:00Z' },
|
|
22
|
+
];
|
|
23
|
+
server = http.createServer((req, res) => {
|
|
24
|
+
let b = '';
|
|
25
|
+
req.on('data', (c) => (b += c));
|
|
26
|
+
req.on('end', () => {
|
|
27
|
+
const send = (s, o) => { res.statusCode = s; res.end(JSON.stringify(o || {})); };
|
|
28
|
+
if (req.headers['x-admin-key'] !== KEY) return send(401);
|
|
29
|
+
const r = req.url.replace('/api/admin/node-credentials/', '');
|
|
30
|
+
const body = b ? JSON.parse(b) : {};
|
|
31
|
+
if (r === 'pending') return send(200, { pending: tokens.filter((t) => t.status === 'pending') });
|
|
32
|
+
if (r === 'approve') {
|
|
33
|
+
const t = tokens.find((x) => x.status === 'pending' && x.team_id === body.team_id && x.node_name === body.node_name && x.fingerprint === body.fingerprint);
|
|
34
|
+
if (!t) return send(404);
|
|
35
|
+
let n = 0;
|
|
36
|
+
for (const o of tokens) if (o !== t && o.team_id === t.team_id && o.node_name === t.node_name && o.status !== 'revoked') { o.status = 'revoked'; n++; }
|
|
37
|
+
t.status = 'active';
|
|
38
|
+
return send(200, { status: 'active', revoked_others: n });
|
|
39
|
+
}
|
|
40
|
+
if (r === 'revoke') {
|
|
41
|
+
let n = 0;
|
|
42
|
+
for (const o of tokens) if (o.team_id === body.team_id && o.node_name === body.node_name && o.status !== 'revoked') { o.status = 'revoked'; n++; }
|
|
43
|
+
return send(200, { status: 'revoked', revoked: n });
|
|
44
|
+
}
|
|
45
|
+
if (r === 'ban') { blocks.push(body); return send(200, { banned: true, revoked: 1, protect_bound_names: protect }); }
|
|
46
|
+
if (r === 'unban') { blocks = []; return send(200, { unbanned: 1 }); }
|
|
47
|
+
if (r === 'blocked') return send(200, { blocked: blocks.map((b) => ({ team_id: b.team_id, kind: 'name', value: b.node_name, reason: b.reason, created_at: 'now' })) });
|
|
48
|
+
send(404);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
52
|
+
url = `http://127.0.0.1:${server.address().port}`;
|
|
53
|
+
});
|
|
54
|
+
test.after(() => server.close());
|
|
55
|
+
|
|
56
|
+
const cli = (args, env = { RELAY_ADMIN_KEY: KEY }) =>
|
|
57
|
+
new Promise((resolve) => {
|
|
58
|
+
const p = spawn(process.execPath, [CLI, '--relay', url, ...args], { env: { ...process.env, RELAY_ADMIN_KEY: '', RELAY_ADMIN_KEY_FILE: '', ...env } });
|
|
59
|
+
let out = ''; p.stdout.on('data', (d) => (out += d)); p.stderr.on('data', (d) => (out += d));
|
|
60
|
+
p.on('close', (code) => resolve({ code, out }));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('list -> wrong fingerprint refused -> approve activates and reports revoked tokens', async () => {
|
|
64
|
+
let r = await cli(['list']);
|
|
65
|
+
assert.strictEqual(r.code, 0); assert.match(r.out, /t1\tn1\tAAAA-1111/);
|
|
66
|
+
r = await cli(['approve', 't1', 'n1', 'ZZZZ-0000']);
|
|
67
|
+
assert.strictEqual(r.code, 1); assert.match(r.out, /HTTP 404/);
|
|
68
|
+
assert.strictEqual(tokens[0].status, 'pending');
|
|
69
|
+
r = await cli(['approve', 't1', 'n1', 'AAAA-1111']);
|
|
70
|
+
assert.strictEqual(r.code, 0); assert.match(r.out, /revoked 1 other token/);
|
|
71
|
+
assert.strictEqual(tokens[0].status, 'active'); assert.strictEqual(tokens[1].status, 'revoked');
|
|
72
|
+
r = await cli(['list']);
|
|
73
|
+
assert.match(r.out, /no pending/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('revoke', async () => {
|
|
77
|
+
const r = await cli(['revoke', 't1', 'n1']);
|
|
78
|
+
assert.strictEqual(r.code, 0); assert.match(r.out, /revoked 1 token/);
|
|
79
|
+
assert.strictEqual(tokens[0].status, 'revoked');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('wrong admin key and key file', async () => {
|
|
83
|
+
let r = await cli(['list'], { RELAY_ADMIN_KEY: 'nope' });
|
|
84
|
+
assert.strictEqual(r.code, 1); assert.match(r.out, /wrong admin key/);
|
|
85
|
+
const f = path.join(os.tmpdir(), `relay-admin-key-${process.pid}`);
|
|
86
|
+
fs.writeFileSync(f, KEY + '\n');
|
|
87
|
+
r = await cli(['list'], { RELAY_ADMIN_KEY_FILE: f });
|
|
88
|
+
fs.unlinkSync(f);
|
|
89
|
+
assert.strictEqual(r.code, 0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('admin key on argv is refused (control: same call via env works)', async () => {
|
|
93
|
+
for (const a of [['--admin-key', KEY, 'list'], ['--key=' + KEY, 'list']]) {
|
|
94
|
+
const r = await cli(a, {});
|
|
95
|
+
assert.strictEqual(r.code, 2); assert.match(r.out, /must not be passed/);
|
|
96
|
+
}
|
|
97
|
+
assert.strictEqual((await cli(['list'])).code, 0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('missing key refused; help text commands are all executed by this suite', async () => {
|
|
101
|
+
assert.strictEqual((await cli(['list'], {})).code, 2);
|
|
102
|
+
const cmds = [...HELP.matchAll(/^ {2}(list|approve|revoke|ban|unban|blocked)\b/gm)].map((m) => m[1]);
|
|
103
|
+
assert.deepStrictEqual(cmds, ['list', 'approve', 'revoke', 'ban', 'unban', 'blocked']);
|
|
104
|
+
const argsFor = { list: ['list'], approve: ['approve', 't', 'n', 'F'], revoke: ['revoke', 't', 'n'], ban: ['ban', 't', 'n'], unban: ['unban', 't', 'n'], blocked: ['blocked'] };
|
|
105
|
+
for (const c of cmds) assert.notStrictEqual((await cli(argsFor[c])).code, 2, c);
|
|
106
|
+
assert.strictEqual(spawnSync(process.execPath, [CLI, 'bogus']).status, 2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('ban prints the tokenless-access warning when the flag is off; no warning when on; blocked/unban', async () => {
|
|
110
|
+
protect = false;
|
|
111
|
+
let r = await cli(['ban', 't1', 'evil', 'because']);
|
|
112
|
+
assert.strictEqual(r.code, 0); assert.match(r.out, /banned t1\/evil/);
|
|
113
|
+
assert.match(r.out, /WARNING: protect_bound_names is OFF.*anonymously/);
|
|
114
|
+
assert.strictEqual(blocks[0].reason, 'because');
|
|
115
|
+
protect = true;
|
|
116
|
+
r = await cli(['ban', 't1', 'evil2', '--machine', 'abc']);
|
|
117
|
+
assert.doesNotMatch(r.out, /WARNING/); assert.match(r.out, /machine abc/);
|
|
118
|
+
assert.strictEqual(blocks[1].machine_id_hash, 'abc');
|
|
119
|
+
r = await cli(['blocked']);
|
|
120
|
+
assert.match(r.out, /t1\tname\tevil\t/);
|
|
121
|
+
r = await cli(['unban', 't1', 'evil']);
|
|
122
|
+
assert.match(r.out, /unbanned 1 entry/);
|
|
123
|
+
assert.match((await cli(['blocked'])).out, /nothing blocked/);
|
|
124
|
+
assert.strictEqual((await cli(['ban', 't1'])).code, 2);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('list shows self-reported machine info only when sent', async () => {
|
|
128
|
+
tokens.push({ team_id: 't1', node_name: 'm1', fingerprint: 'CCCC', status: 'pending', created_at: 'x', hostname: 'box', os: 'linux', machine_id_hash: 'abcdef0123456789' });
|
|
129
|
+
const r = await cli(['list']);
|
|
130
|
+
assert.match(r.out, /m1\tCCCC\tx\thost=box os=linux machine=abcdef012345 \(self-reported\)/);
|
|
131
|
+
});
|
|
@@ -48,8 +48,18 @@ Independent Codex sessions use **pull-on-prompt**: in `full` mode with trusted h
|
|
|
48
48
|
|
|
49
49
|
Resolution: env `KENAZ_MODE` > `<project>/.kenaz/kenaz.yaml` > `~/.kenaz/kenaz.yaml` > `full`. Hooks fail open (backend down or core not found: no output, exit 0).
|
|
50
50
|
|
|
51
|
+
## Agent SDK sessions
|
|
52
|
+
|
|
53
|
+
Programs built on `claude_agent_sdk` (`CLAUDE_CODE_ENTRYPOINT` `sdk-py` / `sdk-ts`) load `~/.claude/settings.json` too, so they would get this plugin. Hooks (`hooks/sdk-gate.js`) and the kenaz-peer mod therefore do nothing for those sessions. `claude -p` (`sdk-cli`) and interactive entrypoints are unchanged. Set `KENAZ_IN_SDK=1` to opt a program in.
|
|
54
|
+
|
|
55
|
+
The MCP tools cannot be gated: `.mcp.json` is a plain HTTP server entry, not a launcher that could exit, and the connection is made before any hook runs. SDK users who do not want the 34 tools pass `setting_sources=[]` (or `[]` plus the sources they need) and `strict_mcp_config` (`extra_args={"strict-mcp-config": None}`) in `ClaudeAgentOptions`.
|
|
56
|
+
|
|
51
57
|
## CLI version gate
|
|
52
58
|
|
|
53
59
|
Not needed: the 2.1.287 gate (TASK_2234, `peer_mod.rs`) exists because kenaz-peer is a mod module (`hooks/register.ts`). This plugin uses only plain hooks, commands, skills and MCP, which older CLIs support. Automatic registration into `CLAUDE_CODE_PLUGIN_DIRS` (as kenaz-peer gets) is a follow-up in `peer_mod.rs`; until then use `--plugin-dir`.
|
|
54
60
|
|
|
55
61
|
Tests: `node --test plugins/kenaz/scripts/kenaz.test.js`. Classification: `INVENTORY.md`.
|
|
62
|
+
|
|
63
|
+
## Peer node identity
|
|
64
|
+
|
|
65
|
+
kenaz-server requests a relay node token on its own; it starts pending until the relay operator approves its fingerprint. `GET http://localhost:18080/api/peer/status?projectPath=<project>` shows `node_identity` (`none` / `pending` with the fingerprint / `active`). Send the fingerprint to the operator out of band. No token or an old relay keeps today behaviour. Operator commands, the enforcement flag and the `X-Node-Token` header for non-Claude agents that call the relay directly: `yggdrasil-relay/DESIGN.md`, section "Node tokens and approval".
|
|
@@ -15,6 +15,7 @@ const net = require('net');
|
|
|
15
15
|
const http = require('http');
|
|
16
16
|
const { spawnSync } = require('child_process');
|
|
17
17
|
const { resolveMode, allows, projectDir } = require('./mode');
|
|
18
|
+
const { isSdkSession } = require('./sdk-gate');
|
|
18
19
|
|
|
19
20
|
const HOST = '127.0.0.1';
|
|
20
21
|
const PORT = Number(process.env.KENAZ_BACKEND_PORT) || 18080;
|
|
@@ -86,7 +87,7 @@ function run(spec, stdin, dir) {
|
|
|
86
87
|
|
|
87
88
|
function main() {
|
|
88
89
|
const spec = HOOKS[process.argv[2]];
|
|
89
|
-
if (!spec) return;
|
|
90
|
+
if (!spec || isSdkSession()) return; // TASK_2455: SDK sessions get nothing, before stdin or any write
|
|
90
91
|
const dir = projectDir();
|
|
91
92
|
// A project that ships the Kenaz hooks itself (the core repo) registers them in its own
|
|
92
93
|
// .claude/settings.json; the plugin yields so no hook runs twice (same rule as _user-scope.js).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// TASK_2455: claude-agent-sdk sessions (CLAUDE_CODE_ENTRYPOINT sdk-py / sdk-ts / ...) must not get Kenaz.
|
|
3
|
+
// sdk-cli is `claude -p` (Kenaz's own dispatch, headless nodes) and keeps working. KENAZ_IN_SDK=1 opts in.
|
|
4
|
+
function isSdkSession(env) {
|
|
5
|
+
env = env || process.env;
|
|
6
|
+
const ep = String(env.CLAUDE_CODE_ENTRYPOINT || '');
|
|
7
|
+
return ep.startsWith('sdk-') && ep !== 'sdk-cli' && env.KENAZ_IN_SDK !== '1';
|
|
8
|
+
}
|
|
9
|
+
module.exports = { isSdkSession };
|
|
@@ -19,6 +19,18 @@ function hook(name, dir, env, input) {
|
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
test('TASK_2455: SDK sessions get nothing; cli / sdk-cli / KENAZ_IN_SDK=1 keep today\'s output', () => {
|
|
23
|
+
const dir = proj();
|
|
24
|
+
for (const n of ['guidance', 'prompt-runner', 'memory-guard', 'flow-post', 'session-start', 'session-stop']) {
|
|
25
|
+
for (const ep of ['sdk-py', 'sdk-ts']) {
|
|
26
|
+
const r = hook(n, dir, { CLAUDE_CODE_ENTRYPOINT: ep });
|
|
27
|
+
assert.strictEqual(r.status, 0); assert.strictEqual(r.stdout, ''); assert.strictEqual(r.stderr, '');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const ep of ['cli', 'sdk-cli']) assert.match(hook('guidance', dir, { CLAUDE_CODE_ENTRYPOINT: ep }).stdout, /Kenaz plugin/);
|
|
31
|
+
assert.match(hook('guidance', dir, { CLAUDE_CODE_ENTRYPOINT: 'sdk-py', KENAZ_IN_SDK: '1' }).stdout, /Kenaz plugin/);
|
|
32
|
+
});
|
|
33
|
+
|
|
22
34
|
test('full prints guidance (control for off)', () => {
|
|
23
35
|
const r = hook('guidance', proj());
|
|
24
36
|
assert.strictEqual(r.status, 0);
|
|
@@ -102,7 +114,7 @@ test('ygg join activates the room in collaboration_projects; status explains how
|
|
|
102
114
|
const srv = http.createServer((req, res) => {
|
|
103
115
|
let b = ''; req.on('data', (d) => { b += d; });
|
|
104
116
|
req.on('end', () => {
|
|
105
|
-
if (req.method === 'POST') posts.push(JSON.parse(b));
|
|
117
|
+
if (req.method === 'POST') { posts.push(JSON.parse(b)); cfg.collaboration_projects = posts[posts.length - 1].collaboration_projects; }
|
|
106
118
|
res.setHeader('content-type', 'application/json');
|
|
107
119
|
res.end(JSON.stringify(req.method === 'POST' ? { ok: true } : cfg));
|
|
108
120
|
});
|
|
@@ -120,7 +132,7 @@ test('ygg join activates the room in collaboration_projects; status explains how
|
|
|
120
132
|
srv.close();
|
|
121
133
|
}
|
|
122
134
|
const { buildStatus, renderStatus } = require('./ygg.js');
|
|
123
|
-
assert.match(renderStatus(buildStatus(cfg, { connected: false, nodes: [] }, null)), /not active in this project - run "ygg join <key>"/);
|
|
135
|
+
assert.match(renderStatus(buildStatus(Object.assign({}, cfg, { collaboration_projects: ['other'] }), { connected: false, nodes: [] }, null)), /not active in this project - run "ygg join <key>"/);
|
|
124
136
|
});
|
|
125
137
|
|
|
126
138
|
// TASK_2416: `ygg create` posts to the relay (config relay_url), then joins with the returned key.
|
|
@@ -139,7 +151,7 @@ async function withStub(relayReply, fn) {
|
|
|
139
151
|
}
|
|
140
152
|
if (req.url.startsWith('/api/nodes')) return res.end('[]');
|
|
141
153
|
if (req.url.startsWith('/api/yggdrasil/config')) {
|
|
142
|
-
if (req.method === 'POST') { seen.config.push(JSON.parse(b)); return res.end(JSON.stringify({ ok: true })); }
|
|
154
|
+
if (req.method === 'POST') { seen.config.push(JSON.parse(b)); cfg.collaboration_projects = seen.config[seen.config.length - 1].collaboration_projects; return res.end(JSON.stringify({ ok: true })); }
|
|
143
155
|
return res.end(JSON.stringify(cfg));
|
|
144
156
|
}
|
|
145
157
|
res.end(JSON.stringify({ success: true, created: false }));
|
|
@@ -149,7 +149,15 @@ function postBody(cfg, patch) {
|
|
|
149
149
|
async function join(dir, key, scope) {
|
|
150
150
|
if (!key || /\s/.test(key)) throw new Error('usage: ygg join <key> [--project|--global]');
|
|
151
151
|
const cfg = await getConfig(dir);
|
|
152
|
+
// TASK_2441: no room key to activate = the POST below would save an empty list and "succeed".
|
|
153
|
+
if (!cfg.effective_project_key) throw new Error("cannot work out this project's room (" + (cfg.error || 'no effective_project_key') + '); does ' + dir + ' have a repo with a remote?');
|
|
152
154
|
await postConfig(dir, postBody(cfg, { enabled: true, publishable_key: key, key_scope: scope, collaboration_projects: withCollab(cfg, true) }));
|
|
155
|
+
// Verify by reading back: a save that returned ok but did not land must not print a status that says "run join".
|
|
156
|
+
const after = await getConfig(dir);
|
|
157
|
+
if (!(after.collaboration_projects || []).includes(cfg.effective_project_key)) {
|
|
158
|
+
throw new Error('join saved the key but room "' + cfg.effective_project_key + '" is not in collaboration_projects after re-reading the config; expected in ' +
|
|
159
|
+
path.join(dir, '.kenaz', 'yggdrasil.local.yaml') + ' (collaboration_projects: "' + cfg.effective_project_key + '"); the server reports ' + JSON.stringify(after.collaboration_projects || []));
|
|
160
|
+
}
|
|
153
161
|
return status(dir);
|
|
154
162
|
}
|
|
155
163
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// node --test plugins/kenaz/scripts/ygg.test.js
|
|
3
|
+
// TASK_2441: ygg join must read the config back and fail loudly when the room did not land.
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
const assert = require('node:assert');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const http = require('http');
|
|
10
|
+
const { spawn } = require('child_process');
|
|
11
|
+
|
|
12
|
+
const YGG = path.join(__dirname, 'ygg.js');
|
|
13
|
+
const ROOM = 'github.com/o/r';
|
|
14
|
+
|
|
15
|
+
async function withBackend(persist, fn) {
|
|
16
|
+
const cfg = { enabled: false, effective_project_key: ROOM, node_name: 'Alice', publishable_key: '', collaboration_projects: [], relay_url: '' };
|
|
17
|
+
const srv = http.createServer((req, res) => {
|
|
18
|
+
let b = ''; req.on('data', (d) => { b += d; });
|
|
19
|
+
req.on('end', () => {
|
|
20
|
+
res.setHeader('content-type', 'application/json');
|
|
21
|
+
if (req.url.startsWith('/api/yggdrasil/config') && req.method === 'POST') {
|
|
22
|
+
if (persist) Object.assign(cfg, JSON.parse(b));
|
|
23
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
24
|
+
}
|
|
25
|
+
res.end(JSON.stringify(req.url.startsWith('/api/yggdrasil/config') ? cfg : req.url.startsWith('/api/projects') ? { success: true } : {}));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
|
|
29
|
+
try { return await fn(srv.address().port); } finally { srv.close(); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cli(port, dir) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const c = spawn(process.execPath, [YGG, 'join', 'ygk_abc', '--project-path', dir],
|
|
35
|
+
{ env: Object.assign({}, process.env, { KENAZ_HTTP_PORT: String(port) }) });
|
|
36
|
+
let out = ''; let err = '';
|
|
37
|
+
c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', (d) => { err += d; });
|
|
38
|
+
c.on('close', (code) => resolve({ code, out, err }));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('join exits non-zero naming the file when the room is not in collaboration_projects after the POST', async () => {
|
|
43
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg2441-'));
|
|
44
|
+
await withBackend(false, async (port) => {
|
|
45
|
+
const r = await cli(port, dir);
|
|
46
|
+
assert.notStrictEqual(r.code, 0);
|
|
47
|
+
assert.match(r.err, /yggdrasil\.local\.yaml/);
|
|
48
|
+
assert.ok(r.err.includes(ROOM), r.err);
|
|
49
|
+
assert.doesNotMatch(r.out, /not active in this project/);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('control: join against a backend that persists the room prints "active in this project"', async () => {
|
|
54
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg2441-'));
|
|
55
|
+
await withBackend(true, async (port) => {
|
|
56
|
+
const r = await cli(port, dir);
|
|
57
|
+
assert.strictEqual(r.code, 0, r.err);
|
|
58
|
+
assert.match(r.out, /active in this project/);
|
|
59
|
+
assert.doesNotMatch(r.out, /not active/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('join refuses when the backend reports no effective_project_key (nothing to activate)', async () => {
|
|
64
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg2441-'));
|
|
65
|
+
const srv = http.createServer((req, res) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ enabled: false, error: 'yggdrasil.yaml not found' })); });
|
|
66
|
+
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
|
|
67
|
+
try {
|
|
68
|
+
const r = await cli(srv.address().port, dir);
|
|
69
|
+
assert.notStrictEqual(r.code, 0);
|
|
70
|
+
assert.match(r.err, /cannot work out this project's room/);
|
|
71
|
+
} finally { srv.close(); }
|
|
72
|
+
});
|
|
@@ -103,6 +103,38 @@ test('control: non-Ansuz session gets no status and no toast', async ($, on) =>
|
|
|
103
103
|
expect(toasts.length).toBe(0)
|
|
104
104
|
})
|
|
105
105
|
|
|
106
|
+
const ANSUZ = [{ role: 'user', content: '# Ansuz -- Specialist Prompt' }]
|
|
107
|
+
|
|
108
|
+
test('TASK_2455: sdk-ts session is never registered as a peer (no heartbeat, no submit)', async ($, on) => {
|
|
109
|
+
const clock = mock.clock(on)
|
|
110
|
+
mock.env(on, { CLAUDE_CODE_ENTRYPOINT: 'sdk-ts' })
|
|
111
|
+
const { calls, submits } = setup(on, () => ANSUZ)
|
|
112
|
+
await $.session.start(START)
|
|
113
|
+
await clock.advance(15_000)
|
|
114
|
+
expect(calls.length).toBe(0)
|
|
115
|
+
expect(submits.length).toBe(0)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('TASK_2455: cli registers (control)', async ($, on) => {
|
|
119
|
+
const clock = mock.clock(on)
|
|
120
|
+
mock.env(on, { CLAUDE_CODE_ENTRYPOINT: 'cli' })
|
|
121
|
+
const { calls } = setup(on, () => ANSUZ)
|
|
122
|
+
await $.session.start(START)
|
|
123
|
+
await clock.advance(5_000)
|
|
124
|
+
await clock.advance(5_000)
|
|
125
|
+
expect(calls[0]).toContain('POST http://127.0.0.1:18080/api/peer/mod-heartbeat')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('TASK_2455: KENAZ_IN_SDK=1 opts an sdk-py session in', async ($, on) => {
|
|
129
|
+
const clock = mock.clock(on)
|
|
130
|
+
mock.env(on, { CLAUDE_CODE_ENTRYPOINT: 'sdk-py', KENAZ_IN_SDK: '1' })
|
|
131
|
+
const { calls } = setup(on, () => ANSUZ)
|
|
132
|
+
await $.session.start(START)
|
|
133
|
+
await clock.advance(5_000)
|
|
134
|
+
await clock.advance(5_000)
|
|
135
|
+
expect(calls[0]).toContain('POST http://127.0.0.1:18080/api/peer/mod-heartbeat')
|
|
136
|
+
})
|
|
137
|
+
|
|
106
138
|
test('plugin-namespaced /kenaz:Ansuz also activates delivery', async ($, on) => {
|
|
107
139
|
const clock = mock.clock(on)
|
|
108
140
|
const { calls, submits } = setup(on, () => [{ role: 'user', content: [{ type: 'text', text: '<command-name>/kenaz:Ansuz</command-name>' }] }])
|
|
@@ -155,6 +155,15 @@ async function cycle($: any): Promise<void> {
|
|
|
155
155
|
|
|
156
156
|
export const register: Register = on => {
|
|
157
157
|
on('session.start', async ($, e, next) => {
|
|
158
|
+
// TASK_2455: claude-agent-sdk sessions (sdk-py / sdk-ts, not `claude -p` = sdk-cli) are never peer sessions.
|
|
159
|
+
// Same rule as plugins/kenaz/hooks/sdk-gate.js; KENAZ_IN_SDK=1 opts in. sessionId stays '' so no hook acts.
|
|
160
|
+
let ep = ''
|
|
161
|
+
let optIn = ''
|
|
162
|
+
try {
|
|
163
|
+
ep = (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) ?? ''
|
|
164
|
+
optIn = (await $.env.get('KENAZ_IN_SDK')) ?? ''
|
|
165
|
+
} catch { /* env unreadable: behave as an interactive session */ }
|
|
166
|
+
if (ep.startsWith('sdk-') && ep !== 'sdk-cli' && optIn !== '1') return next(e)
|
|
158
167
|
sessionId = String(await $.session.id())
|
|
159
168
|
cwd = String(await $.session.cwd())
|
|
160
169
|
$.clock.every(HEARTBEAT_MS, () => { if (ansuz) void beat($) })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kenaz",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.20",
|
|
4
4
|
"description": "Install Kenaz (plugin, hooks, kenaz-server) for Claude Code and Codex: npx kenaz install",
|
|
5
5
|
"bin": {
|
|
6
6
|
"kenaz": "bin/kenaz.js"
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"test": "node --test lib/cli.test.js"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@kenazai/server-darwin-arm64": "0.3.
|
|
24
|
-
"@kenazai/server-darwin-x64": "0.3.
|
|
25
|
-
"@kenazai/server-linux-arm64": "0.3.
|
|
26
|
-
"@kenazai/server-linux-x64": "0.3.
|
|
27
|
-
"@kenazai/server-win32-x64": "0.3.
|
|
23
|
+
"@kenazai/server-darwin-arm64": "0.3.20",
|
|
24
|
+
"@kenazai/server-darwin-x64": "0.3.20",
|
|
25
|
+
"@kenazai/server-linux-arm64": "0.3.20",
|
|
26
|
+
"@kenazai/server-linux-x64": "0.3.20",
|
|
27
|
+
"@kenazai/server-win32-x64": "0.3.20"
|
|
28
28
|
},
|
|
29
29
|
"license": "UNLICENSED",
|
|
30
30
|
"repository": {
|