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 paneCmd = execFileSync(this.config.tmuxPath, ['display-message', '-t', `=${tmuxSession}:`, '-p', '#{pane_current_command}'], { encoding: 'utf-8', timeout: 5000 }).trim();
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, Claude may have exited — session is dead
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}`);
@@ -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: ctx.tunnel?.getExternalUrl(`/view/${v.id}`) ?? null,
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: ctx.tunnel?.getExternalUrl(`/view/${updated.id}`) ?? null,
983
+ tunnelUrl: viewTunnelUrl(updated.id),
973
984
  updatedAt: updated.updatedAt,
974
985
  });
975
986
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Persistent autonomy infrastructure for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",