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,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { COMPACT_WORDMARK } = require('./compact-wordmark');
|
|
3
|
+
|
|
4
|
+
// Render into the alternate screen using cell-based dimensions. The terminal
|
|
5
|
+
// controls font size; the layout expands and centers within available cells.
|
|
6
|
+
function createFullScreen({output,logo,color,motion}) {
|
|
7
|
+
const style=(code,text)=>color?`\x1b[${code}m${text}\x1b[0m`:text;
|
|
8
|
+
const fit=(text,width)=>Array.from(text).slice(0,Math.max(0,width)).join('').padEnd(Math.max(0,width),' ');
|
|
9
|
+
const logoWidth=Math.max(...logo.map(line=>line.length));
|
|
10
|
+
let tick=0;
|
|
11
|
+
const previousAvailability={};
|
|
12
|
+
const appearedAt={};
|
|
13
|
+
function render({view,selected,selection,completed,current,failure,clientAvailability={}},clear=false){
|
|
14
|
+
for(const client of ['codex','claude']){
|
|
15
|
+
if(clientAvailability[client]==='installed' && previousAvailability[client]!=='installed') appearedAt[client]=tick;
|
|
16
|
+
previousAvailability[client]=clientAvailability[client];
|
|
17
|
+
}
|
|
18
|
+
const cols=Math.max(1,output.columns||80),rows=Math.max(1,output.rows||24);
|
|
19
|
+
const width=Math.min(116,Math.max(4,cols-4)),inner=width-2;
|
|
20
|
+
const rich=rows>=31&&cols>=60, spacious=rows>=24&&cols>=50;
|
|
21
|
+
const contents=[];
|
|
22
|
+
const add=(text='',tone='normal')=>contents.push({text,tone});
|
|
23
|
+
add('');
|
|
24
|
+
add(view==='select'?' CONNECT YOUR CLIENTS':' SETTING UP SLAUNT','heading');
|
|
25
|
+
add('');
|
|
26
|
+
if(view==='select'){
|
|
27
|
+
add(cols<52?' Choose clients':' Which clients would you like to connect?','title');
|
|
28
|
+
if(spacious)add('');
|
|
29
|
+
const entries=[['Codex + Claude Code','Connect both clients in one setup.'],['Codex','Connect Codex.'],['Claude Code','Connect Claude Code.']];
|
|
30
|
+
entries.forEach(([label,description],i)=>{
|
|
31
|
+
const marker=i===selected?(motion&&tick%8<4?'●':'◉'):'○';
|
|
32
|
+
const recommended=i===0&&width>=60?' RECOMMENDED':'';
|
|
33
|
+
const client=i===1?'codex':i===2?'claude':null;
|
|
34
|
+
const status=client && clientAvailability[client];
|
|
35
|
+
const age=client ? tick-appearedAt[client] : 0;
|
|
36
|
+
const symbol=motion && age<8 ? ['✧','✦','✧','✓'][Math.floor(age/2)] : '✓';
|
|
37
|
+
const badge=status==='installed'?(width<42?` ${symbol}`:` ${symbol} INSTALLED`):status==='checking'?(width<42?' …':' · CHECKING'):status==='missing'?(width<42?' ×':' · NOT FOUND'):'';
|
|
38
|
+
add(` ${marker} ${label}${recommended}${badge}`,i===selected?'selected':'normal');
|
|
39
|
+
if(spacious)add(` ${description}`,'muted');
|
|
40
|
+
if(rich&&i<2)add('');
|
|
41
|
+
});
|
|
42
|
+
if(spacious)add('');
|
|
43
|
+
add(' ↑↓ move ↵ select esc','muted');
|
|
44
|
+
} else {
|
|
45
|
+
add(` ${selection||'Preparing your setup'}`,'title');
|
|
46
|
+
add('');
|
|
47
|
+
for(const item of completed.slice(-4))add(` ✓ ${item}`,'success');
|
|
48
|
+
if(current)add(` ${motion?'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[tick%10]:'…'} ${current}`,'selected');
|
|
49
|
+
if(failure){add(` ! ${failure.message}`,'warning');if(failure.detail)add(` ${failure.detail}`,'muted');}
|
|
50
|
+
if(!current&&!failure&&completed.length===0)add(' Getting things ready…','muted');
|
|
51
|
+
if(spacious)add('');
|
|
52
|
+
add(' Slaunt setup','muted');
|
|
53
|
+
}
|
|
54
|
+
if(rich)add('');
|
|
55
|
+
const borderTop='╭'+'─'.repeat(Math.max(0,inner))+'╮',borderBottom='╰'+'─'.repeat(Math.max(0,inner))+'╯';
|
|
56
|
+
const panel=[{text:borderTop,tone:'border'},...contents.map(item=>({text:' '+fit(item.text,inner)+' ',tone:item.tone})),{text:borderBottom,tone:'border'}];
|
|
57
|
+
const compactWidth=Math.max(...COMPACT_WORDMARK.map(line=>line.length));
|
|
58
|
+
const header=rich ? logo : cols>=compactWidth && rows>=panel.length+COMPACT_WORDMARK.length+2
|
|
59
|
+
? COMPACT_WORDMARK : rows>=panel.length+3 ? ['slaunt'] : [];
|
|
60
|
+
const total=header.length+(header.length?2:0)+panel.length;
|
|
61
|
+
const start=Math.max(1,Math.floor((rows-total)/2)+1);
|
|
62
|
+
const left=Math.max(1,Math.floor((cols-width)/2)+1);
|
|
63
|
+
if(clear)output.write('\x1b[2J');
|
|
64
|
+
if(header.length){
|
|
65
|
+
const headerWidth=Math.max(...header.map(line=>line.length));
|
|
66
|
+
const logoLeft=Math.max(1,Math.floor((cols-headerWidth)/2)+1);
|
|
67
|
+
header.forEach((line,index)=>{
|
|
68
|
+
if(start+index>rows)return;
|
|
69
|
+
let rendered=style(rich ? (motion&&tick%12<6?'36':'96') : '1;97',line);
|
|
70
|
+
if(rich&&motion&&color){
|
|
71
|
+
const beam=(tick*3)%(logoWidth+12)-6;let run='',bright=false;rendered='';
|
|
72
|
+
[...line].forEach((char,x)=>{const next=char!==' '&&Math.abs(x-index*.7-beam)<3;if(run&&next!==bright){rendered+=style(bright?'1;97':'2;36',run);run='';}bright=next;run+=char;});
|
|
73
|
+
if(run)rendered+=style(bright?'1;97':'2;36',run);
|
|
74
|
+
}
|
|
75
|
+
output.write(`\x1b[${start+index};${logoLeft}H${rendered}`);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const panelTop=start+header.length+(header.length?2:0);
|
|
79
|
+
panel.forEach(({text,tone},index)=>{
|
|
80
|
+
const y=panelTop+index;if(y>rows)return;
|
|
81
|
+
let code=tone==='selected'?'1;96':tone==='title'?'1':tone==='heading'?'1;36':tone==='success'?'32':tone==='warning'?'33':tone==='muted'?'2':tone==='border'?'2;36':'0';
|
|
82
|
+
output.write(`\x1b[${y};${left}H${style(code,text)}`);
|
|
83
|
+
});
|
|
84
|
+
tick++;
|
|
85
|
+
}
|
|
86
|
+
return {render};
|
|
87
|
+
}
|
|
88
|
+
module.exports={createFullScreen};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs=require('node:fs/promises');
|
|
3
|
+
const os=require('node:os');
|
|
4
|
+
const path=require('node:path');
|
|
5
|
+
const crypto=require('node:crypto');
|
|
6
|
+
const {execFile}=require('node:child_process');
|
|
7
|
+
const {promisify}=require('node:util');
|
|
8
|
+
const exec=promisify(execFile);
|
|
9
|
+
|
|
10
|
+
async function prepareRelease({platform=process.platform,arch=process.arch,execute=exec}={}) {
|
|
11
|
+
let manifest;
|
|
12
|
+
try {manifest=require('../release/manifest.json');} catch {throw new Error('killbar-release-missing');}
|
|
13
|
+
if(platform!=='darwin'||arch!==manifest.arch)throw new Error('killbar-platform-unsupported');
|
|
14
|
+
if(manifest.format!==1||manifest.signing!=='adhoc'||!/^[a-f0-9]{64}$/.test(manifest.sha256||'')||!/^[a-f0-9]{40}$/.test(manifest.cdhash||''))throw new Error('killbar-release-unavailable');
|
|
15
|
+
const archive=path.resolve(__dirname,'../release/SlauntKillBar.zip');
|
|
16
|
+
let bytes;try{bytes=await fs.readFile(archive);}catch{throw new Error('killbar-release-missing');}
|
|
17
|
+
if(crypto.createHash('sha256').update(bytes).digest('hex')!==manifest.sha256)throw new Error('killbar-release-untrusted');
|
|
18
|
+
const dir=await fs.mkdtemp(path.join(os.tmpdir(),'slaunt-helper-'));
|
|
19
|
+
try {
|
|
20
|
+
// Extract the bytes we actually verified, from an owner-only directory.
|
|
21
|
+
const copy=path.join(dir,'helper.zip');await fs.writeFile(copy,bytes,{mode:0o600,flag:'wx'});
|
|
22
|
+
await execute('/usr/bin/ditto',['-x','-k',copy,dir],{timeout:60000});
|
|
23
|
+
await fs.unlink(copy);
|
|
24
|
+
return {app:path.join(dir,'SlauntKillBar.app'),team:{mode:'adhoc',cdhash:manifest.cdhash},cleanup:()=>fs.rm(dir,{recursive:true,force:true})};
|
|
25
|
+
} catch {await fs.rm(dir,{recursive:true,force:true});throw new Error('killbar-release-untrusted');}
|
|
26
|
+
}
|
|
27
|
+
module.exports={prepareRelease};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { spawn } = require('node:child_process');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const MAX_OUTPUT = 65536;
|
|
5
|
+
function validateRequest(raw, issuer) {
|
|
6
|
+
const url = new URL(raw);
|
|
7
|
+
const expected = new URL('/authorize', issuer);
|
|
8
|
+
if (expected.protocol !== 'https:' && !(expected.protocol === 'http:' && ['127.0.0.1','localhost','[::1]'].includes(expected.hostname))) throw new Error('unsafe-issuer');
|
|
9
|
+
if (url.origin !== expected.origin || url.pathname !== expected.pathname || url.username || url.password || url.hash) throw new Error('unexpected-authorization-endpoint');
|
|
10
|
+
for (const name of ['state','client_id','redirect_uri','code_challenge','code_challenge_method','response_type']) {
|
|
11
|
+
if (url.searchParams.getAll(name).length !== 1 || !url.searchParams.get(name)) throw new Error('invalid-authorization-request');
|
|
12
|
+
}
|
|
13
|
+
if (url.searchParams.get('code_challenge_method') !== 'S256' || url.searchParams.get('response_type') !== 'code') throw new Error('invalid-authorization-request');
|
|
14
|
+
const redirect = new URL(url.searchParams.get('redirect_uri'));
|
|
15
|
+
if (redirect.protocol !== 'http:' || !['127.0.0.1','localhost','[::1]'].includes(redirect.hostname) || redirect.username || redirect.password || redirect.hash) throw new Error('unsafe-redirect');
|
|
16
|
+
return { url, redirect, state: url.searchParams.get('state') };
|
|
17
|
+
}
|
|
18
|
+
function validateCallback(raw, request) {
|
|
19
|
+
const url = new URL(raw);
|
|
20
|
+
const expected = request.redirect;
|
|
21
|
+
if (url.origin !== expected.origin || url.pathname !== expected.pathname || url.hash || url.username || url.password) throw new Error('callback-mismatch');
|
|
22
|
+
for (const [key,value] of expected.searchParams) if (url.searchParams.getAll(key).length !== 1 || url.searchParams.get(key) !== value) throw new Error('callback-mismatch');
|
|
23
|
+
if (url.searchParams.getAll('state').length !== 1 || url.searchParams.get('state') !== request.state || url.searchParams.getAll('code').length !== 1 || !url.searchParams.get('code') || url.searchParams.has('error')) throw new Error('invalid-callback');
|
|
24
|
+
return url.toString();
|
|
25
|
+
}
|
|
26
|
+
async function runHiddenLogin({ executable, commandPath = executable, issuer, authorize, timeoutMs = 120000, spawnImpl = spawn, signal }) {
|
|
27
|
+
if (!['codex','claude'].includes(executable) || typeof authorize !== 'function') throw new Error('invalid-client');
|
|
28
|
+
if (typeof commandPath !== 'string' || !commandPath || /[\x00-\x1f\x7f]/.test(commandPath) || (commandPath !== executable && !path.isAbsolute(commandPath))) throw new Error('invalid-client-path');
|
|
29
|
+
const args = ['mcp','login','slaunt','--no-browser'];
|
|
30
|
+
// Claude requires an interactive terminal even when the callback is supplied programmatically.
|
|
31
|
+
const command = executable === 'claude' ? 'python3' : commandPath;
|
|
32
|
+
const commandArgs = executable === 'claude' ? [path.join(__dirname, 'pty-wrapper.py'), commandPath, ...args] : args;
|
|
33
|
+
const child = spawnImpl(command, commandArgs, { stdio:['pipe','pipe','pipe'] });
|
|
34
|
+
let settled = false, text = '', found = false, submitted = false, timer;
|
|
35
|
+
const result = new Promise((resolve, reject) => {
|
|
36
|
+
const onAbort = () => finish(new Error('login-cancelled'));
|
|
37
|
+
const finish = (err) => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort); if (err) { child.kill(); reject(err); } else resolve(); };
|
|
38
|
+
if (signal?.aborted) { finish(new Error('login-cancelled')); return; }
|
|
39
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
40
|
+
child.stdin.on('error', () => finish(new Error('login-input-failed')));
|
|
41
|
+
timer = setTimeout(() => finish(new Error('login-timeout')), timeoutMs);
|
|
42
|
+
child.on('error', () => finish(new Error('client-start-failed')));
|
|
43
|
+
child.on('close', code => finish(code === 0 && submitted ? null : new Error('login-failed')));
|
|
44
|
+
const receive = chunk => {
|
|
45
|
+
if (settled || found) return;
|
|
46
|
+
text += chunk.toString('utf8');
|
|
47
|
+
if (Buffer.byteLength(text) > MAX_OUTPUT) return finish(new Error('client-output-too-large'));
|
|
48
|
+
// Wait for a line terminator so partial stream chunks cannot truncate a URL.
|
|
49
|
+
const lines = text.split(/\r?\n/); text = lines.pop();
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
const candidate = line.match(/https?:\/\/[^\s<>"'\x00-\x1f\x7f]+/)?.[0];
|
|
52
|
+
if (!candidate) continue;
|
|
53
|
+
let request;
|
|
54
|
+
try { request = validateRequest(candidate, issuer); } catch { continue; }
|
|
55
|
+
found = true;
|
|
56
|
+
Promise.resolve().then(() => authorize({client: executable, authorizationUrl: request.url.toString()})).then(callback => {
|
|
57
|
+
if (settled) return;
|
|
58
|
+
const validated = validateCallback(callback, request);
|
|
59
|
+
submitted = true;
|
|
60
|
+
child.stdin.write(validated + '\n');
|
|
61
|
+
child.stdin.end();
|
|
62
|
+
}).catch(() => finish(new Error('authorization-handoff-failed')));
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
child.stdout.on('data', receive); child.stderr.on('data', receive);
|
|
67
|
+
});
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
module.exports = { validateRequest, validateCallback, runHiddenLogin };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const crypto = require('node:crypto');
|
|
3
|
+
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
|
|
4
|
+
const newToken = () => crypto.randomBytes(32).toString('base64url');
|
|
5
|
+
// Models independent downstream refresh grants backed by a shared upstream
|
|
6
|
+
// OAuth session. store.transact MUST serialize per upstream session and commit
|
|
7
|
+
// atomically across processes; the production store/issuer is not implemented.
|
|
8
|
+
function createIndependentGrants({store, refreshUpstream, now = Date.now}) {
|
|
9
|
+
if (!store || typeof store.transact !== 'function' || typeof refreshUpstream !== 'function') throw new Error('grant-dependencies-required');
|
|
10
|
+
async function freshen(session) {
|
|
11
|
+
if (session.expiresAt > now() + 60000) return;
|
|
12
|
+
const fresh=await refreshUpstream(session.upstreamRefreshToken);
|
|
13
|
+
if (!fresh || typeof fresh.accessToken !== 'string' || !fresh.accessToken || typeof fresh.refreshToken !== 'string' || !fresh.refreshToken || !Number.isSafeInteger(fresh.expiresAt) || fresh.expiresAt <= now()) throw new Error('upstream-refresh-failed');
|
|
14
|
+
session.accessToken=fresh.accessToken;session.upstreamRefreshToken=fresh.refreshToken;session.expiresAt=fresh.expiresAt;
|
|
15
|
+
}
|
|
16
|
+
async function issue({sessionId, clientId}) {
|
|
17
|
+
if (typeof clientId !== 'string' || !clientId || clientId.length > 128) throw new Error('invalid-client');
|
|
18
|
+
return store.transact(sessionId, async session => {
|
|
19
|
+
if (!session || session.revoked) throw new Error('invalid-session');
|
|
20
|
+
await freshen(session);
|
|
21
|
+
const raw=newToken();
|
|
22
|
+
session.grants[digest(raw)]={clientId};
|
|
23
|
+
return {refreshToken:raw,accessToken:session.accessToken,expiresAt:session.expiresAt};
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
async function refresh({sessionId, clientId, refreshToken}) {
|
|
27
|
+
if (typeof refreshToken !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(refreshToken) || typeof clientId !== 'string') throw new Error('invalid-grant');
|
|
28
|
+
return store.transact(sessionId, async session => {
|
|
29
|
+
const key=digest(refreshToken);const grant=session?.grants?.[key];
|
|
30
|
+
if (!session || session.revoked || !grant || grant.clientId !== clientId) throw new Error('invalid-grant');
|
|
31
|
+
await freshen(session);
|
|
32
|
+
delete session.grants[key];const next=newToken();session.grants[digest(next)]={clientId};
|
|
33
|
+
return {refreshToken:next,accessToken:session.accessToken,expiresAt:session.expiresAt};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return {issue,refresh};
|
|
37
|
+
}
|
|
38
|
+
module.exports={createIndependentGrants};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const os=require('node:os');
|
|
3
|
+
const path=require('node:path');
|
|
4
|
+
const {spawnSync}=require('node:child_process');
|
|
5
|
+
const {installNativeConfig}=require('./native-config');
|
|
6
|
+
const {runSetupFlow}=require('./setup-flow');
|
|
7
|
+
const {createKillbarEnrollment,verifyHelper,readHelperStatus}=require('./killbar-enrollment');
|
|
8
|
+
const {installBackground}=require('./killbar-background-install');
|
|
9
|
+
const {installHooksForDetectedClients}=require('./setup/hook-installer');
|
|
10
|
+
const {prepareRelease}=require('./helper-release');
|
|
11
|
+
const {createBrokerKeychain}=require('./desktop/broker-keychain');
|
|
12
|
+
const {installBrokerLaunchAgent}=require('./desktop/broker-launch-agent');
|
|
13
|
+
|
|
14
|
+
// The release metadata is shipped with the helper, never taken from the user's
|
|
15
|
+
// environment. No credentials are sent until the helper has been verified.
|
|
16
|
+
async function installFlow({clients,executablePaths,terminal,fetchImpl,home=os.homedir(),release=prepareRelease,
|
|
17
|
+
verify=verifyHelper,configure=installNativeConfig,setup=runSetupFlow,enrollment=createKillbarEnrollment,
|
|
18
|
+
background=installBackground,installHooks=installHooksForDetectedClients,readStatus=readHelperStatus,keychain=createBrokerKeychain,startBroker=installBrokerLaunchAgent}={}) {
|
|
19
|
+
const artifact=await release();
|
|
20
|
+
let changed=false;
|
|
21
|
+
try {
|
|
22
|
+
await verify(artifact.app,artifact.team);
|
|
23
|
+
const nativeClaude=clients.includes('claude');
|
|
24
|
+
const broker=nativeClaude?{command:process.execPath,args:['--slaunt-mcp-stdio']}:undefined;
|
|
25
|
+
const brokerGrantSink=nativeClaude?keychain({app:artifact.app,trust:artifact.team}):undefined;
|
|
26
|
+
for (const client of clients) {
|
|
27
|
+
terminal.progress(`Configuring ${client === 'codex' ? 'Codex' : 'Claude Code'}`);
|
|
28
|
+
const result=configure({clientId:client==='codex'?'codex-cli':'claude-code',homeDir:home});
|
|
29
|
+
if (!['installed','unchanged'].includes(result.status)) {
|
|
30
|
+
const error=new Error(result.status==='conflict'?'client-configuration-conflict':'client-configuration-failed');
|
|
31
|
+
error.setupClient=client;
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
changed ||= result.status==='installed';
|
|
35
|
+
terminal.done(`${client === 'codex' ? 'Codex' : 'Claude Code'} configured`);
|
|
36
|
+
}
|
|
37
|
+
let identity;
|
|
38
|
+
const enroll=enrollment({app:artifact.app,team:artifact.team});
|
|
39
|
+
terminal.progress('Signing in and connecting clients');
|
|
40
|
+
changed=true; // OAuth or enrollment can change state before a failure.
|
|
41
|
+
await setup({clients,executablePaths,fetchImpl,brokerGrantSink,killbarEnrollment:async input=>{
|
|
42
|
+
const result=await enroll(input); identity=input.identity; return result;
|
|
43
|
+
}});
|
|
44
|
+
if (!identity) throw new Error('killbar-enrollment-unverified');
|
|
45
|
+
terminal.done('Clients signed in and KillBar enrolled');
|
|
46
|
+
terminal.progress('Starting KillBar');
|
|
47
|
+
const started=await background({sourceApp:artifact.app,team:artifact.team,home,verifyEnrollment:async app=>{
|
|
48
|
+
const status=await readStatus(path.join(app,'Contents/MacOS/SlauntKillBar'));
|
|
49
|
+
return status.enrolled===true && !status.requires_restart &&
|
|
50
|
+
status.org_id?.toLowerCase()===identity.orgId.toLowerCase() &&
|
|
51
|
+
status.project_id?.toLowerCase()===identity.projectId.toLowerCase();
|
|
52
|
+
}});
|
|
53
|
+
if (started?.registered!==true) throw new Error('killbar-background-unverified');
|
|
54
|
+
terminal.done('KillBar startup registered');
|
|
55
|
+
if(nativeClaude){
|
|
56
|
+
terminal.progress('Starting Claude authorization broker');
|
|
57
|
+
const launched=await startBroker({home});
|
|
58
|
+
if(launched?.registered!==true)throw new Error('broker-start-unverified');
|
|
59
|
+
terminal.done('Claude authorization broker started');
|
|
60
|
+
}
|
|
61
|
+
terminal.progress('Installing Slaunt hooks');
|
|
62
|
+
const hooks=installHooks({homeDir:home,broker,nativeBroker:nativeClaude,versionProbe:()=>spawnSync(executablePaths.claude||'claude',['--version'],{encoding:'utf8',timeout:10000,stdio:['ignore','pipe','ignore']}),clients:clients.map(client=>({id:client==='codex'?'codex-cli':'claude-code',surface:'cli'}))});
|
|
63
|
+
if (hooks.summary.detected!==clients.length || hooks.summary.failed || hooks.results.some(x=>!['installed','unchanged','partial'].includes(x.status))) throw new Error('hook-installation-failed');
|
|
64
|
+
terminal.done('Slaunt hooks installed');
|
|
65
|
+
return {installed:true};
|
|
66
|
+
} catch (error) {
|
|
67
|
+
error.setupMayHaveChanged=changed;
|
|
68
|
+
throw error;
|
|
69
|
+
} finally {await artifact.cleanup?.();}
|
|
70
|
+
}
|
|
71
|
+
module.exports={installFlow};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs=require('node:fs/promises');
|
|
3
|
+
const path=require('node:path');
|
|
4
|
+
const os=require('node:os');
|
|
5
|
+
const {execFile}=require('node:child_process');
|
|
6
|
+
const {promisify}=require('node:util');
|
|
7
|
+
const {createLaunchAgentPlist,LABEL}=require('./killbar-launch-agent');
|
|
8
|
+
const {verifyHelper}=require('./killbar-enrollment');
|
|
9
|
+
const exec=promisify(execFile);
|
|
10
|
+
async function ownedDirectory(dir){
|
|
11
|
+
try{await fs.mkdir(dir,{mode:0o700});}catch(e){if(e.code!=='EEXIST')throw new Error('killbar-directory-unavailable');}
|
|
12
|
+
const s=await fs.lstat(dir);
|
|
13
|
+
if(!s.isDirectory()||s.isSymbolicLink()||(typeof process.getuid==='function'&&s.uid!==process.getuid())||(s.mode&0o022))throw new Error('killbar-directory-unsafe');
|
|
14
|
+
}
|
|
15
|
+
async function absent(p){try{await fs.lstat(p);return false;}catch(e){if(e.code==='ENOENT')return true;throw e;}}
|
|
16
|
+
async function installBackground({sourceApp,team,verifyEnrollment,home=os.homedir(),platform=process.platform,verify=verifyHelper,execute=exec}={}){
|
|
17
|
+
if(platform!=='darwin'||!path.isAbsolute(home)||!path.isAbsolute(sourceApp||'')||typeof process.getuid!=='function')throw new Error('killbar-background-unavailable');
|
|
18
|
+
if(typeof verifyEnrollment!=='function')throw new Error('killbar-identity-check-required');
|
|
19
|
+
await verify(sourceApp,team);
|
|
20
|
+
// Developer ID releases retain Gatekeeper assessment. Ad hoc packages are
|
|
21
|
+
// verified against the package's pinned digest and code-directory hash.
|
|
22
|
+
if(team?.mode!=='adhoc')try{await execute('/usr/sbin/spctl',['--assess','--type','execute',sourceApp],{timeout:15000});await execute('/usr/bin/xcrun',['stapler','validate',sourceApp],{timeout:15000});}catch{throw new Error('killbar-release-untrusted');}
|
|
23
|
+
if(await verifyEnrollment(sourceApp)!==true)throw new Error('killbar-identity-unverified');
|
|
24
|
+
const library=path.join(home,'Library'),support=path.join(library,'Application Support'),root=path.join(support,'Slaunt'),agents=path.join(library,'LaunchAgents');
|
|
25
|
+
for(const dir of [library,support,root,agents])await ownedDirectory(dir);
|
|
26
|
+
const installRoot=path.join(root,'KillBar'),app=path.join(installRoot,'SlauntKillBar.app'),plist=path.join(agents,`${LABEL}.plist`);
|
|
27
|
+
if(!(await absent(installRoot))||!(await absent(plist)))throw new Error('killbar-existing-install');
|
|
28
|
+
const domain=`gui/${process.getuid()}`;
|
|
29
|
+
try{await execute('/bin/launchctl',['print',domain],{timeout:10000});}catch{throw new Error('killbar-login-session-unavailable');}
|
|
30
|
+
// The new install directory is exclusively created; never replace another app.
|
|
31
|
+
try{await fs.mkdir(installRoot,{mode:0o700});}catch{throw new Error('killbar-existing-install');}
|
|
32
|
+
let plistCreated=false,bootstrapAttempted=false;
|
|
33
|
+
try{
|
|
34
|
+
await execute('/usr/bin/ditto',[sourceApp,app],{timeout:60000});
|
|
35
|
+
await verify(app,team);
|
|
36
|
+
await fs.writeFile(plist,createLaunchAgentPlist({app}),{mode:0o600,flag:'wx'});plistCreated=true;
|
|
37
|
+
bootstrapAttempted=true;
|
|
38
|
+
await execute('/bin/launchctl',['bootstrap',domain,plist],{timeout:15000});
|
|
39
|
+
await execute('/bin/launchctl',['print',`${domain}/${LABEL}`],{timeout:10000});
|
|
40
|
+
return {registered:true};
|
|
41
|
+
}catch{
|
|
42
|
+
// After bootstrap, state may be ambiguous; preserve artifacts for recovery.
|
|
43
|
+
if(!bootstrapAttempted){if(plistCreated)await fs.unlink(plist).catch(()=>{});await fs.rm(installRoot,{recursive:true,force:true});}
|
|
44
|
+
throw new Error(bootstrapAttempted?'killbar-background-state-unknown':'killbar-background-install-failed');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
module.exports={installBackground};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path=require('node:path');
|
|
3
|
+
const fs=require('node:fs/promises');
|
|
4
|
+
const os=require('node:os');
|
|
5
|
+
const {execFile,spawn}=require('node:child_process');
|
|
6
|
+
const {promisify}=require('node:util');
|
|
7
|
+
const exec=promisify(execFile);
|
|
8
|
+
|
|
9
|
+
async function verifyHelper(app,team){
|
|
10
|
+
if(team && typeof team==='object' && team.mode==='adhoc') {
|
|
11
|
+
if(process.platform!=='darwin'||!path.isAbsolute(app)||!app.endsWith('/SlauntKillBar.app')||!/^[a-f0-9]{40}$/.test(team.cdhash||''))throw new Error('killbar-helper-unavailable');
|
|
12
|
+
try {
|
|
13
|
+
await exec('/usr/bin/codesign',['--verify','--deep','--strict',app],{timeout:15000,maxBuffer:8192});
|
|
14
|
+
const info=await exec('/usr/bin/codesign',['-dv','--verbose=4',app],{timeout:15000,maxBuffer:16384});
|
|
15
|
+
const output=`${info.stdout}\n${info.stderr}`;
|
|
16
|
+
if(!/^Identifier=ai\.slaunt\.killbar$/m.test(output)||!output.includes(`CDHash=${team.cdhash}`)||!/^Signature=adhoc$/m.test(output))throw new Error('signature-mismatch');
|
|
17
|
+
return;
|
|
18
|
+
} catch {throw new Error('killbar-helper-untrusted');}
|
|
19
|
+
}
|
|
20
|
+
if(process.platform!=='darwin'||!path.isAbsolute(app)||!app.endsWith('/SlauntKillBar.app')||!/^[A-Z0-9]{10}$/.test(team||'')) throw new Error('killbar-helper-unavailable');
|
|
21
|
+
const requirement=`=identifier "ai.slaunt.killbar" and anchor apple generic and certificate leaf[subject.OU] = "${team}"`;
|
|
22
|
+
try {await exec('/usr/bin/codesign',['--verify','--deep','--strict','-R',requirement,app],{timeout:15000,maxBuffer:8192});}
|
|
23
|
+
catch {throw new Error('killbar-helper-untrusted');}
|
|
24
|
+
}
|
|
25
|
+
function invokeHelper(binary,token,identity){
|
|
26
|
+
return new Promise((resolve,reject)=>{
|
|
27
|
+
const child=spawn(binary,['--desktop-enroll-stdin'],{stdio:['pipe','pipe','ignore'],env:{HOME:process.env.HOME,PATH:'/usr/bin:/bin',TMPDIR:process.env.TMPDIR}});
|
|
28
|
+
let output='',finished=false;
|
|
29
|
+
const finish=(error)=>{if(finished)return;finished=true;clearTimeout(timer);error?reject(new Error('killbar-enrollment-failed')):resolve();};
|
|
30
|
+
const timer=setTimeout(()=>{child.kill('SIGKILL');finish(true);},60000);
|
|
31
|
+
child.stdout.on('data',chunk=>{output+=chunk;if(Buffer.byteLength(output)>8192){child.kill('SIGKILL');finish(true);}});
|
|
32
|
+
child.stdin.on('error',()=>finish(true));
|
|
33
|
+
child.on('error',()=>finish(true));
|
|
34
|
+
child.on('close',code=>{try{const result=JSON.parse(output);finish(code!==0||result.protocol_version!==1||result.enrolled!==true);}catch{finish(true);}});
|
|
35
|
+
child.stdin.end(JSON.stringify({access_token:token,org_id:identity.orgId,project_id:identity.projectId}));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async function readHelperStatus(binary){
|
|
39
|
+
let stdout;
|
|
40
|
+
try {({stdout}=await exec(binary,['--desktop-control-status'],{timeout:15000,maxBuffer:8192,env:{HOME:process.env.HOME,PATH:'/usr/bin:/bin',TMPDIR:process.env.TMPDIR}}));}
|
|
41
|
+
catch {throw new Error('killbar-status-unavailable');}
|
|
42
|
+
let status;try{status=JSON.parse(stdout);}catch{throw new Error('killbar-status-invalid');}
|
|
43
|
+
if(status?.protocol_version!==1||typeof status.enrolled!=='boolean'||(status.requires_restart!==undefined&&typeof status.requires_restart!=='boolean')) throw new Error('killbar-status-invalid');
|
|
44
|
+
return status;
|
|
45
|
+
}
|
|
46
|
+
async function withEnrollmentLock(operation,{home=os.homedir()}={}){
|
|
47
|
+
const dir=path.join(home,'.slaunt');
|
|
48
|
+
try {await fs.mkdir(dir,{mode:0o700});}catch(error){if(error.code!=='EEXIST')throw new Error('killbar-lock-unavailable');}
|
|
49
|
+
let stat;try{stat=await fs.lstat(dir);}catch{throw new Error('killbar-lock-unavailable');}
|
|
50
|
+
if(!stat.isDirectory()||stat.isSymbolicLink()||(typeof process.getuid==='function'&&stat.uid!==process.getuid())||(stat.mode&0o077)!==0)throw new Error('killbar-lock-unavailable');
|
|
51
|
+
const lock=path.join(dir,'killbar-enrollment.lock');
|
|
52
|
+
try {await fs.mkdir(lock,{mode:0o700});}catch(error){throw new Error(error.code==='EEXIST'?'killbar-enrollment-busy':'killbar-lock-unavailable');}
|
|
53
|
+
try{return await operation();}finally{await fs.rmdir(lock);}
|
|
54
|
+
}
|
|
55
|
+
function createKillbarEnrollment({app,team,verify=verifyHelper,readStatus=readHelperStatus,invoke=invokeHelper,withLock=withEnrollmentLock}={}){
|
|
56
|
+
return async ({accessToken,identity}={})=>{
|
|
57
|
+
await verify(app,team);
|
|
58
|
+
if(typeof accessToken!=='string'||accessToken.length<1||accessToken.length>16384||/[\u0000-\u001f\u007f]/.test(accessToken))throw new Error('killbar-authentication-failed');
|
|
59
|
+
const uuid=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
60
|
+
if(!uuid.test(identity?.orgId || '') || !uuid.test(identity?.projectId || ''))throw new Error('killbar-identity-invalid');
|
|
61
|
+
return withLock(async()=>{
|
|
62
|
+
const binary=path.join(app,'Contents/MacOS/SlauntKillBar');
|
|
63
|
+
const status=await readStatus(binary);
|
|
64
|
+
if(status.requires_restart)throw new Error('killbar-restart-required');
|
|
65
|
+
// Existing device credentials may belong to a different workspace.
|
|
66
|
+
if(status.enrolled)throw new Error('killbar-already-enrolled');
|
|
67
|
+
await invoke(binary,accessToken,identity);
|
|
68
|
+
const confirmed=await readStatus(binary);
|
|
69
|
+
if(confirmed.enrolled!==true || confirmed.requires_restart || typeof confirmed.org_id!=='string' || typeof confirmed.project_id!=='string' || confirmed.org_id.toLowerCase()!==identity.orgId.toLowerCase() || confirmed.project_id.toLowerCase()!==identity.projectId.toLowerCase())throw new Error('killbar-enrollment-unverified');
|
|
70
|
+
return {enrolled:true};
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
module.exports={createKillbarEnrollment,verifyHelper,invokeHelper,readHelperStatus,withEnrollmentLock};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path=require('node:path');
|
|
3
|
+
const LABEL='ai.slaunt.killbar.cli';
|
|
4
|
+
const xml=value=>value.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
5
|
+
function createLaunchAgentPlist({app}) {
|
|
6
|
+
if(typeof app!=='string'||!path.isAbsolute(app)||!app.endsWith('/SlauntKillBar.app')||/[\u0000-\u001f\u007f]/.test(app))throw new Error('invalid-killbar-app');
|
|
7
|
+
const executable=path.join(app,'Contents/MacOS/SlauntKillBar');
|
|
8
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${LABEL}</string>\n<key>ProgramArguments</key><array><string>${xml(executable)}</string><string>--desktop-managed</string></array>\n<key>RunAtLoad</key><true/>\n<key>KeepAlive</key><true/>\n<key>ThrottleInterval</key><integer>30</integer>\n<key>ProcessType</key><string>Background</string>\n</dict></plist>\n`;
|
|
9
|
+
}
|
|
10
|
+
module.exports={createLaunchAgentPlist,LABEL};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { parse: parseToml } = require('smol-toml');
|
|
7
|
+
|
|
8
|
+
const SLAUNT_SERVER_NAME = 'slaunt';
|
|
9
|
+
const ENDPOINT = 'https://central-mcp-ccr-production.up.railway.app/mcp';
|
|
10
|
+
const MAX_CONFIG_BYTES = 5 * 1024 * 1024;
|
|
11
|
+
const CLIENT_TARGETS = Object.freeze({
|
|
12
|
+
'claude-code': Object.freeze({ directory: null, file: '.claude.json', format: 'json' }),
|
|
13
|
+
'codex-cli': Object.freeze({ directory: '.codex', file: 'config.toml', format: 'toml' })
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function ensureDirectory(homeDir, directoryName) {
|
|
17
|
+
const target = directoryName ? path.join(homeDir, directoryName) : homeDir;
|
|
18
|
+
try {
|
|
19
|
+
const stats = fs.lstatSync(target);
|
|
20
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) throw new Error('unsafe-config-directory');
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
23
|
+
fs.mkdirSync(target, { mode: 0o700 });
|
|
24
|
+
}
|
|
25
|
+
const stats = fs.lstatSync(target);
|
|
26
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) throw new Error('unsafe-config-directory');
|
|
27
|
+
return target;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readExistingConfig(filePath) {
|
|
31
|
+
let descriptor;
|
|
32
|
+
try {
|
|
33
|
+
descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
34
|
+
const stats = fs.fstatSync(descriptor);
|
|
35
|
+
if (!stats.isFile()) throw new Error('unsafe-config-file');
|
|
36
|
+
if (stats.size > MAX_CONFIG_BYTES) throw new Error('config-file-too-large');
|
|
37
|
+
return {
|
|
38
|
+
content: fs.readFileSync(descriptor, 'utf8'),
|
|
39
|
+
identity: { dev: stats.dev, ino: stats.ino, size: stats.size, mtimeMs: stats.mtimeMs }
|
|
40
|
+
};
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error?.code === 'ENOENT') return null;
|
|
43
|
+
if (error?.code === 'ELOOP') throw new Error('unsafe-config-file');
|
|
44
|
+
if (error?.message?.startsWith('unsafe-') || error?.message === 'config-file-too-large') throw error;
|
|
45
|
+
throw new Error('config-read-failed');
|
|
46
|
+
} finally {
|
|
47
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assertUnchanged(filePath, previous) {
|
|
52
|
+
try {
|
|
53
|
+
const current = fs.lstatSync(filePath);
|
|
54
|
+
if (current.isSymbolicLink() || !current.isFile()) throw new Error('unsafe-config-file');
|
|
55
|
+
if (!previous
|
|
56
|
+
|| current.dev !== previous.identity.dev
|
|
57
|
+
|| current.ino !== previous.identity.ino
|
|
58
|
+
|| current.size !== previous.identity.size
|
|
59
|
+
|| current.mtimeMs !== previous.identity.mtimeMs) {
|
|
60
|
+
throw new Error('config-changed-during-setup');
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error?.code === 'ENOENT' && !previous) return;
|
|
64
|
+
if (error?.code === 'ENOENT' || error?.message?.startsWith('unsafe-')) throw error;
|
|
65
|
+
throw new Error('config-changed-during-setup');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeConfigAtomically(filePath, directory, serialized, previous) {
|
|
70
|
+
const temporaryPath = path.join(
|
|
71
|
+
directory,
|
|
72
|
+
`.slaunt-${process.pid}-${crypto.randomBytes(10).toString('hex')}.tmp`
|
|
73
|
+
);
|
|
74
|
+
let descriptor;
|
|
75
|
+
try {
|
|
76
|
+
descriptor = fs.openSync(temporaryPath, 'wx', 0o600);
|
|
77
|
+
fs.writeFileSync(descriptor, serialized, 'utf8');
|
|
78
|
+
fs.fsyncSync(descriptor);
|
|
79
|
+
fs.closeSync(descriptor);
|
|
80
|
+
descriptor = undefined;
|
|
81
|
+
|
|
82
|
+
assertUnchanged(filePath, previous);
|
|
83
|
+
fs.renameSync(temporaryPath, filePath);
|
|
84
|
+
fs.chmodSync(filePath, 0o600);
|
|
85
|
+
} finally {
|
|
86
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
87
|
+
try {
|
|
88
|
+
fs.unlinkSync(temporaryPath);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function updateClaudeConfig(previous) {
|
|
96
|
+
const config=previous?.content?.trim()?JSON.parse(previous.content):{};
|
|
97
|
+
if(!config||typeof config!=='object'||Array.isArray(config))throw new Error('invalid-config-root');
|
|
98
|
+
if(config.mcpServers!==undefined&&(!config.mcpServers||typeof config.mcpServers!=='object'||Array.isArray(config.mcpServers)))throw new Error('invalid-mcp-servers');
|
|
99
|
+
const servers=config.mcpServers||{};
|
|
100
|
+
if(Object.hasOwn(servers,SLAUNT_SERVER_NAME)){
|
|
101
|
+
const entry=servers[SLAUNT_SERVER_NAME];
|
|
102
|
+
if(!entry||entry.type!=='http'||entry.url!==ENDPOINT||Object.keys(entry).some(k=>!['type','url'].includes(k)))throw new Error('server-name-conflict');
|
|
103
|
+
return {status:'unchanged'};
|
|
104
|
+
}
|
|
105
|
+
config.mcpServers={...servers,[SLAUNT_SERVER_NAME]:{type:'http',url:ENDPOINT}};
|
|
106
|
+
return {status:'installed',content:`${JSON.stringify(config,null,2)}\n`};
|
|
107
|
+
}
|
|
108
|
+
function updateCodexConfig(previous) {
|
|
109
|
+
const source=previous?.content||'';const parsed=source.trim()?parseToml(source):{};
|
|
110
|
+
if(parsed.mcp_servers!==undefined&&(!parsed.mcp_servers||typeof parsed.mcp_servers!=='object'||Array.isArray(parsed.mcp_servers)))throw new Error('invalid-mcp-servers');
|
|
111
|
+
const servers=parsed.mcp_servers||{};
|
|
112
|
+
if(Object.hasOwn(servers,SLAUNT_SERVER_NAME)){
|
|
113
|
+
const entry=servers[SLAUNT_SERVER_NAME];
|
|
114
|
+
if(!entry||entry.url!==ENDPOINT||entry.enabled===false||Object.keys(entry).some(k=>!['url','enabled'].includes(k)))throw new Error('server-name-conflict');
|
|
115
|
+
return {status:'unchanged'};
|
|
116
|
+
}
|
|
117
|
+
const separator=source.length===0||source.endsWith('\n\n')?'':source.endsWith('\n')?'\n':'\n\n';
|
|
118
|
+
const content=`${source}${separator}[mcp_servers.slaunt]\nurl = ${JSON.stringify(ENDPOINT)}\n`;
|
|
119
|
+
const updated=parseToml(content);if(updated.mcp_servers?.slaunt?.url!==ENDPOINT)throw new Error('invalid-mcp-servers');
|
|
120
|
+
return {status:'installed',content};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function safeReason(error) {
|
|
124
|
+
const known = new Set([
|
|
125
|
+
'unsafe-config-directory',
|
|
126
|
+
'unsafe-config-file',
|
|
127
|
+
'config-file-too-large',
|
|
128
|
+
'config-read-failed',
|
|
129
|
+
'config-changed-during-setup',
|
|
130
|
+
'invalid-config-root',
|
|
131
|
+
'invalid-mcp-servers',
|
|
132
|
+
'server-name-conflict'
|
|
133
|
+
]);
|
|
134
|
+
if (known.has(error?.message)) return error.message;
|
|
135
|
+
if (error?.code === 'EACCES' || error?.code === 'EPERM') return 'permission-denied';
|
|
136
|
+
if (error?.code === 'ENOSPC') return 'insufficient-storage';
|
|
137
|
+
return 'config-write-failed';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function installNativeConfig({clientId,homeDir}={}) {
|
|
141
|
+
const target=CLIENT_TARGETS[clientId];
|
|
142
|
+
if(!target||!path.isAbsolute(homeDir||''))return {status:'error',reason:'invalid-install-request'};
|
|
143
|
+
try {
|
|
144
|
+
const directory=ensureDirectory(homeDir,target.directory);const filePath=path.join(directory,target.file);const previous=readExistingConfig(filePath);
|
|
145
|
+
const updated=target.format==='json'?updateClaudeConfig(previous):updateCodexConfig(previous);
|
|
146
|
+
if(updated.status==='unchanged')return {status:'unchanged',reason:null};
|
|
147
|
+
writeConfigAtomically(filePath,directory,updated.content,previous);return {status:'installed',reason:null};
|
|
148
|
+
}catch(error){return {status:error?.message==='server-name-conflict'?'conflict':'error',reason:safeReason(error)};}
|
|
149
|
+
}
|
|
150
|
+
module.exports={installNativeConfig,ENDPOINT};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { spawnSync } = require('node:child_process');
|
|
3
|
+
const ENDPOINT = 'https://central-mcp-ccr-production.up.railway.app/mcp';
|
|
4
|
+
const NAME = 'slaunt';
|
|
5
|
+
function installOAuth({ clients, run = spawnSync, output = console } = {}) {
|
|
6
|
+
const results = [];
|
|
7
|
+
for (const client of clients) {
|
|
8
|
+
const executable = client === 'codex' ? 'codex' : 'claude';
|
|
9
|
+
const inspect = run(executable, client === 'codex' ? ['mcp', 'get', NAME, '--json'] : ['mcp', 'get', NAME], { encoding: 'utf8', timeout: 30000 });
|
|
10
|
+
if (inspect.error) { output.error(`${executable} is unavailable.`); results.push({ client, status: 'error' }); continue; }
|
|
11
|
+
if (inspect.status === 0) {
|
|
12
|
+
let matches = false;
|
|
13
|
+
if (client === 'codex') {
|
|
14
|
+
try { const entry = JSON.parse(inspect.stdout); matches = entry.transport?.type === 'streamable_http' && entry.transport.url === ENDPOINT; } catch {}
|
|
15
|
+
} else {
|
|
16
|
+
// The command may return secrets in other fields; inspect locally and never print it.
|
|
17
|
+
matches = (inspect.stdout || '').split(/\r?\n/).some(line => /^\s*URL:\s*https:\/\/central-mcp-ccr-production\.up\.railway\.app\/mcp\s*$/.test(line));
|
|
18
|
+
}
|
|
19
|
+
if (!matches) { output.error(`${executable}: existing slaunt server conflicts or could not be verified; preserved.`); results.push({client, status:'conflict'}); continue; }
|
|
20
|
+
} else {
|
|
21
|
+
const args = client === 'codex' ? ['mcp','add',NAME,'--url',ENDPOINT] : ['mcp','add','--scope','user','--transport','http',NAME,ENDPOINT];
|
|
22
|
+
const added = run(executable, args, { stdio: 'inherit', timeout: 30000 });
|
|
23
|
+
if (added.error || added.status !== 0) { output.error(`${executable}: server configuration failed.`); results.push({client,status:'error'}); continue; }
|
|
24
|
+
}
|
|
25
|
+
output.log(`${executable}: complete Slaunt OAuth sign-in in your browser.`);
|
|
26
|
+
const login = run(executable, ['mcp','login',NAME], { stdio:'inherit', timeout: 315000 });
|
|
27
|
+
const status = !login.error && login.status === 0 ? 'authenticated' : 'authentication-failed';
|
|
28
|
+
results.push({client,status});
|
|
29
|
+
if (status !== 'authenticated') output.error(`${executable}: sign-in did not complete; its server configuration remains for retry.`);
|
|
30
|
+
}
|
|
31
|
+
return results;
|
|
32
|
+
}
|
|
33
|
+
module.exports = { installOAuth, ENDPOINT };
|