tmux-ide 1.2.1 → 2.0.0

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 (205) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +33 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/{attach.js → attach.ts} +7 -4
  6. package/src/cli.test.ts +314 -0
  7. package/src/command-center/discovery.test.ts +265 -0
  8. package/src/command-center/discovery.ts +296 -0
  9. package/src/command-center/index.ts +31 -0
  10. package/src/command-center/schemas.ts +57 -0
  11. package/src/command-center/server.test.ts +818 -0
  12. package/src/command-center/server.ts +1426 -0
  13. package/src/config-cli.test.ts +86 -0
  14. package/src/config.test.ts +205 -0
  15. package/src/config.ts +313 -0
  16. package/src/detect.test.ts +142 -0
  17. package/src/{detect.js → detect.ts} +31 -15
  18. package/src/dispatch.ts +102 -0
  19. package/src/doctor.test.ts +119 -0
  20. package/src/doctor.ts +167 -0
  21. package/src/init.test.ts +101 -0
  22. package/src/init.ts +144 -0
  23. package/src/inspect.test.ts +67 -0
  24. package/src/{inspect.js → inspect.ts} +95 -10
  25. package/src/integration.test.ts.skip +276 -0
  26. package/src/js-yaml.d.ts +10 -0
  27. package/src/launch.test.ts +222 -0
  28. package/src/launch.ts +494 -0
  29. package/src/lib/auth/auth-service.test.ts +178 -0
  30. package/src/lib/auth/auth-service.ts +227 -0
  31. package/src/lib/auth/middleware.test.ts +119 -0
  32. package/src/lib/auth/middleware.ts +56 -0
  33. package/src/lib/auth/types.ts +13 -0
  34. package/src/lib/authorship.test.ts +242 -0
  35. package/src/lib/authorship.ts +280 -0
  36. package/src/lib/cast/recorder.test.ts +127 -0
  37. package/src/lib/cast/recorder.ts +148 -0
  38. package/src/lib/daemon-watchdog.ts +102 -0
  39. package/src/lib/daemon.ts +417 -0
  40. package/src/lib/dot-path.test.ts +66 -0
  41. package/src/lib/dot-path.ts +17 -0
  42. package/src/lib/errors.ts +44 -0
  43. package/src/lib/event-log.test.ts +189 -0
  44. package/src/lib/event-log.ts +237 -0
  45. package/src/lib/github-pr.ts +182 -0
  46. package/src/lib/hq/client.test.ts +154 -0
  47. package/src/lib/hq/client.ts +142 -0
  48. package/src/lib/hq/mdns.test.ts +54 -0
  49. package/src/lib/hq/mdns.ts +88 -0
  50. package/src/lib/hq/registry.test.ts +237 -0
  51. package/src/lib/hq/registry.ts +145 -0
  52. package/src/lib/hq/types.ts +44 -0
  53. package/src/lib/ipc/socket-protocol.test.ts +126 -0
  54. package/src/lib/ipc/socket-protocol.ts +324 -0
  55. package/src/lib/launch-plan.test.ts +119 -0
  56. package/src/lib/launch-plan.ts +90 -0
  57. package/src/lib/log.test.ts +39 -0
  58. package/src/lib/log.ts +71 -0
  59. package/src/lib/metrics.test.ts +213 -0
  60. package/src/lib/metrics.ts +345 -0
  61. package/src/lib/orchestrator.test.ts +2358 -0
  62. package/src/lib/orchestrator.ts +1792 -0
  63. package/src/lib/{output.js → output.ts} +18 -10
  64. package/src/lib/plan-store.test.ts +148 -0
  65. package/src/lib/plan-store.ts +131 -0
  66. package/src/lib/research.test.ts +208 -0
  67. package/src/lib/research.ts +475 -0
  68. package/src/lib/round-trip.test.ts +341 -0
  69. package/src/lib/session-monitor.test.ts +152 -0
  70. package/src/lib/session-monitor.ts +282 -0
  71. package/src/lib/session-options.test.ts +146 -0
  72. package/src/lib/{session-options.js → session-options.ts} +11 -6
  73. package/src/lib/shell.test.ts +20 -0
  74. package/src/lib/shell.ts +8 -0
  75. package/src/lib/sizes.test.ts +73 -0
  76. package/src/lib/{sizes.js → sizes.ts} +4 -4
  77. package/src/lib/skill-registry.test.ts +134 -0
  78. package/src/lib/skill-registry.ts +86 -0
  79. package/src/lib/slugify.test.ts +35 -0
  80. package/src/lib/slugify.ts +10 -0
  81. package/src/lib/task-store.ts +352 -0
  82. package/src/lib/tmux.test.ts +500 -0
  83. package/src/lib/tmux.ts +372 -0
  84. package/src/lib/token-tracker.test.ts +102 -0
  85. package/src/lib/token-tracker.ts +70 -0
  86. package/src/lib/tunnels/cloudflare.test.ts +73 -0
  87. package/src/lib/tunnels/cloudflare.ts +254 -0
  88. package/src/lib/tunnels/manager.test.ts +63 -0
  89. package/src/lib/tunnels/manager.ts +132 -0
  90. package/src/lib/tunnels/ngrok.test.ts +94 -0
  91. package/src/lib/tunnels/ngrok.ts +261 -0
  92. package/src/lib/tunnels/tailscale.test.ts +103 -0
  93. package/src/lib/tunnels/tailscale.ts +1048 -0
  94. package/src/lib/tunnels/types.ts +44 -0
  95. package/src/lib/validation.test.ts +289 -0
  96. package/src/lib/validation.ts +123 -0
  97. package/src/lib/webhook.ts +51 -0
  98. package/src/lib/workflow-store.ts +209 -0
  99. package/src/lib/ws-v3/protocol.test.ts +127 -0
  100. package/src/lib/ws-v3/protocol.ts +160 -0
  101. package/src/lib/yaml-io.test.ts +125 -0
  102. package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
  103. package/src/ls.test.ts +76 -0
  104. package/src/{ls.js → ls.ts} +4 -4
  105. package/src/metrics-cli.ts +177 -0
  106. package/src/notify.ts +85 -0
  107. package/src/orchestrator-status.ts +228 -0
  108. package/src/plan.ts +117 -0
  109. package/src/postinstall.test.ts +79 -0
  110. package/src/remote.test.ts +115 -0
  111. package/src/remote.ts +148 -0
  112. package/src/restart.ts +24 -0
  113. package/src/schemas/domain.ts +422 -0
  114. package/src/schemas/ide-config.ts +148 -0
  115. package/src/schemas/index.ts +53 -0
  116. package/src/send.test.ts +70 -0
  117. package/src/send.ts +181 -0
  118. package/src/skill.test.ts +114 -0
  119. package/src/skill.ts +138 -0
  120. package/src/status.ts +66 -0
  121. package/src/stop.test.ts +176 -0
  122. package/src/stop.ts +47 -0
  123. package/src/task.test.ts +1466 -0
  124. package/src/task.ts +1143 -0
  125. package/src/tunnel.test.ts +80 -0
  126. package/src/tunnel.ts +133 -0
  127. package/src/types.ts +15 -0
  128. package/src/ui/index.ts +32 -0
  129. package/src/ui/terminal/index.ts +9 -0
  130. package/src/ui/types.ts +91 -0
  131. package/src/ui/web/base.css +80 -0
  132. package/src/ui/web/components/Box.tsx +59 -0
  133. package/src/ui/web/components/Input.tsx +32 -0
  134. package/src/ui/web/components/ScrollBox.tsx +60 -0
  135. package/src/ui/web/components/Text.tsx +28 -0
  136. package/src/ui/web/hooks.ts +106 -0
  137. package/src/ui/web/index.ts +27 -0
  138. package/src/ui/web/render.ts +77 -0
  139. package/src/ui/web/utils/color.test.ts +93 -0
  140. package/src/ui/web/utils/color.ts +27 -0
  141. package/src/validate.test.ts +385 -0
  142. package/src/validate.ts +217 -0
  143. package/src/widgets/changes/index.tsx +576 -0
  144. package/src/widgets/config/index.tsx +440 -0
  145. package/src/widgets/costs/index.tsx +216 -0
  146. package/src/widgets/explorer/breadcrumbs.tsx +77 -0
  147. package/src/widgets/explorer/footer.tsx +20 -0
  148. package/src/widgets/explorer/header.tsx +23 -0
  149. package/src/widgets/explorer/index.tsx +408 -0
  150. package/src/widgets/explorer/tree-model.test.ts +214 -0
  151. package/src/widgets/explorer/tree-model.ts +103 -0
  152. package/src/widgets/explorer/tree.tsx +156 -0
  153. package/src/widgets/lib/config-model.ts +116 -0
  154. package/src/widgets/lib/files.test.ts +103 -0
  155. package/src/widgets/lib/files.ts +88 -0
  156. package/src/widgets/lib/git.test.ts +151 -0
  157. package/src/widgets/lib/git.ts +88 -0
  158. package/src/widgets/lib/pane-comms.test.ts +178 -0
  159. package/src/widgets/lib/pane-comms.ts +192 -0
  160. package/src/widgets/lib/theme.ts +151 -0
  161. package/src/widgets/lib/watcher.ts +50 -0
  162. package/src/widgets/mission-control/activity-feed.tsx +59 -0
  163. package/src/widgets/mission-control/agent-panel.tsx +132 -0
  164. package/src/widgets/mission-control/command-bar.tsx +63 -0
  165. package/src/widgets/mission-control/index.tsx +751 -0
  166. package/src/widgets/mission-control/task-panel.tsx +238 -0
  167. package/src/widgets/preview/index.tsx +415 -0
  168. package/src/widgets/resolve.ts +40 -0
  169. package/src/widgets/setup/agent-naming.tsx +112 -0
  170. package/src/widgets/setup/config-tree.tsx +238 -0
  171. package/src/widgets/setup/detect-panel.tsx +72 -0
  172. package/src/widgets/setup/field-editor.tsx +265 -0
  173. package/src/widgets/setup/footer.tsx +107 -0
  174. package/src/widgets/setup/index.tsx +340 -0
  175. package/src/widgets/setup/layout-picker.tsx +96 -0
  176. package/src/widgets/setup/orchestrator-panel.tsx +200 -0
  177. package/src/widgets/setup/review-panel.tsx +140 -0
  178. package/src/widgets/setup/setup-model.test.ts +303 -0
  179. package/src/widgets/setup/setup-model.ts +188 -0
  180. package/src/widgets/tasks/index.tsx +129 -0
  181. package/src/widgets/tasks/task-detail.tsx +309 -0
  182. package/src/widgets/tasks/task-form.tsx +193 -0
  183. package/src/widgets/tasks/task-list.tsx +205 -0
  184. package/src/widgets/tasks/task-model.test.ts +473 -0
  185. package/src/widgets/tasks/task-model.ts +157 -0
  186. package/templates/AGENTS.md +20 -0
  187. package/templates/missions.yml +41 -0
  188. package/templates/skills/backend.md +30 -0
  189. package/templates/skills/frontend.md +30 -0
  190. package/templates/skills/general-worker.md +30 -0
  191. package/templates/skills/researcher.md +69 -0
  192. package/templates/skills/reviewer.md +30 -0
  193. package/src/config.js +0 -341
  194. package/src/doctor.js +0 -91
  195. package/src/init.js +0 -73
  196. package/src/launch.js +0 -245
  197. package/src/lib/dot-path.js +0 -16
  198. package/src/lib/errors.js +0 -35
  199. package/src/lib/launch-plan.js +0 -49
  200. package/src/lib/session-monitor.js +0 -179
  201. package/src/lib/tmux.js +0 -237
  202. package/src/restart.js +0 -16
  203. package/src/status.js +0 -35
  204. package/src/stop.js +0 -25
  205. package/src/validate.js +0 -154
