slaunt 0.1.17 → 0.1.19

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.
Files changed (168) hide show
  1. package/README.md +17 -122
  2. package/bin/slaunt-hooks.js +99 -0
  3. package/package.json +9 -59
  4. package/release/SlauntKillBar.zip +0 -0
  5. package/release/manifest.json +7 -0
  6. package/scripts/killbar-portability-check.js +67 -0
  7. package/scripts/package-killbar-macos.sh +38 -0
  8. package/src/approval-manager.js +53 -0
  9. package/src/cli-auth.js +230 -0
  10. package/src/compact-wordmark.js +13 -0
  11. package/src/desktop/broker-daemon.js +18 -0
  12. package/src/desktop/broker-grants.js +37 -0
  13. package/src/desktop/broker-hook-command.js +23 -0
  14. package/src/desktop/broker-keychain.js +29 -0
  15. package/src/desktop/broker-launch-agent.js +34 -0
  16. package/src/desktop/broker-service.js +40 -0
  17. package/src/desktop/chat-capture-command.js +7 -0
  18. package/src/desktop/chat-hook-capture.js +445 -0
  19. package/src/desktop/claude-lifecycle.js +143 -0
  20. package/src/desktop/client-identity.js +19 -0
  21. package/src/desktop/native-claude-broker.js +34 -0
  22. package/src/desktop/native-claude-connection.js +42 -0
  23. package/src/desktop/tool-guard-command.js +13 -0
  24. package/src/desktop/tool-guard.js +225 -0
  25. package/src/discovery/catalog.js +734 -0
  26. package/src/full-screen.js +86 -0
  27. package/src/helper-release.js +27 -0
  28. package/src/hidden-oauth.js +70 -0
  29. package/src/independent-grants.js +38 -0
  30. package/src/install-flow.js +71 -0
  31. package/src/killbar-background-install.js +47 -0
  32. package/src/killbar-enrollment.js +74 -0
  33. package/src/killbar-launch-agent.js +10 -0
  34. package/src/mcp-identity.js +6 -0
  35. package/src/native-config.js +150 -0
  36. package/src/native-oauth.js +33 -0
  37. package/src/oauth-cohort.js +44 -0
  38. package/src/pty-wrapper.py +57 -0
  39. package/src/read-json.js +8 -0
  40. package/src/setup/client-installer.js +93 -0
  41. package/src/setup/codex-guard-supervisor.js +34 -0
  42. package/src/setup/hook-installer.js +496 -0
  43. package/src/setup/managed-policy.js +26 -0
  44. package/src/setup/mcp-config-installer.js +204 -0
  45. package/src/setup-flow.js +66 -0
  46. package/src/tui.js +205 -0
  47. package/test/approval-manager.test.js +27 -0
  48. package/test/broker-grants.test.js +14 -0
  49. package/test/broker-keychain.test.js +14 -0
  50. package/test/broker-launch-agent.test.js +15 -0
  51. package/test/broker-service.test.js +42 -0
  52. package/test/cli-auth-recovery.test.js +15 -0
  53. package/test/codex-guard-supervisor.test.js +25 -0
  54. package/test/full-screen.test.js +49 -0
  55. package/test/helper-release.test.js +22 -0
  56. package/test/hidden-oauth.test.js +56 -0
  57. package/test/hook-installer.test.js +232 -0
  58. package/test/independent-grants.test.js +16 -0
  59. package/test/install-flow.test.js +24 -0
  60. package/test/killbar-background-install.test.js +15 -0
  61. package/test/killbar-enrollment.test.js +115 -0
  62. package/test/killbar-launch-agent.test.js +12 -0
  63. package/test/killbar-portability.test.js +17 -0
  64. package/test/managed-policy.test.js +18 -0
  65. package/test/native-claude-broker.test.js +19 -0
  66. package/test/native-claude-connection.test.js +17 -0
  67. package/test/native-claude-hooks.test.js +9 -0
  68. package/test/native-config.test.js +3 -0
  69. package/test/native-integration.cjs +29 -0
  70. package/test/native-oauth.test.js +23 -0
  71. package/test/oauth-cohort.test.js +26 -0
  72. package/test/tui.test.js +100 -0
  73. package/LICENSE +0 -201
  74. package/dist/audit/risk-engine.d.ts +0 -4
  75. package/dist/audit/risk-engine.d.ts.map +0 -1
  76. package/dist/audit/risk-engine.js +0 -174
  77. package/dist/audit/risk-engine.js.map +0 -1
  78. package/dist/audit/run.d.ts +0 -18
  79. package/dist/audit/run.d.ts.map +0 -1
  80. package/dist/audit/run.js +0 -68
  81. package/dist/audit/run.js.map +0 -1
  82. package/dist/classification/builtin-catalog.d.ts +0 -3
  83. package/dist/classification/builtin-catalog.d.ts.map +0 -1
  84. package/dist/classification/builtin-catalog.js +0 -283
  85. package/dist/classification/builtin-catalog.js.map +0 -1
  86. package/dist/classification/catalog.d.ts +0 -10
  87. package/dist/classification/catalog.d.ts.map +0 -1
  88. package/dist/classification/catalog.js +0 -194
  89. package/dist/classification/catalog.js.map +0 -1
  90. package/dist/classification/classifier.d.ts +0 -3
  91. package/dist/classification/classifier.d.ts.map +0 -1
  92. package/dist/classification/classifier.js +0 -64
  93. package/dist/classification/classifier.js.map +0 -1
  94. package/dist/classification/public-catalog-config.d.ts +0 -3
  95. package/dist/classification/public-catalog-config.d.ts.map +0 -1
  96. package/dist/classification/public-catalog-config.js +0 -5
  97. package/dist/classification/public-catalog-config.js.map +0 -1
  98. package/dist/cli.d.ts +0 -3
  99. package/dist/cli.d.ts.map +0 -1
  100. package/dist/cli.js +0 -239
  101. package/dist/cli.js.map +0 -1
  102. package/dist/config/discover.d.ts +0 -8
  103. package/dist/config/discover.d.ts.map +0 -1
  104. package/dist/config/discover.js +0 -163
  105. package/dist/config/discover.js.map +0 -1
  106. package/dist/config/install.d.ts +0 -10
  107. package/dist/config/install.d.ts.map +0 -1
  108. package/dist/config/install.js +0 -143
  109. package/dist/config/install.js.map +0 -1
  110. package/dist/config/paths.d.ts +0 -24
  111. package/dist/config/paths.d.ts.map +0 -1
  112. package/dist/config/paths.js +0 -90
  113. package/dist/config/paths.js.map +0 -1
  114. package/dist/demo.d.ts +0 -4
  115. package/dist/demo.d.ts.map +0 -1
  116. package/dist/demo.js +0 -99
  117. package/dist/demo.js.map +0 -1
  118. package/dist/explanation/local.d.ts +0 -24
  119. package/dist/explanation/local.d.ts.map +0 -1
  120. package/dist/explanation/local.js +0 -696
  121. package/dist/explanation/local.js.map +0 -1
  122. package/dist/mcp/introspect.d.ts +0 -18
  123. package/dist/mcp/introspect.d.ts.map +0 -1
  124. package/dist/mcp/introspect.js +0 -236
  125. package/dist/mcp/introspect.js.map +0 -1
  126. package/dist/mcp/oauth.d.ts +0 -14
  127. package/dist/mcp/oauth.d.ts.map +0 -1
  128. package/dist/mcp/oauth.js +0 -257
  129. package/dist/mcp/oauth.js.map +0 -1
  130. package/dist/presentation/plain-language.d.ts +0 -12
  131. package/dist/presentation/plain-language.d.ts.map +0 -1
  132. package/dist/presentation/plain-language.js +0 -120
  133. package/dist/presentation/plain-language.js.map +0 -1
  134. package/dist/privacy/redaction.d.ts +0 -2
  135. package/dist/privacy/redaction.d.ts.map +0 -1
  136. package/dist/privacy/redaction.js +0 -50
  137. package/dist/privacy/redaction.js.map +0 -1
  138. package/dist/report/html.d.ts +0 -4
  139. package/dist/report/html.d.ts.map +0 -1
  140. package/dist/report/html.js +0 -101
  141. package/dist/report/html.js.map +0 -1
  142. package/dist/tui/analysis-path.d.ts +0 -17
  143. package/dist/tui/analysis-path.d.ts.map +0 -1
  144. package/dist/tui/analysis-path.js +0 -112
  145. package/dist/tui/analysis-path.js.map +0 -1
  146. package/dist/tui/render.d.ts +0 -4
  147. package/dist/tui/render.d.ts.map +0 -1
  148. package/dist/tui/render.js +0 -262
  149. package/dist/tui/render.js.map +0 -1
  150. package/dist/tui/splash.d.ts +0 -15
  151. package/dist/tui/splash.d.ts.map +0 -1
  152. package/dist/tui/splash.js +0 -130
  153. package/dist/tui/splash.js.map +0 -1
  154. package/dist/tui/stream.d.ts +0 -6
  155. package/dist/tui/stream.d.ts.map +0 -1
  156. package/dist/tui/stream.js +0 -56
  157. package/dist/tui/stream.js.map +0 -1
  158. package/dist/types.d.ts +0 -132
  159. package/dist/types.d.ts.map +0 -1
  160. package/dist/types.js +0 -2
  161. package/dist/types.js.map +0 -1
  162. package/dist/version.d.ts +0 -2
  163. package/dist/version.d.ts.map +0 -1
  164. package/dist/version.js +0 -8
  165. package/dist/version.js.map +0 -1
  166. package/models/APACHE-2.0.txt +0 -202
  167. package/models/MODEL.json +0 -29
  168. package/models/NOTICE.md +0 -23
