@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.
- package/dist/auth/auth-store.js +134 -0
- package/dist/auth/http-auth.js +37 -0
- package/dist/auth/manager.js +87 -0
- package/dist/auth/password-hash.js +32 -0
- package/dist/auth/session-manager.js +17 -0
- package/dist/auth/token-manager.js +27 -0
- package/dist/index.js +30 -2
- package/dist/opencode-config/package.json +1 -1
- package/dist/opencode-config/plugin/lib/background-process.ts +14 -70
- package/dist/opencode-config/plugin/lib/client.ts +22 -54
- package/dist/opencode-config/plugin/lib/request.ts +124 -0
- package/dist/server/http-server.js +102 -5
- package/dist/server/routes/auth-pages/login.html +134 -0
- package/dist/server/routes/auth-pages/token.html +93 -0
- package/dist/server/routes/auth.js +128 -0
- package/dist/workspaces/instance-events.js +6 -1
- package/dist/workspaces/manager.js +23 -1
- package/dist/workspaces/opencode-auth.js +16 -0
- package/dist/workspaces/runtime.js +13 -1
- package/package.json +3 -3
- package/public/assets/index-DhjiW0WU.css +1 -0
- package/public/assets/{loading-BIWzmJ53.js → loading-A3c3OC91.js} +1 -1
- package/public/assets/main-C3qD3Vm8.js +184 -0
- package/public/index.html +3 -3
- package/public/loading.html +3 -3
- package/public/assets/index-CKQiPGtF.css +0 -1
- package/public/assets/main-BSwx5oHC.js +0 -184
- /package/public/assets/{index-BC0I6SzM.js → index-D4j6wgeo.js} +0 -0
|
@@ -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
|
-
|
|
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,
|
|
@@ -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
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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 === "
|
|
239
|
+
function isSeparator(token: string): boolean {
|
|
240
|
+
return token === "|" || token === "&" || token === ";"
|
|
271
241
|
}
|
|
272
242
|
|
|
273
|
-
function unquote(
|
|
274
|
-
if (
|
|
275
|
-
|
|
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
|
|
247
|
+
return token
|
|
282
248
|
}
|
|
283
249
|
|
|
284
|
-
function isWithinBase(
|
|
285
|
-
const relative = path.relative(
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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(
|
|
27
|
+
const initialBody = await connectWithRetries(requester, 3)
|
|
57
28
|
|
|
58
29
|
// After startup, keep reconnecting; throw after 3 consecutive failures.
|
|
59
|
-
void consumeWithReconnect(
|
|
30
|
+
void consumeWithReconnect(requester, onEvent, initialBody)
|
|
60
31
|
}
|
|
61
32
|
|
|
62
|
-
async function connectWithRetries(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
61
|
+
body = await connectWithRetries(requester, 3)
|
|
94
62
|
}
|
|
95
63
|
|
|
96
64
|
await consumeSseBody(body, onEvent)
|