@@ -0,0 +1,227 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+ import * as crypto from "node:crypto";
3
+ import { readFileSync, existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { logger } from "../log.ts";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Types
10
+ // ---------------------------------------------------------------------------
11
+
12
+ interface AuthChallenge {
13
+ challengeId: string;
14
+ challenge: Buffer;
15
+ timestamp: number;
16
+ userId: string;
17
+ }
18
+
19
+ export interface AuthResult {
20
+ success: boolean;
21
+ userId?: string;
22
+ token?: string;
23
+ error?: string;
24
+ }
25
+
26
+ interface SSHKeyAuth {
27
+ publicKey: string;
28
+ signature: string;
29
+ challengeId: string;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // JWT helpers (HMAC-SHA256, no external dependency)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ function base64url(buf: Buffer | string): string {
37
+ const b = typeof buf === "string" ? Buffer.from(buf) : buf;
38
+ return b.toString("base64url");
39
+ }
40
+
41
+ function decodeBase64url(str: string): Buffer {
42
+ return Buffer.from(str, "base64url");
43
+ }
44
+
45
+ function signJwt(payload: Record<string, unknown>, secret: string, expiresInSec: number): string {
46
+ const header = { alg: "HS256", typ: "JWT" };
47
+ const now = Math.floor(Date.now() / 1000);
48
+ const body = { ...payload, iat: now, exp: now + expiresInSec };
49
+
50
+ const segments = [base64url(JSON.stringify(header)), base64url(JSON.stringify(body))];
51
+ const sigInput = segments.join(".");
52
+ const sig = crypto.createHmac("sha256", secret).update(sigInput).digest();
53
+ segments.push(base64url(sig));
54
+ return segments.join(".");
55
+ }
56
+
57
+ function verifyJwt(
58
+ token: string,
59
+ secret: string,
60
+ ): { valid: true; payload: Record<string, unknown> } | { valid: false } {
61
+ const parts = token.split(".");
62
+ if (parts.length !== 3) return { valid: false };
63
+
64
+ const sigInput = parts[0] + "." + parts[1];
65
+ const expected = crypto.createHmac("sha256", secret).update(sigInput).digest();
66
+ const actual = decodeBase64url(parts[2]!);
67
+
68
+ if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
69
+ return { valid: false };
70
+ }
71
+
72
+ try {
73
+ const payload = JSON.parse(decodeBase64url(parts[1]!).toString()) as Record<string, unknown>;
74
+ if (typeof payload.exp === "number" && payload.exp < Math.floor(Date.now() / 1000)) {
75
+ return { valid: false };
76
+ }
77
+ return { valid: true, payload };
78
+ } catch {
79
+ return { valid: false };
80
+ }
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // AuthService
85
+ // ---------------------------------------------------------------------------
86
+
87
+ const TOKEN_EXPIRY_SEC = 24 * 60 * 60; // 24 hours
88
+ const CHALLENGE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
89
+
90
+ export class AuthService {
91
+ private challenges = new Map<string, AuthChallenge>();
92
+ private jwtSecret: string;
93
+ private cleanupTimer: ReturnType<typeof setInterval>;
94
+
95
+ constructor(secret?: string) {
96
+ this.jwtSecret = secret ?? process.env.JWT_SECRET ?? crypto.randomBytes(64).toString("hex");
97
+ this.cleanupTimer = setInterval(() => this.cleanupExpiredChallenges(), 60_000);
98
+ }
99
+
100
+ dispose(): void {
101
+ clearInterval(this.cleanupTimer);
102
+ }
103
+
104
+ private cleanupExpiredChallenges(): void {
105
+ const now = Date.now();
106
+ for (const [id, ch] of this.challenges) {
107
+ if (now - ch.timestamp > CHALLENGE_TIMEOUT_MS) {
108
+ this.challenges.delete(id);
109
+ }
110
+ }
111
+ }
112
+
113
+ // ---- JWT ----------------------------------------------------------------
114
+
115
+ generateToken(userId: string): string {
116
+ return signJwt({ userId }, this.jwtSecret, TOKEN_EXPIRY_SEC);
117
+ }
118
+
119
+ verifyToken(token: string): { valid: boolean; userId?: string } {
120
+ const result = verifyJwt(token, this.jwtSecret);
121
+ if (!result.valid) return { valid: false };
122
+ return { valid: true, userId: result.payload.userId as string };
123
+ }
124
+
125
+ // ---- SSH challenge-response --------------------------------------------
126
+
127
+ createChallenge(userId: string): { challengeId: string; challenge: string } {
128
+ const challengeId = crypto.randomUUID();
129
+ const challenge = crypto.randomBytes(32);
130
+
131
+ this.challenges.set(challengeId, {
132
+ challengeId,
133
+ challenge,
134
+ timestamp: Date.now(),
135
+ userId,
136
+ });
137
+
138
+ return { challengeId, challenge: challenge.toString("base64") };
139
+ }
140
+
141
+ async authenticateWithSSHKey(auth: SSHKeyAuth): Promise<AuthResult> {
142
+ const challenge = this.challenges.get(auth.challengeId);
143
+ if (!challenge) {
144
+ return { success: false, error: "Invalid or expired challenge" };
145
+ }
146
+
147
+ const sigBuf = Buffer.from(auth.signature, "base64");
148
+ if (!this.verifySSHSignature(challenge.challenge, sigBuf, auth.publicKey)) {
149
+ return { success: false, error: "Invalid SSH key signature" };
150
+ }
151
+
152
+ const authorized = this.checkSSHKeyAuthorization(challenge.userId, auth.publicKey);
153
+ if (!authorized) {
154
+ return { success: false, error: "SSH key not authorized for this user" };
155
+ }
156
+
157
+ this.challenges.delete(auth.challengeId);
158
+ const token = this.generateToken(challenge.userId);
159
+ return { success: true, userId: challenge.userId, token };
160
+ }
161
+
162
+ // ---- SSH helpers -------------------------------------------------------
163
+
164
+ private verifySSHSignature(challenge: Buffer, signature: Buffer, publicKeyStr: string): boolean {
165
+ try {
166
+ const parts = publicKeyStr.trim().split(" ");
167
+ if (parts.length < 2) return false;
168
+
169
+ const keyType = parts[0];
170
+ const keyData = parts[1]!;
171
+
172
+ if (keyType !== "ssh-ed25519") {
173
+ logger.warn("auth", `Unsupported key type: ${keyType}`);
174
+ return false;
175
+ }
176
+
177
+ if (signature.length !== 64) return false;
178
+
179
+ const sshBuf = Buffer.from(keyData, "base64");
180
+ let offset = 0;
181
+ const algLen = sshBuf.readUInt32BE(offset);
182
+ offset += 4 + algLen;
183
+ const keyLen = sshBuf.readUInt32BE(offset);
184
+ offset += 4;
185
+ if (keyLen !== 32) return false;
186
+
187
+ const rawPub = sshBuf.subarray(offset, offset + 32);
188
+ const pubKey = crypto.createPublicKey({
189
+ key: Buffer.concat([
190
+ Buffer.from([0x30, 0x2a]),
191
+ Buffer.from([0x30, 0x05]),
192
+ Buffer.from([0x06, 0x03, 0x2b, 0x65, 0x70]),
193
+ Buffer.from([0x03, 0x21, 0x00]),
194
+ rawPub,
195
+ ]),
196
+ format: "der",
197
+ type: "spki",
198
+ });
199
+
200
+ return crypto.verify(null, challenge, pubKey, signature);
201
+ } catch (err) {
202
+ logger.error("auth", "SSH signature verification failed", {
203
+ error: String(err),
204
+ });
205
+ return false;
206
+ }
207
+ }
208
+
209
+ private checkSSHKeyAuthorization(userId: string, publicKey: string): boolean {
210
+ try {
211
+ const home = userId === process.env.USER ? homedir() : `/home/${userId}`;
212
+ const authKeysPath = join(home, ".ssh", "authorized_keys");
213
+ if (!existsSync(authKeysPath)) return false;
214
+
215
+ const authorizedKeys = readFileSync(authKeysPath, "utf-8");
216
+ const parts = publicKey.trim().split(" ");
217
+ const keyData = parts.length > 1 ? parts[1]! : parts[0]!;
218
+ return authorizedKeys.includes(keyData);
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ getCurrentUser(): string {
225
+ return process.env.USER ?? process.env.USERNAME ?? "unknown";
226
+ }
227
+ }
@@ -0,0 +1,119 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
3
+ import { Hono } from "hono";
4
+ import { AuthService } from "./auth-service.ts";
5
+ import { authMiddleware, validateWsToken } from "./middleware.ts";
6
+ import type { AuthConfig } from "./types.ts";
7
+
8
+ describe("authMiddleware", () => {
9
+ let auth: AuthService;
10
+ const SECRET = "test-middleware-secret";
11
+
12
+ beforeEach(() => {
13
+ auth = new AuthService(SECRET);
14
+ });
15
+
16
+ afterEach(() => {
17
+ auth.dispose();
18
+ });
19
+
20
+ function buildApp(config: AuthConfig): Hono {
21
+ const app = new Hono();
22
+ app.use("/*", authMiddleware(auth, config));
23
+ app.get("/health", (c) => c.json({ ok: true }));
24
+ app.get("/api/auth/challenge", (c) => c.json({ bypassed: true }));
25
+ app.get("/api/sessions", (c) => c.json({ sessions: [] }));
26
+ return app;
27
+ }
28
+
29
+ describe("method = none (backward compatible)", () => {
30
+ it("passes through all requests without auth", async () => {
31
+ const app = buildApp({ method: "none", token_expiry: 86400 });
32
+ const res = await app.request("/api/sessions");
33
+ expect(res.status).toBe(200);
34
+ });
35
+ });
36
+
37
+ describe("method = ssh", () => {
38
+ const config: AuthConfig = { method: "ssh", token_expiry: 86400 };
39
+
40
+ it("bypasses /health", async () => {
41
+ const app = buildApp(config);
42
+ const res = await app.request("/health");
43
+ expect(res.status).toBe(200);
44
+ });
45
+
46
+ it("bypasses /api/auth/* routes", async () => {
47
+ const app = buildApp(config);
48
+ const res = await app.request("/api/auth/challenge");
49
+ expect(res.status).toBe(200);
50
+ const body = await res.json();
51
+ expect(body.bypassed).toBe(true);
52
+ });
53
+
54
+ it("rejects requests without Authorization header", async () => {
55
+ const app = buildApp(config);
56
+ const res = await app.request("/api/sessions");
57
+ expect(res.status).toBe(401);
58
+ });
59
+
60
+ it("rejects requests with invalid token", async () => {
61
+ const app = buildApp(config);
62
+ const res = await app.request("/api/sessions", {
63
+ headers: { Authorization: "Bearer invalid-token" },
64
+ });
65
+ expect(res.status).toBe(401);
66
+ });
67
+
68
+ it("allows requests with valid token", async () => {
69
+ const app = buildApp(config);
70
+ const token = auth.generateToken("alice");
71
+ const res = await app.request("/api/sessions", {
72
+ headers: { Authorization: `Bearer ${token}` },
73
+ });
74
+ expect(res.status).toBe(200);
75
+ });
76
+
77
+ it("rejects requests with non-Bearer auth", async () => {
78
+ const app = buildApp(config);
79
+ const res = await app.request("/api/sessions", {
80
+ headers: { Authorization: "Basic dXNlcjpwYXNz" },
81
+ });
82
+ expect(res.status).toBe(401);
83
+ });
84
+ });
85
+ });
86
+
87
+ describe("validateWsToken", () => {
88
+ let auth: AuthService;
89
+ const SECRET = "test-ws-secret";
90
+
91
+ beforeEach(() => {
92
+ auth = new AuthService(SECRET);
93
+ });
94
+
95
+ afterEach(() => {
96
+ auth.dispose();
97
+ });
98
+
99
+ it("returns 'anonymous' when method is none", () => {
100
+ const result = validateWsToken(auth, { method: "none", token_expiry: 86400 }, null);
101
+ expect(result).toBe("anonymous");
102
+ });
103
+
104
+ it("returns null when method is ssh and no token provided", () => {
105
+ const result = validateWsToken(auth, { method: "ssh", token_expiry: 86400 }, null);
106
+ expect(result).toBeNull();
107
+ });
108
+
109
+ it("returns userId for valid token", () => {
110
+ const token = auth.generateToken("bob");
111
+ const result = validateWsToken(auth, { method: "ssh", token_expiry: 86400 }, token);
112
+ expect(result).toBe("bob");
113
+ });
114
+
115
+ it("returns null for invalid token", () => {
116
+ const result = validateWsToken(auth, { method: "ssh", token_expiry: 86400 }, "bad-token");
117
+ expect(result).toBeNull();
118
+ });
119
+ });
@@ -0,0 +1,56 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+ import type { Context, Next, MiddlewareHandler } from "hono";
3
+ import { AuthService } from "./auth-service.ts";
4
+ import type { AuthConfig } from "./types.ts";
5
+
6
+ /**
7
+ * Hono middleware that checks Authorization Bearer header.
8
+ *
9
+ * - Bypasses /health and /api/auth/* routes.
10
+ * - When method is "none" (default), passes through everything (backward compatible).
11
+ * - Otherwise validates the JWT and sets c.set("userId", ...).
12
+ */
13
+ export function authMiddleware(authService: AuthService, config: AuthConfig): MiddlewareHandler {
14
+ return async (c: Context, next: Next) => {
15
+ // Backward compatible: no auth required when method is "none"
16
+ if (config.method === "none") {
17
+ return next();
18
+ }
19
+
20
+ const path = new URL(c.req.url).pathname;
21
+
22
+ // Bypass health and auth endpoints
23
+ if (path === "/health" || path.startsWith("/api/auth/")) {
24
+ return next();
25
+ }
26
+
27
+ const authHeader = c.req.header("Authorization");
28
+ if (!authHeader?.startsWith("Bearer ")) {
29
+ return c.json({ error: "Missing or invalid Authorization header" }, 401);
30
+ }
31
+
32
+ const token = authHeader.slice(7);
33
+ const result = authService.verifyToken(token);
34
+ if (!result.valid) {
35
+ return c.json({ error: "Invalid or expired token" }, 401);
36
+ }
37
+
38
+ c.set("userId", result.userId);
39
+ return next();
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Validate a JWT token from a query parameter (for WebSocket upgrade).
45
+ * Returns the userId if valid, null otherwise.
46
+ */
47
+ export function validateWsToken(
48
+ authService: AuthService,
49
+ config: AuthConfig,
50
+ token: string | null,
51
+ ): string | null {
52
+ if (config.method === "none") return "anonymous";
53
+ if (!token) return null;
54
+ const result = authService.verifyToken(token);
55
+ return result.valid ? (result.userId ?? null) : null;
56
+ }
@@ -0,0 +1,13 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+ import { z } from "zod";
3
+
4
+ export const AuthConfigSchema = z.object({
5
+ /** Auth method: "none" disables auth (default), "ssh" enables SSH key challenge-response. */
6
+ method: z.enum(["none", "ssh"]).default("none"),
7
+ /** JWT secret — auto-generated if omitted. */
8
+ secret: z.string().optional(),
9
+ /** Token expiry in seconds (default 86400 = 24h). */
10
+ token_expiry: z.number().min(60).default(86400),
11
+ });
12
+
13
+ export type AuthConfig = z.infer<typeof AuthConfigSchema>;
@@ -0,0 +1,242 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ extractMarks,
4
+ embedMarks,
5
+ calculateStats,
6
+ createAuthored,
7
+ normalizeQuote,
8
+ reanchorMarks,
9
+ tagContent,
10
+ generateMarkId,
11
+ type Mark,
12
+ type MarksDocument,
13
+ } from "./authorship.ts";
14
+
15
+ describe("normalizeQuote", () => {
16
+ it("collapses whitespace", () => {
17
+ expect(normalizeQuote(" hello world ")).toBe("hello world");
18
+ });
19
+
20
+ it("handles newlines", () => {
21
+ expect(normalizeQuote("line1\n line2\n")).toBe("line1 line2");
22
+ });
23
+ });
24
+
25
+ describe("generateMarkId", () => {
26
+ it("returns unique IDs", () => {
27
+ const a = generateMarkId();
28
+ const b = generateMarkId();
29
+ expect(a).not.toBe(b);
30
+ expect(a.startsWith("m")).toBeTruthy();
31
+ });
32
+ });
33
+
34
+ describe("createAuthored", () => {
35
+ it("creates a mark with correct fields", () => {
36
+ const mark = createAuthored("ai:François", { from: 0, to: 50 }, "some text");
37
+ expect(mark.kind).toBe("authored");
38
+ expect(mark.by).toBe("ai:François");
39
+ expect(mark.range.from).toBe(0);
40
+ expect(mark.range.to).toBe(50);
41
+ expect(mark.quote).toBe("some text");
42
+ expect(mark.id.startsWith("m")).toBeTruthy();
43
+ expect(mark.at.includes("T")).toBeTruthy(); // ISO timestamp
44
+ });
45
+ });
46
+
47
+ describe("extractMarks", () => {
48
+ it("extracts marks from markdown with comment block", () => {
49
+ const doc: MarksDocument = {
50
+ version: 2,
51
+ marks: {
52
+ m1: {
53
+ id: "m1",
54
+ kind: "authored",
55
+ by: "ai:François",
56
+ at: "2026-01-01T00:00:00Z",
57
+ range: { from: 0, to: 13 },
58
+ quote: "Hello world!",
59
+ },
60
+ },
61
+ };
62
+ const md = `Hello world!\n\n<!-- TMUX-IDE:MARKS\n${JSON.stringify(doc, null, 2)}\n-->\n`;
63
+
64
+ const result = extractMarks(md);
65
+ expect(result.marks).toBeTruthy();
66
+ expect(result.marks!.version).toBe(2);
67
+ expect(Object.keys(result.marks!.marks).length).toBe(1);
68
+ expect(result.marks!.marks["m1"]!.by).toBe("ai:François");
69
+ expect(result.content.includes("Hello world!")).toBeTruthy();
70
+ expect(!result.content.includes("TMUX-IDE:MARKS")).toBeTruthy();
71
+ });
72
+
73
+ it("returns null marks when no comment block", () => {
74
+ const md = "Just content.";
75
+ const result = extractMarks(md);
76
+ expect(result.marks).toBe(null);
77
+ expect(result.content).toBe(md);
78
+ });
79
+
80
+ it("handles malformed JSON gracefully", () => {
81
+ const md = "Content.\n\n<!-- TMUX-IDE:MARKS\nnot json\n-->\n";
82
+ const result = extractMarks(md);
83
+ expect(result.marks).toBe(null);
84
+ });
85
+ });
86
+
87
+ describe("embedMarks", () => {
88
+ it("appends marks comment to markdown", () => {
89
+ const md = "Hello world!";
90
+ const doc: MarksDocument = {
91
+ version: 2,
92
+ marks: {
93
+ m1: createAuthored("ai:François", { from: 0, to: 12 }, "Hello world!"),
94
+ },
95
+ };
96
+
97
+ const result = embedMarks(md, doc);
98
+ expect(result.includes("Hello world!")).toBeTruthy();
99
+ expect(result.includes("TMUX-IDE:MARKS")).toBeTruthy();
100
+ expect(result.includes("ai:François")).toBeTruthy();
101
+ });
102
+
103
+ it("replaces existing marks comment", () => {
104
+ const oldDoc: MarksDocument = { version: 2, marks: {} };
105
+ const md = `Content.\n\n<!-- TMUX-IDE:MARKS\n${JSON.stringify(oldDoc)}\n-->\n`;
106
+
107
+ const newDoc: MarksDocument = {
108
+ version: 2,
109
+ marks: {
110
+ m1: createAuthored("human:thijs", { from: 0, to: 8 }, "Content."),
111
+ },
112
+ };
113
+
114
+ const result = embedMarks(md, newDoc);
115
+ const count = (result.match(/TMUX-IDE:MARKS/g) || []).length;
116
+ expect(count).toBe(1);
117
+ expect(result.includes("human:thijs")).toBeTruthy();
118
+ });
119
+ });
120
+
121
+ describe("calculateStats", () => {
122
+ it("calculates AI vs human percentages from marks", () => {
123
+ const marks: Record<string, Mark> = {
124
+ m1: createAuthored("ai:François", { from: 0, to: 300 }, "ai text"),
125
+ m2: createAuthored("human:thijs", { from: 300, to: 500 }, "human text"),
126
+ };
127
+ const stats = calculateStats(marks);
128
+ expect(stats.totalChars).toBe(500);
129
+ expect(stats.aiPercent).toBe(60);
130
+ expect(stats.humanPercent).toBe(40);
131
+ });
132
+
133
+ it("returns 100% AI when all marks are AI", () => {
134
+ const marks: Record<string, Mark> = {
135
+ m1: createAuthored("ai:Amélie", { from: 0, to: 100 }, "text"),
136
+ m2: createAuthored("ai:Louis", { from: 100, to: 200 }, "more text"),
137
+ };
138
+ const stats = calculateStats(marks);
139
+ expect(stats.aiPercent).toBe(100);
140
+ expect(stats.humanPercent).toBe(0);
141
+ });
142
+
143
+ it("returns zeros for empty marks", () => {
144
+ const stats = calculateStats({});
145
+ expect(stats.totalChars).toBe(0);
146
+ expect(stats.aiPercent).toBe(0);
147
+ });
148
+
149
+ it("skips orphaned marks", () => {
150
+ const marks: Record<string, Mark> = {
151
+ m1: { ...createAuthored("ai:X", { from: 0, to: 100 }, "text"), orphaned: true },
152
+ m2: createAuthored("human:Y", { from: 0, to: 50 }, "text"),
153
+ };
154
+ const stats = calculateStats(marks);
155
+ expect(stats.totalChars).toBe(50);
156
+ expect(stats.humanPercent).toBe(100);
157
+ });
158
+
159
+ it("skips non-authored marks", () => {
160
+ const marks: Record<string, Mark> = {
161
+ m1: createAuthored("ai:X", { from: 0, to: 100 }, "text"),
162
+ m2: { ...createAuthored("human:Y", { from: 0, to: 50 }, "text"), kind: "comment" },
163
+ };
164
+ const stats = calculateStats(marks);
165
+ expect(stats.totalChars).toBe(100);
166
+ expect(stats.aiPercent).toBe(100);
167
+ });
168
+ });
169
+
170
+ describe("reanchorMarks", () => {
171
+ it("keeps marks with matching quotes at correct positions", () => {
172
+ const content = "Hello world!";
173
+ const marks: Record<string, Mark> = {
174
+ m1: createAuthored("ai:X", { from: 0, to: 12 }, "Hello world!"),
175
+ };
176
+ const result = reanchorMarks(content, marks);
177
+ expect(result["m1"]!.range.from).toBe(0);
178
+ expect(result["m1"]!.range.to).toBe(12);
179
+ expect(result["m1"]!.orphaned).toBe(undefined);
180
+ });
181
+
182
+ it("re-anchors marks when content shifts", () => {
183
+ // Content was "XHello" but now prefix was removed
184
+ const content = "Hello world!";
185
+ const marks: Record<string, Mark> = {
186
+ m1: createAuthored("ai:X", { from: 1, to: 13 }, "Hello world!"),
187
+ };
188
+ const result = reanchorMarks(content, marks);
189
+ expect(result["m1"]!.range.from).toBe(0);
190
+ expect(result["m1"]!.range.to).toBe(12);
191
+ });
192
+
193
+ it("orphans marks when quote not found", () => {
194
+ const content = "Completely different text.";
195
+ const marks: Record<string, Mark> = {
196
+ m1: createAuthored("ai:X", { from: 0, to: 12 }, "Hello world!"),
197
+ };
198
+ const result = reanchorMarks(content, marks);
199
+ expect(result["m1"]!.orphaned).toBe(true);
200
+ });
201
+ });
202
+
203
+ describe("tagContent", () => {
204
+ it("tags entire content as authored", () => {
205
+ const md = "Hello world!";
206
+ const result = tagContent(md, "ai:François");
207
+
208
+ const { marks } = extractMarks(result);
209
+ expect(marks).toBeTruthy();
210
+ const authored = Object.values(marks!.marks).filter((m) => m.kind === "authored");
211
+ expect(authored.length > 0).toBeTruthy();
212
+ expect(authored[0]!.by).toBe("ai:François");
213
+ expect(authored[0]!.range.from).toBe(0);
214
+ expect(authored[0]!.range.to).toBe(12);
215
+ });
216
+
217
+ it("preserves existing marks and only tags uncovered ranges", () => {
218
+ const existingMark = createAuthored("human:thijs", { from: 0, to: 5 }, "Hello");
219
+ const doc: MarksDocument = { version: 2, marks: { [existingMark.id]: existingMark } };
220
+ const md = embedMarks("Hello world!", doc);
221
+
222
+ const result = tagContent(md, "ai:Amélie");
223
+ const { marks } = extractMarks(result);
224
+ expect(marks).toBeTruthy();
225
+
226
+ const marksList = Object.values(marks!.marks).filter(
227
+ (m) => m.kind === "authored" && !m.orphaned,
228
+ );
229
+ // Should have human mark for "Hello" and AI mark for the rest
230
+ const humanMarks = marksList.filter((m) => m.by === "human:thijs");
231
+ const aiMarks = marksList.filter((m) => m.by === "ai:Amélie");
232
+ expect(humanMarks.length > 0).toBeTruthy();
233
+ expect(aiMarks.length > 0).toBeTruthy();
234
+ });
235
+
236
+ it("round-trips: extract after tag returns same content", () => {
237
+ const original = "Some plan content here.\n\n## Section Two\n\nMore content.";
238
+ const tagged = tagContent(original, "ai:Louis");
239
+ const { content } = extractMarks(tagged);
240
+ expect(content).toBe(original);
241
+ });
242
+ });