@neuralnomads/codenomad 0.6.0 → 0.7.1

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.
@@ -0,0 +1,134 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { hashPassword, verifyPassword } from "./password-hash";
4
+ export class AuthStore {
5
+ constructor(authFilePath, logger) {
6
+ this.authFilePath = authFilePath;
7
+ this.logger = logger;
8
+ this.cachedFile = null;
9
+ this.overrideAuth = null;
10
+ this.bootstrapUsername = null;
11
+ }
12
+ getAuthFilePath() {
13
+ return this.authFilePath;
14
+ }
15
+ load() {
16
+ if (this.overrideAuth) {
17
+ return this.overrideAuth;
18
+ }
19
+ if (this.cachedFile) {
20
+ return this.cachedFile;
21
+ }
22
+ try {
23
+ if (!fs.existsSync(this.authFilePath)) {
24
+ return null;
25
+ }
26
+ const raw = fs.readFileSync(this.authFilePath, "utf-8");
27
+ const parsed = JSON.parse(raw);
28
+ if (!parsed || parsed.version !== 1) {
29
+ this.logger.warn({ authFilePath: this.authFilePath }, "Auth file has unsupported version");
30
+ return null;
31
+ }
32
+ this.cachedFile = parsed;
33
+ return parsed;
34
+ }
35
+ catch (error) {
36
+ this.logger.warn({ err: error, authFilePath: this.authFilePath }, "Failed to load auth file");
37
+ return null;
38
+ }
39
+ }
40
+ ensureInitialized(params) {
41
+ const password = params.password?.trim();
42
+ if (password) {
43
+ const now = new Date().toISOString();
44
+ const runtime = {
45
+ version: 1,
46
+ username: params.username,
47
+ password: hashPassword(password),
48
+ userProvided: true,
49
+ updatedAt: now,
50
+ };
51
+ this.overrideAuth = runtime;
52
+ this.cachedFile = null;
53
+ this.bootstrapUsername = null;
54
+ this.logger.debug({ authFilePath: this.authFilePath }, "Using runtime auth password override; ignoring auth file");
55
+ return;
56
+ }
57
+ const existing = this.load();
58
+ if (existing) {
59
+ if (existing.username !== params.username) {
60
+ // Keep existing username unless explicitly overridden later.
61
+ this.logger.debug({ existing: existing.username, requested: params.username }, "Auth username differs from requested");
62
+ }
63
+ this.bootstrapUsername = null;
64
+ return;
65
+ }
66
+ if (params.allowBootstrapWithoutPassword) {
67
+ this.bootstrapUsername = params.username;
68
+ this.logger.debug({ authFilePath: this.authFilePath }, "No auth file present; bootstrap-only mode enabled");
69
+ return;
70
+ }
71
+ throw new Error(`No server password configured. Create ${this.authFilePath} or start with --password / CODENOMAD_SERVER_PASSWORD.`);
72
+ }
73
+ validateCredentials(username, password) {
74
+ const auth = this.load();
75
+ if (!auth) {
76
+ return false;
77
+ }
78
+ if (username !== auth.username) {
79
+ return false;
80
+ }
81
+ return verifyPassword(password, auth.password);
82
+ }
83
+ setPassword(params) {
84
+ if (this.overrideAuth) {
85
+ throw new Error("Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.");
86
+ }
87
+ const current = this.load();
88
+ if (!current) {
89
+ if (!this.bootstrapUsername) {
90
+ throw new Error("Auth is not initialized");
91
+ }
92
+ const created = {
93
+ version: 1,
94
+ username: this.bootstrapUsername,
95
+ password: hashPassword(params.password),
96
+ userProvided: params.markUserProvided,
97
+ updatedAt: new Date().toISOString(),
98
+ };
99
+ this.persist(created);
100
+ this.bootstrapUsername = null;
101
+ return { username: created.username, passwordUserProvided: created.userProvided };
102
+ }
103
+ const next = {
104
+ ...current,
105
+ password: hashPassword(params.password),
106
+ userProvided: params.markUserProvided,
107
+ updatedAt: new Date().toISOString(),
108
+ };
109
+ this.persist(next);
110
+ return { username: next.username, passwordUserProvided: next.userProvided };
111
+ }
112
+ getStatus() {
113
+ const current = this.load();
114
+ if (current) {
115
+ return { username: current.username, passwordUserProvided: current.userProvided };
116
+ }
117
+ if (this.bootstrapUsername) {
118
+ return { username: this.bootstrapUsername, passwordUserProvided: false };
119
+ }
120
+ throw new Error("Auth is not initialized");
121
+ }
122
+ persist(auth) {
123
+ try {
124
+ fs.mkdirSync(path.dirname(this.authFilePath), { recursive: true });
125
+ fs.writeFileSync(this.authFilePath, JSON.stringify(auth, null, 2), "utf-8");
126
+ this.cachedFile = auth;
127
+ this.logger.debug({ authFilePath: this.authFilePath }, "Persisted auth file");
128
+ }
129
+ catch (error) {
130
+ this.logger.error({ err: error, authFilePath: this.authFilePath }, "Failed to persist auth file");
131
+ throw error;
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,37 @@
1
+ export function parseCookies(header) {
2
+ const result = {};
3
+ if (!header)
4
+ return result;
5
+ const parts = header.split(";");
6
+ for (const part of parts) {
7
+ const index = part.indexOf("=");
8
+ if (index < 0)
9
+ continue;
10
+ const key = part.slice(0, index).trim();
11
+ const value = part.slice(index + 1).trim();
12
+ if (!key)
13
+ continue;
14
+ result[key] = decodeURIComponent(value);
15
+ }
16
+ return result;
17
+ }
18
+ export function isLoopbackAddress(remoteAddress) {
19
+ if (!remoteAddress)
20
+ return false;
21
+ if (remoteAddress === "127.0.0.1" || remoteAddress === "::1")
22
+ return true;
23
+ if (remoteAddress === "::ffff:127.0.0.1")
24
+ return true;
25
+ return false;
26
+ }
27
+ export function wantsHtml(request) {
28
+ const accept = (request.headers["accept"] ?? "").toString().toLowerCase();
29
+ return accept.includes("text/html") || accept.includes("application/xhtml");
30
+ }
31
+ export function sendUnauthorized(request, reply) {
32
+ if (request.method === "GET" && !request.url.startsWith("/api/") && wantsHtml(request)) {
33
+ reply.redirect("/login");
34
+ return;
35
+ }
36
+ reply.code(401).send({ error: "Unauthorized" });
37
+ }
@@ -0,0 +1,87 @@
1
+ import path from "path";
2
+ import { AuthStore } from "./auth-store";
3
+ import { TokenManager } from "./token-manager";
4
+ import { SessionManager } from "./session-manager";
5
+ import { isLoopbackAddress, parseCookies } from "./http-auth";
6
+ export const BOOTSTRAP_TOKEN_STDOUT_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:";
7
+ export const DEFAULT_AUTH_USERNAME = "codenomad";
8
+ export const DEFAULT_AUTH_COOKIE_NAME = "codenomad_session";
9
+ export class AuthManager {
10
+ constructor(init, logger) {
11
+ this.init = init;
12
+ this.logger = logger;
13
+ this.sessionManager = new SessionManager();
14
+ this.cookieName = DEFAULT_AUTH_COOKIE_NAME;
15
+ const authFilePath = resolveAuthFilePath(init.configPath);
16
+ this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }));
17
+ // Startup: password comes from CLI/env, auth.json, or bootstrap-only mode.
18
+ this.authStore.ensureInitialized({
19
+ username: init.username,
20
+ password: init.password,
21
+ allowBootstrapWithoutPassword: init.generateToken,
22
+ });
23
+ this.tokenManager = init.generateToken ? new TokenManager(60000) : null;
24
+ }
25
+ getCookieName() {
26
+ return this.cookieName;
27
+ }
28
+ isTokenBootstrapEnabled() {
29
+ return Boolean(this.tokenManager);
30
+ }
31
+ issueBootstrapToken() {
32
+ if (!this.tokenManager)
33
+ return null;
34
+ return this.tokenManager.generate();
35
+ }
36
+ consumeBootstrapToken(token) {
37
+ if (!this.tokenManager)
38
+ return false;
39
+ return this.tokenManager.consume(token);
40
+ }
41
+ validateLogin(username, password) {
42
+ return this.authStore.validateCredentials(username, password);
43
+ }
44
+ createSession(username) {
45
+ return this.sessionManager.createSession(username);
46
+ }
47
+ getStatus() {
48
+ return this.authStore.getStatus();
49
+ }
50
+ setPassword(password) {
51
+ return this.authStore.setPassword({ password, markUserProvided: true });
52
+ }
53
+ isLoopbackRequest(request) {
54
+ return isLoopbackAddress(request.socket.remoteAddress);
55
+ }
56
+ getSessionFromRequest(request) {
57
+ const cookies = parseCookies(request.headers.cookie);
58
+ const sessionId = cookies[this.cookieName];
59
+ const session = this.sessionManager.getSession(sessionId);
60
+ if (!session)
61
+ return null;
62
+ return { username: session.username, sessionId: session.id };
63
+ }
64
+ setSessionCookie(reply, sessionId) {
65
+ reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId));
66
+ }
67
+ clearSessionCookie(reply) {
68
+ reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }));
69
+ }
70
+ }
71
+ function resolveAuthFilePath(configPath) {
72
+ const resolvedConfigPath = resolvePath(configPath);
73
+ return path.join(path.dirname(resolvedConfigPath), "auth.json");
74
+ }
75
+ function resolvePath(filePath) {
76
+ if (filePath.startsWith("~/")) {
77
+ return path.join(process.env.HOME ?? "", filePath.slice(2));
78
+ }
79
+ return path.resolve(filePath);
80
+ }
81
+ function buildSessionCookie(name, value, options) {
82
+ const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"];
83
+ if (options?.maxAgeSeconds !== undefined) {
84
+ parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`);
85
+ }
86
+ return parts.join("; ");
87
+ }
@@ -0,0 +1,32 @@
1
+ import crypto from "crypto";
2
+ const DEFAULT_SCRYPT_PARAMS = {
3
+ N: 16384,
4
+ r: 8,
5
+ p: 1,
6
+ maxmem: 32 * 1024 * 1024,
7
+ };
8
+ export function hashPassword(password) {
9
+ const salt = crypto.randomBytes(16);
10
+ const params = DEFAULT_SCRYPT_PARAMS;
11
+ const keyLength = 64;
12
+ const derived = crypto.scryptSync(password, salt, keyLength, params);
13
+ return {
14
+ algorithm: "scrypt",
15
+ saltBase64: salt.toString("base64"),
16
+ hashBase64: Buffer.from(derived).toString("base64"),
17
+ keyLength,
18
+ params,
19
+ };
20
+ }
21
+ export function verifyPassword(password, record) {
22
+ if (record.algorithm !== "scrypt") {
23
+ return false;
24
+ }
25
+ const salt = Buffer.from(record.saltBase64, "base64");
26
+ const expected = Buffer.from(record.hashBase64, "base64");
27
+ const derived = crypto.scryptSync(password, salt, record.keyLength, record.params);
28
+ if (expected.length !== derived.length) {
29
+ return false;
30
+ }
31
+ return crypto.timingSafeEqual(expected, Buffer.from(derived));
32
+ }
@@ -0,0 +1,17 @@
1
+ import crypto from "crypto";
2
+ export class SessionManager {
3
+ constructor() {
4
+ this.sessions = new Map();
5
+ }
6
+ createSession(username) {
7
+ const id = crypto.randomBytes(32).toString("base64url");
8
+ const info = { id, createdAt: Date.now(), username };
9
+ this.sessions.set(id, info);
10
+ return info;
11
+ }
12
+ getSession(id) {
13
+ if (!id)
14
+ return undefined;
15
+ return this.sessions.get(id);
16
+ }
17
+ }
@@ -0,0 +1,27 @@
1
+ import crypto from "crypto";
2
+ export class TokenManager {
3
+ constructor(ttlMs) {
4
+ this.ttlMs = ttlMs;
5
+ this.token = null;
6
+ }
7
+ generate() {
8
+ const token = crypto.randomBytes(32).toString("base64url");
9
+ this.token = { token, createdAt: Date.now(), consumed: false };
10
+ return token;
11
+ }
12
+ consume(token) {
13
+ if (!this.token)
14
+ return false;
15
+ if (this.token.consumed)
16
+ return false;
17
+ if (Date.now() - this.token.createdAt > this.ttlMs)
18
+ return false;
19
+ if (token !== this.token.token)
20
+ return false;
21
+ this.token.consumed = true;
22
+ return true;
23
+ }
24
+ peek() {
25
+ return this.token?.token ?? null;
26
+ }
27
+ }
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { InstanceEventBridge } from "./workspaces/instance-events";
17
17
  import { createLogger } from "./logger";
18
18
  import { launchInBrowser } from "./launcher";
19
19
  import { startReleaseMonitor } from "./releases/release-monitor";
20
+ import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager";
20
21
  const require = createRequire(import.meta.url);
21
22
  const packageJson = require("../package.json");
22
23
  const __filename = fileURLToPath(import.meta.url);
@@ -40,7 +41,14 @@ function parseCliOptions(argv) {
40
41
  .addOption(new Option("--log-destination <path>", "Log destination file (defaults to stdout)").env("CLI_LOG_DESTINATION"))
41
42
  .addOption(new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR))
42
43
  .addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
43
- .addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false));
44
+ .addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
45
+ .addOption(new Option("--username <username>", "Username for server authentication")
46
+ .env("CODENOMAD_SERVER_USERNAME")
47
+ .default(DEFAULT_AUTH_USERNAME))
48
+ .addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
49
+ .addOption(new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
50
+ .env("CODENOMAD_GENERATE_TOKEN")
51
+ .default(false));
44
52
  program.parse(argv, { from: "user" });
45
53
  const parsed = program.opts();
46
54
  const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd();
@@ -56,6 +64,9 @@ function parseCliOptions(argv) {
56
64
  uiStaticDir: parsed.uiDir,
57
65
  uiDevServer: parsed.uiDevServer,
58
66
  launch: Boolean(parsed.launch),
67
+ authUsername: parsed.username,
68
+ authPassword: parsed.password,
69
+ generateToken: Boolean(parsed.generateToken),
59
70
  };
60
71
  }
61
72
  function parsePort(input) {
@@ -77,7 +88,11 @@ async function main() {
77
88
  const workspaceLogger = logger.child({ component: "workspace" });
78
89
  const configLogger = logger.child({ component: "config" });
79
90
  const eventLogger = logger.child({ component: "events" });
80
- logger.info({ options }, "Starting CodeNomad CLI server");
91
+ const logOptions = {
92
+ ...options,
93
+ authPassword: options.authPassword ? "[REDACTED]" : undefined,
94
+ };
95
+ logger.info({ options: logOptions }, "Starting CodeNomad CLI server");
81
96
  const eventBus = new EventBus(eventLogger);
82
97
  const serverMeta = {
83
98
  httpBaseUrl: `http://${options.host}:${options.port}`,
@@ -89,6 +104,18 @@ async function main() {
89
104
  workspaceRoot: options.rootDir,
90
105
  addresses: [],
91
106
  };
107
+ const authManager = new AuthManager({
108
+ configPath: options.configPath,
109
+ username: options.authUsername,
110
+ password: options.authPassword,
111
+ generateToken: options.generateToken,
112
+ }, logger.child({ component: "auth" }));
113
+ if (options.generateToken) {
114
+ const token = authManager.issueBootstrapToken();
115
+ if (token) {
116
+ console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`);
117
+ }
118
+ }
92
119
  const configStore = new ConfigStore(options.configPath, eventBus, configLogger);
93
120
  const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger);
94
121
  const workspaceManager = new WorkspaceManager({
@@ -129,6 +156,7 @@ async function main() {
129
156
  eventBus,
130
157
  serverMeta,
131
158
  instanceStore,
159
+ authManager,
132
160
  uiStaticDir: options.uiStaticDir,
133
161
  uiDevServerUrl: options.uiDevServer,
134
162
  logger,
@@ -3,6 +3,6 @@
3
3
  "version": "0.5.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@opencode-ai/plugin": "1.1.8"
6
+ "@opencode-ai/plugin": "1.1.16"
7
7
  }
8
8
  }
@@ -1,5 +1,6 @@
1
1
  import path from "path"
2
2
  import { tool } from "@opencode-ai/plugin/tool"
3
+ import { createCodeNomadRequester, type CodeNomadConfig } from "./request"
3
4
 
4
5
  type BackgroundProcess = {
5
6
  id: string
@@ -12,11 +13,6 @@ type BackgroundProcess = {
12
13
  outputSizeBytes?: number
13
14
  }
14
15
 
15
- type CodeNomadConfig = {
16
- instanceId: string
17
- baseUrl: string
18
- }
19
-
20
16
  type BackgroundProcessOptions = {
21
17
  baseDir: string
22
18
  }
@@ -27,30 +23,10 @@ type ParsedCommand = {
27
23
  }
28
24
 
29
25
  export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
30
- const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
31
-
32
- const base = config.baseUrl.replace(/\/+$/, "")
33
- const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}`
34
- const headers = normalizeHeaders(init?.headers)
35
- if (init?.body !== undefined) {
36
- headers["Content-Type"] = "application/json"
37
- }
38
-
39
- const response = await fetch(url, {
40
- ...init,
41
- headers,
42
- })
26
+ const requester = createCodeNomadRequester(config)
43
27
 
44
- if (!response.ok) {
45
- const message = await response.text()
46
- throw new Error(message || `Request failed with ${response.status}`)
47
- }
48
-
49
- if (response.status === 204) {
50
- return undefined as T
51
- }
52
-
53
- return (await response.json()) as T
28
+ const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
29
+ return requester.requestJson<T>(`/background-processes${path}`, init)
54
30
  }
55
31
 
56
32
  return {
@@ -249,13 +225,7 @@ function tokenize(input: string): string[] {
249
225
 
250
226
  if (char === "|" || char === "&" || char === ";") {
251
227
  flush()
252
- const next = input[index + 1]
253
- if ((char === "|" || char === "&") && next === char) {
254
- tokens.push(char + next)
255
- index += 1
256
- } else {
257
- tokens.push(char)
258
- }
228
+ tokens.push(char)
259
229
  continue
260
230
  }
261
231
 
@@ -266,44 +236,18 @@ function tokenize(input: string): string[] {
266
236
  return tokens
267
237
  }
268
238
 
269
- function isSeparator(token: string) {
270
- return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&"
239
+ function isSeparator(token: string): boolean {
240
+ return token === "|" || token === "&" || token === ";"
271
241
  }
272
242
 
273
- function unquote(value: string) {
274
- if (value.length >= 2) {
275
- const first = value[0]
276
- const last = value[value.length - 1]
277
- if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
278
- return value.slice(1, -1)
279
- }
243
+ function unquote(token: string): string {
244
+ if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
245
+ return token.slice(1, -1)
280
246
  }
281
- return value
247
+ return token
282
248
  }
283
249
 
284
- function isWithinBase(baseDir: string, target: string) {
285
- const relative = path.relative(baseDir, target)
286
- if (!relative) return true
287
- return !relative.startsWith("..") && !path.isAbsolute(relative)
288
- }
289
-
290
- function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
291
- const output: Record<string, string> = {}
292
- if (!headers) return output
293
-
294
- if (headers instanceof Headers) {
295
- headers.forEach((value, key) => {
296
- output[key] = value
297
- })
298
- return output
299
- }
300
-
301
- if (Array.isArray(headers)) {
302
- for (const [key, value] of headers) {
303
- output[key] = value
304
- }
305
- return output
306
- }
307
-
308
- return { ...headers }
250
+ function isWithinBase(base: string, candidate: string): boolean {
251
+ const relative = path.relative(base, candidate)
252
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
309
253
  }
@@ -1,74 +1,41 @@
1
- export type PluginEvent = {
2
- type: string
3
- properties?: Record<string, unknown>
4
- }
1
+ import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request"
5
2
 
6
- export type CodeNomadConfig = {
7
- instanceId: string
8
- baseUrl: string
9
- }
10
-
11
- export function getCodeNomadConfig(): CodeNomadConfig {
12
- return {
13
- instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
14
- baseUrl: requireEnv("CODENOMAD_BASE_URL"),
15
- }
16
- }
3
+ export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request"
17
4
 
18
5
  export function createCodeNomadClient(config: CodeNomadConfig) {
19
- return {
20
- postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
21
- startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
22
- }
23
- }
6
+ const requester = createCodeNomadRequester(config)
24
7
 
25
- function requireEnv(key: string): string {
26
- const value = process.env[key]
27
- if (!value || !value.trim()) {
28
- throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
8
+ return {
9
+ postEvent: (event: PluginEvent) =>
10
+ requester.requestVoid("/event", {
11
+ method: "POST",
12
+ body: JSON.stringify(event),
13
+ }),
14
+ startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(requester, onEvent),
29
15
  }
30
- return value
31
16
  }
32
17
 
33
18
  function delay(ms: number) {
34
19
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
35
20
  }
36
21
 
37
- async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
38
- const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
39
- const response = await fetch(url, {
40
- method: "POST",
41
- headers: {
42
- "Content-Type": "application/json",
43
- },
44
- body: JSON.stringify(event),
45
- })
46
-
47
- if (!response.ok) {
48
- throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
49
- }
50
- }
51
-
52
- async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
53
- const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
54
-
22
+ async function startPluginEvents(
23
+ requester: ReturnType<typeof createCodeNomadRequester>,
24
+ onEvent: (event: PluginEvent) => void,
25
+ ) {
55
26
  // Fail plugin startup if we cannot establish the initial connection.
56
- const initialBody = await connectWithRetries(url, 3)
27
+ const initialBody = await connectWithRetries(requester, 3)
57
28
 
58
29
  // After startup, keep reconnecting; throw after 3 consecutive failures.
59
- void consumeWithReconnect(url, onEvent, initialBody)
30
+ void consumeWithReconnect(requester, onEvent, initialBody)
60
31
  }
61
32
 
62
- async function connectWithRetries(url: string, maxAttempts: number) {
33
+ async function connectWithRetries(requester: ReturnType<typeof createCodeNomadRequester>, maxAttempts: number) {
63
34
  let lastError: unknown
64
35
 
65
36
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
66
37
  try {
67
- const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
68
- if (!response.ok || !response.body) {
69
- throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
70
- }
71
- return response.body
38
+ return await requester.requestSseBody("/events")
72
39
  } catch (error) {
73
40
  lastError = error
74
41
  await delay(500 * attempt)
@@ -76,11 +43,12 @@ async function connectWithRetries(url: string, maxAttempts: number) {
76
43
  }
77
44
 
78
45
  const reason = lastError instanceof Error ? lastError.message : String(lastError)
79
- throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
46
+ const url = requester.buildUrl("/events")
47
+ throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad at ${url} after ${maxAttempts} retries: ${reason}`)
80
48
  }
81
49
 
82
50
  async function consumeWithReconnect(
83
- url: string,
51
+ requester: ReturnType<typeof createCodeNomadRequester>,
84
52
  onEvent: (event: PluginEvent) => void,
85
53
  initialBody: ReadableStream<Uint8Array>,
86
54
  ) {
@@ -90,7 +58,7 @@ async function consumeWithReconnect(
90
58
  while (true) {
91
59
  try {
92
60
  if (!body) {
93
- body = await connectWithRetries(url, 3)
61
+ body = await connectWithRetries(requester, 3)
94
62
  }
95
63
 
96
64
  await consumeSseBody(body, onEvent)