@buildifyx/desktop-agent 0.1.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/README.md +337 -0
- package/package.json +32 -0
- package/src/audit/logger.js +30 -0
- package/src/cli/commands/cloud.js +89 -0
- package/src/cli/commands/doctor.js +18 -0
- package/src/cli/commands/login.js +59 -0
- package/src/cli/commands/logout.js +20 -0
- package/src/cli/commands/remote.js +64 -0
- package/src/cli/commands/status.js +32 -0
- package/src/cli/commands/update.js +31 -0
- package/src/cli/help.js +52 -0
- package/src/cli/main.js +55 -0
- package/src/cli/options.js +34 -0
- package/src/cli.js +20 -0
- package/src/core/dispatcher.js +49 -0
- package/src/core/errors.js +37 -0
- package/src/core/permissions.js +53 -0
- package/src/core/runtime.js +45 -0
- package/src/events/bus.js +39 -0
- package/src/permissions/approvals.js +47 -0
- package/src/permissions/controller.js +92 -0
- package/src/permissions/evaluator.js +93 -0
- package/src/permissions/manager.js +57 -0
- package/src/permissions/policy.js +28 -0
- package/src/permissions/store.js +27 -0
- package/src/security/path.js +61 -0
- package/src/security/scope.js +45 -0
- package/src/server.js +1 -0
- package/src/services/commands.js +107 -0
- package/src/services/files.js +104 -0
- package/src/services/index.js +11 -0
- package/src/services/system.js +17 -0
- package/src/transport/cloud.js +206 -0
- package/src/transport/mcp/manifest-store.js +32 -0
- package/src/transport/mcp/response.js +25 -0
- package/src/transport/mcp/server.js +114 -0
- package/src/transport/mcp/tools/commands.js +33 -0
- package/src/transport/mcp/tools/files.js +55 -0
- package/src/transport/mcp/tools/index.js +1 -0
- package/src/transport/mcp/tools/registry.js +92 -0
- package/src/transport/mcp/tools/system.js +16 -0
- package/src/tui/app.js +392 -0
- package/src/tui/commands.js +71 -0
- package/src/tui/index.js +37 -0
- package/src/tui/layout.js +48 -0
- package/src/tui/model.js +62 -0
- package/src/tui/profiles.js +32 -0
- package/src/utils/credentials.js +35 -0
- package/src/utils/text.js +180 -0
- package/src/version.js +111 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { normalizePolicy } from './policy.js';
|
|
3
|
+
import { savePolicy } from './store.js';
|
|
4
|
+
|
|
5
|
+
export function createPolicyManager(initialPolicy, { filePath } = {}) {
|
|
6
|
+
let policy = normalizePolicy(initialPolicy);
|
|
7
|
+
const listeners = new Set();
|
|
8
|
+
|
|
9
|
+
function notify() {
|
|
10
|
+
for (const listener of listeners) listener(policy);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function persist() {
|
|
14
|
+
policy = await savePolicy(policy, filePath);
|
|
15
|
+
notify();
|
|
16
|
+
return policy;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
get: () => policy,
|
|
21
|
+
subscribe(listener) {
|
|
22
|
+
listeners.add(listener);
|
|
23
|
+
return () => listeners.delete(listener);
|
|
24
|
+
},
|
|
25
|
+
async setCategory(category, action) {
|
|
26
|
+
policy = normalizePolicy({ ...policy, categories: { ...policy.categories, [category]: action } });
|
|
27
|
+
return persist();
|
|
28
|
+
},
|
|
29
|
+
async addCommandRule(rule) {
|
|
30
|
+
const normalized = {
|
|
31
|
+
executable: rule.executable,
|
|
32
|
+
argsPrefix: Array.isArray(rule.argsPrefix) ? rule.argsPrefix : [],
|
|
33
|
+
action: rule.action ?? 'ask'
|
|
34
|
+
};
|
|
35
|
+
const commandRules = policy.commandRules.filter((item) =>
|
|
36
|
+
!(item.executable === normalized.executable && JSON.stringify(item.argsPrefix ?? []) === JSON.stringify(normalized.argsPrefix))
|
|
37
|
+
);
|
|
38
|
+
commandRules.push(normalized);
|
|
39
|
+
policy = normalizePolicy({ ...policy, commandRules });
|
|
40
|
+
return persist();
|
|
41
|
+
},
|
|
42
|
+
async removeCommandRule(index) {
|
|
43
|
+
policy = normalizePolicy({ ...policy, commandRules: policy.commandRules.filter((_, current) => current !== index) });
|
|
44
|
+
return persist();
|
|
45
|
+
},
|
|
46
|
+
async addRoot(root) {
|
|
47
|
+
const absolute = path.resolve(root);
|
|
48
|
+
const additionalRoots = [...new Set([...policy.additionalRoots, absolute])];
|
|
49
|
+
policy = normalizePolicy({ ...policy, additionalRoots });
|
|
50
|
+
return persist();
|
|
51
|
+
},
|
|
52
|
+
async removeRoot(index) {
|
|
53
|
+
policy = normalizePolicy({ ...policy, additionalRoots: policy.additionalRoots.filter((_, current) => current !== index) });
|
|
54
|
+
return persist();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const Decision = Object.freeze({
|
|
2
|
+
ALLOW: 'allow',
|
|
3
|
+
ASK: 'ask',
|
|
4
|
+
DENY: 'deny'
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_POLICY = Object.freeze({
|
|
8
|
+
categories: {
|
|
9
|
+
read: Decision.ALLOW,
|
|
10
|
+
write: Decision.ALLOW,
|
|
11
|
+
command: Decision.ALLOW,
|
|
12
|
+
dangerous: Decision.ASK,
|
|
13
|
+
outsideRoot: Decision.ASK
|
|
14
|
+
},
|
|
15
|
+
additionalRoots: [],
|
|
16
|
+
commandRules: []
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export function normalizePolicy(policy = {}) {
|
|
20
|
+
return {
|
|
21
|
+
categories: {
|
|
22
|
+
...DEFAULT_POLICY.categories,
|
|
23
|
+
...(policy.categories ?? {})
|
|
24
|
+
},
|
|
25
|
+
additionalRoots: Array.isArray(policy.additionalRoots) ? [...policy.additionalRoots] : [],
|
|
26
|
+
commandRules: Array.isArray(policy.commandRules) ? policy.commandRules.map((rule) => ({ ...rule })) : []
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { normalizePolicy } from './policy.js';
|
|
5
|
+
|
|
6
|
+
export function defaultPolicyPath() {
|
|
7
|
+
return path.join(os.homedir(), '.buildifyx', 'policy.json');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function loadPolicy(filePath = defaultPolicyPath()) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = await readFile(filePath, 'utf8');
|
|
13
|
+
return normalizePolicy(JSON.parse(raw));
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
16
|
+
return normalizePolicy();
|
|
17
|
+
}
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function savePolicy(policy, filePath = defaultPolicyPath()) {
|
|
23
|
+
const normalized = normalizePolicy(policy);
|
|
24
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
25
|
+
await writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
|
|
26
|
+
return normalized;
|
|
27
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function isInsideRoot(root, target) {
|
|
5
|
+
const relative = path.relative(root, target);
|
|
6
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isGitInternalPath(root, target) {
|
|
10
|
+
const relative = path.relative(root, target);
|
|
11
|
+
return relative.split(path.sep).includes('.git');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function resolveSafePath(root, userPath = '.') {
|
|
15
|
+
const requested = path.resolve(root, userPath);
|
|
16
|
+
|
|
17
|
+
if (!isInsideRoot(root, requested)) {
|
|
18
|
+
throw new Error('Path is outside the allowed root directory.');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (isGitInternalPath(root, requested)) {
|
|
22
|
+
throw new Error('Access to .git internals is not allowed.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Resolve symlinks as well, so a symlink inside root cannot escape root.
|
|
26
|
+
const canonical = await realpath(requested);
|
|
27
|
+
if (!isInsideRoot(root, canonical)) {
|
|
28
|
+
throw new Error('Resolved path is outside the allowed root directory.');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (isGitInternalPath(root, canonical)) {
|
|
32
|
+
throw new Error('Access to .git internals is not allowed.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return canonical;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function resolveSafeNewFilePath(root, userPath) {
|
|
39
|
+
const requested = path.resolve(root, userPath);
|
|
40
|
+
|
|
41
|
+
if (!isInsideRoot(root, requested)) {
|
|
42
|
+
throw new Error('Path is outside the allowed root directory.');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (isGitInternalPath(root, requested)) {
|
|
46
|
+
throw new Error('Access to .git internals is not allowed.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const requestedParent = path.dirname(requested);
|
|
50
|
+
const canonicalParent = await realpath(requestedParent);
|
|
51
|
+
|
|
52
|
+
if (!isInsideRoot(root, canonicalParent)) {
|
|
53
|
+
throw new Error('Resolved parent path is outside the allowed root directory.');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (isGitInternalPath(root, canonicalParent)) {
|
|
57
|
+
throw new Error('Access to .git internals is not allowed.');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return path.join(canonicalParent, path.basename(requested));
|
|
61
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isInsideRoot } from './path.js';
|
|
4
|
+
|
|
5
|
+
function matchingRoot(roots, target) {
|
|
6
|
+
return roots.find((root) => isInsideRoot(root, target)) ?? null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function assertNotGitInternal(target) {
|
|
10
|
+
if (path.resolve(target).split(path.sep).includes('.git')) {
|
|
11
|
+
throw new Error('Access to .git internals is not allowed.');
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function normalizeRoots(primaryRoot, additionalRoots = []) {
|
|
16
|
+
const roots = [await realpath(primaryRoot)];
|
|
17
|
+
for (const candidate of additionalRoots) {
|
|
18
|
+
try {
|
|
19
|
+
const canonical = await realpath(candidate);
|
|
20
|
+
if (!roots.includes(canonical)) roots.push(canonical);
|
|
21
|
+
} catch {
|
|
22
|
+
// Invalid persisted roots are ignored until the user fixes them in the TUI.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return roots;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function classifyPath({ root, additionalRoots = [], userPath = '.', newFile = false }) {
|
|
29
|
+
const roots = await normalizeRoots(root, additionalRoots);
|
|
30
|
+
const requested = path.isAbsolute(userPath) ? path.resolve(userPath) : path.resolve(root, userPath);
|
|
31
|
+
assertNotGitInternal(requested);
|
|
32
|
+
|
|
33
|
+
if (newFile) {
|
|
34
|
+
const parent = await realpath(path.dirname(requested));
|
|
35
|
+
const resolved = path.join(parent, path.basename(requested));
|
|
36
|
+
assertNotGitInternal(resolved);
|
|
37
|
+
const matchedRoot = matchingRoot(roots, resolved);
|
|
38
|
+
return { requested, resolved, matchedRoot, scope: matchedRoot ? (matchedRoot === roots[0] ? 'root' : 'additional') : 'outside' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const resolved = await realpath(requested);
|
|
42
|
+
assertNotGitInternal(resolved);
|
|
43
|
+
const matchedRoot = matchingRoot(roots, resolved);
|
|
44
|
+
return { requested, resolved, matchedRoot, scope: matchedRoot ? (matchedRoot === roots[0] ? 'root' : 'additional') : 'outside' };
|
|
45
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { buildMcpServer, isAllowedOrigin, startMcpServer } from './transport/mcp/server.js';
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { resolveSafePath } from '../security/path.js';
|
|
6
|
+
|
|
7
|
+
export const MAX_OUTPUT_BYTES = 256 * 1024;
|
|
8
|
+
export const MAX_TIMEOUT_MS = 60_000;
|
|
9
|
+
|
|
10
|
+
const RESTRICTED_COMMANDS = new Set(['git', 'npm', 'pnpm', 'yarn']);
|
|
11
|
+
|
|
12
|
+
function validateExecutableName(command) {
|
|
13
|
+
if (command.includes('/') || command.includes('\\') || path.basename(command) !== command) {
|
|
14
|
+
throw new Error('Command must be an executable name, not a path.');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validateRestrictedArgs(command, args) {
|
|
19
|
+
if (command === 'npm' && (args[0] === 'exec' || args[0] === 'x')) {
|
|
20
|
+
throw new Error('npm exec is not allowed in restricted mode.');
|
|
21
|
+
}
|
|
22
|
+
if ((command === 'pnpm' || command === 'yarn') && (args[0] === 'dlx' || args[0] === 'exec')) {
|
|
23
|
+
throw new Error(`${command} ${args[0]} is not allowed in restricted mode.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function validateCommandPolicy(command, args, { fullAccess = false, allowCustomCommand = false } = {}) {
|
|
28
|
+
validateExecutableName(command);
|
|
29
|
+
if (fullAccess || allowCustomCommand) return;
|
|
30
|
+
if (!RESTRICTED_COMMANDS.has(command)) {
|
|
31
|
+
throw new Error(`Command is not allowed in restricted mode: ${command}`);
|
|
32
|
+
}
|
|
33
|
+
validateRestrictedArgs(command, args);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveExecutable(command) {
|
|
37
|
+
if (process.platform === 'win32' && ['npm', 'npx', 'pnpm', 'yarn'].includes(command)) {
|
|
38
|
+
return `${command}.cmd`;
|
|
39
|
+
}
|
|
40
|
+
return command;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function executeFile(executable, args, options) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
execFile(executable, args, options, (error, stdout, stderr) => {
|
|
46
|
+
if (error && typeof error.code === 'string') {
|
|
47
|
+
reject(new Error(`Failed to start ${executable}: ${error.message}`));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
resolve({
|
|
51
|
+
exitCode: error && typeof error.code === 'number' ? error.code : 0,
|
|
52
|
+
signal: error?.signal ?? null,
|
|
53
|
+
timedOut: Boolean(error?.killed && error?.signal),
|
|
54
|
+
stdout: stdout ?? '',
|
|
55
|
+
stderr: stderr ?? ''
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createCommandService({ root, fullAccess = false }) {
|
|
62
|
+
return async function runCommand({ command, args = [], cwd = '.', timeoutMs = 15_000 }, context = {}) {
|
|
63
|
+
validateCommandPolicy(command, args, { fullAccess, allowCustomCommand: context.allowCustomCommand });
|
|
64
|
+
|
|
65
|
+
const resolvedCwd = context.pathInfo?.resolved ?? await resolveSafePath(root, cwd);
|
|
66
|
+
const cwdStat = await stat(resolvedCwd);
|
|
67
|
+
if (!cwdStat.isDirectory()) throw new Error('cwd must be a directory.');
|
|
68
|
+
|
|
69
|
+
context.eventBus?.emit('process.started', {
|
|
70
|
+
requestId: context.requestId,
|
|
71
|
+
command,
|
|
72
|
+
args,
|
|
73
|
+
cwd: resolvedCwd,
|
|
74
|
+
sandboxed: false
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const execution = await executeFile(resolveExecutable(command), args, {
|
|
78
|
+
cwd: resolvedCwd,
|
|
79
|
+
timeout: timeoutMs,
|
|
80
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
81
|
+
windowsHide: true,
|
|
82
|
+
encoding: 'utf8',
|
|
83
|
+
shell: false
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
context.eventBus?.emit('process.completed', {
|
|
87
|
+
requestId: context.requestId,
|
|
88
|
+
command,
|
|
89
|
+
exitCode: execution.exitCode,
|
|
90
|
+
timedOut: execution.timedOut
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
command,
|
|
95
|
+
args,
|
|
96
|
+
cwd: path.relative(root, resolvedCwd) || '.',
|
|
97
|
+
mode: fullAccess ? 'full_access' : context.allowCustomCommand ? 'custom_rule' : 'restricted',
|
|
98
|
+
...execution
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const commandLimits = {
|
|
104
|
+
restrictedCommands: [...RESTRICTED_COMMANDS],
|
|
105
|
+
maxOutputBytes: MAX_OUTPUT_BYTES,
|
|
106
|
+
maxTimeoutMs: MAX_TIMEOUT_MS
|
|
107
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveSafeNewFilePath, resolveSafePath } from '../security/path.js';
|
|
4
|
+
import {
|
|
5
|
+
MAX_TEXT_FILE_BYTES,
|
|
6
|
+
countTextLines,
|
|
7
|
+
createUtf8File,
|
|
8
|
+
readUtf8File,
|
|
9
|
+
replaceCharacters,
|
|
10
|
+
replaceLines,
|
|
11
|
+
writeUtf8FileAtomic
|
|
12
|
+
} from '../utils/text.js';
|
|
13
|
+
|
|
14
|
+
export { MAX_TEXT_FILE_BYTES } from '../utils/text.js';
|
|
15
|
+
|
|
16
|
+
function displayPath(root, target) {
|
|
17
|
+
const relative = path.relative(root, target);
|
|
18
|
+
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : target;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function existingTarget(root, input, context) {
|
|
22
|
+
return context.pathInfo?.resolved ?? resolveSafePath(root, input.path ?? '.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function newTarget(root, input, context) {
|
|
26
|
+
return context.pathInfo?.resolved ?? resolveSafeNewFilePath(root, input.path);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createFileServices({ root }) {
|
|
30
|
+
return {
|
|
31
|
+
async list_directory(input = {}, context = {}) {
|
|
32
|
+
const target = await existingTarget(root, input, context);
|
|
33
|
+
const targetStat = await stat(target);
|
|
34
|
+
if (!targetStat.isDirectory()) throw new Error('The requested path is not a directory.');
|
|
35
|
+
|
|
36
|
+
const dirents = await readdir(target, { withFileTypes: true });
|
|
37
|
+
const limit = input.limit ?? 200;
|
|
38
|
+
const entries = dirents.slice(0, limit).map((entry) => ({
|
|
39
|
+
name: entry.name,
|
|
40
|
+
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : entry.isSymbolicLink() ? 'symlink' : 'other',
|
|
41
|
+
path: displayPath(root, path.join(target, entry.name))
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
context.eventBus?.emit('resource.accessed', { requestId: context.requestId, resourceType: 'directory', operation: 'list', path: target });
|
|
45
|
+
return {
|
|
46
|
+
path: displayPath(root, target),
|
|
47
|
+
scope: context.pathInfo?.scope ?? 'root',
|
|
48
|
+
total: dirents.length,
|
|
49
|
+
returned: entries.length,
|
|
50
|
+
truncated: dirents.length > entries.length,
|
|
51
|
+
entries
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async read_file(input, context = {}) {
|
|
56
|
+
const target = await existingTarget(root, input, context);
|
|
57
|
+
const content = await readUtf8File(target);
|
|
58
|
+
context.eventBus?.emit('resource.accessed', { requestId: context.requestId, resourceType: 'file', operation: 'read', path: target });
|
|
59
|
+
return {
|
|
60
|
+
path: displayPath(root, target),
|
|
61
|
+
scope: context.pathInfo?.scope ?? 'root',
|
|
62
|
+
size: Buffer.byteLength(content, 'utf8'),
|
|
63
|
+
lineCount: countTextLines(content),
|
|
64
|
+
content
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async write_file(input, context = {}) {
|
|
69
|
+
const target = await newTarget(root, input, context);
|
|
70
|
+
await createUtf8File(target, input.content);
|
|
71
|
+
context.eventBus?.emit('resource.accessed', { requestId: context.requestId, resourceType: 'file', operation: 'create', path: target });
|
|
72
|
+
return {
|
|
73
|
+
path: displayPath(root, target),
|
|
74
|
+
scope: context.pathInfo?.scope ?? 'root',
|
|
75
|
+
created: true,
|
|
76
|
+
size: Buffer.byteLength(input.content, 'utf8'),
|
|
77
|
+
lineCount: countTextLines(input.content)
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
async edit_file(input, context = {}) {
|
|
82
|
+
const target = await existingTarget(root, input, context);
|
|
83
|
+
const previousContent = await readUtf8File(target);
|
|
84
|
+
let nextContent;
|
|
85
|
+
switch (input.operation) {
|
|
86
|
+
case 'overwrite': nextContent = input.content; break;
|
|
87
|
+
case 'replace_lines': nextContent = replaceLines(previousContent, input.startLine, input.endLine, input.lines); break;
|
|
88
|
+
case 'replace_characters': nextContent = replaceCharacters(previousContent, input.start, input.end, input.content); break;
|
|
89
|
+
default: throw new Error(`Unsupported edit operation: ${input.operation}`);
|
|
90
|
+
}
|
|
91
|
+
const changed = nextContent !== previousContent;
|
|
92
|
+
if (changed) await writeUtf8FileAtomic(target, nextContent);
|
|
93
|
+
context.eventBus?.emit('resource.accessed', { requestId: context.requestId, resourceType: 'file', operation: 'write', path: target, changed });
|
|
94
|
+
return {
|
|
95
|
+
path: displayPath(root, target),
|
|
96
|
+
scope: context.pathInfo?.scope ?? 'root',
|
|
97
|
+
operation: input.operation,
|
|
98
|
+
changed,
|
|
99
|
+
size: Buffer.byteLength(nextContent, 'utf8'),
|
|
100
|
+
lineCount: countTextLines(nextContent)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createCommandService } from './commands.js';
|
|
2
|
+
import { createFileServices } from './files.js';
|
|
3
|
+
import { createSystemServices } from './system.js';
|
|
4
|
+
|
|
5
|
+
export function createServices({ root, fullAccess = false }) {
|
|
6
|
+
return {
|
|
7
|
+
...createSystemServices({ root }),
|
|
8
|
+
...createFileServices({ root }),
|
|
9
|
+
run_command: createCommandService({ root, fullAccess })
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
|
|
4
|
+
export function createSystemServices({ root }) {
|
|
5
|
+
return {
|
|
6
|
+
async get_system_info() {
|
|
7
|
+
return {
|
|
8
|
+
platform: process.platform,
|
|
9
|
+
arch: process.arch,
|
|
10
|
+
hostname: os.hostname(),
|
|
11
|
+
osRelease: os.release(),
|
|
12
|
+
nodeVersion: process.version,
|
|
13
|
+
allowedRoot: root
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { normalizeError } from '../core/errors.js';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_CLOUD_ORIGIN = 'https://bdxa.buildifyx.com';
|
|
5
|
+
|
|
6
|
+
export function normalizeCloudOrigin(value = DEFAULT_CLOUD_ORIGIN) {
|
|
7
|
+
const url = new URL(value);
|
|
8
|
+
if (!['https:', 'http:'].includes(url.protocol)) throw new Error('Cloud URL must use http or https.');
|
|
9
|
+
url.pathname = url.pathname.replace(/\/+$/, '');
|
|
10
|
+
url.search = '';
|
|
11
|
+
url.hash = '';
|
|
12
|
+
return url.toString().replace(/\/$/, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getCloudEndpoints(origin = DEFAULT_CLOUD_ORIGIN) {
|
|
16
|
+
const normalized = normalizeCloudOrigin(origin);
|
|
17
|
+
const http = new URL(normalized);
|
|
18
|
+
const ws = new URL(normalized);
|
|
19
|
+
ws.protocol = http.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
20
|
+
return {
|
|
21
|
+
origin: normalized,
|
|
22
|
+
loginUrl: new URL('/api/auth/device-login', http).toString(),
|
|
23
|
+
meUrl: new URL('/api/device/me', http).toString(),
|
|
24
|
+
logoutUrl: new URL('/api/device/logout', http).toString(),
|
|
25
|
+
agentUrl: new URL('/agent', ws).toString()
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function parseJsonResponse(response) {
|
|
30
|
+
const text = await response.text();
|
|
31
|
+
let body = null;
|
|
32
|
+
if (text) {
|
|
33
|
+
try { body = JSON.parse(text); } catch { body = { message: text }; }
|
|
34
|
+
}
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const message = body?.error?.message ?? body?.message ?? `Cloud request failed (${response.status})`;
|
|
37
|
+
const error = new Error(message);
|
|
38
|
+
error.status = response.status;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
return body;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function loginDevice({ cloudUrl = DEFAULT_CLOUD_ORIGIN, token, device, signal }) {
|
|
45
|
+
const { loginUrl } = getCloudEndpoints(cloudUrl);
|
|
46
|
+
const response = await fetch(loginUrl, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
49
|
+
body: JSON.stringify({ token, device }),
|
|
50
|
+
signal
|
|
51
|
+
});
|
|
52
|
+
return parseJsonResponse(response);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function getDeviceMe({ cloudUrl = DEFAULT_CLOUD_ORIGIN, deviceToken, signal }) {
|
|
56
|
+
const { meUrl } = getCloudEndpoints(cloudUrl);
|
|
57
|
+
const response = await fetch(meUrl, {
|
|
58
|
+
headers: { authorization: `Bearer ${deviceToken}`, accept: 'application/json' },
|
|
59
|
+
signal
|
|
60
|
+
});
|
|
61
|
+
return parseJsonResponse(response);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function logoutDevice({ cloudUrl = DEFAULT_CLOUD_ORIGIN, deviceToken, signal }) {
|
|
65
|
+
const { logoutUrl } = getCloudEndpoints(cloudUrl);
|
|
66
|
+
const response = await fetch(logoutUrl, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { authorization: `Bearer ${deviceToken}`, accept: 'application/json' },
|
|
69
|
+
signal
|
|
70
|
+
});
|
|
71
|
+
if (response.status === 204) return null;
|
|
72
|
+
return parseJsonResponse(response);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function safeSend(socket, payload) {
|
|
76
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(payload));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createCloudAgent({
|
|
80
|
+
cloudUrl = DEFAULT_CLOUD_ORIGIN,
|
|
81
|
+
credentials,
|
|
82
|
+
runtime,
|
|
83
|
+
manifest,
|
|
84
|
+
version,
|
|
85
|
+
deviceInfo,
|
|
86
|
+
eventBus,
|
|
87
|
+
heartbeatMs = 30_000,
|
|
88
|
+
reconnect = true
|
|
89
|
+
}) {
|
|
90
|
+
const endpoint = getCloudEndpoints(cloudUrl).agentUrl;
|
|
91
|
+
let socket = null;
|
|
92
|
+
let stopped = false;
|
|
93
|
+
let heartbeat = null;
|
|
94
|
+
let retryTimer = null;
|
|
95
|
+
let retryAttempt = 0;
|
|
96
|
+
const listeners = new Set();
|
|
97
|
+
let state = { status: 'disconnected', endpoint, retryAttempt: 0, lastError: null };
|
|
98
|
+
|
|
99
|
+
function setState(patch) {
|
|
100
|
+
state = { ...state, ...patch };
|
|
101
|
+
eventBus?.emit('cloud.connection', state);
|
|
102
|
+
for (const listener of listeners) listener({ ...state });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function scheduleReconnect() {
|
|
106
|
+
if (stopped || !reconnect || retryTimer) return;
|
|
107
|
+
const delays = [1000, 2000, 5000, 10_000, 30_000];
|
|
108
|
+
const delay = delays[Math.min(retryAttempt, delays.length - 1)];
|
|
109
|
+
retryAttempt += 1;
|
|
110
|
+
setState({ status: 'reconnecting', retryAttempt, retryInMs: delay });
|
|
111
|
+
retryTimer = setTimeout(() => {
|
|
112
|
+
retryTimer = null;
|
|
113
|
+
connect();
|
|
114
|
+
}, delay);
|
|
115
|
+
retryTimer.unref?.();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function handleMessage(raw) {
|
|
119
|
+
let message;
|
|
120
|
+
try { message = JSON.parse(raw.toString()); } catch { return; }
|
|
121
|
+
if (message.type === 'ping') {
|
|
122
|
+
safeSend(socket, { type: 'pong', timestamp: new Date().toISOString() });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (message.type !== 'tool.call' || !message.requestId || !message.tool) return;
|
|
126
|
+
|
|
127
|
+
eventBus?.emit('cloud.tool.received', { requestId: message.requestId, tool: message.tool });
|
|
128
|
+
try {
|
|
129
|
+
const result = await runtime.dispatch(message.tool, message.arguments ?? {});
|
|
130
|
+
safeSend(socket, { type: 'tool.result', requestId: message.requestId, result });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const normalized = normalizeError(error);
|
|
133
|
+
safeSend(socket, {
|
|
134
|
+
type: 'tool.error',
|
|
135
|
+
requestId: message.requestId,
|
|
136
|
+
error: { code: normalized.code, message: normalized.message, details: normalized.details }
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function connect() {
|
|
142
|
+
if (stopped) return;
|
|
143
|
+
if (socket && [WebSocket.OPEN, WebSocket.CONNECTING].includes(socket.readyState)) return;
|
|
144
|
+
setState({ status: 'connecting', endpoint, lastError: null });
|
|
145
|
+
socket = new WebSocket(endpoint, {
|
|
146
|
+
headers: {
|
|
147
|
+
authorization: `Bearer ${credentials.deviceToken}`,
|
|
148
|
+
'user-agent': `buildifyx-desktop-agent/${version}`
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
socket.on('open', () => {
|
|
153
|
+
retryAttempt = 0;
|
|
154
|
+
setState({ status: 'connected', retryAttempt: 0, retryInMs: null, connectedAt: new Date().toISOString() });
|
|
155
|
+
safeSend(socket, {
|
|
156
|
+
type: 'device.ready',
|
|
157
|
+
deviceId: credentials.deviceId,
|
|
158
|
+
agentVersion: version,
|
|
159
|
+
device: deviceInfo,
|
|
160
|
+
toolManifest: { count: manifest.count, hash: manifest.hash, manifestVersion: manifest.manifestVersion }
|
|
161
|
+
});
|
|
162
|
+
clearInterval(heartbeat);
|
|
163
|
+
heartbeat = setInterval(() => safeSend(socket, {
|
|
164
|
+
type: 'device.heartbeat',
|
|
165
|
+
deviceId: credentials.deviceId,
|
|
166
|
+
timestamp: new Date().toISOString()
|
|
167
|
+
}), heartbeatMs);
|
|
168
|
+
heartbeat.unref?.();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
socket.on('message', (raw) => { void handleMessage(raw); });
|
|
172
|
+
socket.on('error', (error) => setState({ lastError: error.message }));
|
|
173
|
+
socket.on('close', (code, reason) => {
|
|
174
|
+
clearInterval(heartbeat);
|
|
175
|
+
heartbeat = null;
|
|
176
|
+
socket = null;
|
|
177
|
+
setState({ status: 'disconnected', closeCode: code, closeReason: reason.toString() || null });
|
|
178
|
+
scheduleReconnect();
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function stop() {
|
|
183
|
+
stopped = true;
|
|
184
|
+
clearInterval(heartbeat);
|
|
185
|
+
clearTimeout(retryTimer);
|
|
186
|
+
heartbeat = null;
|
|
187
|
+
retryTimer = null;
|
|
188
|
+
if (!socket) return;
|
|
189
|
+
await new Promise((resolve) => {
|
|
190
|
+
const active = socket;
|
|
191
|
+
const timeout = setTimeout(resolve, 1000);
|
|
192
|
+
active.once('close', () => { clearTimeout(timeout); resolve(); });
|
|
193
|
+
active.close(1000, 'agent shutdown');
|
|
194
|
+
});
|
|
195
|
+
socket = null;
|
|
196
|
+
setState({ status: 'stopped' });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
endpoint,
|
|
201
|
+
connect,
|
|
202
|
+
stop,
|
|
203
|
+
getState: () => ({ ...state }),
|
|
204
|
+
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }
|
|
205
|
+
};
|
|
206
|
+
}
|