slaunt 0.1.16 → 0.1.18
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 +17 -106
- package/bin/slaunt-hooks.js +96 -0
- package/package.json +9 -59
- package/release/SlauntKillBar.zip +0 -0
- package/release/manifest.json +7 -0
- package/scripts/killbar-portability-check.js +67 -0
- package/scripts/package-killbar-macos.sh +38 -0
- package/src/approval-manager.js +53 -0
- package/src/cli-auth.js +230 -0
- package/src/compact-wordmark.js +13 -0
- package/src/desktop/broker-daemon.js +18 -0
- package/src/desktop/broker-grants.js +37 -0
- package/src/desktop/broker-hook-command.js +23 -0
- package/src/desktop/broker-keychain.js +29 -0
- package/src/desktop/broker-launch-agent.js +34 -0
- package/src/desktop/broker-service.js +40 -0
- package/src/desktop/chat-capture-command.js +7 -0
- package/src/desktop/chat-hook-capture.js +445 -0
- package/src/desktop/claude-lifecycle.js +143 -0
- package/src/desktop/client-identity.js +19 -0
- package/src/desktop/native-claude-broker.js +34 -0
- package/src/desktop/native-claude-connection.js +42 -0
- package/src/desktop/tool-guard-command.js +13 -0
- package/src/desktop/tool-guard.js +225 -0
- package/src/discovery/catalog.js +734 -0
- package/src/full-screen.js +88 -0
- package/src/helper-release.js +27 -0
- package/src/hidden-oauth.js +70 -0
- package/src/independent-grants.js +38 -0
- package/src/install-flow.js +71 -0
- package/src/killbar-background-install.js +47 -0
- package/src/killbar-enrollment.js +74 -0
- package/src/killbar-launch-agent.js +10 -0
- package/src/mcp-identity.js +6 -0
- package/src/native-config.js +150 -0
- package/src/native-oauth.js +33 -0
- package/src/oauth-cohort.js +43 -0
- package/src/pty-wrapper.py +57 -0
- package/src/read-json.js +8 -0
- package/src/setup/client-installer.js +93 -0
- package/src/setup/codex-guard-supervisor.js +34 -0
- package/src/setup/hook-installer.js +496 -0
- package/src/setup/managed-policy.js +26 -0
- package/src/setup/mcp-config-installer.js +204 -0
- package/src/setup-flow.js +66 -0
- package/src/tui.js +205 -0
- package/test/approval-manager.test.js +27 -0
- package/test/broker-grants.test.js +14 -0
- package/test/broker-keychain.test.js +14 -0
- package/test/broker-launch-agent.test.js +15 -0
- package/test/broker-service.test.js +42 -0
- package/test/cli-auth-recovery.test.js +15 -0
- package/test/codex-guard-supervisor.test.js +25 -0
- package/test/full-screen.test.js +49 -0
- package/test/helper-release.test.js +22 -0
- package/test/hidden-oauth.test.js +44 -0
- package/test/hook-installer.test.js +232 -0
- package/test/independent-grants.test.js +16 -0
- package/test/install-flow.test.js +24 -0
- package/test/killbar-background-install.test.js +15 -0
- package/test/killbar-enrollment.test.js +115 -0
- package/test/killbar-launch-agent.test.js +12 -0
- package/test/killbar-portability.test.js +17 -0
- package/test/managed-policy.test.js +18 -0
- package/test/native-claude-broker.test.js +19 -0
- package/test/native-claude-connection.test.js +17 -0
- package/test/native-claude-hooks.test.js +9 -0
- package/test/native-config.test.js +3 -0
- package/test/native-integration.cjs +29 -0
- package/test/native-oauth.test.js +23 -0
- package/test/oauth-cohort.test.js +26 -0
- package/test/tui.test.js +100 -0
- package/LICENSE +0 -201
- package/dist/audit/risk-engine.d.ts +0 -4
- package/dist/audit/risk-engine.d.ts.map +0 -1
- package/dist/audit/risk-engine.js +0 -174
- package/dist/audit/risk-engine.js.map +0 -1
- package/dist/audit/run.d.ts +0 -16
- package/dist/audit/run.d.ts.map +0 -1
- package/dist/audit/run.js +0 -66
- package/dist/audit/run.js.map +0 -1
- package/dist/classification/builtin-catalog.d.ts +0 -3
- package/dist/classification/builtin-catalog.d.ts.map +0 -1
- package/dist/classification/builtin-catalog.js +0 -283
- package/dist/classification/builtin-catalog.js.map +0 -1
- package/dist/classification/catalog.d.ts +0 -10
- package/dist/classification/catalog.d.ts.map +0 -1
- package/dist/classification/catalog.js +0 -194
- package/dist/classification/catalog.js.map +0 -1
- package/dist/classification/classifier.d.ts +0 -3
- package/dist/classification/classifier.d.ts.map +0 -1
- package/dist/classification/classifier.js +0 -64
- package/dist/classification/classifier.js.map +0 -1
- package/dist/classification/public-catalog-config.d.ts +0 -3
- package/dist/classification/public-catalog-config.d.ts.map +0 -1
- package/dist/classification/public-catalog-config.js +0 -5
- package/dist/classification/public-catalog-config.js.map +0 -1
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -220
- package/dist/cli.js.map +0 -1
- package/dist/config/discover.d.ts +0 -8
- package/dist/config/discover.d.ts.map +0 -1
- package/dist/config/discover.js +0 -163
- package/dist/config/discover.js.map +0 -1
- package/dist/config/install.d.ts +0 -10
- package/dist/config/install.d.ts.map +0 -1
- package/dist/config/install.js +0 -143
- package/dist/config/install.js.map +0 -1
- package/dist/config/paths.d.ts +0 -24
- package/dist/config/paths.d.ts.map +0 -1
- package/dist/config/paths.js +0 -90
- package/dist/config/paths.js.map +0 -1
- package/dist/demo.d.ts +0 -4
- package/dist/demo.d.ts.map +0 -1
- package/dist/demo.js +0 -99
- package/dist/demo.js.map +0 -1
- package/dist/explanation/local.d.ts +0 -24
- package/dist/explanation/local.d.ts.map +0 -1
- package/dist/explanation/local.js +0 -667
- package/dist/explanation/local.js.map +0 -1
- package/dist/mcp/introspect.d.ts +0 -14
- package/dist/mcp/introspect.d.ts.map +0 -1
- package/dist/mcp/introspect.js +0 -186
- package/dist/mcp/introspect.js.map +0 -1
- package/dist/presentation/plain-language.d.ts +0 -12
- package/dist/presentation/plain-language.d.ts.map +0 -1
- package/dist/presentation/plain-language.js +0 -120
- package/dist/presentation/plain-language.js.map +0 -1
- package/dist/privacy/redaction.d.ts +0 -2
- package/dist/privacy/redaction.d.ts.map +0 -1
- package/dist/privacy/redaction.js +0 -50
- package/dist/privacy/redaction.js.map +0 -1
- package/dist/report/html.d.ts +0 -4
- package/dist/report/html.d.ts.map +0 -1
- package/dist/report/html.js +0 -101
- package/dist/report/html.js.map +0 -1
- package/dist/tui/analysis-path.d.ts +0 -17
- package/dist/tui/analysis-path.d.ts.map +0 -1
- package/dist/tui/analysis-path.js +0 -184
- package/dist/tui/analysis-path.js.map +0 -1
- package/dist/tui/render.d.ts +0 -4
- package/dist/tui/render.d.ts.map +0 -1
- package/dist/tui/render.js +0 -273
- package/dist/tui/render.js.map +0 -1
- package/dist/tui/splash.d.ts +0 -15
- package/dist/tui/splash.d.ts.map +0 -1
- package/dist/tui/splash.js +0 -130
- package/dist/tui/splash.js.map +0 -1
- package/dist/tui/stream.d.ts +0 -6
- package/dist/tui/stream.d.ts.map +0 -1
- package/dist/tui/stream.js +0 -56
- package/dist/tui/stream.js.map +0 -1
- package/dist/types.d.ts +0 -131
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/version.d.ts +0 -2
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js +0 -8
- package/dist/version.js.map +0 -1
- package/models/APACHE-2.0.txt +0 -202
- package/models/MODEL.json +0 -29
- package/models/NOTICE.md +0 -23
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test=require('node:test'),assert=require('node:assert/strict');
|
|
3
|
+
const {Writable,PassThrough}=require('node:stream');
|
|
4
|
+
const {createFullScreen}=require('../src/full-screen');
|
|
5
|
+
const {createTerminal,SPLASH_LOGO}=require('../src/tui');
|
|
6
|
+
const {COMPACT_WORDMARK}=require('../src/compact-wordmark');
|
|
7
|
+
function stream(columns,rows){let text='';const output=new Writable({write(chunk,_encoding,done){text+=chunk;done();}});output.isTTY=true;output.columns=columns;output.rows=rows;return {output,text:()=>text};}
|
|
8
|
+
const initial={view:'select',selected:0,selection:null,completed:[],current:null,failure:null};
|
|
9
|
+
test('large terminal layout centers a wide panel and shows the full logo',()=>{
|
|
10
|
+
const out=stream(160,60);const screen=createFullScreen({output:out.output,logo:SPLASH_LOGO,color:false,motion:false});screen.render(initial,true);
|
|
11
|
+
assert.match(out.text(),/\x1b\[\d+;23H╭─{114}╮/);assert.ok(out.text().includes(SPLASH_LOGO[7]));assert.match(out.text(),/Codex \+ Claude Code\s+RECOMMENDED/);
|
|
12
|
+
assert.doesNotMatch(out.text(),/│/);
|
|
13
|
+
});
|
|
14
|
+
test('selection animation changes frames while reduced motion stays static',()=>{
|
|
15
|
+
const animated=stream(100,40),still=stream(100,40);const moving=createFullScreen({output:animated.output,logo:SPLASH_LOGO,color:false,motion:true}),fixed=createFullScreen({output:still.output,logo:SPLASH_LOGO,color:false,motion:false});
|
|
16
|
+
for(let i=0;i<6;i++){moving.render(initial);fixed.render(initial);}
|
|
17
|
+
assert.match(animated.text(),/● Codex/);assert.match(animated.text(),/◉ Codex/);assert.doesNotMatch(still.text(),/● Codex/);
|
|
18
|
+
});
|
|
19
|
+
test('border stays steady across animated frames',()=>{
|
|
20
|
+
const out=stream(100,40);const screen=createFullScreen({output:out.output,logo:SPLASH_LOGO,color:true,motion:true});
|
|
21
|
+
for(let i=0;i<12;i++)screen.render(initial);
|
|
22
|
+
const borders=[...out.text().matchAll(/\x1b\[([\d;]+)m╭─+╮/g)].map(match=>match[1]);
|
|
23
|
+
assert.equal(borders.length,12);assert.deepEqual(new Set(borders),new Set(['2;36']));
|
|
24
|
+
});
|
|
25
|
+
test('compact screen uses the tight wordmark and a short legend',()=>{
|
|
26
|
+
const out=stream(50,24);const screen=createFullScreen({output:out.output,logo:SPLASH_LOGO,color:false,motion:false});screen.render(initial,true);
|
|
27
|
+
assert.ok(out.text().includes(COMPACT_WORDMARK[2]));assert.match(out.text(),/↑↓ move ↵ select esc/);assert.ok(!out.text().includes(SPLASH_LOGO[7]));
|
|
28
|
+
});
|
|
29
|
+
test('small terminals keep drawing within the available screen',()=>{
|
|
30
|
+
const out=stream(30,15);createFullScreen({output:out.output,logo:SPLASH_LOGO,color:false,motion:false}).render(initial,true);
|
|
31
|
+
for(const match of out.text().matchAll(/\x1b\[(\d+);(\d+)H/g)){assert.ok(+match[1]<=15);assert.ok(+match[2]<=30);}
|
|
32
|
+
assert.match(out.text(),/Choose clients/);
|
|
33
|
+
});
|
|
34
|
+
test('full screen animation stops after selection and restores the terminal',async()=>{
|
|
35
|
+
const out=stream(100,40),input=new PassThrough();input.isTTY=true;input.isRaw=false;input.setRawMode=value=>{input.isRaw=value;};
|
|
36
|
+
const terminal=createTerminal({input,output:out.output,error:out.output,env:{TERM:'xterm',NO_COLOR:'1'}});await terminal.enter();terminal.banner();
|
|
37
|
+
const selecting=terminal.selectClients();const before=out.text().length;await new Promise(r=>setTimeout(r,270));assert.ok(out.text().length>before);
|
|
38
|
+
input.emit('keypress','',{name:'return'});assert.equal(await selecting,'both');terminal.close();const end=out.text().length;await new Promise(r=>setTimeout(r,250));assert.equal(out.text().length,end);assert.equal(input.isRaw,false);
|
|
39
|
+
});
|
|
40
|
+
test('detected clients animate briefly and reduced motion remains static',()=>{
|
|
41
|
+
const moving=stream(100,40),still=stream(100,40);
|
|
42
|
+
const animated=createFullScreen({output:moving.output,logo:SPLASH_LOGO,color:false,motion:true});
|
|
43
|
+
const fixed=createFullScreen({output:still.output,logo:SPLASH_LOGO,color:false,motion:false});
|
|
44
|
+
const state={...initial,clientAvailability:{codex:'installed',claude:'missing'}};
|
|
45
|
+
for(let i=0;i<12;i++){animated.render(state);fixed.render(state);}
|
|
46
|
+
assert.match(moving.text(),/Codex ✧ INSTALLED/);assert.match(moving.text(),/Codex ✓ INSTALLED/);
|
|
47
|
+
assert.match(moving.text(),/Claude Code · NOT FOUND/);assert.doesNotMatch(moving.text(),/Claude Code [✧✦✓] INSTALLED/);
|
|
48
|
+
assert.match(still.text(),/Codex ✓ INSTALLED/);assert.doesNotMatch(still.text(),/[✧✦]/);
|
|
49
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test=require('node:test'),assert=require('node:assert/strict');
|
|
3
|
+
const fs=require('node:fs/promises'),os=require('node:os'),path=require('node:path');
|
|
4
|
+
const {prepareRelease}=require('../src/helper-release');
|
|
5
|
+
const {verifyHelper}=require('../src/killbar-enrollment');
|
|
6
|
+
|
|
7
|
+
test('packaged helper verifies after archive extraction', {skip:process.platform!=='darwin'||process.arch!=='arm64'}, async()=>{
|
|
8
|
+
const release=await prepareRelease();
|
|
9
|
+
try{await verifyHelper(release.app,release.team);}finally{await release.cleanup();}
|
|
10
|
+
});
|
|
11
|
+
test('modified archive is rejected before extraction',async()=>{
|
|
12
|
+
const dir=await fs.mkdtemp(path.join(os.tmpdir(),'slaunt-tampered-release-'));
|
|
13
|
+
try{
|
|
14
|
+
await fs.mkdir(path.join(dir,'src'));await fs.mkdir(path.join(dir,'release'));
|
|
15
|
+
await fs.copyFile(path.join(__dirname,'../src/helper-release.js'),path.join(dir,'src/helper-release.js'));
|
|
16
|
+
await fs.copyFile(path.join(__dirname,'../release/manifest.json'),path.join(dir,'release/manifest.json'));
|
|
17
|
+
await fs.writeFile(path.join(dir,'release/SlauntKillBar.zip'),'modified');
|
|
18
|
+
let executed=false;
|
|
19
|
+
await assert.rejects(require(path.join(dir,'src/helper-release.js')).prepareRelease({platform:'darwin',arch:'arm64',execute:async()=>{executed=true;}}),/killbar-release-untrusted/);
|
|
20
|
+
assert.equal(executed,false);
|
|
21
|
+
}finally{await fs.rm(dir,{recursive:true,force:true});}
|
|
22
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const { PassThrough } = require('node:stream');
|
|
5
|
+
const { EventEmitter } = require('node:events');
|
|
6
|
+
const { validateRequest, validateCallback, runHiddenLogin } = require('../src/hidden-oauth');
|
|
7
|
+
const issuer = 'https://example.test';
|
|
8
|
+
const request = 'https://example.test/authorize?response_type=code&client_id=test&redirect_uri=http%3A%2F%2F127.0.0.1%3A1234%2Fcallback&state=xyz&code_challenge=abc&code_challenge_method=S256';
|
|
9
|
+
test('validates authorization endpoint, PKCE and loopback callback', () => {
|
|
10
|
+
const r = validateRequest(request, issuer);
|
|
11
|
+
assert.equal(validateCallback('http://127.0.0.1:1234/callback?code=ok&state=xyz', r).includes('code=ok'), true);
|
|
12
|
+
assert.throws(() => validateCallback('http://127.0.0.1:1234/callback?code=ok&state=wrong', r));
|
|
13
|
+
assert.throws(() => validateRequest(request.replace('example.test','attacker.test'), issuer));
|
|
14
|
+
assert.throws(() => validateRequest(request.replace('127.0.0.1','example.com'), issuer));
|
|
15
|
+
});
|
|
16
|
+
test('passes a validated callback only to the native client', async () => {
|
|
17
|
+
const child = new EventEmitter(); child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.kill = () => {};
|
|
18
|
+
let written = ''; child.stdin.on('data', x => { written += x; });
|
|
19
|
+
child.stdin.on('end', () => child.emit('close',0));
|
|
20
|
+
const running = runHiddenLogin({executable:'codex',issuer,spawnImpl:()=>child,authorize:async ()=>'http://127.0.0.1:1234/callback?code=ok&state=xyz'});
|
|
21
|
+
child.stdout.write('Open URL: ' + request + '\n');
|
|
22
|
+
await running; assert.match(written,/state=xyz/);
|
|
23
|
+
});
|
|
24
|
+
test('uses the detected absolute client path for native login', async () => {
|
|
25
|
+
const child = new EventEmitter(); child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.kill = () => {};
|
|
26
|
+
child.stdin.on('end', () => child.emit('close',0)); child.stdin.resume();
|
|
27
|
+
let command;
|
|
28
|
+
const running=runHiddenLogin({executable:'codex',commandPath:'/Applications/ChatGPT.app/codex',issuer,spawnImpl:(cmd)=>{command=cmd;return child;},authorize:async()=> 'http://127.0.0.1:1234/callback?code=ok&state=xyz'});
|
|
29
|
+
child.stdout.write(request+'\n');await running;assert.equal(command,'/Applications/ChatGPT.app/codex');
|
|
30
|
+
await assert.rejects(runHiddenLogin({executable:'codex',commandPath:'../codex',issuer,authorize:async()=>{}}),/invalid-client-path/);
|
|
31
|
+
});
|
|
32
|
+
test('rejects callback with mismatched state', async () => {
|
|
33
|
+
const child = new EventEmitter(); child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.kill = () => {};
|
|
34
|
+
const running = runHiddenLogin({executable:'claude',issuer,spawnImpl:()=>child,authorize:async ()=>'http://127.0.0.1:1234/callback?code=ok&state=bad'});
|
|
35
|
+
child.stdout.write(request + '\n');
|
|
36
|
+
await assert.rejects(running,/authorization-handoff-failed/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('does not report success when client exits before callback is supplied', async () => {
|
|
40
|
+
const child = new EventEmitter(); child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.kill = () => {};
|
|
41
|
+
const running = runHiddenLogin({executable:'codex',issuer,spawnImpl:()=>child,authorize:()=>new Promise(()=>{})});
|
|
42
|
+
child.stdout.write(request + '\n'); child.emit('close',0);
|
|
43
|
+
await assert.rejects(running,/login-failed/);
|
|
44
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
|
|
9
|
+
const { onboardingCatalog } = require('../src/discovery/catalog');
|
|
10
|
+
const {
|
|
11
|
+
SLAUNT_SERVER_NAME,
|
|
12
|
+
CLIENT_HOOK_TARGETS,
|
|
13
|
+
createClaudeCodeHooks,
|
|
14
|
+
createSlauntHooks,
|
|
15
|
+
installCodexHooks,
|
|
16
|
+
installClaudeCodeHooks,
|
|
17
|
+
installHooksForDetectedClients,
|
|
18
|
+
mergeSlauntHooks
|
|
19
|
+
} = require('../src/setup/hook-installer');
|
|
20
|
+
|
|
21
|
+
function makeTempHome() {
|
|
22
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'slaunt-hook-installer-'));
|
|
23
|
+
const homeDir = path.join(root, 'home');
|
|
24
|
+
fs.mkdirSync(homeDir);
|
|
25
|
+
return {
|
|
26
|
+
root,
|
|
27
|
+
homeDir,
|
|
28
|
+
configPath: path.join(homeDir, '.codex', 'hooks.json')
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('Codex hook payload uses Slaunt MCP and only sends metadata fields', () => {
|
|
33
|
+
const hooks = createSlauntHooks().hooks;
|
|
34
|
+
assert.deepEqual(Object.keys(hooks), [
|
|
35
|
+
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
|
|
36
|
+
'SubagentStart', 'SubagentStop', 'PreCompact', 'PostCompact', 'Stop', 'Interrupt'
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const handlers = Object.values(hooks).flatMap((groups) => groups.flatMap((group) => group.hooks));
|
|
40
|
+
assert.ok(handlers.filter(hook => hook.type === 'mcp_tool').every(hook => hook.server === 'slaunt'));
|
|
41
|
+
assert.equal(hooks.PreToolUse[0].hooks[0].type, 'mcp_tool');
|
|
42
|
+
assert.equal(hooks.PreToolUse[0].hooks[0].tool, 'tool_authorize_codex');
|
|
43
|
+
assert.equal(hooks.PreToolUse[0].matcher, '*');
|
|
44
|
+
assert.equal(SLAUNT_SERVER_NAME, 'slaunt');
|
|
45
|
+
|
|
46
|
+
const inputKeys = handlers.flatMap((hook) => Object.keys(hook.input || {}));
|
|
47
|
+
assert.ok(!inputKeys.some((key) => /^(prompt|transcript|tool_input|tool_response)$/i.test(key)));
|
|
48
|
+
assert.ok(handlers.filter(hook => hook.type === 'mcp_tool').every(hook => ['codex_hook_event','tool_authorize_codex'].includes(hook.tool)));
|
|
49
|
+
assert.deepEqual(hooks.SessionStart[0].hooks[0].input, {
|
|
50
|
+
event_name: 'SessionStart', session_id: '${session_id}', model: '${model}'
|
|
51
|
+
});
|
|
52
|
+
assert.ok(handlers.filter(hook => hook.type === 'mcp_tool').every(hook => hook.input.session_id === '${session_id}'));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('Claude Code hook payload uses supported metadata fields and avoids unavailable lifecycle events', () => {
|
|
56
|
+
const hooks = createClaudeCodeHooks().hooks;
|
|
57
|
+
const handlers = Object.values(hooks).flatMap((groups) => groups.flatMap((group) => group.hooks));
|
|
58
|
+
const sessionStart = hooks.SessionStart[0];
|
|
59
|
+
|
|
60
|
+
assert.equal(sessionStart.matcher, '^(clear|compact)$');
|
|
61
|
+
assert.ok(!Object.hasOwn(hooks, 'Interrupt'));
|
|
62
|
+
assert.equal(hooks.PreToolUse[0].hooks[0].onFailure, 'block');
|
|
63
|
+
assert.equal(createSlauntHooks().hooks.PreToolUse[0].hooks[0].onFailure, undefined);
|
|
64
|
+
assert.ok(handlers.filter(hook => hook.type === 'mcp_tool').every(hook => hook.server === 'slaunt'));
|
|
65
|
+
assert.ok(handlers.every((hook) => !Object.hasOwn(hook.input || {}, 'turn_id')));
|
|
66
|
+
assert.ok(handlers.some((hook) => hook.input?.tool_use_id === '${tool_use_id}'));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('hook merge preserves unrelated handlers and upgrades prior Slaunt-managed server alias', () => {
|
|
70
|
+
const unrelated = { type: 'command', command: 'echo keep-me' };
|
|
71
|
+
const oldManagedHook = {
|
|
72
|
+
type: 'mcp_tool',
|
|
73
|
+
server: 'slaunt-2',
|
|
74
|
+
tool: 'codex_hook_event',
|
|
75
|
+
input: { event_name: 'Stop' }
|
|
76
|
+
};
|
|
77
|
+
const input = {
|
|
78
|
+
custom: true,
|
|
79
|
+
hooks: {
|
|
80
|
+
SessionStart: [{ hooks: [unrelated, {
|
|
81
|
+
type: 'mcp_tool', server: 'slaunt', tool: 'aki_workspace_context', input: { include_agents: true }
|
|
82
|
+
}] }],
|
|
83
|
+
Stop: [{ hooks: [unrelated, oldManagedHook] }]
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const once = mergeSlauntHooks(input);
|
|
88
|
+
const twice = mergeSlauntHooks(once);
|
|
89
|
+
const allHandlers = Object.values(twice.hooks).flatMap((groups) => groups.flatMap((group) => group.hooks));
|
|
90
|
+
|
|
91
|
+
assert.equal(twice.custom, true);
|
|
92
|
+
assert.ok(allHandlers.includes(unrelated));
|
|
93
|
+
assert.equal(allHandlers.filter((hook) => hook.server === 'slaunt').length, 10);
|
|
94
|
+
assert.equal(allHandlers.filter((hook) => hook.tool === 'tool_authorize_codex').length, 1);
|
|
95
|
+
assert.ok(!allHandlers.some((hook) => hook.server === 'slaunt-2'));
|
|
96
|
+
assert.ok(!allHandlers.some((hook) => hook.tool === 'aki_workspace_context'));
|
|
97
|
+
assert.deepEqual(twice, once);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('installer atomically writes a private Codex hooks file and is idempotent', () => {
|
|
101
|
+
const { root, homeDir, configPath } = makeTempHome();
|
|
102
|
+
try {
|
|
103
|
+
const first = installCodexHooks(homeDir);
|
|
104
|
+
const second = installCodexHooks(homeDir);
|
|
105
|
+
const saved = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
106
|
+
|
|
107
|
+
assert.deepEqual(first, { status: 'installed' });
|
|
108
|
+
assert.deepEqual(second, { status: 'unchanged' });
|
|
109
|
+
assert.equal(fs.statSync(configPath).mode & 0o777, 0o600);
|
|
110
|
+
assert.equal(saved.hooks.SessionStart[0].hooks[0].server, 'slaunt');
|
|
111
|
+
assert.deepEqual(fs.readdirSync(path.dirname(configPath)), ['hooks.json']);
|
|
112
|
+
} finally {
|
|
113
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('hook adapters use only the documented fixed config paths', () => {
|
|
118
|
+
assert.deepEqual(CLIENT_HOOK_TARGETS, {
|
|
119
|
+
'codex-mcp-tool': { directory: '.codex', file: 'hooks.json' },
|
|
120
|
+
'claude-mcp-tool': { directory: '.claude', file: 'settings.json' }
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('Claude Code hooks merge into user settings and report the lifecycle coverage as partial', () => {
|
|
125
|
+
const { root, homeDir } = makeTempHome();
|
|
126
|
+
const claudeConfig = path.join(homeDir, '.claude', 'settings.json');
|
|
127
|
+
fs.mkdirSync(path.dirname(claudeConfig), { recursive: true });
|
|
128
|
+
fs.writeFileSync(claudeConfig, JSON.stringify({ theme: 'dark', hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{type:'command',command:'echo keep'}] }] } }));
|
|
129
|
+
try {
|
|
130
|
+
const result = installClaudeCodeHooks(homeDir, fs, {versionProbe:()=>({status:0,stdout:'2.1.295 (Claude Code)'})});
|
|
131
|
+
const saved = JSON.parse(fs.readFileSync(claudeConfig, 'utf8'));
|
|
132
|
+
const handler = saved.hooks.SessionStart[0].hooks[0];
|
|
133
|
+
|
|
134
|
+
assert.deepEqual(result, { status: 'partial', reason: 'claude-lifecycle-differences' });
|
|
135
|
+
assert.equal(saved.theme, 'dark');
|
|
136
|
+
assert.equal(handler.server, 'slaunt');
|
|
137
|
+
assert.equal(handler.tool, 'slaunt_hook_event');
|
|
138
|
+
assert.equal(saved.hooks.PreToolUse[0].hooks[0].command, 'echo keep');
|
|
139
|
+
assert.equal(saved.hooks.PreToolUse[1].hooks[0].onFailure, 'block');
|
|
140
|
+
installClaudeCodeHooks(homeDir, fs, {versionProbe:()=>({status:0,stdout:'2.1.295 (Claude Code)'})});
|
|
141
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(claudeConfig, 'utf8')),saved);
|
|
142
|
+
assert.equal(fs.statSync(claudeConfig).mode & 0o777, 0o600);
|
|
143
|
+
} finally {
|
|
144
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('older Claude versions are rejected before configuration changes', () => {
|
|
149
|
+
const {root,homeDir}=makeTempHome();
|
|
150
|
+
try {
|
|
151
|
+
assert.throws(()=>installClaudeCodeHooks(homeDir,fs,{versionProbe:()=>({status:0,stdout:'2.1.294 (Claude Code)'})}),/claude-version-unsupported/);
|
|
152
|
+
assert.equal(fs.existsSync(path.join(homeDir,'.claude')),false);
|
|
153
|
+
} finally { fs.rmSync(root,{recursive:true,force:true}); }
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('setup hooks target only Claude Code and Codex', () => {
|
|
157
|
+
const { root, homeDir, configPath } = makeTempHome();
|
|
158
|
+
try {
|
|
159
|
+
const detected = [
|
|
160
|
+
{ id: 'codex-cli', surface: 'cli' },
|
|
161
|
+
{ id: 'claude-code', surface: 'cli' },
|
|
162
|
+
{ id: 'gemini-cli', surface: 'cli' },
|
|
163
|
+
{ id: 'opencode', surface: 'cli' }
|
|
164
|
+
];
|
|
165
|
+
const result = installHooksForDetectedClients({ clients: detected, homeDir, versionProbe:()=>({status:0,stdout:'2.1.295 (Claude Code)'}) });
|
|
166
|
+
|
|
167
|
+
assert.equal(result.serverName, 'slaunt');
|
|
168
|
+
assert.deepEqual(result.results.map(({ id, status }) => [id, status]), [
|
|
169
|
+
['codex-cli', 'installed'],
|
|
170
|
+
['claude-code', 'partial']
|
|
171
|
+
]);
|
|
172
|
+
assert.equal(result.summary.detected, 2);
|
|
173
|
+
assert.equal(result.summary.installed, 1);
|
|
174
|
+
assert.equal(result.summary.partial, 1);
|
|
175
|
+
assert.equal(result.summary.unsupported, 0);
|
|
176
|
+
assert.ok(fs.existsSync(configPath));
|
|
177
|
+
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'settings.json')));
|
|
178
|
+
assert.deepEqual(fs.readdirSync(homeDir).sort(), ['.claude', '.codex']);
|
|
179
|
+
} finally {
|
|
180
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('invalid existing config is preserved and reported rather than overwritten', () => {
|
|
185
|
+
const { root, homeDir, configPath } = makeTempHome();
|
|
186
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
187
|
+
fs.writeFileSync(configPath, '{ invalid json');
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const result = installHooksForDetectedClients({
|
|
191
|
+
clients: [{ id: 'codex-cli', surface: 'cli' }],
|
|
192
|
+
homeDir
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
assert.equal(result.results[0].status, 'error');
|
|
196
|
+
assert.equal(result.results[0].reason, 'invalid-config-json');
|
|
197
|
+
assert.equal(fs.readFileSync(configPath, 'utf8'), '{ invalid json');
|
|
198
|
+
} finally {
|
|
199
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('symlinked Codex hook targets are rejected without modifying their destinations', () => {
|
|
204
|
+
const { root, homeDir, configPath } = makeTempHome();
|
|
205
|
+
const outsideConfig = path.join(root, 'outside-hooks.json');
|
|
206
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
207
|
+
fs.writeFileSync(outsideConfig, '{"keep":true}\n');
|
|
208
|
+
fs.symlinkSync(outsideConfig, configPath);
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const result = installHooksForDetectedClients({
|
|
212
|
+
clients: [{ id: 'codex-cli', surface: 'cli' }],
|
|
213
|
+
homeDir
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
assert.equal(result.results[0].status, 'error');
|
|
217
|
+
assert.equal(result.results[0].reason, 'unsafe-config-file');
|
|
218
|
+
assert.equal(fs.readFileSync(outsideConfig, 'utf8'), '{"keep":true}\n');
|
|
219
|
+
} finally {
|
|
220
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('clients outside the release catalog are ignored', () => {
|
|
225
|
+
const result = installHooksForDetectedClients({
|
|
226
|
+
clients: [{ id: 'unknown', surface: 'desktop' }],
|
|
227
|
+
homeDir: os.tmpdir()
|
|
228
|
+
});
|
|
229
|
+
assert.deepEqual(result.results, []);
|
|
230
|
+
assert.equal(result.summary.detected, 0);
|
|
231
|
+
assert.ok(onboardingCatalog.some((client) => client.id === 'codex-cli'));
|
|
232
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test=require('node:test');const assert=require('node:assert/strict');const {createIndependentGrants}=require('../src/independent-grants');
|
|
3
|
+
// Serializing test store only. A production store must be durable and encrypted.
|
|
4
|
+
function makeStore(){let tail=Promise.resolve();const record={accessToken:'upstream-access',upstreamRefreshToken:'upstream-refresh',expiresAt:120000,grants:{}};return {transact:(_id,fn)=>{const next=tail.then(()=>fn(record));tail=next.catch(()=>{});return next;}};}
|
|
5
|
+
test('independent client refresh grants survive concurrent refresh and rotate separately',async()=>{
|
|
6
|
+
let calls=0;let time=1000;const service=createIndependentGrants({store:makeStore(),now:()=>time,refreshUpstream:async value=>{calls++;assert.equal(value,'upstream-refresh');return {accessToken:'new-access',refreshToken:'new-upstream',expiresAt:180000};}});
|
|
7
|
+
const a=await service.issue({sessionId:'session',clientId:'codex'});const b=await service.issue({sessionId:'session',clientId:'claude'});assert.notEqual(a.refreshToken,b.refreshToken);
|
|
8
|
+
time=61000;const [aa,bb]=await Promise.all([service.refresh({sessionId:'session',clientId:'codex',refreshToken:a.refreshToken}),service.refresh({sessionId:'session',clientId:'claude',refreshToken:b.refreshToken})]);
|
|
9
|
+
assert.equal(calls,1);assert.notEqual(aa.refreshToken,bb.refreshToken);assert.equal(aa.accessToken,'new-access');assert.equal(bb.accessToken,'new-access');
|
|
10
|
+
await assert.rejects(service.refresh({sessionId:'session',clientId:'codex',refreshToken:a.refreshToken}),/invalid-grant/);
|
|
11
|
+
await assert.rejects(service.refresh({sessionId:'session',clientId:'codex',refreshToken:bb.refreshToken}),/invalid-grant/);
|
|
12
|
+
});
|
|
13
|
+
test('failed upstream refresh does not rotate downstream grant',async()=>{
|
|
14
|
+
let fail=true;let time=1000;const service=createIndependentGrants({store:makeStore(),now:()=>time,refreshUpstream:async()=>{if(fail)throw new Error('unavailable');return {accessToken:'a',refreshToken:'b',expiresAt:120000};}});
|
|
15
|
+
const grant=await service.issue({sessionId:'session',clientId:'codex'});time=61000;await assert.rejects(service.refresh({sessionId:'session',clientId:'codex',refreshToken:grant.refreshToken}));fail=false;assert.ok(await service.refresh({sessionId:'session',clientId:'codex',refreshToken:grant.refreshToken}));
|
|
16
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test=require('node:test'),assert=require('node:assert/strict');
|
|
3
|
+
const {installFlow}=require('../src/install-flow');
|
|
4
|
+
const identity={orgId:'11111111-1111-4111-8111-111111111111',projectId:'22222222-2222-4222-8222-222222222222'};
|
|
5
|
+
function fixture() {
|
|
6
|
+
const events=[];
|
|
7
|
+
return {events,options:{clients:['codex','claude'],executablePaths:{codex:'/app/codex',claude:'/app/claude'},home:'/tmp',terminal:{progress(){},done(){}},release:()=>({app:'/tmp/SlauntKillBar.app',team:'ABCDEF1234',broker:{command:'/app/broker',args:['--slaunt-mcp-stdio']}}),verify:async()=>events.push('verify'),configure:({clientId})=>{events.push(clientId);return {status:'installed'};},enrollment:()=>async input=>{events.push('enroll');assert.deepEqual(input.identity,identity);return {enrolled:true};},keychain:()=>({async save(){events.push('grant');}}),startBroker:async()=>{events.push('broker');return {registered:true};},setup:async ({killbarEnrollment,brokerGrantSink})=>{events.push('oauth');await killbarEnrollment({accessToken:'secret',identity});await brokerGrantSink.save({});},readStatus:async()=>({enrolled:true,org_id:identity.orgId,project_id:identity.projectId}),background:async ({verifyEnrollment})=>{events.push('startup');assert.equal(await verifyEnrollment('/tmp/SlauntKillBar.app'),true);return {registered:true};},installHooks:()=>{events.push('hooks');return {summary:{detected:2,failed:0},results:[{status:'installed'},{status:'partial'}]};}}};
|
|
8
|
+
}
|
|
9
|
+
test('orchestrates verified helper, client setup, OAuth, enrollment, startup and hooks',async()=>{
|
|
10
|
+
const {options,events}=fixture();assert.deepEqual(await installFlow(options),{installed:true});assert.deepEqual(events,['verify','codex-cli','claude-code','oauth','enroll','grant','startup','broker','hooks']);
|
|
11
|
+
});
|
|
12
|
+
test('never reports success when startup fails',async()=>{
|
|
13
|
+
const {options,events}=fixture();options.background=async()=>{throw new Error('startup-failed');};
|
|
14
|
+
await assert.rejects(installFlow(options),error=>error.message==='startup-failed'&&error.setupMayHaveChanged);assert.ok(!events.includes('hooks'));
|
|
15
|
+
});
|
|
16
|
+
test('does not install hooks if the broker fails to start',async()=>{
|
|
17
|
+
const {options,events}=fixture();options.startBroker=async()=>{throw new Error('broker-start-unverified');};
|
|
18
|
+
await assert.rejects(installFlow(options),/broker-start-unverified/);assert.ok(!events.includes('hooks'));
|
|
19
|
+
});
|
|
20
|
+
test('identifies a client configuration conflict without exposing configuration contents',async()=>{
|
|
21
|
+
const {options,events}=fixture();options.configure=()=>({status:'conflict',reason:'server-name-conflict'});
|
|
22
|
+
await assert.rejects(installFlow(options),error=>error.message==='client-configuration-conflict'&&error.setupClient==='codex'&&!error.setupMayHaveChanged);
|
|
23
|
+
assert.ok(!events.includes('oauth'));
|
|
24
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs/promises'),os=require('node:os'),path=require('node:path');
|
|
3
|
+
const {installBackground}=require('../src/killbar-background-install');
|
|
4
|
+
test('installer requires verified identity before any installation',async()=>{
|
|
5
|
+
const home=await fs.mkdtemp(path.join(os.tmpdir(),'slaunt-service-test-'));
|
|
6
|
+
try{await assert.rejects(installBackground({sourceApp:'/tmp/SlauntKillBar.app',home,platform:'darwin',verify:async()=>{}}),/identity-check-required/);assert.deepEqual(await fs.readdir(home),[]);}finally{await fs.rm(home,{recursive:true,force:true});}
|
|
7
|
+
});
|
|
8
|
+
test('installer preserves existing installation and does not start launchd',async()=>{
|
|
9
|
+
const home=await fs.mkdtemp(path.join(os.tmpdir(),'slaunt-service-test-'));let calls=[];
|
|
10
|
+
try{
|
|
11
|
+
await fs.mkdir(path.join(home,'Library/Application Support/Slaunt/KillBar'),{recursive:true,mode:0o700});
|
|
12
|
+
await assert.rejects(installBackground({sourceApp:'/tmp/SlauntKillBar.app',home,platform:'darwin',team:'ABCDEF1234',verify:async()=>{},verifyEnrollment:async()=>true,execute:async(command,args)=>{calls.push([command,args]);}}),/existing-install/);
|
|
13
|
+
assert.ok(!calls.some(([cmd])=>cmd==='/bin/launchctl'));
|
|
14
|
+
}finally{await fs.rm(home,{recursive:true,force:true});}
|
|
15
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test=require('node:test'),assert=require('node:assert/strict');
|
|
3
|
+
const {createKillbarEnrollment}=require('../src/killbar-enrollment');
|
|
4
|
+
const {runSetupFlow}=require('../src/setup-flow');
|
|
5
|
+
const withLock=fn=>fn();
|
|
6
|
+
const identity={orgId:'11111111-1111-4111-8111-111111111111',projectId:'22222222-2222-4222-8222-222222222222'};
|
|
7
|
+
const identityResponse=()=>Response.json({org_id:identity.orgId,project_id:identity.projectId});
|
|
8
|
+
test('verifies helper before handing off the access token',async()=>{
|
|
9
|
+
const events=[];let enrolled=false;const enroll=createKillbarEnrollment({withLock,app:'/a/SlauntKillBar.app',team:'ABCDEF1234',verify:async()=>events.push('verify'),readStatus:async()=>{events.push('status');return {enrolled,org_id:identity.orgId,project_id:identity.projectId};},invoke:async(binary,token)=>{events.push([binary,token]);enrolled=true;}});
|
|
10
|
+
assert.deepEqual(await enroll({accessToken:'access',identity}),{enrolled:true});assert.deepEqual(events,['verify','status',['/a/SlauntKillBar.app/Contents/MacOS/SlauntKillBar','access'],'status']);
|
|
11
|
+
});
|
|
12
|
+
test('rejects a persisted enrollment for another workspace',async()=>{
|
|
13
|
+
let reads=0;const enroll=createKillbarEnrollment({withLock,app:'/a/SlauntKillBar.app',verify:async()=>{},readStatus:async()=>++reads===1?{enrolled:false}:{enrolled:true,org_id:identity.orgId,project_id:'33333333-3333-4333-8333-333333333333'},invoke:async()=>{}});
|
|
14
|
+
await assert.rejects(enroll({accessToken:'access',identity}),/killbar-enrollment-unverified/);
|
|
15
|
+
});
|
|
16
|
+
test('a successful helper command without persistent enrollment is rejected',async()=>{
|
|
17
|
+
const enroll=createKillbarEnrollment({withLock,app:'/a/SlauntKillBar.app',team:'ABCDEF1234',verify:async()=>{},readStatus:async()=>({enrolled:false}),invoke:async()=>{}});
|
|
18
|
+
await assert.rejects(enroll({accessToken:'access',identity}),/killbar-enrollment-unverified/);
|
|
19
|
+
});
|
|
20
|
+
test('untrusted helper stops before token handoff',async()=>{
|
|
21
|
+
let called=false;const enroll=createKillbarEnrollment({withLock,verify:async()=>{throw new Error('untrusted');},invoke:async()=>{called=true;}});
|
|
22
|
+
await assert.rejects(enroll({accessToken:'access',identity}),/untrusted/);assert.equal(called,false);
|
|
23
|
+
});
|
|
24
|
+
test('one Slaunt sign-in enrolls KillBar and approves the native client cohort',async()=>{
|
|
25
|
+
const events=[];
|
|
26
|
+
await runSetupFlow({clients:['codex','claude'],issuer:'https://example.test',authenticate:async({sessionSink})=>{events.push('sign-in');await sessionSink.save({accessToken:'test-access',refreshToken:'test-refresh',setupTicket:'test-ticket'});},killbarEnrollment:async({accessToken})=>{assert.equal(accessToken,'test-access');events.push('enroll');return {enrolled:true};},login:async({executable,authorize})=>{const cb=await authorize({client:executable,authorizationUrl:'request'});assert.equal(cb,'callback');events.push(executable);},fetchImpl:async url=>{if(new URL(url).pathname==='/api/desktop/session')return identityResponse();events.push('approve');return new Response(JSON.stringify({callbacks:{codex:'callback',claude:'callback'}}),{status:200});}});
|
|
27
|
+
assert.deepEqual(events.slice(0,3),['sign-in','enroll','approve']);assert.deepEqual(events.slice(3).sort(),['claude','codex']);
|
|
28
|
+
});
|
|
29
|
+
test('missing or unconfirmed helper enrollment never approves client grants',async()=>{
|
|
30
|
+
let calls=0;
|
|
31
|
+
await assert.rejects(runSetupFlow({clients:['codex']}),/killbar-enrollment-required/);
|
|
32
|
+
await assert.rejects(runSetupFlow({clients:['codex'],issuer:'https://example.test',authenticate:async({sessionSink})=>sessionSink.save({accessToken:'a'}),killbarEnrollment:async()=>({enrolled:false}),login:async({authorize})=>authorize({client:'codex',authorizationUrl:'request'}),fetchImpl:async url=>{if(new URL(url).pathname==='/api/desktop/session')return identityResponse();calls++;}}),/cohort-failed/);
|
|
33
|
+
assert.equal(calls,0);
|
|
34
|
+
});
|
|
35
|
+
test('Claude broker grant is saved before the native callback is released',async()=>{
|
|
36
|
+
const events=[];let received;
|
|
37
|
+
await runSetupFlow({clients:['claude'],issuer:'https://example.test',authenticate:async({sessionSink})=>sessionSink.save({accessToken:'test-access',refreshToken:'test-refresh',setupTicket:'test-ticket'}),killbarEnrollment:async()=>({enrolled:true}),brokerGrantSink:{async save(grant){received=grant;events.push('saved');}},login:async({authorize})=>{await authorize({client:'claude',authorizationUrl:'request'});events.push('callback');},fetchImpl:async(url,options)=>{if(new URL(url).pathname==='/api/desktop/session')return identityResponse();assert.equal(JSON.parse(options.body).request_broker,true);return Response.json({callbacks:{claude:'callback'},broker:{access_token:'bg1.abc',refresh_token:'sg1.'+'a'.repeat(43),scope:'slaunt:guard',token_type:'Bearer',expires_in:300}});}});
|
|
38
|
+
assert.deepEqual(events,['saved','callback']);assert.equal(received.accessToken,'bg1.abc');
|
|
39
|
+
});
|
|
40
|
+
test('setup handoff retries an uncertain response with the same approval and requests',async()=>{
|
|
41
|
+
const bodies=[];
|
|
42
|
+
await runSetupFlow({clients:['codex'],issuer:'https://example.test',authenticate:async({sessionSink})=>sessionSink.save({accessToken:'access',refreshToken:'refresh',setupTicket:'ticket'}),killbarEnrollment:async()=>({enrolled:true}),login:async({authorize})=>authorize({client:'codex',authorizationUrl:'request'}),fetchImpl:async(url,options)=>{
|
|
43
|
+
if(new URL(url).pathname==='/api/desktop/session')return identityResponse();bodies.push(options.body);if(bodies.length===1)throw new Error('connection lost');return Response.json({callbacks:{codex:'callback'}});
|
|
44
|
+
}});
|
|
45
|
+
assert.equal(bodies.length,2);assert.equal(bodies[0],bodies[1]);
|
|
46
|
+
});
|
|
47
|
+
test('failed KillBar enrollment prevents native authorization',async()=>{
|
|
48
|
+
let calls=0;await assert.rejects(runSetupFlow({clients:['codex'],issuer:'https://example.test',authenticate:async({sessionSink})=>sessionSink.save({accessToken:'a'}),killbarEnrollment:async()=>{throw new Error('enrollment-failed');},login:async({authorize})=>authorize({client:'codex',authorizationUrl:'request'}),fetchImpl:async url=>{if(new URL(url).pathname==='/api/desktop/session')return identityResponse();calls++;}}),/cohort-failed/);assert.equal(calls,0);
|
|
49
|
+
});
|
|
50
|
+
test('missing authenticated workspace scope stops before helper enrollment',async()=>{
|
|
51
|
+
let enrolled=false,approvals=0;
|
|
52
|
+
await assert.rejects(runSetupFlow({clients:['codex'],issuer:'https://example.test',authenticate:async({sessionSink})=>sessionSink.save({accessToken:'a'}),killbarEnrollment:async()=>{enrolled=true;},login:async({authorize})=>authorize({client:'codex',authorizationUrl:'request'}),fetchImpl:async url=>{if(new URL(url).pathname==='/api/desktop/session')return Response.json({org_id:identity.orgId});approvals++;return Response.json({});}}),/cohort-failed/);
|
|
53
|
+
assert.equal(enrolled,false);assert.equal(approvals,0);
|
|
54
|
+
});
|
|
55
|
+
test('helper enrollment sends credentials only over stdin',async()=>{
|
|
56
|
+
const fs=require('node:fs/promises'),os=require('node:os'),path=require('node:path');const {invokeHelper}=require('../src/killbar-enrollment');
|
|
57
|
+
const dir=await fs.mkdtemp(path.join(os.tmpdir(),'slaunt-enroll-test-'));const script=path.join(dir,'helper');
|
|
58
|
+
try{
|
|
59
|
+
await fs.writeFile(script,`#!/bin/sh\n[ "$1" = "--desktop-enroll-stdin" ] || exit 1\n[ "$#" = 1 ] || exit 1\nread -r input\n[ "$input" = '{"access_token":"test-token","org_id":"11111111-1111-4111-8111-111111111111","project_id":"22222222-2222-4222-8222-222222222222"}' ] || exit 1\nprintf '{"protocol_version":1,"enrolled":true}'\n`,{mode:0o700});
|
|
60
|
+
await invokeHelper(script,'test-token',identity);
|
|
61
|
+
}finally{await fs.rm(dir,{recursive:true,force:true});}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('existing credentials or an old reporter prevent re-enrollment',async()=>{
|
|
65
|
+
for(const state of [{enrolled:true},{enrolled:false,requires_restart:true}]){
|
|
66
|
+
let called=false;const enroll=createKillbarEnrollment({withLock,app:'/a/SlauntKillBar.app',verify:async()=>{},readStatus:async()=>state,invoke:async()=>{called=true;}});
|
|
67
|
+
await assert.rejects(enroll({accessToken:'access',identity}),/killbar-(already-enrolled|restart-required)/);assert.equal(called,false);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
test('unavailable status blocks token handoff',async()=>{
|
|
71
|
+
let called=false;const enroll=createKillbarEnrollment({withLock,app:'/a/SlauntKillBar.app',verify:async()=>{},readStatus:async()=>{throw new Error('killbar-status-unavailable');},invoke:async()=>{called=true;}});
|
|
72
|
+
await assert.rejects(enroll({accessToken:'access',identity}),/killbar-status-unavailable/);assert.equal(called,false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('concurrent CLI enrollments cannot both enter the critical section',async()=>{
|
|
76
|
+
const fs=require('node:fs/promises'),os=require('node:os'),path=require('node:path');const {withEnrollmentLock}=require('../src/killbar-enrollment');
|
|
77
|
+
const home=await fs.mkdtemp(path.join(os.tmpdir(),'slaunt-lock-test-'));let release;let entered;const started=new Promise(r=>{entered=r;});
|
|
78
|
+
try{
|
|
79
|
+
const first=withEnrollmentLock(async()=>{entered();await new Promise(r=>{release=r;});},{home});await started;
|
|
80
|
+
await assert.rejects(withEnrollmentLock(async()=>{}, {home}),/killbar-enrollment-busy/);release();await first;
|
|
81
|
+
await withEnrollmentLock(async()=>{}, {home});
|
|
82
|
+
}finally{await fs.rm(home,{recursive:true,force:true});}
|
|
83
|
+
});
|
|
84
|
+
test('enrollment refuses a symlink lock directory',async()=>{
|
|
85
|
+
const fs=require('node:fs/promises'),os=require('node:os'),path=require('node:path');const {withEnrollmentLock}=require('../src/killbar-enrollment');
|
|
86
|
+
const home=await fs.mkdtemp(path.join(os.tmpdir(),'slaunt-lock-test-'));
|
|
87
|
+
try{await fs.symlink(os.tmpdir(),path.join(home,'.slaunt'));await assert.rejects(withEnrollmentLock(async()=>{}, {home}),/killbar-lock-unavailable/);}
|
|
88
|
+
finally{await fs.rm(home,{recursive:true,force:true});}
|
|
89
|
+
});
|
|
90
|
+
test('enrollment lock excludes a separate process',async()=>{
|
|
91
|
+
const fs=require('node:fs/promises'),os=require('node:os'),path=require('node:path'),{spawn}=require('node:child_process');
|
|
92
|
+
const home=await fs.mkdtemp(path.join(os.tmpdir(),'slaunt-lock-process-'));
|
|
93
|
+
const modulePath=require.resolve('../src/killbar-enrollment');
|
|
94
|
+
const worker=`const {withEnrollmentLock}=require(process.argv[1]);withEnrollmentLock(async()=>{process.stdout.write('ready\\n');await new Promise(r=>process.stdin.once('data',r));},{home:process.argv[2]}).catch(()=>process.exit(1));`;
|
|
95
|
+
const child=spawn(process.execPath,['-e',worker,modulePath,home],{stdio:['pipe','pipe','ignore']});
|
|
96
|
+
try{
|
|
97
|
+
await new Promise((resolve,reject)=>{const timeout=setTimeout(()=>reject(new Error('worker timeout')),5000);child.once('error',reject);child.stdout.once('data',()=>{clearTimeout(timeout);resolve();});});
|
|
98
|
+
const {withEnrollmentLock}=require('../src/killbar-enrollment');
|
|
99
|
+
await assert.rejects(withEnrollmentLock(async()=>{}, {home}),/killbar-enrollment-busy/);
|
|
100
|
+
const exited=new Promise(resolve=>child.once('close',resolve));child.stdin.end('x');assert.equal(await exited,0);
|
|
101
|
+
await withEnrollmentLock(async()=>{}, {home});
|
|
102
|
+
}finally{child.kill();await fs.rm(home,{recursive:true,force:true});}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('setup failures report only an allowlisted stage and never the underlying secret',async()=>{
|
|
106
|
+
for(const stage of ['sign-in','workspace','enrollment','client-grants']) {
|
|
107
|
+
const secret='private-token-should-not-appear';
|
|
108
|
+
await assert.rejects(runSetupFlow({clients:['codex'],issuer:'https://example.test',
|
|
109
|
+
authenticate:async({sessionSink})=>{if(stage==='sign-in')throw new Error(secret);await sessionSink.save({accessToken:'a'});},
|
|
110
|
+
killbarEnrollment:async()=>{if(stage==='enrollment')throw new Error(secret);return {enrolled:true};},
|
|
111
|
+
login:async({authorize})=>authorize({client:'codex',authorizationUrl:'request'}),
|
|
112
|
+
fetchImpl:async url=>{if(new URL(url).pathname==='/api/desktop/session'){if(stage==='workspace')throw new Error(secret);return identityResponse();}throw new Error(secret);}
|
|
113
|
+
}),error=>error.message==='cohort-failed'&&error.setupDetail===stage&&!JSON.stringify(error).includes(secret));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test=require('node:test'),assert=require('node:assert/strict');
|
|
3
|
+
const {createLaunchAgentPlist,LABEL}=require('../src/killbar-launch-agent');
|
|
4
|
+
test('launch agent starts the managed helper at login and restarts it on exit',()=>{
|
|
5
|
+
const xml=createLaunchAgentPlist({app:'/private/a & b/SlauntKillBar.app'});
|
|
6
|
+
assert.match(xml,new RegExp(`<key>Label</key><string>${LABEL}</string>`));
|
|
7
|
+
assert.match(xml,/<key>RunAtLoad<\/key><true\/>/);assert.match(xml,/<key>KeepAlive<\/key><true\/>/);
|
|
8
|
+
assert.match(xml,/a & b\/SlauntKillBar.app\/Contents\/MacOS\/SlauntKillBar/);assert.match(xml,/<string>--desktop-managed<\/string>/);
|
|
9
|
+
});
|
|
10
|
+
test('launch agent rejects invalid paths and control characters',()=>{
|
|
11
|
+
for(const app of ['relative/SlauntKillBar.app','/tmp/other.app','/tmp/\n/SlauntKillBar.app'])assert.throws(()=>createLaunchAgentPlist({app}),/invalid-killbar-app/);
|
|
12
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test=require('node:test'),assert=require('node:assert/strict');
|
|
3
|
+
const {test:probe,parseArgs}=require('../scripts/killbar-portability-check');
|
|
4
|
+
test('portability probe requires absolute app path and explicit trusted team',()=>{
|
|
5
|
+
assert.throws(()=>parseArgs(['--app','relative','--team','ABCDEF1234']));
|
|
6
|
+
assert.throws(()=>parseArgs(['--app','/some/app','--team','invalid']));
|
|
7
|
+
assert.deepEqual(parseArgs(['--app','/some/app','--team','ABCDEF1234']),{app:'/some/app',team:'ABCDEF1234'});
|
|
8
|
+
});
|
|
9
|
+
test('portability probe refuses unsupported platforms',()=>{
|
|
10
|
+
const checks=probe({app:'/some/app',team:'ABCDEF1234'},{platform:'linux'});
|
|
11
|
+
assert.deepEqual(checks,[{name:'macOS platform',result:'FAIL'}]);
|
|
12
|
+
});
|
|
13
|
+
test('failed signature prevents execution and never reports overall success',()=>{
|
|
14
|
+
let calls=0;const checks=probe({app:'/some/app',team:'ABCDEF1234'},{platform:'darwin',runCommand(){calls++;throw new Error('untrusted');}});
|
|
15
|
+
assert.equal(calls,1);assert.equal(checks[0].result,'FAIL');
|
|
16
|
+
assert.ok(checks.some(c=>c.name==='Service starts after logout/login or restart'&&c.result==='NOT VERIFIED'));
|
|
17
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test=require('node:test'),assert=require('node:assert/strict');
|
|
3
|
+
const {parse}=require('smol-toml');
|
|
4
|
+
const {createManagedPolicies,createCodexManagedPolicy}=require('../src/setup/managed-policy');
|
|
5
|
+
const {claudeSupportsBlocking}=require('../src/setup/hook-installer');
|
|
6
|
+
test('managed policies configure all-tool guards and block Claude hook failures',()=>{
|
|
7
|
+
const {codex,claude}=createManagedPolicies({managedDir:'/opt/slaunt/hooks',guardPath:'/opt/slaunt/hooks/guard',supervisorPath:'/opt/slaunt/hooks/supervisor.js',nodePath:'/usr/local/bin/node'});
|
|
8
|
+
const config=parse(codex);assert.equal(config.features.hooks,true);assert.equal(config.hooks.PreToolUse[0].matcher,'.*');assert.equal(config.hooks.PreToolUse[0].hooks[0].type,'mcp_tool');assert.equal(config.hooks.PreToolUse[0].hooks[0].tool,'tool_authorize_codex');
|
|
9
|
+
assert.deepEqual(parse(createCodexManagedPolicy()),config);
|
|
10
|
+
assert.equal(claude.hooks.PreToolUse[0].hooks[0].onFailure,'block');assert.equal(claude.hooks.PreToolUse[0].hooks[0].type,'command');
|
|
11
|
+
});
|
|
12
|
+
test('managed guard path cannot escape its administrator-protected directory',()=>{
|
|
13
|
+
for(const guardPath of ['/opt/slaunt/hooks/../evil','/tmp/evil','guard','/opt/slaunt/hooks2/guard','/opt/slaunt/hooks/x\nfoo']) assert.throws(()=>createManagedPolicies({managedDir:'/opt/slaunt/hooks',guardPath}),/invalid-managed-guard-path/);
|
|
14
|
+
});
|
|
15
|
+
test('Claude must support onFailure block',()=>{
|
|
16
|
+
for(const v of ['2.1.294 (Claude Code)','1.99.999','invalid',''])assert.equal(claudeSupportsBlocking(v),false);
|
|
17
|
+
for(const v of ['2.1.295 (Claude Code)','2.1.311 (Claude Code)','3.0.0'])assert.equal(claudeSupportsBlocking(v),true);
|
|
18
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test=require('node:test'),assert=require('node:assert/strict');
|
|
3
|
+
const fs=require('node:fs/promises'),os=require('node:os'),path=require('node:path');
|
|
4
|
+
const {createNativeClaudeBroker}=require('../src/desktop/native-claude-broker');
|
|
5
|
+
const {authorizeHook}=require('../src/desktop/tool-guard');
|
|
6
|
+
const input={hook_event_name:'PreToolUse',session_id:'session',tool_name:'Bash',tool_use_id:'call',tool_input:{command:'true'}};
|
|
7
|
+
test('Claude broker forwards a bound allow and fails closed after stop or upstream denial',async()=>{
|
|
8
|
+
const root=await fs.mkdtemp('/tmp/sg-');let allow=true;
|
|
9
|
+
const client={async callTool({name,arguments:args}){if(name==='tool_authorize')return {structuredContent:{decision:allow?'allow':'deny',...Object.fromEntries(['request_id','tool_use_id','tool_name','input_sha256'].map(k=>[k,args[k]]))}};return {content:[]};}};
|
|
10
|
+
const broker=createNativeClaudeBroker({client,root});
|
|
11
|
+
try{
|
|
12
|
+
await broker.event({event_name:'UserPromptSubmit',session_id:'session'});
|
|
13
|
+
let result=await authorizeHook({input,clientKey:'claude-code',root});assert.equal(result.hookSpecificOutput.permissionDecision,undefined);
|
|
14
|
+
allow=false;result=await authorizeHook({input,clientKey:'claude-code',root});assert.equal(result.hookSpecificOutput.permissionDecision,'deny');
|
|
15
|
+
allow=true;await broker.event({event_name:'Stop',session_id:'session'});
|
|
16
|
+
result=await authorizeHook({input,clientKey:'claude-code',root});assert.equal(result.hookSpecificOutput.permissionDecision,'deny');
|
|
17
|
+
}finally{broker.close();await fs.rm(root,{recursive:true,force:true});}
|
|
18
|
+
});
|
|
19
|
+
test('broker requires a client connection',()=>assert.throws(()=>createNativeClaudeBroker(),/broker-connection-required/));
|