@neuralnomads/codenomad 0.5.1 → 0.7.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.
- 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/background-processes/manager.js +52 -12
- 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-D4j6wgeo.js +1 -0
- package/public/assets/index-DhjiW0WU.css +1 -0
- package/public/assets/{loading-DDkv7He2.js → loading-A3c3OC91.js} +1 -1
- package/public/assets/main-ChQciNJ1.js +184 -0
- package/public/index.html +3 -3
- package/public/loading.html +3 -3
- package/public/assets/index-DByfuA7Q.css +0 -1
- package/public/assets/index-SWVRNzDr.js +0 -1
- package/public/assets/main-lEyCX2HE.js +0 -184
|
@@ -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
|
+
}
|
|
@@ -6,6 +6,7 @@ const ROOT_DIR = ".codenomad/background_processes";
|
|
|
6
6
|
const INDEX_FILE = "index.json";
|
|
7
7
|
const OUTPUT_FILE = "output.txt";
|
|
8
8
|
const STOP_TIMEOUT_MS = 2000;
|
|
9
|
+
const EXIT_WAIT_TIMEOUT_MS = 5000;
|
|
9
10
|
const MAX_OUTPUT_BYTES = 20 * 1024;
|
|
10
11
|
const OUTPUT_PUBLISH_INTERVAL_MS = 1000;
|
|
11
12
|
export class BackgroundProcessManager {
|
|
@@ -35,6 +36,10 @@ export class BackgroundProcessManager {
|
|
|
35
36
|
const child = spawn("bash", ["-c", command], {
|
|
36
37
|
cwd: workspace.path,
|
|
37
38
|
stdio: ["ignore", "pipe", "pipe"],
|
|
39
|
+
detached: process.platform !== "win32",
|
|
40
|
+
});
|
|
41
|
+
child.on("exit", () => {
|
|
42
|
+
this.killProcessTree(child, "SIGTERM");
|
|
38
43
|
});
|
|
39
44
|
const record = {
|
|
40
45
|
id,
|
|
@@ -60,7 +65,7 @@ export class BackgroundProcessManager {
|
|
|
60
65
|
resolve();
|
|
61
66
|
});
|
|
62
67
|
});
|
|
63
|
-
this.running.set(id, { child, outputPath, exitPromise, workspaceId });
|
|
68
|
+
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId });
|
|
64
69
|
let lastPublishAt = 0;
|
|
65
70
|
const maybePublishSize = () => {
|
|
66
71
|
const now = Date.now();
|
|
@@ -92,7 +97,7 @@ export class BackgroundProcessManager {
|
|
|
92
97
|
}
|
|
93
98
|
const running = this.running.get(processId);
|
|
94
99
|
if (running?.child && !running.child.killed) {
|
|
95
|
-
running.child
|
|
100
|
+
this.killProcessTree(running.child, "SIGTERM");
|
|
96
101
|
await this.waitForExit(running);
|
|
97
102
|
}
|
|
98
103
|
if (record.status === "running") {
|
|
@@ -110,7 +115,7 @@ export class BackgroundProcessManager {
|
|
|
110
115
|
return;
|
|
111
116
|
const running = this.running.get(processId);
|
|
112
117
|
if (running?.child && !running.child.killed) {
|
|
113
|
-
running.child
|
|
118
|
+
this.killProcessTree(running.child, "SIGTERM");
|
|
114
119
|
await this.waitForExit(running);
|
|
115
120
|
}
|
|
116
121
|
await this.removeFromIndex(workspaceId, processId);
|
|
@@ -197,22 +202,57 @@ export class BackgroundProcessManager {
|
|
|
197
202
|
for (const [, running] of this.running.entries()) {
|
|
198
203
|
if (running.workspaceId !== workspaceId)
|
|
199
204
|
continue;
|
|
200
|
-
running.child
|
|
205
|
+
this.killProcessTree(running.child, "SIGTERM");
|
|
201
206
|
await this.waitForExit(running);
|
|
202
207
|
}
|
|
203
208
|
await this.removeWorkspaceDir(workspaceId);
|
|
204
209
|
}
|
|
210
|
+
killProcessTree(child, signal) {
|
|
211
|
+
const pid = child.pid;
|
|
212
|
+
if (!pid)
|
|
213
|
+
return;
|
|
214
|
+
if (process.platform !== "win32") {
|
|
215
|
+
try {
|
|
216
|
+
process.kill(-pid, signal);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Fall back to killing the direct child.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
child.kill(signal);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// ignore
|
|
228
|
+
}
|
|
229
|
+
}
|
|
205
230
|
async waitForExit(running) {
|
|
206
|
-
let
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
231
|
+
let exited = false;
|
|
232
|
+
const exitPromise = running.exitPromise.finally(() => {
|
|
233
|
+
exited = true;
|
|
234
|
+
});
|
|
235
|
+
const killTimeout = setTimeout(() => {
|
|
236
|
+
if (!exited) {
|
|
237
|
+
this.killProcessTree(running.child, "SIGKILL");
|
|
210
238
|
}
|
|
211
239
|
}, STOP_TIMEOUT_MS);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
240
|
+
try {
|
|
241
|
+
await Promise.race([
|
|
242
|
+
exitPromise,
|
|
243
|
+
new Promise((resolve) => {
|
|
244
|
+
setTimeout(resolve, EXIT_WAIT_TIMEOUT_MS);
|
|
245
|
+
}),
|
|
246
|
+
]);
|
|
247
|
+
if (!exited) {
|
|
248
|
+
this.killProcessTree(running.child, "SIGKILL");
|
|
249
|
+
this.running.delete(running.id);
|
|
250
|
+
this.deps.logger.warn({ pid: running.child.pid }, "Timed out waiting for background process to exit");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
clearTimeout(killTimeout);
|
|
255
|
+
}
|
|
216
256
|
}
|
|
217
257
|
statusFromExit(code) {
|
|
218
258
|
if (code === null)
|
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
|
}
|