@@ -0,0 +1,204 @@
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 } = require('../mcp-identity');
9
+ const MAX_CONFIG_BYTES = 5 * 1024 * 1024;
10
+ const CLIENT_TARGETS = Object.freeze({
11
+ 'claude-code': Object.freeze({ directory: null, file: '.claude.json', format: 'json' }),
12
+ 'codex-cli': Object.freeze({ directory: '.codex', file: 'config.toml', format: 'toml' })
13
+ });
14
+
15
+ function ensureDirectory(homeDir, directoryName) {
16
+ const target = directoryName ? path.join(homeDir, directoryName) : homeDir;
17
+ try {
18
+ const stats = fs.lstatSync(target);
19
+ if (stats.isSymbolicLink() || !stats.isDirectory()) throw new Error('unsafe-config-directory');
20
+ } catch (error) {
21
+ if (error?.code !== 'ENOENT') throw error;
22
+ fs.mkdirSync(target, { mode: 0o700 });
23
+ }
24
+ const stats = fs.lstatSync(target);
25
+ if (stats.isSymbolicLink() || !stats.isDirectory()) throw new Error('unsafe-config-directory');
26
+ return target;
27
+ }
28
+
29
+ function readExistingConfig(filePath) {
30
+ let descriptor;
31
+ try {
32
+ descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
33
+ const stats = fs.fstatSync(descriptor);
34
+ if (!stats.isFile()) throw new Error('unsafe-config-file');
35
+ if (stats.size > MAX_CONFIG_BYTES) throw new Error('config-file-too-large');
36
+ return {
37
+ content: fs.readFileSync(descriptor, 'utf8'),
38
+ identity: { dev: stats.dev, ino: stats.ino, size: stats.size, mtimeMs: stats.mtimeMs }
39
+ };
40
+ } catch (error) {
41
+ if (error?.code === 'ENOENT') return null;
42
+ if (error?.code === 'ELOOP') throw new Error('unsafe-config-file');
43
+ if (error?.message?.startsWith('unsafe-') || error?.message === 'config-file-too-large') throw error;
44
+ throw new Error('config-read-failed');
45
+ } finally {
46
+ if (descriptor !== undefined) fs.closeSync(descriptor);
47
+ }
48
+ }
49
+
50
+ function assertUnchanged(filePath, previous) {
51
+ try {
52
+ const current = fs.lstatSync(filePath);
53
+ if (current.isSymbolicLink() || !current.isFile()) throw new Error('unsafe-config-file');
54
+ if (!previous
55
+ || current.dev !== previous.identity.dev
56
+ || current.ino !== previous.identity.ino
57
+ || current.size !== previous.identity.size
58
+ || current.mtimeMs !== previous.identity.mtimeMs) {
59
+ throw new Error('config-changed-during-setup');
60
+ }
61
+ } catch (error) {
62
+ if (error?.code === 'ENOENT' && !previous) return;
63
+ if (error?.code === 'ENOENT' || error?.message?.startsWith('unsafe-')) throw error;
64
+ throw new Error('config-changed-during-setup');
65
+ }
66
+ }
67
+
68
+ function writeConfigAtomically(filePath, directory, serialized, previous) {
69
+ const temporaryPath = path.join(
70
+ directory,
71
+ `.slaunt-${process.pid}-${crypto.randomBytes(10).toString('hex')}.tmp`
72
+ );
73
+ let descriptor;
74
+ try {
75
+ descriptor = fs.openSync(temporaryPath, 'wx', 0o600);
76
+ fs.writeFileSync(descriptor, serialized, 'utf8');
77
+ fs.fsyncSync(descriptor);
78
+ fs.closeSync(descriptor);
79
+ descriptor = undefined;
80
+
81
+ assertUnchanged(filePath, previous);
82
+ fs.renameSync(temporaryPath, filePath);
83
+ fs.chmodSync(filePath, 0o600);
84
+ } finally {
85
+ if (descriptor !== undefined) fs.closeSync(descriptor);
86
+ try {
87
+ fs.unlinkSync(temporaryPath);
88
+ } catch (error) {
89
+ if (error?.code !== 'ENOENT') throw error;
90
+ }
91
+ }
92
+ }
93
+
94
+ function matchesBrokerEntry(existing, broker) {
95
+ return Boolean(existing
96
+ && typeof existing === 'object'
97
+ && !Array.isArray(existing)
98
+ && existing.command === broker.command
99
+ && Array.isArray(existing.args)
100
+ && existing.args.length === broker.args.length
101
+ && existing.args.every((argument, index) => argument === broker.args[index]));
102
+ }
103
+
104
+ function matchesPriorBrokerEntry(existing, broker) {
105
+ const args = broker.args.filter(argument => !argument.startsWith('--slaunt-client='));
106
+ return args.length === broker.args.length - 1 && matchesBrokerEntry(existing, { ...broker, args });
107
+ }
108
+
109
+ function updateClaudeConfig(previous, broker) {
110
+ const config = previous?.content?.trim() ? JSON.parse(previous.content) : {};
111
+ if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('invalid-config-root');
112
+ if (config.mcpServers !== undefined
113
+ && (!config.mcpServers || typeof config.mcpServers !== 'object' || Array.isArray(config.mcpServers))) {
114
+ throw new Error('invalid-mcp-servers');
115
+ }
116
+ const servers = config.mcpServers || {};
117
+ if (Object.hasOwn(servers, SLAUNT_SERVER_NAME)) {
118
+ if (matchesBrokerEntry(servers[SLAUNT_SERVER_NAME], broker)) return { status: 'unchanged' };
119
+ if (!matchesPriorBrokerEntry(servers[SLAUNT_SERVER_NAME], broker)) throw new Error('server-name-conflict');
120
+ servers[SLAUNT_SERVER_NAME] = { ...servers[SLAUNT_SERVER_NAME], args: broker.args };
121
+ return { status: 'installed', content: `${JSON.stringify(config, null, 2)}\n` };
122
+ }
123
+ config.mcpServers = {
124
+ ...servers,
125
+ [SLAUNT_SERVER_NAME]: { command: broker.command, args: broker.args }
126
+ };
127
+ return { status: 'installed', content: `${JSON.stringify(config, null, 2)}\n` };
128
+ }
129
+
130
+ function updateCodexConfig(previous, broker) {
131
+ const source = previous?.content || '';
132
+ const parsed = source.trim() ? parseToml(source) : {};
133
+ if (parsed.mcp_servers !== undefined
134
+ && (!parsed.mcp_servers || typeof parsed.mcp_servers !== 'object' || Array.isArray(parsed.mcp_servers))) {
135
+ throw new Error('invalid-mcp-servers');
136
+ }
137
+ const servers = parsed.mcp_servers || {};
138
+ if (Object.hasOwn(servers, SLAUNT_SERVER_NAME)) {
139
+ if (matchesBrokerEntry(servers[SLAUNT_SERVER_NAME], broker)) return { status: 'unchanged' };
140
+ if (!matchesPriorBrokerEntry(servers[SLAUNT_SERVER_NAME], broker)) throw new Error('server-name-conflict');
141
+ // Upgrade only our generated simple args line, preserving all surrounding TOML.
142
+ const section = /(^\[mcp_servers\.slaunt\][ \t]*(?:#[^\n]*)?\r?\n)([\s\S]*?)(?=^\[|(?![\s\S]))/m;
143
+ const match = section.exec(source);
144
+ if (!match || !/^args[ \t]*=[ \t]*\[[^\n]*\][ \t]*$/m.test(match[2])) throw new Error('server-name-conflict');
145
+ const body = match[2].replace(/^args[ \t]*=[ \t]*\[[^\n]*\][ \t]*$/m, () => `args = ${JSON.stringify(broker.args)}`);
146
+ const content = source.slice(0, match.index) + match[1] + body + source.slice(match.index + match[0].length);
147
+ const updated = parseToml(content);
148
+ if (!matchesBrokerEntry(updated.mcp_servers?.[SLAUNT_SERVER_NAME], broker)) throw new Error('server-name-conflict');
149
+ return { status: 'installed', content };
150
+ }
151
+
152
+ const separator = source.length === 0 || source.endsWith('\n\n') ? '' : source.endsWith('\n') ? '\n' : '\n\n';
153
+ const content = `${source}${separator}[mcp_servers.${SLAUNT_SERVER_NAME}]\nenabled = true\ncommand = ${JSON.stringify(broker.command)}\nargs = ${JSON.stringify(broker.args)}\n`;
154
+ // Parse the complete result before it can reach the user's config file.
155
+ parseToml(content);
156
+ return { status: 'installed', content };
157
+ }
158
+
159
+ function safeReason(error) {
160
+ const known = new Set([
161
+ 'unsafe-config-directory',
162
+ 'unsafe-config-file',
163
+ 'config-file-too-large',
164
+ 'config-read-failed',
165
+ 'config-changed-during-setup',
166
+ 'invalid-config-root',
167
+ 'invalid-mcp-servers',
168
+ 'server-name-conflict'
169
+ ]);
170
+ if (known.has(error?.message)) return error.message;
171
+ if (error?.code === 'EACCES' || error?.code === 'EPERM') return 'permission-denied';
172
+ if (error?.code === 'ENOSPC') return 'insufficient-storage';
173
+ return 'config-write-failed';
174
+ }
175
+
176
+ function installMcpConfigForClient({ clientId, homeDir, broker } = {}) {
177
+ const target = CLIENT_TARGETS[clientId];
178
+ if (!target) return { status: 'unsupported', reason: 'adapter-unavailable' };
179
+ if (!path.isAbsolute(homeDir || '') || !broker || !path.isAbsolute(broker.command)
180
+ || !Array.isArray(broker.args) || broker.args.length > 4
181
+ || broker.args.some((argument) => typeof argument !== 'string' || argument.length > 512)) {
182
+ return { status: 'error', reason: 'invalid-install-request' };
183
+ }
184
+
185
+ try {
186
+ const directory = ensureDirectory(homeDir, target.directory);
187
+ const filePath = path.join(directory, target.file);
188
+ const previous = readExistingConfig(filePath);
189
+ const updated = target.format === 'json'
190
+ ? updateClaudeConfig(previous, broker)
191
+ : updateCodexConfig(previous, broker);
192
+ if (updated.status === 'unchanged') return { status: 'unchanged', reason: null };
193
+ writeConfigAtomically(filePath, directory, updated.content, previous);
194
+ return { status: 'installed', reason: null };
195
+ } catch (error) {
196
+ return { status: error?.message === 'server-name-conflict' ? 'conflict' : 'error', reason: safeReason(error) };
197
+ }
198
+ }
199
+
200
+ module.exports = {
201
+ SLAUNT_SERVER_NAME,
202
+ CLIENT_TARGETS,
203
+ installMcpConfigForClient
204
+ };
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+ const {spawn}=require('node:child_process');
3
+ const {authorizeCohort}=require('./oauth-cohort');
4
+ const {startOAuthFlow}=require('./cli-auth');
5
+ const {readJson}=require('./read-json');
6
+ const ISSUER='https://central-mcp-ccr-production.up.railway.app';
7
+ async function openBrowser(url) {
8
+ const command=process.platform==='darwin'?'open':'xdg-open';
9
+ await new Promise((resolve,reject)=>{const child=spawn(command,[url],{stdio:'ignore'});child.on('error',reject);child.on('close',code=>code===0?resolve():reject(new Error('browser-unavailable')));});
10
+ }
11
+ async function runSetupFlow({clients,issuer=ISSUER,fetchImpl=fetch,shell={openExternal:openBrowser},login,executablePaths,killbarEnrollment,brokerGrantSink,authenticate=startOAuthFlow}={}) {
12
+ // The client grants must never be issued without enrolling the helper that
13
+ // supplies the local control boundary for this setup.
14
+ if(typeof killbarEnrollment !== 'function') throw new Error('killbar-enrollment-required');
15
+ const origin=new URL(issuer);
16
+ if(origin.protocol!=='https:' && !(origin.protocol==='http:' && ['127.0.0.1','localhost','[::1]'].includes(origin.hostname)))throw new Error('unsafe-issuer');
17
+ let session=null;
18
+ try {
19
+ await authorizeCohort({clients,issuer,executablePaths,timeoutMs:360000,...(login?{login}:{}),approve:async inputs=>{
20
+ const requests=inputs.map(({client,authorizationUrl})=>({client,authorization_url:authorizationUrl}));
21
+ let diagnosticStage='sign-in';
22
+ try {
23
+ await authenticate({shell,issuer,fetchImpl,requests,sessionSink:{async save(value){session=value;}}});
24
+ if(!session)throw new Error('authentication-required');
25
+ diagnosticStage='workspace';
26
+ const identityResponse=await fetchImpl(new URL('/api/desktop/session',origin),{method:'GET',redirect:'error',signal:AbortSignal.timeout(15000),headers:{authorization:`Bearer ${session.accessToken}`}});
27
+ if(!identityResponse.ok)throw new Error('workspace-identity-unavailable');
28
+ const identity=await readJson(identityResponse,32768);
29
+ const uuid=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
30
+ if(!uuid.test(identity?.org_id || '') || !uuid.test(identity?.project_id || ''))throw new Error('workspace-identity-invalid');
31
+ // Enrollment shares the same authenticated Slaunt sign-in. The helper
32
+ // performs its own device challenge and stores its own credentials.
33
+ diagnosticStage='enrollment';
34
+ const enrollment = await killbarEnrollment({accessToken:session.accessToken,identity:{orgId:identity.org_id,projectId:identity.project_id}});
35
+ if(enrollment?.enrolled !== true) throw new Error('killbar-enrollment-unverified');
36
+ diagnosticStage='client-grants';
37
+ const requestBroker=clients.includes('claude') && Boolean(brokerGrantSink);
38
+ if(requestBroker && typeof brokerGrantSink.save!=='function')throw new Error('broker-grant-store-unavailable');
39
+ const requestBody=JSON.stringify({refresh_token:session.refreshToken,setup_ticket:session.setupTicket,requests,...(requestBroker?{request_broker:true}:{})});
40
+ let payload;
41
+ for(let attempt=0;attempt<3;attempt++){
42
+ try{
43
+ const response=await fetchImpl(new URL('/authorize/cli',origin),{method:'POST',redirect:'error',signal:AbortSignal.timeout(45000),headers:{'content-type':'application/json',authorization:`Bearer ${session.accessToken}`},body:requestBody});
44
+ if(!response.ok && response.status<500)throw new Error('setup-authorization-rejected');
45
+ if(!response.ok)throw new Error('setup-authorization-failed');
46
+ payload=await readJson(response,262144);break;
47
+ }catch(error){if(error?.message==='setup-authorization-rejected'||attempt===2)throw new Error('setup-authorization-failed');}
48
+ }
49
+ if(!payload||typeof payload.callbacks!=='object')throw new Error('setup-response-invalid');
50
+ if(requestBroker){
51
+ const grant=payload.broker,lifetime=Number(grant?.expires_in);
52
+ if(!/^bg1\.[A-Za-z0-9_-]+$/.test(grant?.access_token||'') || grant.access_token.length>131072 || !/^sg1\.[A-Za-z0-9_-]{43}$/.test(grant?.refresh_token||'') || grant.scope!=='slaunt:guard' || String(grant.token_type||'').toLowerCase()!=='bearer' || !Number.isSafeInteger(lifetime) || lifetime<1 || lifetime>300)throw new Error('broker-grant-invalid');
53
+ try {await brokerGrantSink.save({accessToken:grant.access_token,refreshToken:grant.refresh_token,expiresAt:Date.now()+lifetime*1000});}
54
+ catch {
55
+ // Best-effort cleanup; never hand native callbacks to clients after
56
+ // failing to persist the broker grant.
57
+ try {await fetchImpl(new URL('/revoke',origin),{method:'POST',redirect:'error',signal:AbortSignal.timeout(10000),headers:{'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({client_id:'ai.slaunt.claude-broker',token:grant.refresh_token})});}catch{}
58
+ throw new Error('broker-grant-store-unavailable');
59
+ }
60
+ }
61
+ return payload.callbacks;
62
+ } catch { const error=new Error('setup-step-failed'); error.setupDetail=diagnosticStage; throw error; }
63
+ }});
64
+ } finally {session=null;}
65
+ }
66
+ module.exports={runSetupFlow,ISSUER};
package/src/tui.js ADDED
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+ const readline = require('node:readline');
3
+ const { createFullScreen } = require('./full-screen');
4
+ const { COMPACT_WORDMARK } = require('./compact-wordmark');
5
+
6
+ // Terminal conventions follow guided setup CLIs: a recommended first choice,
7
+ // one question at a time, visible completed steps, and a clear terminal result.
8
+ const FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
9
+ const SPLASH_LOGO = String.raw` /$$ /$$
10
+ | $$ | $$
11
+ /$$$$$$$| $$ /$$$$$$ /$$ /$$ /$$$$$$$ /$$$$$$
12
+ /$$_____/| $$ |____ $$| $$ | $$| $$__ $$|_ $$_/
13
+ | $$$$$$ | $$ /$$$$$$$| $$ | $$| $$ \ $$ | $$
14
+ \____ $$| $$ /$$__ $$| $$ | $$| $$ | $$ | $$ /$$
15
+ /$$$$$$$/| $$| $$$$$$$| $$$$$$/| $$ | $$ | $$$$/
16
+ |_______/ |__/ \_______/ \______/ |__/ |__/ \___/`.split('\n');
17
+ const SPLASH_WIDTH = Math.max(...SPLASH_LOGO.map(line => line.length));
18
+ const COMPACT_WIDTH = Math.max(...COMPACT_WORDMARK.map(line => line.length));
19
+ const CLIENTS = [
20
+ { value: 'both', label: 'Codex + Claude Code', hint: 'recommended' },
21
+ { value: 'codex', label: 'Codex' },
22
+ { value: 'claude', label: 'Claude Code' }
23
+ ];
24
+
25
+ function createTerminal({ input = process.stdin, output = process.stdout, error = process.stderr, env = process.env } = {}) {
26
+ const tty = Boolean(input.isTTY && output.isTTY && env.TERM !== 'dumb');
27
+ const interactive = tty && !env.CI && typeof input.setRawMode === 'function';
28
+ const color = tty && !Object.hasOwn(env, 'NO_COLOR');
29
+ const motion = interactive && env.SLAUNT_REDUCED_MOTION !== '1';
30
+ const style = (code, value) => color ? `\x1b[${code}m${value}\x1b[0m` : value;
31
+ const dim = value => style('2', value);
32
+ const accent = value => style('36', value);
33
+ const bold = value => style('1', value);
34
+ let timer = null;
35
+ let progressLine = false;
36
+ let ended = false;
37
+ let screenActive = false;
38
+ let finalMessage = null;
39
+ let rawModeActive = false;
40
+ let originalRawMode = false;
41
+ let screen = null;
42
+ const screenState = {view:'status',selected:0,selection:null,completed:[],current:null,failure:null,clientAvailability:{codex:'unknown',claude:'unknown'}};
43
+ const onScreenResize = () => { if(screenActive&&screen)screen.render(screenState,true); };
44
+ async function enter() {
45
+ if (!interactive) return;
46
+ screenActive = true;
47
+ output.write('\x1b[?1049h\x1b[2J\x1b[H\x1b[?25l');
48
+ let lastSize = '';
49
+ const drawSplash = shimmer => {
50
+ const width = Math.max(1, output.columns || 80);
51
+ const height = Math.max(1, output.rows || 24);
52
+ const lines = width >= SPLASH_WIDTH && height >= SPLASH_LOGO.length + 3 ? SPLASH_LOGO
53
+ : width >= COMPACT_WIDTH && height >= COMPACT_WORDMARK.length + 3 ? COMPACT_WORDMARK : ['SLAUNT'];
54
+ const lineWidth = Math.max(...lines.map(line => line.length));
55
+ const row = Math.max(1, Math.floor((height - lines.length - 1) / 2) + 1);
56
+ const column = Math.max(1, Math.floor((width - lineWidth) / 2) + 1);
57
+ const size = `${width}:${height}`;
58
+ if (size !== lastSize) { output.write('\x1b[2J'); lastSize = size; }
59
+ for (const [index, line] of lines.entries()) {
60
+ let rendered = '';
61
+ if (shimmer === null || !color) rendered = color ? accent(line) : line;
62
+ else {
63
+ let group = '', highlight = false;
64
+ for (const [columnIndex, char] of [...line].entries()) {
65
+ const nextHighlight = Math.abs(columnIndex - index * 0.7 - shimmer) <= 3 && char !== ' ';
66
+ if (group && nextHighlight !== highlight) { rendered += style(highlight ? '1;97' : '2;36', group); group = ''; }
67
+ highlight = nextHighlight; group += char;
68
+ }
69
+ if (group) rendered += style(highlight ? '1;97' : '2;36', group);
70
+ }
71
+ output.write(`\x1b[${row + index};${column}H${rendered}`);
72
+ }
73
+ if (height >= lines.length + 3 && width >= 24) output.write(`\x1b[${row + lines.length + 1};${Math.max(1, Math.floor((width - 15) / 2) + 1)}H${dim('Preparing setup')}`);
74
+ };
75
+ if (motion && color) {
76
+ for (let offset = -6; offset <= SPLASH_WIDTH + 6; offset += 6) {
77
+ if (!screenActive) return;
78
+ drawSplash(offset);
79
+ await new Promise(resolve => setTimeout(resolve, 65));
80
+ }
81
+ } else {
82
+ drawSplash(null);
83
+ await new Promise(resolve => setTimeout(resolve, 250));
84
+ }
85
+ if (screenActive) {
86
+ output.write('\x1b[2J\x1b[H\x1b[?25l');
87
+ screen=createFullScreen({output,logo:SPLASH_LOGO,color,motion});
88
+ output.on('resize',onScreenResize);
89
+ }
90
+ }
91
+ function close() {
92
+ stop();
93
+ if (rawModeActive) { input.setRawMode(originalRawMode); rawModeActive = false; }
94
+ if (!screenActive) return;
95
+ screenActive = false;
96
+ output.removeListener('resize',onScreenResize);
97
+ output.write('\x1b[?25h\x1b[?1049l');
98
+ if (finalMessage) output.write(`\nSlaunt: ${finalMessage.message}\n${finalMessage.detail ? `${finalMessage.detail}\n` : ''}`);
99
+ }
100
+ function stop() {
101
+ if (timer) { clearInterval(timer); timer = null; }
102
+ if (progressLine) { output.write('\r\x1b[2K'); progressLine = false; }
103
+ }
104
+ function banner() {
105
+ if (screenActive && screen) { screen.render(screenState,true); return; }
106
+ output.write(`\n${accent('┌')} ${bold('Slaunt setup')}\n${dim('│')}\n`);
107
+ }
108
+ function setClientAvailability(client,status) {
109
+ if (!Object.hasOwn(screenState.clientAvailability,client)) return;
110
+ screenState.clientAvailability[client]=status;
111
+ if(screenActive&&screen&&screenState.view==='select')screen.render(screenState);
112
+ }
113
+ function setSelection(value) {
114
+ const choice=CLIENTS.find(item=>item.value===value);
115
+ screenState.selection=choice?choice.label:null;
116
+ if(screenActive&&screen)screen.render(screenState,true);
117
+ }
118
+ function progress(label) {
119
+ stop();
120
+ if(screenActive&&screen){
121
+ screenState.current=label;screen.render(screenState,true);
122
+ if(motion){timer=setInterval(()=>screen.render(screenState),100);timer.unref?.();}
123
+ return;
124
+ }
125
+ if (!motion) { output.write(`${dim('│')} ${label}…\n`); return; }
126
+ let frame = 0;
127
+ const render = () => { output.write(`\r\x1b[2K${accent(FRAMES[frame++ % FRAMES.length])} ${label}`); progressLine = true; };
128
+ render(); timer = setInterval(render, 80); timer.unref?.();
129
+ }
130
+ function done(label) {
131
+ stop();
132
+ if(screenActive&&screen){screenState.current=null;screenState.completed.push(label);screen.render(screenState,true);return;}
133
+ output.write(`${accent('◇')} ${label}\n${dim('│')}\n`);
134
+ }
135
+ function fail(message, detail) {
136
+ stop();
137
+ if (ended) return;
138
+ ended = true;
139
+ finalMessage = { message, detail };
140
+ if(screenActive&&screen){screenState.view='status';screenState.current=null;screenState.failure=finalMessage;screen.render(screenState,true);return;}
141
+ const dest = tty ? output : error;
142
+ dest.write(`${style('33', '▲')} ${bold(message)}\n${detail ? `${dim('│')} ${detail}\n` : ''}${dim('└')} Setup stopped\n\n`);
143
+ }
144
+ function success(message, detail) {
145
+ stop();
146
+ if (ended) return;
147
+ ended = true;
148
+ finalMessage = { message, detail };
149
+ if (screenActive && screen) { screenState.current=null; screenState.completed.push(message); screen.render(screenState,true); return; }
150
+ output.write(`${accent('◇')} ${bold(message)}\n${detail ? `${dim('│')} ${detail}\n` : ''}${dim('└')}\n`);
151
+ }
152
+ async function selectClients() {
153
+ if (!interactive) return 'both';
154
+ let selected = 0;
155
+ const screenWasActive = screenActive;
156
+ const compact = () => (output.columns || 80) < 52;
157
+ const question = () => compact() ? 'Choose clients' : 'Which clients would you like to connect?';
158
+ const rows = () => [
159
+ `${accent('◆')} ${bold(question())}`,
160
+ ...CLIENTS.map((item, index) => `${dim('│')} ${index === selected ? accent('●') : dim('○')} ${item.label}${item.hint && (output.columns || 80) >= 50 ? ` ${dim(`(${item.hint})`)}` : ''}`),
161
+ `${dim('│')} ${dim(compact() ? '↑/↓ · Enter' : '↑/↓ to navigate · Enter to confirm')}`,
162
+ dim('└')
163
+ ];
164
+ function draw(clear=false) { if(screenWasActive&&screenActive&&screen){screenState.view='select';screenState.selected=selected;screen.render(screenState,clear);}else output.write(`${rows().join('\n')}\n`); }
165
+ function erase() { if(!screenWasActive)output.write(`\x1b[${rows().length}A\r\x1b[J`); }
166
+ output.write('\x1b[?25l');
167
+ draw(true);
168
+ if(screenWasActive&&motion){timer=setInterval(()=>draw(),120);timer.unref?.();}
169
+ readline.emitKeypressEvents(input);
170
+ const wasRaw = Boolean(input.isRaw);
171
+ try {
172
+ input.setRawMode(true); originalRawMode = wasRaw; rawModeActive = true; input.resume();
173
+ return await new Promise(resolve => {
174
+ let settled = false;
175
+ const finish = value => {
176
+ if (settled) return;
177
+ settled = true;
178
+ input.removeListener('keypress', onKey);
179
+ input.removeListener('end', onEnd);
180
+ input.removeListener('error', onEnd);
181
+ stop();
182
+ if (!screenWasActive || screenActive) {
183
+ erase();
184
+ if (value && screenWasActive) {screenState.view='status';screenState.selection=CLIENTS[selected].label;screen.render(screenState,true);}
185
+ else if (value) output.write(`${accent('◇')} ${question()}\n${dim('│')} ${CLIENTS[selected].label}\n${dim('│')}\n`);
186
+ }
187
+ resolve(value);
188
+ };
189
+ const onEnd = () => finish(null);
190
+ const onKey = (_text, key = {}) => {
191
+ if ((key.ctrl && ['c','d'].includes(key.name)) || key.name === 'escape') return finish(null);
192
+ if (key.name === 'up' || key.name === 'k') { selected = (selected + CLIENTS.length - 1) % CLIENTS.length; erase(); draw(); }
193
+ else if (key.name === 'down' || key.name === 'j') { selected = (selected + 1) % CLIENTS.length; erase(); draw(); }
194
+ else if (key.name === 'return' || key.name === 'enter') finish(CLIENTS[selected].value);
195
+ };
196
+ input.on('keypress', onKey); input.once('end', onEnd); input.once('error', onEnd);
197
+ });
198
+ } finally {
199
+ if (rawModeActive) { input.setRawMode(wasRaw); rawModeActive = false; }
200
+ input.pause(); output.write(screenActive ? '\x1b[?25l' : '\x1b[?25h');
201
+ }
202
+ }
203
+ return { enter, close, banner, progress, done, fail, success, stop, selectClients, setSelection, setClientAvailability, tty, interactive };
204
+ }
205
+ module.exports = { createTerminal, SPLASH_LOGO };
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const {createApprovalManager} = require('../src/approval-manager');
5
+ const makeReq = (name,state) => ({client:name, authorizationUrl:`https://example.test/authorize?response_type=code&client_id=${name}&redirect_uri=http%3A%2F%2F127.0.0.1%3A1234%2Fcallback&state=${state}&code_challenge=abc&code_challenge_method=S256`});
6
+ const requests = [makeReq('codex','codex-state'),makeReq('claude','claude-state')];
7
+ const manager = options => createApprovalManager({issuer:'https://example.test',validateClient:async()=>true,issueCallback:async({request})=>{let u=new URL(request.searchParams.get('redirect_uri'));u.searchParams.set('code','test');u.searchParams.set('state',request.searchParams.get('state'));return u.toString();},...options});
8
+ test('one approval yields callbacks bound to each request and rejects replay',async()=>{
9
+ let issued=[]; const m=manager({issueCallback:async({client,request,principal})=>{issued.push([client,principal]);let u=new URL(request.searchParams.get('redirect_uri'));u.searchParams.set('code',client);u.searchParams.set('state',request.searchParams.get('state'));return u.toString();}});
10
+ const session=await m.create(requests);assert.deepEqual(m.approve({...session,principal:'user',accepted:true}).clients,['codex','claude']);
11
+ const cb=await m.redeem(session);assert.match(cb.codex,/codex-state/);assert.match(cb.claude,/claude-state/);assert.deepEqual(issued,[['codex','user'],['claude','user']]);await assert.rejects(m.redeem(session));
12
+ });
13
+ test('rejects invalid approval, wrong redemption proof and denial',async()=>{
14
+ const m=manager();const s=await m.create(requests);assert.throws(()=>m.approve({...s,approveSecret:'wrong',principal:'user',accepted:true}));m.approve({...s,principal:'user',accepted:true});await assert.rejects(m.redeem({...s,redeemSecret:'wrong'}));assert.ok(await m.redeem(s));
15
+ const d=await m.create(requests);assert.equal(m.approve({...d,principal:'user',accepted:false}).approved,false);await assert.rejects(m.redeem(d));
16
+ });
17
+ test('rejects unregistered clients and expired approval',async()=>{
18
+ const m=manager({validateClient:async()=>false});await assert.rejects(m.create(requests));
19
+ let time=0;const exp=manager({now:()=>time,ttlMs:10});const s=await exp.create(requests);time=11;assert.throws(()=>exp.approve({...s,principal:'user',accepted:true}));
20
+ });
21
+ test('only one concurrent redemption can claim an approved batch',async()=>{
22
+ let issued=0;const m=manager({issueCallback:async({request})=>{issued++;await new Promise(resolve=>setTimeout(resolve,5));const u=new URL(request.searchParams.get('redirect_uri'));u.searchParams.set('code','test');u.searchParams.set('state',request.searchParams.get('state'));return u.toString();}});
23
+ const s=await m.create(requests);m.approve({...s,principal:'user',accepted:true});const results=await Promise.allSettled([m.redeem(s),m.redeem(s)]);assert.deepEqual(results.map(x=>x.status).sort(),['fulfilled','rejected']);assert.equal(issued,2);
24
+ });
25
+ test('a failed issuer consumes the approval and prevents replay',async()=>{
26
+ const m=manager({issueCallback:async()=>{throw new Error('issuer unavailable');}});const s=await m.create(requests);m.approve({...s,principal:'user',accepted:true});await assert.rejects(m.redeem(s));await assert.rejects(m.redeem(s),/invalid-redemption/);
27
+ });
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+ const test=require('node:test'),assert=require('node:assert/strict');
3
+ const {createBrokerGrantProvider,CLIENT_ID}=require('../src/desktop/broker-grants');
4
+ test('refreshes and commits a separate guard-scoped grant',async()=>{
5
+ let saved={accessToken:'expired',refreshToken:'previous',expiresAt:1},calls=0;
6
+ const store={async get(){return saved;},async update(fn){saved=await fn(saved);return saved;}};
7
+ const provider=createBrokerGrantProvider({store,now:()=>100000,fetchImpl:async(url,options)=>{calls++;assert.equal(options.body.get('client_id'),CLIENT_ID);assert.equal(options.body.get('refresh_token'),'previous');return new Response(JSON.stringify({token_type:'Bearer',scope:'slaunt:guard',access_token:'fresh',refresh_token:'next',expires_in:3600}));}});
8
+ assert.equal((await provider.getFreshTokens()).accessToken,'fresh');assert.equal(saved.refreshToken,'next');assert.equal((await provider.getFreshTokens()).accessToken,'fresh');assert.equal(calls,1);
9
+ });
10
+ test('rejects a general-purpose or malformed grant without saving it',async()=>{
11
+ let saved={accessToken:'expired',refreshToken:'old',expiresAt:1};const store={async get(){return saved;},async update(fn){saved=await fn(saved);return saved;}};
12
+ const provider=createBrokerGrantProvider({store,now:()=>100000,fetchImpl:async()=>new Response(JSON.stringify({token_type:'Bearer',scope:'mcp:tools',access_token:'fresh',refresh_token:'next',expires_in:3600}))});
13
+ await assert.rejects(provider.getFreshTokens(),/broker-refresh-invalid/);assert.equal(saved.refreshToken,'old');
14
+ });
@@ -0,0 +1,14 @@
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 {createBrokerKeychain}=require('../src/desktop/broker-keychain');
5
+ test('grant bridge sends data over stdin and serializes updates',async()=>{
6
+ const dir=await fs.mkdtemp(path.join(os.tmpdir(),'slaunt-broker-store-test-')),app=path.join(dir,'SlauntKillBar.app'),binary=path.join(app,'Contents/MacOS/SlauntKillBar'),data=path.join(dir,'fixture.json');
7
+ try{
8
+ await fs.mkdir(path.dirname(binary),{recursive:true});
9
+ await fs.writeFile(binary,`#!/bin/sh\ncase "$1" in\n--broker-grant-read) cat '${data}';;\n--broker-grant-create-stdin|--broker-grant-update-stdin) cat > '${data}';;\n*) exit 1;;\nesac\n`,{mode:0o700});
10
+ const store=createBrokerKeychain({app,verify:async()=>{}});await store.save({count:0});
11
+ await Promise.all([store.update(async x=>({count:x.count+1})),store.update(async x=>({count:x.count+1}))]);
12
+ assert.equal((await store.get()).count,2);
13
+ }finally{await fs.rm(dir,{recursive:true,force:true});}
14
+ });
@@ -0,0 +1,15 @@
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 {installBrokerLaunchAgent,brokerPlist}=require('../src/desktop/broker-launch-agent');
5
+ test('launch agent registers the broker and checks its private socket',async()=>{
6
+ const home=await fs.mkdtemp(path.join(os.tmpdir(),'slaunt-launch-')),calls=[];
7
+ try{
8
+ await fs.mkdir(path.join(home,'Library/LaunchAgents'),{recursive:true,mode:0o700});
9
+ const result=await installBrokerLaunchAgent({home,platform:'darwin',exec:async(command,args)=>calls.push([command,args]),check:async()=>true});
10
+ assert.equal(result.registered,true);assert.ok(calls.some(([,args])=>args[0]==='bootstrap'));
11
+ const plist=await fs.readFile(path.join(home,'Library/LaunchAgents/ai.slaunt.claude-broker.plist'),'utf8');assert.match(plist,/broker-daemon\.js/);assert.match(plist,/<key>KeepAlive<\/key><true\/>/);
12
+ await assert.rejects(installBrokerLaunchAgent({home,platform:'darwin',exec:async()=>{},check:async()=>true}),/broker-existing-install/);
13
+ }finally{await fs.rm(home,{recursive:true,force:true});}
14
+ });
15
+ test('launch plist escapes arguments',()=>assert.match(brokerPlist({node:'/tmp/node&x',script:'/tmp/script'}),/node&amp;x/));
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+ const test=require('node:test'),assert=require('node:assert/strict');
3
+ const fs=require('node:fs/promises');
4
+ const {startBrokerService}=require('../src/desktop/broker-service');
5
+ const {sendLifecycle}=require('../src/desktop/broker-hook-command');
6
+ const {createNativeClaudeBroker}=require('../src/desktop/native-claude-broker');
7
+ const {authorizeHook}=require('../src/desktop/tool-guard');
8
+ const {health}=require('../src/desktop/broker-launch-agent');
9
+ const path=require('node:path');
10
+ test('persistent service creates isolated session connections and closes them on stop',async()=>{
11
+ const root=await fs.mkdtemp('/tmp/sb-'),events=[];
12
+ const service=await startBrokerService({root,grantProvider:{},connect:async()=>({async event(input){events.push(input);},async close(){events.push('closed');}})});
13
+ try{
14
+ await sendLifecycle({hook_event_name:'UserPromptSubmit',session_id:'one',prompt:'not sent'},{root});
15
+ await sendLifecycle({hook_event_name:'UserPromptSubmit',session_id:'two'},{root});
16
+ await sendLifecycle({hook_event_name:'Stop',session_id:'one'},{root});
17
+ assert.deepEqual(events,[{event_name:'UserPromptSubmit',session_id:'one'},{event_name:'UserPromptSubmit',session_id:'two'},{event_name:'Stop',session_id:'one'},'closed']);
18
+ await assert.rejects(startBrokerService({root,grantProvider:{}}),/broker-already-running/);
19
+ }finally{await service.close();await fs.rm(root,{recursive:true,force:true});}
20
+ });
21
+ test('service readiness waits for credential initialization',async()=>{
22
+ const root=await fs.mkdtemp('/tmp/sb-');let release;const waiting=new Promise(resolve=>{release=resolve;});
23
+ const started=startBrokerService({root,grantProvider:{},initialize:()=>waiting});
24
+ try{
25
+ let exists=false;for(let i=0;i<30;i++){try{await fs.lstat(path.join(root,'control.sock'));exists=true;break;}catch{await new Promise(r=>setTimeout(r,5));}}
26
+ assert.equal(exists,true);let result;const pending=health(path.join(root,'control.sock')).then(value=>{result=value;});
27
+ await new Promise(r=>setTimeout(r,30));assert.equal(result,undefined);release();const service=await started;await pending;assert.equal(result,true);await service.close();
28
+ }finally{release?.();await fs.rm(root,{recursive:true,force:true});}
29
+ });
30
+ test('lifecycle delivery binds a guard that enforces upstream allow and deny',async()=>{
31
+ const root=await fs.mkdtemp('/tmp/sb-'),guardRoot=await fs.mkdtemp('/tmp/sg-');let allow=true;
32
+ const client={async callTool({name,arguments:args}){return name==='tool_authorize'?{structuredContent:{decision:allow?'allow':'deny',...Object.fromEntries(['request_id','tool_use_id','tool_name','input_sha256'].map(k=>[k,args[k]]))}}:{content:[]};}};
33
+ const service=await startBrokerService({root,grantProvider:{},connect:async()=>{const broker=createNativeClaudeBroker({client,root:guardRoot});return {event:broker.event,async close(){broker.close();}};}});
34
+ const input={hook_event_name:'PreToolUse',session_id:'session',tool_name:'Bash',tool_use_id:'call',tool_input:{command:'true'}};
35
+ try{
36
+ await sendLifecycle({hook_event_name:'UserPromptSubmit',session_id:'session'},{root});
37
+ assert.equal((await authorizeHook({input,clientKey:'claude-code',root:guardRoot})).hookSpecificOutput.permissionDecision,undefined);
38
+ allow=false;assert.equal((await authorizeHook({input,clientKey:'claude-code',root:guardRoot})).hookSpecificOutput.permissionDecision,'deny');
39
+ await sendLifecycle({hook_event_name:'Stop',session_id:'session'},{root});
40
+ assert.equal((await authorizeHook({input,clientKey:'claude-code',root:guardRoot})).hookSpecificOutput.permissionDecision,'deny');
41
+ }finally{await service.close();await fs.rm(root,{recursive:true,force:true});await fs.rm(guardRoot,{recursive:true,force:true});}
42
+ });
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+ const test=require('node:test'),assert=require('node:assert/strict');
3
+ const {exchangeAuthorizationCode}=require('../src/cli-auth');
4
+ test('retries an interrupted token response with the same code and PKCE verifier',async()=>{
5
+ const calls=[];let saved;
6
+ await exchangeAuthorizationCode({code:'test-code',codeVerifier:'test-verifier',sessionSink:{async save(value){saved=value;}},fetchImpl:async(_url,options)=>{
7
+ calls.push(options.body.toString());
8
+ if(calls.length===1)throw new Error('connection lost');
9
+ return Response.json({access_token:'access',refresh_token:'refresh',setup_ticket:'sat1.'+'a'.repeat(43),token_type:'Bearer',expires_in:3600,scope:'mcp:tools'});
10
+ }});
11
+ assert.equal(calls.length,2);assert.equal(calls[0],calls[1]);assert.equal(saved.accessToken,'access');
12
+ });
13
+ test('does not retry an explicit invalid-grant response',async()=>{
14
+ let calls=0;await assert.rejects(exchangeAuthorizationCode({code:'code',codeVerifier:'verifier',sessionSink:{async save(){}},fetchImpl:async()=>{calls++;return new Response('{}',{status:400});}}),/oauth-token-exchange-failed/);assert.equal(calls,1);
15
+ });
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+ const test=require('node:test'),assert=require('node:assert/strict');
3
+ const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),{spawnSync}=require('node:child_process');
4
+ const supervisor=path.resolve(__dirname,'../src/setup/codex-guard-supervisor.js');
5
+ function run(script) {
6
+ const dir=fs.mkdtempSync(path.join(os.tmpdir(),'slaunt-guard-'));
7
+ try { const file=path.join(dir,'guard');fs.writeFileSync(file,`#!/bin/sh\n${script}\n`,{mode:0o700});
8
+ const result=spawnSync(process.execPath,[supervisor,file],{input:'{"hook_event_name":"PreToolUse"}',encoding:'utf8',timeout:12000});
9
+ assert.equal(result.status,0);return JSON.parse(result.stdout).hookSpecificOutput;
10
+ } finally {fs.rmSync(dir,{recursive:true,force:true});}
11
+ }
12
+ test('supervisor forwards a valid guard permit and denial',()=>{
13
+ assert.deepEqual(run(`printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse"}}'`),{hookEventName:'PreToolUse'});
14
+ assert.equal(run(`printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"blocked"}}'`).permissionDecision,'deny');
15
+ });
16
+ test('supervisor denies on crash, malformed output and unexpected permission decision',()=>{
17
+ for(const script of ['exit 1',`printf '%s' '{bad'`,`printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'`]) assert.equal(run(script).permissionDecision,'deny');
18
+ });
19
+ test('supervisor denies when guard cannot start',()=>{
20
+ const result=spawnSync(process.execPath,[supervisor,'/does-not-exist/slaunt-guard'],{input:'{}',encoding:'utf8',timeout:2000});
21
+ assert.equal(result.status,0);assert.equal(JSON.parse(result.stdout).hookSpecificOutput.permissionDecision,'deny');
22
+ });
23
+ test('supervisor denies when guard exceeds its own deadline',{timeout:13000},()=>{
24
+ assert.equal(run('exec sleep 30').permissionDecision,'deny');
25
+ });