instar 0.6.4 → 0.6.6
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.
|
@@ -150,13 +150,22 @@ export class SessionManager extends EventEmitter {
|
|
|
150
150
|
return false;
|
|
151
151
|
// Verify Claude process is running inside the tmux session
|
|
152
152
|
try {
|
|
153
|
-
const
|
|
153
|
+
const paneInfo = execFileSync(this.config.tmuxPath, ['display-message', '-t', `=${tmuxSession}:`, '-p', '#{pane_current_command}||#{pane_start_command}'], { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
154
|
+
const [paneCmd, startCmd] = paneInfo.split('||');
|
|
154
155
|
// Claude Code runs as 'claude' or 'node' process
|
|
155
156
|
if (paneCmd && (paneCmd.includes('claude') || paneCmd.includes('node'))) {
|
|
156
157
|
return true;
|
|
157
158
|
}
|
|
158
|
-
// If pane command is bash/zsh/sh,
|
|
159
|
+
// If pane command is bash/zsh/sh, check whether the session was launched
|
|
160
|
+
// with a direct command (e.g., a bash script as claudePath). In that case
|
|
161
|
+
// bash IS the expected running process — not a leftover shell after Claude exits.
|
|
162
|
+
// tmux kills sessions launched with direct commands when the command exits,
|
|
163
|
+
// so if has-session succeeds and start_command is non-empty, it's still running.
|
|
159
164
|
if (paneCmd === 'bash' || paneCmd === 'zsh' || paneCmd === 'sh') {
|
|
165
|
+
if (startCmd && startCmd !== paneCmd) {
|
|
166
|
+
// Session was launched with a specific command (not a bare shell) — still alive
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
160
169
|
return false;
|
|
161
170
|
}
|
|
162
171
|
// For any other command, assume alive (could be a Claude subprocess)
|
|
@@ -12,6 +12,8 @@ export interface PrivateView {
|
|
|
12
12
|
id: string;
|
|
13
13
|
title: string;
|
|
14
14
|
markdown: string;
|
|
15
|
+
/** SHA-256 hash of the PIN, if PIN-protected */
|
|
16
|
+
pinHash?: string;
|
|
15
17
|
createdAt: string;
|
|
16
18
|
updatedAt?: string;
|
|
17
19
|
}
|
|
@@ -25,9 +27,9 @@ export declare class PrivateViewer {
|
|
|
25
27
|
constructor(config: PrivateViewerConfig);
|
|
26
28
|
/**
|
|
27
29
|
* Store markdown content for private viewing.
|
|
28
|
-
*
|
|
30
|
+
* If a PIN is provided, the view requires PIN entry before content is shown.
|
|
29
31
|
*/
|
|
30
|
-
create(title: string, markdown: string): PrivateView;
|
|
32
|
+
create(title: string, markdown: string, pin?: string): PrivateView;
|
|
31
33
|
/**
|
|
32
34
|
* Update an existing view.
|
|
33
35
|
*/
|
|
@@ -44,6 +46,14 @@ export declare class PrivateViewer {
|
|
|
44
46
|
* Delete a view.
|
|
45
47
|
*/
|
|
46
48
|
delete(id: string): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Verify a PIN against a view's stored hash.
|
|
51
|
+
*/
|
|
52
|
+
verifyPin(id: string, pin: string): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Render a PIN entry page for a protected view.
|
|
55
|
+
*/
|
|
56
|
+
renderPinPage(view: PrivateView, error?: boolean): string;
|
|
47
57
|
/**
|
|
48
58
|
* Render a view as self-contained HTML.
|
|
49
59
|
*/
|
|
@@ -24,9 +24,9 @@ export class PrivateViewer {
|
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
26
|
* Store markdown content for private viewing.
|
|
27
|
-
*
|
|
27
|
+
* If a PIN is provided, the view requires PIN entry before content is shown.
|
|
28
28
|
*/
|
|
29
|
-
create(title, markdown) {
|
|
29
|
+
create(title, markdown, pin) {
|
|
30
30
|
const id = crypto.randomUUID();
|
|
31
31
|
// Ensure monotonically increasing timestamps even within same millisecond
|
|
32
32
|
let now = Date.now();
|
|
@@ -40,6 +40,9 @@ export class PrivateViewer {
|
|
|
40
40
|
markdown,
|
|
41
41
|
createdAt: new Date(now).toISOString(),
|
|
42
42
|
};
|
|
43
|
+
if (pin) {
|
|
44
|
+
view.pinHash = crypto.createHash('sha256').update(pin).digest('hex');
|
|
45
|
+
}
|
|
43
46
|
this.save(view);
|
|
44
47
|
return view;
|
|
45
48
|
}
|
|
@@ -105,6 +108,139 @@ export class PrivateViewer {
|
|
|
105
108
|
return false;
|
|
106
109
|
}
|
|
107
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Verify a PIN against a view's stored hash.
|
|
113
|
+
*/
|
|
114
|
+
verifyPin(id, pin) {
|
|
115
|
+
const view = this.get(id);
|
|
116
|
+
if (!view || !view.pinHash)
|
|
117
|
+
return false;
|
|
118
|
+
const hash = crypto.createHash('sha256').update(pin).digest('hex');
|
|
119
|
+
return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(view.pinHash, 'hex'));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Render a PIN entry page for a protected view.
|
|
123
|
+
*/
|
|
124
|
+
renderPinPage(view, error = false) {
|
|
125
|
+
return `<!DOCTYPE html>
|
|
126
|
+
<html lang="en">
|
|
127
|
+
<head>
|
|
128
|
+
<meta charset="UTF-8">
|
|
129
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
130
|
+
<title>${escapeHtml(view.title)}</title>
|
|
131
|
+
<style>
|
|
132
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
133
|
+
body {
|
|
134
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
135
|
+
background: #f8f9fa;
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
justify-content: center;
|
|
139
|
+
min-height: 100vh;
|
|
140
|
+
color: #1a1a2e;
|
|
141
|
+
}
|
|
142
|
+
.pin-box {
|
|
143
|
+
background: #fff;
|
|
144
|
+
border-radius: 12px;
|
|
145
|
+
padding: 2.5rem;
|
|
146
|
+
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
|
147
|
+
max-width: 380px;
|
|
148
|
+
width: 90%;
|
|
149
|
+
text-align: center;
|
|
150
|
+
}
|
|
151
|
+
.pin-box h1 {
|
|
152
|
+
font-size: 1.3rem;
|
|
153
|
+
margin-bottom: 0.5rem;
|
|
154
|
+
color: #16213e;
|
|
155
|
+
}
|
|
156
|
+
.pin-box p {
|
|
157
|
+
font-size: 0.9rem;
|
|
158
|
+
color: #666;
|
|
159
|
+
margin-bottom: 1.5rem;
|
|
160
|
+
}
|
|
161
|
+
.pin-input {
|
|
162
|
+
width: 100%;
|
|
163
|
+
padding: 0.75rem 1rem;
|
|
164
|
+
font-size: 1.5rem;
|
|
165
|
+
letter-spacing: 0.3em;
|
|
166
|
+
text-align: center;
|
|
167
|
+
border: 2px solid #e0e0e0;
|
|
168
|
+
border-radius: 8px;
|
|
169
|
+
outline: none;
|
|
170
|
+
transition: border-color 0.2s;
|
|
171
|
+
}
|
|
172
|
+
.pin-input:focus { border-color: #533483; }
|
|
173
|
+
.pin-input.error { border-color: #e74c3c; }
|
|
174
|
+
.error-msg {
|
|
175
|
+
color: #e74c3c;
|
|
176
|
+
font-size: 0.85rem;
|
|
177
|
+
margin-top: 0.5rem;
|
|
178
|
+
display: ${error ? 'block' : 'none'};
|
|
179
|
+
}
|
|
180
|
+
.submit-btn {
|
|
181
|
+
width: 100%;
|
|
182
|
+
padding: 0.75rem;
|
|
183
|
+
margin-top: 1.25rem;
|
|
184
|
+
background: #16213e;
|
|
185
|
+
color: #fff;
|
|
186
|
+
border: none;
|
|
187
|
+
border-radius: 8px;
|
|
188
|
+
font-size: 1rem;
|
|
189
|
+
cursor: pointer;
|
|
190
|
+
transition: background 0.2s;
|
|
191
|
+
}
|
|
192
|
+
.submit-btn:hover { background: #533483; }
|
|
193
|
+
.submit-btn:disabled { background: #aaa; cursor: not-allowed; }
|
|
194
|
+
.lock-icon { font-size: 2rem; margin-bottom: 0.75rem; }
|
|
195
|
+
</style>
|
|
196
|
+
</head>
|
|
197
|
+
<body>
|
|
198
|
+
<div class="pin-box">
|
|
199
|
+
<div class="lock-icon">🔒</div>
|
|
200
|
+
<h1>${escapeHtml(view.title)}</h1>
|
|
201
|
+
<p>This content is PIN-protected.</p>
|
|
202
|
+
<form id="pin-form">
|
|
203
|
+
<input type="password" class="pin-input${error ? ' error' : ''}" id="pin" name="pin"
|
|
204
|
+
placeholder="Enter PIN" autocomplete="off" inputmode="numeric" autofocus>
|
|
205
|
+
<div class="error-msg" id="error-msg">Incorrect PIN. Please try again.</div>
|
|
206
|
+
<button type="submit" class="submit-btn">Unlock</button>
|
|
207
|
+
</form>
|
|
208
|
+
</div>
|
|
209
|
+
<script>
|
|
210
|
+
document.getElementById('pin-form').addEventListener('submit', async (e) => {
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
const pin = document.getElementById('pin').value;
|
|
213
|
+
const btn = document.querySelector('.submit-btn');
|
|
214
|
+
btn.disabled = true;
|
|
215
|
+
btn.textContent = 'Verifying...';
|
|
216
|
+
try {
|
|
217
|
+
const res = await fetch(window.location.pathname + '/unlock', {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers: { 'Content-Type': 'application/json' },
|
|
220
|
+
body: JSON.stringify({ pin }),
|
|
221
|
+
});
|
|
222
|
+
if (res.ok) {
|
|
223
|
+
const html = await res.text();
|
|
224
|
+
document.open();
|
|
225
|
+
document.write(html);
|
|
226
|
+
document.close();
|
|
227
|
+
} else {
|
|
228
|
+
document.getElementById('error-msg').style.display = 'block';
|
|
229
|
+
document.getElementById('pin').classList.add('error');
|
|
230
|
+
document.getElementById('pin').value = '';
|
|
231
|
+
document.getElementById('pin').focus();
|
|
232
|
+
btn.disabled = false;
|
|
233
|
+
btn.textContent = 'Unlock';
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
btn.disabled = false;
|
|
237
|
+
btn.textContent = 'Unlock';
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
</script>
|
|
241
|
+
</body>
|
|
242
|
+
</html>`;
|
|
243
|
+
}
|
|
108
244
|
/**
|
|
109
245
|
* Render a view as self-contained HTML.
|
|
110
246
|
*/
|
|
@@ -18,5 +18,10 @@ export declare function rateLimiter(windowMs?: number, maxRequests?: number): (r
|
|
|
18
18
|
* Returns 408 if the request takes longer than the timeout.
|
|
19
19
|
*/
|
|
20
20
|
export declare function requestTimeout(timeoutMs?: number): (req: Request, res: Response, next: NextFunction) => void;
|
|
21
|
+
/**
|
|
22
|
+
* HMAC-sign a view path so the URL can be opened in a browser without exposing the auth token.
|
|
23
|
+
* The signature is path-specific — sharing a signed URL only grants access to that one view.
|
|
24
|
+
*/
|
|
25
|
+
export declare function signViewPath(viewPath: string, authToken: string): string;
|
|
21
26
|
export declare function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction): void;
|
|
22
27
|
//# sourceMappingURL=middleware.d.ts.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Express middleware — JSON parsing, CORS, auth, error handling.
|
|
3
3
|
*/
|
|
4
|
-
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
5
5
|
export function corsMiddleware(req, res, next) {
|
|
6
6
|
// Restrict CORS to localhost origins only — this is a local management API
|
|
7
7
|
const origin = req.headers.origin;
|
|
@@ -32,21 +32,20 @@ export function authMiddleware(authToken) {
|
|
|
32
32
|
next();
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
else if (queryToken) {
|
|
44
|
-
token = queryToken;
|
|
35
|
+
// View routes support signed URLs for browser access (see ?sig= below)
|
|
36
|
+
if (req.path.startsWith('/view/') && req.method === 'GET') {
|
|
37
|
+
const sig = typeof req.query.sig === 'string' ? req.query.sig : null;
|
|
38
|
+
if (sig && verifyViewSignature(req.path, sig, authToken)) {
|
|
39
|
+
next();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
45
42
|
}
|
|
46
|
-
|
|
43
|
+
const header = req.headers.authorization;
|
|
44
|
+
if (!header || !header.startsWith('Bearer ')) {
|
|
47
45
|
res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
|
48
46
|
return;
|
|
49
47
|
}
|
|
48
|
+
const token = header.slice(7);
|
|
50
49
|
// Hash both sides so lengths are always equal — prevents timing leak of token length
|
|
51
50
|
const ha = createHash('sha256').update(token).digest();
|
|
52
51
|
const hb = createHash('sha256').update(authToken).digest();
|
|
@@ -117,6 +116,23 @@ export function requestTimeout(timeoutMs = 30_000) {
|
|
|
117
116
|
next();
|
|
118
117
|
};
|
|
119
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* HMAC-sign a view path so the URL can be opened in a browser without exposing the auth token.
|
|
121
|
+
* The signature is path-specific — sharing a signed URL only grants access to that one view.
|
|
122
|
+
*/
|
|
123
|
+
export function signViewPath(viewPath, authToken) {
|
|
124
|
+
return createHmac('sha256', authToken).update(viewPath).digest('hex');
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Verify a signed view URL. Returns true if the sig matches the path.
|
|
128
|
+
*/
|
|
129
|
+
function verifyViewSignature(viewPath, sig, authToken) {
|
|
130
|
+
const expected = createHmac('sha256', authToken).update(viewPath).digest();
|
|
131
|
+
const provided = Buffer.from(sig, 'hex');
|
|
132
|
+
if (expected.length !== provided.length)
|
|
133
|
+
return false;
|
|
134
|
+
return timingSafeEqual(expected, provided);
|
|
135
|
+
}
|
|
120
136
|
export function errorHandler(err, _req, res, _next) {
|
|
121
137
|
const message = err instanceof Error ? err.message : String(err);
|
|
122
138
|
console.error(`[server] Error: ${message}`);
|
package/dist/server/routes.js
CHANGED
|
@@ -9,7 +9,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
9
9
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
|
-
import { rateLimiter } from './middleware.js';
|
|
12
|
+
import { rateLimiter, signViewPath } from './middleware.js';
|
|
13
13
|
// Validation patterns for route parameters
|
|
14
14
|
const SESSION_NAME_RE = /^[a-zA-Z0-9_-]{1,200}$/;
|
|
15
15
|
const JOB_SLUG_RE = /^[a-zA-Z0-9_-]{1,100}$/;
|
|
@@ -880,13 +880,15 @@ export function createRoutes(ctx) {
|
|
|
880
880
|
});
|
|
881
881
|
// ── Private Views (auth-gated rendered markdown) ────────────────
|
|
882
882
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
883
|
-
/** Build a browser-clickable tunnel URL with
|
|
883
|
+
/** Build a browser-clickable tunnel URL with HMAC signature for auth */
|
|
884
884
|
function viewTunnelUrl(viewId) {
|
|
885
885
|
const base = ctx.tunnel?.getExternalUrl(`/view/${viewId}`);
|
|
886
886
|
if (!base)
|
|
887
887
|
return null;
|
|
888
888
|
if (ctx.config.authToken) {
|
|
889
|
-
|
|
889
|
+
const viewPath = `/view/${viewId}`;
|
|
890
|
+
const sig = signViewPath(viewPath, ctx.config.authToken);
|
|
891
|
+
return `${base}?sig=${sig}`;
|
|
890
892
|
}
|
|
891
893
|
return base;
|
|
892
894
|
}
|
|
@@ -895,7 +897,7 @@ export function createRoutes(ctx) {
|
|
|
895
897
|
res.status(503).json({ error: 'Private viewer not configured' });
|
|
896
898
|
return;
|
|
897
899
|
}
|
|
898
|
-
const { title, markdown } = req.body;
|
|
900
|
+
const { title, markdown, pin } = req.body;
|
|
899
901
|
if (!title || typeof title !== 'string' || title.length > 256) {
|
|
900
902
|
res.status(400).json({ error: '"title" must be a string under 256 characters' });
|
|
901
903
|
return;
|
|
@@ -908,10 +910,15 @@ export function createRoutes(ctx) {
|
|
|
908
910
|
res.status(400).json({ error: '"markdown" must be under 500KB' });
|
|
909
911
|
return;
|
|
910
912
|
}
|
|
911
|
-
|
|
913
|
+
if (pin !== undefined && (typeof pin !== 'string' || pin.length < 4 || pin.length > 32)) {
|
|
914
|
+
res.status(400).json({ error: '"pin" must be a string between 4 and 32 characters' });
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const view = ctx.viewer.create(title, markdown, pin);
|
|
912
918
|
res.status(201).json({
|
|
913
919
|
id: view.id,
|
|
914
920
|
title: view.title,
|
|
921
|
+
pinProtected: !!view.pinHash,
|
|
915
922
|
localUrl: `/view/${view.id}`,
|
|
916
923
|
tunnelUrl: viewTunnelUrl(view.id),
|
|
917
924
|
createdAt: view.createdAt,
|
|
@@ -931,11 +938,52 @@ export function createRoutes(ctx) {
|
|
|
931
938
|
res.status(404).json({ error: 'View not found' });
|
|
932
939
|
return;
|
|
933
940
|
}
|
|
941
|
+
// PIN-protected views show PIN entry page
|
|
942
|
+
if (view.pinHash) {
|
|
943
|
+
const html = ctx.viewer.renderPinPage(view);
|
|
944
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
945
|
+
res.send(html);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
934
948
|
// Serve rendered HTML
|
|
935
949
|
const html = ctx.viewer.renderHtml(view);
|
|
936
950
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
937
951
|
res.send(html);
|
|
938
952
|
});
|
|
953
|
+
router.post('/view/:id/unlock', (req, res) => {
|
|
954
|
+
if (!ctx.viewer) {
|
|
955
|
+
res.status(503).json({ error: 'Private viewer not configured' });
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (!UUID_RE.test(req.params.id)) {
|
|
959
|
+
res.status(400).json({ error: 'Invalid view ID' });
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
const view = ctx.viewer.get(req.params.id);
|
|
963
|
+
if (!view) {
|
|
964
|
+
res.status(404).json({ error: 'View not found' });
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (!view.pinHash) {
|
|
968
|
+
// No PIN needed — return content directly
|
|
969
|
+
const html = ctx.viewer.renderHtml(view);
|
|
970
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
971
|
+
res.send(html);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const { pin } = req.body;
|
|
975
|
+
if (!pin || typeof pin !== 'string') {
|
|
976
|
+
res.status(400).json({ error: '"pin" is required' });
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
if (!ctx.viewer.verifyPin(req.params.id, pin)) {
|
|
980
|
+
res.status(403).json({ error: 'Incorrect PIN' });
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const html = ctx.viewer.renderHtml(view);
|
|
984
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
985
|
+
res.send(html);
|
|
986
|
+
});
|
|
939
987
|
router.get('/views', (_req, res) => {
|
|
940
988
|
if (!ctx.viewer) {
|
|
941
989
|
res.json({ views: [] });
|