llm-cli-gateway 2.3.0 → 2.5.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/CHANGELOG.md +79 -9
- package/README.md +3 -1
- package/dist/auth.d.ts +44 -1
- package/dist/auth.js +60 -13
- package/dist/config.d.ts +19 -0
- package/dist/config.js +235 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +22 -11
- package/dist/executor.js +17 -21
- package/dist/flight-recorder.d.ts +2 -1
- package/dist/http-transport.js +74 -12
- package/dist/index.d.ts +42 -7
- package/dist/index.js +1161 -82
- package/dist/metrics.d.ts +3 -3
- package/dist/metrics.js +8 -8
- package/dist/oauth.d.ts +38 -0
- package/dist/oauth.js +441 -0
- package/dist/request-context.d.ts +7 -0
- package/dist/request-context.js +8 -0
- package/dist/request-helpers.d.ts +8 -8
- package/dist/resources.js +56 -7
- package/dist/session-manager-pg.d.ts +6 -6
- package/dist/session-manager-pg.js +1 -0
- package/dist/session-manager.d.ts +16 -12
- package/dist/session-manager.js +4 -1
- package/dist/upstream-contracts.d.ts +84 -0
- package/dist/upstream-contracts.js +714 -6
- package/dist/workspace-registry.d.ts +63 -0
- package/dist/workspace-registry.js +417 -0
- package/dist/xai-api-provider.d.ts +43 -0
- package/dist/xai-api-provider.js +191 -0
- package/migrations/001_initial_schema.sql +65 -0
- package/migrations/002_session_ids_as_text.sql +26 -0
- package/migrations/003_provider_type_sessions.sql +20 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/setup/status.schema.json +42 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type CliType } from "./session-manager.js";
|
|
2
|
+
import type { Logger } from "./logger.js";
|
|
3
|
+
export interface WorkspaceRepo {
|
|
4
|
+
alias: string;
|
|
5
|
+
path: string;
|
|
6
|
+
providers: CliType[];
|
|
7
|
+
allowWorktree: boolean;
|
|
8
|
+
allowAddDir: boolean;
|
|
9
|
+
kind: "git" | "folder";
|
|
10
|
+
operatorEntry: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface WorkspaceAllowedRoot {
|
|
13
|
+
alias: string;
|
|
14
|
+
path: string;
|
|
15
|
+
allowRegisterExistingGitRepos: boolean;
|
|
16
|
+
allowCreateDirectories: boolean;
|
|
17
|
+
allowInitGitRepos: boolean;
|
|
18
|
+
maxCreateDepth: number;
|
|
19
|
+
}
|
|
20
|
+
export interface WorkspaceRegistry {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
defaultAlias: string | null;
|
|
23
|
+
allowUnregisteredWorkingDir: boolean;
|
|
24
|
+
repos: WorkspaceRepo[];
|
|
25
|
+
allowedRoots: WorkspaceAllowedRoot[];
|
|
26
|
+
sources: {
|
|
27
|
+
configFile: string | null;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export interface EffectiveWorkspace {
|
|
31
|
+
alias: string;
|
|
32
|
+
root: string;
|
|
33
|
+
cwd: string;
|
|
34
|
+
worktreePath?: string;
|
|
35
|
+
repo: WorkspaceRepo;
|
|
36
|
+
}
|
|
37
|
+
export interface CreateWorkspaceInput {
|
|
38
|
+
alias: string;
|
|
39
|
+
rootAlias: string;
|
|
40
|
+
slug: string;
|
|
41
|
+
kind: "folder" | "git";
|
|
42
|
+
setDefault?: boolean;
|
|
43
|
+
configPath?: string;
|
|
44
|
+
logger?: Logger;
|
|
45
|
+
}
|
|
46
|
+
export declare class WorkspaceRegistryError extends Error {
|
|
47
|
+
constructor(message: string);
|
|
48
|
+
}
|
|
49
|
+
export declare function validateWorkspaceAlias(alias: string): string;
|
|
50
|
+
export declare function loadWorkspaceRegistry(logger?: Logger, configPath?: string): WorkspaceRegistry;
|
|
51
|
+
export declare function getWorkspace(registry: WorkspaceRegistry, alias: string): WorkspaceRepo;
|
|
52
|
+
export declare function resolveWorkspaceForProvider(registry: WorkspaceRegistry, provider: CliType, requestedAlias?: string, sessionMetadata?: Record<string, unknown>): EffectiveWorkspace;
|
|
53
|
+
export declare function validatePathInsideWorkspace(workspace: EffectiveWorkspace, candidate: string, policy: "workingDir" | "addDir"): string;
|
|
54
|
+
export declare function createWorkspace(input: CreateWorkspaceInput): WorkspaceRepo;
|
|
55
|
+
export declare function registerExistingWorkspace(input: {
|
|
56
|
+
alias: string;
|
|
57
|
+
repoPath: string;
|
|
58
|
+
setDefault?: boolean;
|
|
59
|
+
configPath?: string;
|
|
60
|
+
logger?: Logger;
|
|
61
|
+
}): WorkspaceRepo;
|
|
62
|
+
export declare function createTempWorkspaceConfig(contents: string): string;
|
|
63
|
+
export declare function describeWorkspace(repo: WorkspaceRepo): Record<string, unknown>;
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, openSync, closeSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { z } from "zod/v3";
|
|
7
|
+
import { CLI_TYPES } from "./session-manager.js";
|
|
8
|
+
import { defaultGatewayConfigPath } from "./config.js";
|
|
9
|
+
import { logWarn, noopLogger } from "./logger.js";
|
|
10
|
+
const ALIAS_PATTERN = /^[A-Za-z][A-Za-z0-9._-]{0,63}$/;
|
|
11
|
+
const SAFE_SEGMENT_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
12
|
+
const DENIED_NAMES = new Set([
|
|
13
|
+
".llm-cli-gateway",
|
|
14
|
+
".ssh",
|
|
15
|
+
".aws",
|
|
16
|
+
".azure",
|
|
17
|
+
".config",
|
|
18
|
+
".gnupg",
|
|
19
|
+
".kube",
|
|
20
|
+
".password-store",
|
|
21
|
+
]);
|
|
22
|
+
const WorkspaceRepoSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
alias: z.string().min(1),
|
|
25
|
+
path: z.string().min(1),
|
|
26
|
+
providers: z.array(z.enum(CLI_TYPES)).default([...CLI_TYPES]),
|
|
27
|
+
allow_worktree: z.boolean().default(true),
|
|
28
|
+
allow_add_dir: z.boolean().default(false),
|
|
29
|
+
kind: z.enum(["git", "folder"]).default("git"),
|
|
30
|
+
operator_entry: z.boolean().default(true),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
const WorkspaceAllowedRootSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
alias: z.string().min(1).optional(),
|
|
36
|
+
path: z.string().min(1),
|
|
37
|
+
allow_register_existing_git_repos: z.boolean().default(false),
|
|
38
|
+
allow_create_directories: z.boolean().default(false),
|
|
39
|
+
allow_init_git_repos: z.boolean().default(false),
|
|
40
|
+
max_create_depth: z.number().int().min(1).max(8).default(2),
|
|
41
|
+
})
|
|
42
|
+
.strict();
|
|
43
|
+
const WorkspacesSchema = z
|
|
44
|
+
.object({
|
|
45
|
+
default: z.string().optional(),
|
|
46
|
+
allow_unregistered_working_dir: z.boolean().default(false),
|
|
47
|
+
repos: z.array(WorkspaceRepoSchema).default([]),
|
|
48
|
+
allowed_roots: z.array(WorkspaceAllowedRootSchema).default([]),
|
|
49
|
+
})
|
|
50
|
+
.strict();
|
|
51
|
+
export class WorkspaceRegistryError extends Error {
|
|
52
|
+
constructor(message) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "WorkspaceRegistryError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function expandHome(p) {
|
|
58
|
+
return p === "~" ? homedir() : p.startsWith("~/") ? path.join(homedir(), p.slice(2)) : p;
|
|
59
|
+
}
|
|
60
|
+
function readToml(configPath) {
|
|
61
|
+
if (!existsSync(configPath))
|
|
62
|
+
return {};
|
|
63
|
+
const require = createRequire(import.meta.url);
|
|
64
|
+
const TOML = require("smol-toml");
|
|
65
|
+
return TOML.parse(readFileSync(configPath, "utf8"));
|
|
66
|
+
}
|
|
67
|
+
function writeToml(configPath, data) {
|
|
68
|
+
const require = createRequire(import.meta.url);
|
|
69
|
+
const TOML = require("smol-toml");
|
|
70
|
+
const dir = path.dirname(configPath);
|
|
71
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
72
|
+
const tmp = path.join(dir, `.config.toml.tmp.${process.pid}.${Date.now()}`);
|
|
73
|
+
writeFileSync(tmp, TOML.stringify(data), { mode: 0o600 });
|
|
74
|
+
const fd = openSync(tmp, "r+");
|
|
75
|
+
closeSync(fd);
|
|
76
|
+
renameSync(tmp, configPath);
|
|
77
|
+
chmodSync(configPath, 0o600);
|
|
78
|
+
}
|
|
79
|
+
export function validateWorkspaceAlias(alias) {
|
|
80
|
+
if (!ALIAS_PATTERN.test(alias) || alias === "." || alias === ".." || alias.includes("..")) {
|
|
81
|
+
throw new WorkspaceRegistryError(`Invalid workspace alias "${alias}" (allowed: A-Z a-z 0-9 . _ -, must start with a letter)`);
|
|
82
|
+
}
|
|
83
|
+
return alias;
|
|
84
|
+
}
|
|
85
|
+
function defaultRootAlias(rootPath, existing) {
|
|
86
|
+
const base = path.basename(rootPath).replace(/[^A-Za-z0-9._-]/g, "-") || "root";
|
|
87
|
+
let candidate = ALIAS_PATTERN.test(base) ? base : `root-${base}`;
|
|
88
|
+
let i = 2;
|
|
89
|
+
while (existing.has(candidate)) {
|
|
90
|
+
candidate = `${base}-${i}`;
|
|
91
|
+
i += 1;
|
|
92
|
+
}
|
|
93
|
+
return candidate;
|
|
94
|
+
}
|
|
95
|
+
function assertNotDeniedPath(realPath) {
|
|
96
|
+
const names = realPath.split(path.sep).filter(Boolean);
|
|
97
|
+
for (const name of names) {
|
|
98
|
+
if (DENIED_NAMES.has(name)) {
|
|
99
|
+
throw new WorkspaceRegistryError(`Workspace path targets denied directory "${name}"`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function realExistingPath(p) {
|
|
104
|
+
if (!path.isAbsolute(p)) {
|
|
105
|
+
throw new WorkspaceRegistryError(`Workspace path must be absolute: ${p}`);
|
|
106
|
+
}
|
|
107
|
+
if (!existsSync(p)) {
|
|
108
|
+
throw new WorkspaceRegistryError(`Workspace path does not exist: ${p}`);
|
|
109
|
+
}
|
|
110
|
+
const real = realpathSync(p);
|
|
111
|
+
assertNotDeniedPath(real);
|
|
112
|
+
return real;
|
|
113
|
+
}
|
|
114
|
+
function isGitRepo(p) {
|
|
115
|
+
return existsSync(path.join(p, ".git"));
|
|
116
|
+
}
|
|
117
|
+
function isUnder(parent, child) {
|
|
118
|
+
const relative = path.relative(parent, child);
|
|
119
|
+
return (relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)));
|
|
120
|
+
}
|
|
121
|
+
function normalizeSlug(slug, maxDepth) {
|
|
122
|
+
if (path.isAbsolute(slug)) {
|
|
123
|
+
throw new WorkspaceRegistryError("Workspace slug must be relative");
|
|
124
|
+
}
|
|
125
|
+
const normalized = path.normalize(slug).replace(/\\/g, "/");
|
|
126
|
+
if (normalized === "." || normalized.startsWith("../") || normalized === "..") {
|
|
127
|
+
throw new WorkspaceRegistryError("Workspace slug must not traverse outside the allowed root");
|
|
128
|
+
}
|
|
129
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
130
|
+
if (segments.length === 0 || segments.length > maxDepth) {
|
|
131
|
+
throw new WorkspaceRegistryError(`Workspace slug must contain 1-${maxDepth} path segment(s)`);
|
|
132
|
+
}
|
|
133
|
+
for (const segment of segments) {
|
|
134
|
+
if (segment === "." ||
|
|
135
|
+
segment === ".." ||
|
|
136
|
+
segment.startsWith(".") ||
|
|
137
|
+
segment.includes("..") ||
|
|
138
|
+
!SAFE_SEGMENT_PATTERN.test(segment) ||
|
|
139
|
+
DENIED_NAMES.has(segment)) {
|
|
140
|
+
throw new WorkspaceRegistryError(`Workspace slug segment "${segment}" is not allowed`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return segments;
|
|
144
|
+
}
|
|
145
|
+
export function loadWorkspaceRegistry(logger = noopLogger, configPath = defaultGatewayConfigPath()) {
|
|
146
|
+
const sourcePath = existsSync(configPath) ? configPath : null;
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = readToml(configPath);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
logWarn(logger, "Invalid gateway config; workspace registry disabled", {
|
|
153
|
+
error: err instanceof Error ? err.message : String(err),
|
|
154
|
+
});
|
|
155
|
+
return disabledRegistry(sourcePath);
|
|
156
|
+
}
|
|
157
|
+
const raw = parsed.workspaces ?? {};
|
|
158
|
+
const result = WorkspacesSchema.safeParse(raw);
|
|
159
|
+
if (!result.success) {
|
|
160
|
+
logWarn(logger, "Invalid [workspaces] config; workspace registry disabled", {
|
|
161
|
+
error: result.error.message,
|
|
162
|
+
});
|
|
163
|
+
return disabledRegistry(sourcePath);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const rootAliases = new Set();
|
|
167
|
+
const allowedRoots = result.data.allowed_roots.map(root => {
|
|
168
|
+
const real = realExistingPath(expandHome(root.path));
|
|
169
|
+
const alias = validateWorkspaceAlias(root.alias ?? defaultRootAlias(real, rootAliases));
|
|
170
|
+
rootAliases.add(alias);
|
|
171
|
+
return {
|
|
172
|
+
alias,
|
|
173
|
+
path: real,
|
|
174
|
+
allowRegisterExistingGitRepos: root.allow_register_existing_git_repos,
|
|
175
|
+
allowCreateDirectories: root.allow_create_directories,
|
|
176
|
+
allowInitGitRepos: root.allow_init_git_repos,
|
|
177
|
+
maxCreateDepth: root.max_create_depth,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
const aliases = new Set();
|
|
181
|
+
const repos = result.data.repos.map(repo => {
|
|
182
|
+
const alias = validateWorkspaceAlias(repo.alias);
|
|
183
|
+
if (aliases.has(alias))
|
|
184
|
+
throw new WorkspaceRegistryError(`Duplicate workspace alias "${alias}"`);
|
|
185
|
+
aliases.add(alias);
|
|
186
|
+
const real = realExistingPath(expandHome(repo.path));
|
|
187
|
+
if (repo.kind === "git" && !isGitRepo(real)) {
|
|
188
|
+
throw new WorkspaceRegistryError(`Workspace "${alias}" is not a Git repository`);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
alias,
|
|
192
|
+
path: real,
|
|
193
|
+
providers: repo.providers,
|
|
194
|
+
allowWorktree: repo.allow_worktree,
|
|
195
|
+
allowAddDir: repo.allow_add_dir,
|
|
196
|
+
kind: repo.kind,
|
|
197
|
+
operatorEntry: repo.operator_entry,
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
if (result.data.default && !repos.some(repo => repo.alias === result.data.default)) {
|
|
201
|
+
throw new WorkspaceRegistryError(`[workspaces].default references unknown alias "${result.data.default}"`);
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
enabled: repos.length > 0 || allowedRoots.length > 0,
|
|
205
|
+
defaultAlias: result.data.default ?? null,
|
|
206
|
+
allowUnregisteredWorkingDir: result.data.allow_unregistered_working_dir,
|
|
207
|
+
repos,
|
|
208
|
+
allowedRoots,
|
|
209
|
+
sources: { configFile: sourcePath },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
logWarn(logger, "Invalid [workspaces] config; workspace registry disabled", {
|
|
214
|
+
error: err instanceof Error ? err.message : String(err),
|
|
215
|
+
});
|
|
216
|
+
return disabledRegistry(sourcePath);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function disabledRegistry(sourcePath) {
|
|
220
|
+
return {
|
|
221
|
+
enabled: false,
|
|
222
|
+
defaultAlias: null,
|
|
223
|
+
allowUnregisteredWorkingDir: false,
|
|
224
|
+
repos: [],
|
|
225
|
+
allowedRoots: [],
|
|
226
|
+
sources: { configFile: sourcePath },
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
export function getWorkspace(registry, alias) {
|
|
230
|
+
validateWorkspaceAlias(alias);
|
|
231
|
+
const repo = registry.repos.find(candidate => candidate.alias === alias);
|
|
232
|
+
if (!repo)
|
|
233
|
+
throw new WorkspaceRegistryError(`Unknown workspace alias "${alias}"`);
|
|
234
|
+
return repo;
|
|
235
|
+
}
|
|
236
|
+
export function resolveWorkspaceForProvider(registry, provider, requestedAlias, sessionMetadata) {
|
|
237
|
+
const sessionAlias = typeof sessionMetadata?.workspaceAlias === "string"
|
|
238
|
+
? sessionMetadata.workspaceAlias
|
|
239
|
+
: undefined;
|
|
240
|
+
const alias = requestedAlias ?? sessionAlias ?? registry.defaultAlias;
|
|
241
|
+
if (!alias) {
|
|
242
|
+
throw new WorkspaceRegistryError("No workspace selected. Configure [workspaces].default or pass a registered workspace alias.");
|
|
243
|
+
}
|
|
244
|
+
const repo = getWorkspace(registry, alias);
|
|
245
|
+
if (!repo.providers.includes(provider)) {
|
|
246
|
+
throw new WorkspaceRegistryError(`Workspace "${alias}" does not allow provider "${provider}"`);
|
|
247
|
+
}
|
|
248
|
+
return { alias: repo.alias, root: repo.path, cwd: repo.path, repo };
|
|
249
|
+
}
|
|
250
|
+
export function validatePathInsideWorkspace(workspace, candidate, policy) {
|
|
251
|
+
if (path.isAbsolute(candidate) && policy === "workingDir") {
|
|
252
|
+
throw new WorkspaceRegistryError("Absolute workingDir is not allowed for remote workspaces");
|
|
253
|
+
}
|
|
254
|
+
if (path.isAbsolute(candidate) && policy === "addDir" && !workspace.repo.allowAddDir) {
|
|
255
|
+
throw new WorkspaceRegistryError("Absolute addDir is not allowed for this workspace");
|
|
256
|
+
}
|
|
257
|
+
const resolved = path.isAbsolute(candidate)
|
|
258
|
+
? realExistingPath(candidate)
|
|
259
|
+
: realExistingPath(path.join(workspace.root, candidate));
|
|
260
|
+
if (!isUnder(workspace.root, resolved)) {
|
|
261
|
+
throw new WorkspaceRegistryError(`${policy} must stay inside workspace "${workspace.alias}"`);
|
|
262
|
+
}
|
|
263
|
+
return resolved;
|
|
264
|
+
}
|
|
265
|
+
export function createWorkspace(input) {
|
|
266
|
+
const logger = input.logger ?? noopLogger;
|
|
267
|
+
if (input.kind !== "folder" && input.kind !== "git") {
|
|
268
|
+
throw new WorkspaceRegistryError("Workspace kind must be folder or git");
|
|
269
|
+
}
|
|
270
|
+
const configPath = input.configPath ?? defaultGatewayConfigPath();
|
|
271
|
+
const raw = readToml(configPath);
|
|
272
|
+
const registry = loadWorkspaceRegistry(logger, configPath);
|
|
273
|
+
const alias = validateWorkspaceAlias(input.alias);
|
|
274
|
+
if (registry.repos.some(repo => repo.alias === alias)) {
|
|
275
|
+
throw new WorkspaceRegistryError(`Workspace alias "${alias}" already exists`);
|
|
276
|
+
}
|
|
277
|
+
const root = registry.allowedRoots.find(candidate => candidate.alias === input.rootAlias);
|
|
278
|
+
if (!root)
|
|
279
|
+
throw new WorkspaceRegistryError(`Unknown allowed root "${input.rootAlias}"`);
|
|
280
|
+
if (!root.allowCreateDirectories) {
|
|
281
|
+
throw new WorkspaceRegistryError(`Allowed root "${input.rootAlias}" does not permit creation`);
|
|
282
|
+
}
|
|
283
|
+
if (input.kind === "git" && !root.allowInitGitRepos) {
|
|
284
|
+
throw new WorkspaceRegistryError(`Allowed root "${input.rootAlias}" does not permit git init`);
|
|
285
|
+
}
|
|
286
|
+
const segments = normalizeSlug(input.slug, root.maxCreateDepth);
|
|
287
|
+
const target = path.resolve(root.path, ...segments);
|
|
288
|
+
if (!isUnder(root.path, target)) {
|
|
289
|
+
throw new WorkspaceRegistryError("Workspace target escapes the allowed root");
|
|
290
|
+
}
|
|
291
|
+
const parent = path.dirname(target);
|
|
292
|
+
const parentReal = realExistingPath(parent);
|
|
293
|
+
if (!isUnder(root.path, parentReal)) {
|
|
294
|
+
throw new WorkspaceRegistryError("Workspace parent escapes the allowed root");
|
|
295
|
+
}
|
|
296
|
+
if (existsSync(target)) {
|
|
297
|
+
const stat = statSync(target);
|
|
298
|
+
if (!stat.isDirectory()) {
|
|
299
|
+
throw new WorkspaceRegistryError("Workspace target exists and is not a directory");
|
|
300
|
+
}
|
|
301
|
+
if (readdirSync(target).length > 0) {
|
|
302
|
+
throw new WorkspaceRegistryError("Workspace target exists and is not empty");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
306
|
+
const targetReal = realpathSync(target);
|
|
307
|
+
if (!isUnder(root.path, targetReal)) {
|
|
308
|
+
rmSync(target, { recursive: true, force: true });
|
|
309
|
+
throw new WorkspaceRegistryError("Workspace target escapes the allowed root");
|
|
310
|
+
}
|
|
311
|
+
assertNotDeniedPath(targetReal);
|
|
312
|
+
if (input.kind === "git") {
|
|
313
|
+
const init = spawnSync("git", ["init"], { cwd: targetReal, encoding: "utf8" });
|
|
314
|
+
if (init.status !== 0) {
|
|
315
|
+
throw new WorkspaceRegistryError(`git init failed: ${init.stderr || init.stdout}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const workspaces = (raw.workspaces ?? {});
|
|
319
|
+
const repos = (Array.isArray(workspaces.repos) ? workspaces.repos : []);
|
|
320
|
+
repos.push({
|
|
321
|
+
alias,
|
|
322
|
+
path: targetReal,
|
|
323
|
+
providers: [...CLI_TYPES],
|
|
324
|
+
allow_worktree: input.kind === "git",
|
|
325
|
+
allow_add_dir: false,
|
|
326
|
+
kind: input.kind,
|
|
327
|
+
operator_entry: false,
|
|
328
|
+
});
|
|
329
|
+
workspaces.repos = repos;
|
|
330
|
+
if (input.setDefault)
|
|
331
|
+
workspaces.default = alias;
|
|
332
|
+
raw.workspaces = workspaces;
|
|
333
|
+
writeToml(configPath, raw);
|
|
334
|
+
return {
|
|
335
|
+
alias,
|
|
336
|
+
path: targetReal,
|
|
337
|
+
providers: [...CLI_TYPES],
|
|
338
|
+
allowWorktree: input.kind === "git",
|
|
339
|
+
allowAddDir: false,
|
|
340
|
+
kind: input.kind,
|
|
341
|
+
operatorEntry: false,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
export function registerExistingWorkspace(input) {
|
|
345
|
+
const logger = input.logger ?? noopLogger;
|
|
346
|
+
const configPath = input.configPath ?? defaultGatewayConfigPath();
|
|
347
|
+
const raw = readToml(configPath);
|
|
348
|
+
const registry = loadWorkspaceRegistry(logger, configPath);
|
|
349
|
+
const alias = validateWorkspaceAlias(input.alias);
|
|
350
|
+
if (registry.repos.some(repo => repo.alias === alias)) {
|
|
351
|
+
throw new WorkspaceRegistryError(`Workspace alias "${alias}" already exists`);
|
|
352
|
+
}
|
|
353
|
+
const real = realExistingPath(input.repoPath);
|
|
354
|
+
if (!isGitRepo(real))
|
|
355
|
+
throw new WorkspaceRegistryError("Existing workspace must be a Git repo");
|
|
356
|
+
const root = registry.allowedRoots.find(candidate => isUnder(candidate.path, real));
|
|
357
|
+
if (!root?.allowRegisterExistingGitRepos) {
|
|
358
|
+
throw new WorkspaceRegistryError("No allowed root permits registering this Git repo");
|
|
359
|
+
}
|
|
360
|
+
const workspaces = (raw.workspaces ?? {});
|
|
361
|
+
const repos = (Array.isArray(workspaces.repos) ? workspaces.repos : []);
|
|
362
|
+
repos.push({
|
|
363
|
+
alias,
|
|
364
|
+
path: real,
|
|
365
|
+
providers: [...CLI_TYPES],
|
|
366
|
+
allow_worktree: true,
|
|
367
|
+
allow_add_dir: false,
|
|
368
|
+
kind: "git",
|
|
369
|
+
operator_entry: false,
|
|
370
|
+
});
|
|
371
|
+
workspaces.repos = repos;
|
|
372
|
+
if (input.setDefault)
|
|
373
|
+
workspaces.default = alias;
|
|
374
|
+
raw.workspaces = workspaces;
|
|
375
|
+
writeToml(configPath, raw);
|
|
376
|
+
return {
|
|
377
|
+
alias,
|
|
378
|
+
path: real,
|
|
379
|
+
providers: [...CLI_TYPES],
|
|
380
|
+
allowWorktree: true,
|
|
381
|
+
allowAddDir: false,
|
|
382
|
+
kind: "git",
|
|
383
|
+
operatorEntry: false,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
export function createTempWorkspaceConfig(contents) {
|
|
387
|
+
const dir = mkdtempSync(path.join(tmpdir(), "workspace-registry-test-"));
|
|
388
|
+
const configPath = path.join(dir, "config.toml");
|
|
389
|
+
writeFileSync(configPath, contents, { mode: 0o600 });
|
|
390
|
+
return configPath;
|
|
391
|
+
}
|
|
392
|
+
export function describeWorkspace(repo) {
|
|
393
|
+
let branch = null;
|
|
394
|
+
let dirty = false;
|
|
395
|
+
if (repo.kind === "git") {
|
|
396
|
+
const branchResult = spawnSync("git", ["branch", "--show-current"], {
|
|
397
|
+
cwd: repo.path,
|
|
398
|
+
encoding: "utf8",
|
|
399
|
+
});
|
|
400
|
+
branch = branchResult.status === 0 ? branchResult.stdout.trim() || null : null;
|
|
401
|
+
const status = spawnSync("git", ["status", "--porcelain"], {
|
|
402
|
+
cwd: repo.path,
|
|
403
|
+
encoding: "utf8",
|
|
404
|
+
});
|
|
405
|
+
dirty = status.status === 0 && status.stdout.trim().length > 0;
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
alias: repo.alias,
|
|
409
|
+
path: repo.path,
|
|
410
|
+
kind: repo.kind,
|
|
411
|
+
providers: repo.providers,
|
|
412
|
+
allow_worktree: repo.allowWorktree,
|
|
413
|
+
allow_add_dir: repo.allowAddDir,
|
|
414
|
+
branch,
|
|
415
|
+
dirty,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Logger } from "./logger.js";
|
|
2
|
+
export type XaiResponsesRole = "system" | "user" | "assistant";
|
|
3
|
+
export type XaiReasoningEffort = "none" | "low" | "medium" | "high";
|
|
4
|
+
export interface XaiResponsesInputMessage {
|
|
5
|
+
role: XaiResponsesRole;
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
export interface XaiResponsesRequest {
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
apiKey: string;
|
|
11
|
+
model: string;
|
|
12
|
+
input: string | XaiResponsesInputMessage[];
|
|
13
|
+
instructions?: string;
|
|
14
|
+
previousResponseId?: string;
|
|
15
|
+
maxOutputTokens?: number;
|
|
16
|
+
temperature?: number;
|
|
17
|
+
topP?: number;
|
|
18
|
+
reasoningEffort?: XaiReasoningEffort;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface XaiResponsesUsage {
|
|
22
|
+
inputTokens?: number;
|
|
23
|
+
outputTokens?: number;
|
|
24
|
+
cacheReadTokens?: number;
|
|
25
|
+
costUsd?: number;
|
|
26
|
+
raw?: unknown;
|
|
27
|
+
}
|
|
28
|
+
export interface XaiResponsesResult {
|
|
29
|
+
responseId: string | null;
|
|
30
|
+
model: string;
|
|
31
|
+
status: string | null;
|
|
32
|
+
text: string;
|
|
33
|
+
usage: XaiResponsesUsage;
|
|
34
|
+
raw: unknown;
|
|
35
|
+
httpStatus: number;
|
|
36
|
+
}
|
|
37
|
+
export declare class XaiApiError extends Error {
|
|
38
|
+
readonly status: number | null;
|
|
39
|
+
readonly responseText: string;
|
|
40
|
+
readonly code?: string | undefined;
|
|
41
|
+
constructor(message: string, status?: number | null, responseText?: string, code?: string | undefined);
|
|
42
|
+
}
|
|
43
|
+
export declare function createXaiResponse(params: XaiResponsesRequest, logger?: Logger): Promise<XaiResponsesResult>;
|