instar 0.6.3 → 0.6.5
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.
|
@@ -163,6 +163,39 @@ Strip the \`[telegram:N]\` prefix before interpreting the message. Respond natur
|
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
|
+
// Private Viewer + Tunnel section
|
|
167
|
+
if (!content.includes('Private Viewing') && !content.includes('POST /view')) {
|
|
168
|
+
const section = `
|
|
169
|
+
**Private Viewing** — Render markdown as auth-gated HTML pages, accessible only through the agent's server (local or via tunnel).
|
|
170
|
+
- Create: \`curl -X POST http://localhost:${port}/view -H 'Content-Type: application/json' -d '{"title":"Report","markdown":"# Private content"}'\`
|
|
171
|
+
- View (HTML): Open \`http://localhost:${port}/view/VIEW_ID\` in a browser
|
|
172
|
+
- List: \`curl http://localhost:${port}/views\`
|
|
173
|
+
- Update: \`curl -X PUT http://localhost:${port}/view/VIEW_ID -H 'Content-Type: application/json' -d '{"title":"Updated","markdown":"# New content"}'\`
|
|
174
|
+
- Delete: \`curl -X DELETE http://localhost:${port}/view/VIEW_ID\`
|
|
175
|
+
|
|
176
|
+
**Use private views for sensitive content. Use Telegraph for public content.**
|
|
177
|
+
|
|
178
|
+
**Cloudflare Tunnel** — Expose the local server to the internet via Cloudflare. Enables remote access to private views, the API, and file serving.
|
|
179
|
+
- Status: \`curl http://localhost:${port}/tunnel\`
|
|
180
|
+
- Configure in \`.instar/config.json\`: \`{"tunnel": {"enabled": true, "type": "quick"}}\`
|
|
181
|
+
- Quick tunnels (default): Zero-config, ephemeral URL (*.trycloudflare.com), no account needed
|
|
182
|
+
- Named tunnels: Persistent custom domain, requires token from Cloudflare dashboard
|
|
183
|
+
- When a tunnel is running, private view responses include a \`tunnelUrl\` with auth token for browser-clickable access
|
|
184
|
+
`;
|
|
185
|
+
// Insert after Publishing section or before Scripts section
|
|
186
|
+
const publishIdx = content.indexOf('**Scripts**');
|
|
187
|
+
if (publishIdx >= 0) {
|
|
188
|
+
content = content.slice(0, publishIdx) + section + '\n' + content.slice(publishIdx);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
content += '\n' + section;
|
|
192
|
+
}
|
|
193
|
+
patched = true;
|
|
194
|
+
result.upgraded.push('CLAUDE.md: added Private Viewer + Cloudflare Tunnel section');
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
result.skipped.push('CLAUDE.md: Private Viewer section already present');
|
|
198
|
+
}
|
|
166
199
|
if (patched) {
|
|
167
200
|
try {
|
|
168
201
|
fs.writeFileSync(claudeMdPath, content);
|
|
@@ -283,6 +316,12 @@ if [ -f "$INSTAR_DIR/config.json" ]; then
|
|
|
283
316
|
if [ "$HEALTH" = "200" ]; then
|
|
284
317
|
CONTEXT="\${CONTEXT}Instar server is running on port \${PORT}. Query your capabilities: curl http://localhost:\${PORT}/capabilities\\n"
|
|
285
318
|
CONTEXT="\${CONTEXT}IMPORTANT: Before claiming you lack a capability, check /capabilities first.\\n"
|
|
319
|
+
# Check for new features
|
|
320
|
+
CAPS=$(curl -s "http://localhost:\${PORT}/capabilities" 2>/dev/null)
|
|
321
|
+
if echo "$CAPS" | python3 -c "import sys,json; d=json.load(sys.stdin); print('tunnel' if d.get('tunnel',{}).get('enabled') else '')" 2>/dev/null | grep -q tunnel; then
|
|
322
|
+
TUNNEL_URL=$(echo "$CAPS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tunnel',{}).get('url',''))" 2>/dev/null)
|
|
323
|
+
[ -n "$TUNNEL_URL" ] && CONTEXT="\${CONTEXT}Cloudflare Tunnel active: $TUNNEL_URL — your server is accessible remotely.\\n"
|
|
324
|
+
fi
|
|
286
325
|
fi
|
|
287
326
|
fi
|
|
288
327
|
|
|
@@ -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)
|
|
@@ -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,6 +32,14 @@ export function authMiddleware(authToken) {
|
|
|
32
32
|
next();
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
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
|
+
}
|
|
42
|
+
}
|
|
35
43
|
const header = req.headers.authorization;
|
|
36
44
|
if (!header || !header.startsWith('Bearer ')) {
|
|
37
45
|
res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
|
@@ -108,6 +116,23 @@ export function requestTimeout(timeoutMs = 30_000) {
|
|
|
108
116
|
next();
|
|
109
117
|
};
|
|
110
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
|
+
}
|
|
111
136
|
export function errorHandler(err, _req, res, _next) {
|
|
112
137
|
const message = err instanceof Error ? err.message : String(err);
|
|
113
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,6 +880,18 @@ 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 HMAC signature for auth */
|
|
884
|
+
function viewTunnelUrl(viewId) {
|
|
885
|
+
const base = ctx.tunnel?.getExternalUrl(`/view/${viewId}`);
|
|
886
|
+
if (!base)
|
|
887
|
+
return null;
|
|
888
|
+
if (ctx.config.authToken) {
|
|
889
|
+
const viewPath = `/view/${viewId}`;
|
|
890
|
+
const sig = signViewPath(viewPath, ctx.config.authToken);
|
|
891
|
+
return `${base}?sig=${sig}`;
|
|
892
|
+
}
|
|
893
|
+
return base;
|
|
894
|
+
}
|
|
883
895
|
router.post('/view', (req, res) => {
|
|
884
896
|
if (!ctx.viewer) {
|
|
885
897
|
res.status(503).json({ error: 'Private viewer not configured' });
|
|
@@ -899,12 +911,11 @@ export function createRoutes(ctx) {
|
|
|
899
911
|
return;
|
|
900
912
|
}
|
|
901
913
|
const view = ctx.viewer.create(title, markdown);
|
|
902
|
-
const tunnelUrl = ctx.tunnel?.getExternalUrl(`/view/${view.id}`) ?? null;
|
|
903
914
|
res.status(201).json({
|
|
904
915
|
id: view.id,
|
|
905
916
|
title: view.title,
|
|
906
917
|
localUrl: `/view/${view.id}`,
|
|
907
|
-
tunnelUrl,
|
|
918
|
+
tunnelUrl: viewTunnelUrl(view.id),
|
|
908
919
|
createdAt: view.createdAt,
|
|
909
920
|
});
|
|
910
921
|
});
|
|
@@ -936,7 +947,7 @@ export function createRoutes(ctx) {
|
|
|
936
947
|
id: v.id,
|
|
937
948
|
title: v.title,
|
|
938
949
|
localUrl: `/view/${v.id}`,
|
|
939
|
-
tunnelUrl:
|
|
950
|
+
tunnelUrl: viewTunnelUrl(v.id),
|
|
940
951
|
createdAt: v.createdAt,
|
|
941
952
|
updatedAt: v.updatedAt,
|
|
942
953
|
}));
|
|
@@ -969,7 +980,7 @@ export function createRoutes(ctx) {
|
|
|
969
980
|
id: updated.id,
|
|
970
981
|
title: updated.title,
|
|
971
982
|
localUrl: `/view/${updated.id}`,
|
|
972
|
-
tunnelUrl:
|
|
983
|
+
tunnelUrl: viewTunnelUrl(updated.id),
|
|
973
984
|
updatedAt: updated.updatedAt,
|
|
974
985
|
});
|
|
975
986
|
});
|