@cyrnixlab/cyrboard-local-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/CHANGELOG.md +9 -0
- package/CONTRIBUTING.md +20 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/SECURITY.md +32 -0
- package/bin/cyrboard-local-agent.js +8 -0
- package/package.json +52 -0
- package/src/agents.js +147 -0
- package/src/args.js +70 -0
- package/src/cli.js +142 -0
- package/src/config.js +56 -0
- package/src/errors.js +6 -0
- package/src/process.js +36 -0
- package/src/redact.js +22 -0
- package/src/runner.js +57 -0
- package/src/tracker-client.js +62 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-05-24
|
|
4
|
+
|
|
5
|
+
- Initial public MVP for Tracker `Local Agent / MCP` jobs.
|
|
6
|
+
- Added `connect`, `run-once`, `start`, `status`, and `disconnect` commands.
|
|
7
|
+
- Added `codex` and `command` local execution modes.
|
|
8
|
+
- Stores runner token in `.cyrboard/local-agent.json` with `0600` permissions.
|
|
9
|
+
- Does not store one-time setup tokens.
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
This repository contains the local connector that users run next to their own
|
|
4
|
+
source code. Keep changes small, auditable, and explicit.
|
|
5
|
+
|
|
6
|
+
## Development
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm test
|
|
10
|
+
npm run smoke
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Rules
|
|
14
|
+
|
|
15
|
+
- Do not commit tokens, environment files, or local `.cyrboard/` directories.
|
|
16
|
+
- Do not add telemetry or network calls beyond the configured Tracker server.
|
|
17
|
+
- Keep dependencies minimal. Prefer Node.js standard library when practical.
|
|
18
|
+
- Redact bearer tokens in errors, logs, tests, and fixtures.
|
|
19
|
+
- Any change that affects authentication or token storage must update
|
|
20
|
+
`SECURITY.md`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cyrboard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Cyrnix Lab Local Agent
|
|
2
|
+
|
|
3
|
+
Local runner connector for Cyrnix/Cyrboard Tracker `Local Agent / MCP` jobs.
|
|
4
|
+
|
|
5
|
+
The connector runs on a developer machine, inside a project repository. It never
|
|
6
|
+
opens inbound ports. It registers with Tracker once, polls jobs over HTTPS, runs
|
|
7
|
+
the selected local AI tool, and reports status back to Tracker.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx @cyrnixlab/cyrboard-local-agent --help
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Connect a repository
|
|
16
|
+
|
|
17
|
+
Copy the one-time command from the Tracker project AI settings and run it from
|
|
18
|
+
the repository root:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx @cyrnixlab/cyrboard-local-agent connect \
|
|
22
|
+
--server https://tracker.example.com \
|
|
23
|
+
--project-id 1 \
|
|
24
|
+
--token cyr_mcp_xxx \
|
|
25
|
+
--repo . \
|
|
26
|
+
--agent codex
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The setup token is short-lived and is not stored. After registration the local
|
|
30
|
+
config stores only the runner token in `.cyrboard/local-agent.json` with `0600`
|
|
31
|
+
permissions.
|
|
32
|
+
|
|
33
|
+
## Run
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx @cyrnixlab/cyrboard-local-agent start
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For a single claim/execute/report cycle:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx @cyrnixlab/cyrboard-local-agent run-once
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Agent modes
|
|
46
|
+
|
|
47
|
+
- `codex`: runs `codex exec` in the repository.
|
|
48
|
+
- `command`: runs an explicit command. Useful for controlled smoke tests.
|
|
49
|
+
|
|
50
|
+
Command mode example:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npx @cyrnixlab/cyrboard-local-agent connect \
|
|
54
|
+
--server http://localhost:8182 \
|
|
55
|
+
--project-id 1 \
|
|
56
|
+
--token cyr_mcp_xxx \
|
|
57
|
+
--repo . \
|
|
58
|
+
--agent command \
|
|
59
|
+
--command "node ./scripts/local-agent-smoke.js"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The child process receives:
|
|
63
|
+
|
|
64
|
+
- `CYRBOARD_JOB_ID`
|
|
65
|
+
- `CYRBOARD_PROJECT_ID`
|
|
66
|
+
- `CYRBOARD_ISSUE_ID`
|
|
67
|
+
- `CYRBOARD_JOB_KIND`
|
|
68
|
+
- `CYRBOARD_BRANCH_NAME`
|
|
69
|
+
- `CYRBOARD_JOB_PROMPT_PATH`
|
|
70
|
+
- `CYRBOARD_JOB_RESULT_PATH`
|
|
71
|
+
|
|
72
|
+
## Security model
|
|
73
|
+
|
|
74
|
+
- No inbound connection to the developer machine.
|
|
75
|
+
- No secrets are committed or shipped in this repository.
|
|
76
|
+
- The setup token is used only for `/tracker/local-runners/register`.
|
|
77
|
+
- The long-lived runner token is local to the repository checkout.
|
|
78
|
+
- `.cyrboard/` should be ignored by Git.
|
|
79
|
+
- Revoke a runner from the Tracker project AI integration page.
|
|
80
|
+
|
|
81
|
+
## Publishing
|
|
82
|
+
|
|
83
|
+
The package is intended to be published under the npm scope `@cyrnixlab`:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm test
|
|
87
|
+
npm run smoke
|
|
88
|
+
npm pack --dry-run
|
|
89
|
+
npm publish --access public
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Before publishing, make sure the GitHub repository URL in `package.json`
|
|
93
|
+
matches the public repository that hosts this code.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
The current `0.x` line is an early MVP. Security fixes are published in the
|
|
6
|
+
latest available version.
|
|
7
|
+
|
|
8
|
+
## Reporting a vulnerability
|
|
9
|
+
|
|
10
|
+
Report security issues privately by email:
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
cyrboard@cyrnix.dev
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Please include:
|
|
17
|
+
|
|
18
|
+
- package version;
|
|
19
|
+
- operating system;
|
|
20
|
+
- Tracker server version, if relevant;
|
|
21
|
+
- exact command that triggered the issue, with tokens redacted;
|
|
22
|
+
- impact and reproduction steps.
|
|
23
|
+
|
|
24
|
+
Do not open public GitHub issues for vulnerabilities until we have coordinated a
|
|
25
|
+
fix.
|
|
26
|
+
|
|
27
|
+
## Token handling
|
|
28
|
+
|
|
29
|
+
- Setup tokens are short-lived and should not be stored by this connector.
|
|
30
|
+
- Runner tokens are stored only in `.cyrboard/local-agent.json`.
|
|
31
|
+
- `.cyrboard/` must not be committed to a project repository.
|
|
32
|
+
- Logs and error messages should not print bearer tokens.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cyrnixlab/cyrboard-local-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local runner connector for Cyrnix/Cyrboard Tracker Local Agent / MCP jobs.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Cyrnix Lab <cyrboard@cyrnix.dev>",
|
|
8
|
+
"homepage": "https://github.com/CyrnixLab/cyrboard-local-agent#readme",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/CyrnixLab/cyrboard-local-agent/issues",
|
|
11
|
+
"email": "cyrboard@cyrnix.dev"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/CyrnixLab/cyrboard-local-agent.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"cyrnix",
|
|
19
|
+
"cyrboard",
|
|
20
|
+
"tracker",
|
|
21
|
+
"local-agent",
|
|
22
|
+
"mcp",
|
|
23
|
+
"codex",
|
|
24
|
+
"claude"
|
|
25
|
+
],
|
|
26
|
+
"bin": {
|
|
27
|
+
"cyrboard-local-agent": "bin/cyrboard-local-agent.js",
|
|
28
|
+
"cyrnixlab-local-agent": "bin/cyrboard-local-agent.js",
|
|
29
|
+
"cyrnix-local-agent": "bin/cyrboard-local-agent.js"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20.0.0"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"bin/",
|
|
36
|
+
"src/",
|
|
37
|
+
"README.md",
|
|
38
|
+
"CHANGELOG.md",
|
|
39
|
+
"CONTRIBUTING.md",
|
|
40
|
+
"SECURITY.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "node --test",
|
|
48
|
+
"smoke": "node ./bin/cyrboard-local-agent.js --help",
|
|
49
|
+
"pack:dry-run": "npm pack --dry-run",
|
|
50
|
+
"prepublishOnly": "npm test && npm run smoke"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/agents.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { runProcess } from './process.js';
|
|
4
|
+
|
|
5
|
+
export async function runAgent(config, job, repoPath) {
|
|
6
|
+
const workspaceDir = resolve(repoPath, '.cyrboard', 'jobs');
|
|
7
|
+
const promptPath = resolve(workspaceDir, `${job.id}-prompt.md`);
|
|
8
|
+
const resultPath = resolve(workspaceDir, `${job.id}-result.md`);
|
|
9
|
+
const prompt = buildPrompt(job);
|
|
10
|
+
|
|
11
|
+
await mkdir(workspaceDir, { recursive: true, mode: 0o700 });
|
|
12
|
+
await writeFile(promptPath, prompt, { mode: 0o600 });
|
|
13
|
+
|
|
14
|
+
if (config.agent === 'codex') {
|
|
15
|
+
return runCodexAgent(config, job, repoPath, promptPath, resultPath);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (config.agent === 'command') {
|
|
19
|
+
return runCommandAgent(config, job, repoPath, promptPath, resultPath);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
throw new Error(`Unsupported agent mode: ${config.agent}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildPrompt(job) {
|
|
26
|
+
return [
|
|
27
|
+
`# Cyrboard Tracker Job #${job.id}`,
|
|
28
|
+
'',
|
|
29
|
+
`Project ID: ${job.projectId}`,
|
|
30
|
+
`Issue ID: ${job.issueId}`,
|
|
31
|
+
`Job kind: ${job.jobKind}`,
|
|
32
|
+
`Command ID: ${job.commandId}`,
|
|
33
|
+
`Branch: ${job.branchName || '-'}`,
|
|
34
|
+
'',
|
|
35
|
+
'## Prompt',
|
|
36
|
+
'',
|
|
37
|
+
job.promptText || '',
|
|
38
|
+
'',
|
|
39
|
+
'## Input',
|
|
40
|
+
'',
|
|
41
|
+
'```json',
|
|
42
|
+
JSON.stringify(job.input || {}, null, 2),
|
|
43
|
+
'```',
|
|
44
|
+
'',
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function runCodexAgent(config, job, repoPath, promptPath, resultPath) {
|
|
49
|
+
const args = [
|
|
50
|
+
'exec',
|
|
51
|
+
'--cd',
|
|
52
|
+
repoPath,
|
|
53
|
+
'--skip-git-repo-check',
|
|
54
|
+
'--sandbox',
|
|
55
|
+
config.sandbox || 'workspace-write',
|
|
56
|
+
'--output-last-message',
|
|
57
|
+
resultPath,
|
|
58
|
+
'-',
|
|
59
|
+
];
|
|
60
|
+
const env = buildJobEnv(config, job, promptPath, resultPath);
|
|
61
|
+
const prompt = await import('node:fs/promises').then((fs) => fs.readFile(promptPath, 'utf8'));
|
|
62
|
+
const result = await runWithInput('codex', args, prompt, { cwd: repoPath, env });
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
summary: trimSummary(result.stdout || result.stderr || `Codex completed job #${job.id}.`),
|
|
66
|
+
resultPath,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function runCommandAgent(config, job, repoPath, promptPath, resultPath) {
|
|
71
|
+
if (!config.command) {
|
|
72
|
+
throw new Error('Command agent requires --command.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const env = buildJobEnv(config, job, promptPath, resultPath);
|
|
76
|
+
const result = await runProcess(config.command, [], {
|
|
77
|
+
cwd: repoPath,
|
|
78
|
+
env,
|
|
79
|
+
shell: true,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
summary: trimSummary(result.stdout || `Command agent completed job #${job.id}.`),
|
|
84
|
+
resultPath,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildJobEnv(config, job, promptPath, resultPath) {
|
|
89
|
+
return {
|
|
90
|
+
...process.env,
|
|
91
|
+
CYRBOARD_SERVER: config.serverUrl,
|
|
92
|
+
CYRBOARD_PROJECT_ID: String(job.projectId),
|
|
93
|
+
CYRBOARD_ISSUE_ID: String(job.issueId),
|
|
94
|
+
CYRBOARD_JOB_ID: String(job.id),
|
|
95
|
+
CYRBOARD_JOB_KIND: String(job.jobKind || ''),
|
|
96
|
+
CYRBOARD_BRANCH_NAME: String(job.branchName || ''),
|
|
97
|
+
CYRBOARD_JOB_PROMPT_PATH: promptPath,
|
|
98
|
+
CYRBOARD_JOB_RESULT_PATH: resultPath,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function trimSummary(value) {
|
|
103
|
+
const normalized = String(value || '').trim();
|
|
104
|
+
|
|
105
|
+
if (normalized.length <= 2000) {
|
|
106
|
+
return normalized;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return `${normalized.slice(0, 1997)}...`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function runWithInput(command, args, input, options = {}) {
|
|
113
|
+
const { spawn } = await import('node:child_process');
|
|
114
|
+
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const child = spawn(command, args, {
|
|
117
|
+
cwd: options.cwd,
|
|
118
|
+
env: options.env,
|
|
119
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
|
+
});
|
|
121
|
+
let stdout = '';
|
|
122
|
+
let stderr = '';
|
|
123
|
+
|
|
124
|
+
child.stdout.on('data', (chunk) => {
|
|
125
|
+
stdout += chunk.toString();
|
|
126
|
+
process.stdout.write(chunk);
|
|
127
|
+
});
|
|
128
|
+
child.stderr.on('data', (chunk) => {
|
|
129
|
+
stderr += chunk.toString();
|
|
130
|
+
process.stderr.write(chunk);
|
|
131
|
+
});
|
|
132
|
+
child.on('error', reject);
|
|
133
|
+
child.on('close', (code) => {
|
|
134
|
+
const result = { code: code ?? 0, stdout, stderr };
|
|
135
|
+
|
|
136
|
+
if (result.code === 0) {
|
|
137
|
+
resolve(result);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const error = new Error(`${command} exited with code ${result.code}.`);
|
|
142
|
+
error.result = result;
|
|
143
|
+
reject(error);
|
|
144
|
+
});
|
|
145
|
+
child.stdin.end(input);
|
|
146
|
+
});
|
|
147
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { UsageError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export function parseArgs(argv) {
|
|
4
|
+
const args = { _: [] };
|
|
5
|
+
|
|
6
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
7
|
+
const item = argv[index];
|
|
8
|
+
|
|
9
|
+
if (!item.startsWith('--')) {
|
|
10
|
+
args._.push(item);
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const eqIndex = item.indexOf('=');
|
|
15
|
+
|
|
16
|
+
if (eqIndex > 2) {
|
|
17
|
+
args[item.slice(2, eqIndex)] = item.slice(eqIndex + 1);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const key = item.slice(2);
|
|
22
|
+
const next = argv[index + 1];
|
|
23
|
+
|
|
24
|
+
if (next === undefined || next.startsWith('--')) {
|
|
25
|
+
args[key] = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
args[key] = next;
|
|
30
|
+
index += 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return args;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function requiredString(args, key) {
|
|
37
|
+
const value = args[key];
|
|
38
|
+
|
|
39
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
40
|
+
throw new UsageError(`Missing required --${key}.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return value.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function optionalString(args, key, fallback) {
|
|
47
|
+
const value = args[key];
|
|
48
|
+
|
|
49
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return value.trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function optionalInt(args, key, fallback) {
|
|
57
|
+
const value = args[key];
|
|
58
|
+
|
|
59
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
60
|
+
return fallback;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const parsed = Number.parseInt(value, 10);
|
|
64
|
+
|
|
65
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
66
|
+
throw new UsageError(`--${key} must be a positive integer.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { parseArgs, optionalInt, optionalString, requiredString } from './args.js';
|
|
2
|
+
import { isLocalConfigIgnored, loadConfig, removeConfig, resolveRepoPath, saveConfig } from './config.js';
|
|
3
|
+
import { UsageError } from './errors.js';
|
|
4
|
+
import { redactSecrets } from './redact.js';
|
|
5
|
+
import { TrackerClient } from './tracker-client.js';
|
|
6
|
+
import { runOnce, startLoop } from './runner.js';
|
|
7
|
+
|
|
8
|
+
export async function main(argv) {
|
|
9
|
+
const args = parseArgs(argv);
|
|
10
|
+
const command = args._[0];
|
|
11
|
+
|
|
12
|
+
if (!command || args.help === true || command === 'help') {
|
|
13
|
+
printHelp();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (command === 'connect') {
|
|
18
|
+
await connect(args);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (command === 'run-once') {
|
|
23
|
+
await runFromConfig(args, true);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (command === 'start') {
|
|
28
|
+
await runFromConfig(args, false);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (command === 'status') {
|
|
33
|
+
await status(args);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (command === 'disconnect') {
|
|
38
|
+
await disconnect(args);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw new UsageError(`Unknown command: ${command}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function connect(args) {
|
|
46
|
+
const serverUrl = requiredString(args, 'server').replace(/\/+$/, '');
|
|
47
|
+
const projectId = optionalInt(args, 'project-id', 0);
|
|
48
|
+
const setupToken = requiredString(args, 'token');
|
|
49
|
+
const repoPath = resolveRepoPath(optionalString(args, 'repo', '.'));
|
|
50
|
+
const agent = optionalString(args, 'agent', 'codex');
|
|
51
|
+
const label = optionalString(args, 'label', `${process.platform} local agent`);
|
|
52
|
+
const command = optionalString(args, 'command', '');
|
|
53
|
+
const sandbox = optionalString(args, 'sandbox', 'workspace-write');
|
|
54
|
+
|
|
55
|
+
if (projectId <= 0) {
|
|
56
|
+
throw new UsageError('Missing required --project-id.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!['codex', 'command'].includes(agent)) {
|
|
60
|
+
throw new UsageError('--agent must be codex or command.');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (agent === 'command' && command === '') {
|
|
64
|
+
throw new UsageError('--command is required for command agent mode.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const client = new TrackerClient(serverUrl);
|
|
68
|
+
const registered = await client.register({ setupToken, projectId, label });
|
|
69
|
+
|
|
70
|
+
if (typeof registered.rawToken !== 'string' || registered.rawToken.trim() === '') {
|
|
71
|
+
throw new Error('Tracker did not return a runner token.');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await saveConfig(repoPath, {
|
|
75
|
+
schemaVersion: 1,
|
|
76
|
+
serverUrl,
|
|
77
|
+
projectId,
|
|
78
|
+
runnerId: registered.runnerId,
|
|
79
|
+
runnerToken: registered.rawToken,
|
|
80
|
+
label,
|
|
81
|
+
agent,
|
|
82
|
+
command,
|
|
83
|
+
sandbox,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
console.log(`Local runner #${registered.runnerId} connected.`);
|
|
87
|
+
console.log(`Config saved to ${repoPath}/.cyrboard/local-agent.json.`);
|
|
88
|
+
|
|
89
|
+
if (!(await isLocalConfigIgnored(repoPath))) {
|
|
90
|
+
console.warn('Warning: add .cyrboard/ to this repository .gitignore before committing.');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function runFromConfig(args, once) {
|
|
95
|
+
const repoPath = resolveRepoPath(optionalString(args, 'repo', '.'));
|
|
96
|
+
const config = await loadConfig(repoPath);
|
|
97
|
+
|
|
98
|
+
if (once) {
|
|
99
|
+
await runOnce(config, repoPath);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const intervalSeconds = optionalInt(args, 'interval', 10);
|
|
104
|
+
await startLoop(config, repoPath, intervalSeconds);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function status(args) {
|
|
108
|
+
const repoPath = resolveRepoPath(optionalString(args, 'repo', '.'));
|
|
109
|
+
const config = await loadConfig(repoPath);
|
|
110
|
+
|
|
111
|
+
console.log(`Server: ${config.serverUrl}`);
|
|
112
|
+
console.log(`Project ID: ${config.projectId}`);
|
|
113
|
+
console.log(`Runner ID: ${config.runnerId}`);
|
|
114
|
+
console.log(`Agent: ${config.agent}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function disconnect(args) {
|
|
118
|
+
const repoPath = resolveRepoPath(optionalString(args, 'repo', '.'));
|
|
119
|
+
|
|
120
|
+
await removeConfig(repoPath);
|
|
121
|
+
console.log('Local config removed. Revoke the runner in Cyrboard UI as well.');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function printHelp() {
|
|
125
|
+
console.log(`Cyrnix Lab Local Agent
|
|
126
|
+
|
|
127
|
+
Usage:
|
|
128
|
+
cyrnixlab-local-agent connect --server <url> --project-id <id> --token <setup-token> [--repo .] [--agent codex]
|
|
129
|
+
cyrnixlab-local-agent run-once [--repo .]
|
|
130
|
+
cyrnixlab-local-agent start [--repo .] [--interval 10]
|
|
131
|
+
cyrnixlab-local-agent status [--repo .]
|
|
132
|
+
cyrnixlab-local-agent disconnect [--repo .]
|
|
133
|
+
|
|
134
|
+
Agent modes:
|
|
135
|
+
codex Run codex exec locally.
|
|
136
|
+
command Run an explicit shell command, intended for controlled smoke tests.
|
|
137
|
+
`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function formatCliError(error) {
|
|
141
|
+
return redactSecrets(error instanceof Error ? error.message : String(error));
|
|
142
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile, chmod } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const CONFIG_DIR = '.cyrboard';
|
|
5
|
+
const CONFIG_FILE = 'local-agent.json';
|
|
6
|
+
|
|
7
|
+
export function resolveRepoPath(repo) {
|
|
8
|
+
return resolve(process.cwd(), repo || '.');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function configPath(repoPath) {
|
|
12
|
+
return resolve(repoPath, CONFIG_DIR, CONFIG_FILE);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function saveConfig(repoPath, config) {
|
|
16
|
+
const dir = resolve(repoPath, CONFIG_DIR);
|
|
17
|
+
const path = configPath(repoPath);
|
|
18
|
+
const body = `${JSON.stringify(config, null, 2)}\n`;
|
|
19
|
+
|
|
20
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
21
|
+
await writeFile(path, body, { mode: 0o600 });
|
|
22
|
+
await chmod(path, 0o600);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function loadConfig(repoPath) {
|
|
26
|
+
const raw = await readFile(configPath(repoPath), 'utf8');
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
|
|
29
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
30
|
+
throw new Error('Local agent config is invalid.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function removeConfig(repoPath) {
|
|
37
|
+
await rm(configPath(repoPath), { force: true });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function isLocalConfigIgnored(repoPath) {
|
|
41
|
+
try {
|
|
42
|
+
const gitignore = await readFile(resolve(repoPath, '.gitignore'), 'utf8');
|
|
43
|
+
const entries = gitignore
|
|
44
|
+
.split(/\r?\n/)
|
|
45
|
+
.map((line) => line.trim())
|
|
46
|
+
.filter((line) => line !== '' && !line.startsWith('#'));
|
|
47
|
+
|
|
48
|
+
return entries.includes(CONFIG_DIR) || entries.includes(`${CONFIG_DIR}/`) || entries.includes(`${CONFIG_DIR}/**`);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error && error.code === 'ENOENT') {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/errors.js
ADDED
package/src/process.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export async function runProcess(command, args, options = {}) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const child = spawn(command, args, {
|
|
6
|
+
cwd: options.cwd,
|
|
7
|
+
env: options.env,
|
|
8
|
+
shell: options.shell || false,
|
|
9
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
10
|
+
});
|
|
11
|
+
let stdout = '';
|
|
12
|
+
let stderr = '';
|
|
13
|
+
|
|
14
|
+
child.stdout.on('data', (chunk) => {
|
|
15
|
+
stdout += chunk.toString();
|
|
16
|
+
process.stdout.write(chunk);
|
|
17
|
+
});
|
|
18
|
+
child.stderr.on('data', (chunk) => {
|
|
19
|
+
stderr += chunk.toString();
|
|
20
|
+
process.stderr.write(chunk);
|
|
21
|
+
});
|
|
22
|
+
child.on('error', reject);
|
|
23
|
+
child.on('close', (code) => {
|
|
24
|
+
const result = { code: code ?? 0, stdout, stderr };
|
|
25
|
+
|
|
26
|
+
if (result.code === 0) {
|
|
27
|
+
resolve(result);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const error = new Error(`${command} exited with code ${result.code}.`);
|
|
32
|
+
error.result = result;
|
|
33
|
+
reject(error);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
package/src/redact.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const TOKEN_PATTERNS = [
|
|
2
|
+
/cyr_mcp_[a-f0-9]+/gi,
|
|
3
|
+
/cyr_runner_[a-f0-9]+/gi,
|
|
4
|
+
/(Authorization:\s*Bearer\s+)[^\s]+/gi,
|
|
5
|
+
/(Bearer\s+)cyr_[^\s]+/gi,
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export function redactSecrets(value) {
|
|
9
|
+
let result = String(value || '');
|
|
10
|
+
|
|
11
|
+
for (const pattern of TOKEN_PATTERNS) {
|
|
12
|
+
result = result.replace(pattern, (match, prefix = '') => {
|
|
13
|
+
if (typeof prefix === 'string' && prefix !== '') {
|
|
14
|
+
return `${prefix}[redacted]`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return '[redacted-token]';
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return result;
|
|
22
|
+
}
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { runAgent } from './agents.js';
|
|
3
|
+
import { TrackerClient } from './tracker-client.js';
|
|
4
|
+
|
|
5
|
+
export async function runOnce(config, repoPath) {
|
|
6
|
+
const client = new TrackerClient(config.serverUrl);
|
|
7
|
+
const claim = await client.claim(config.runnerToken);
|
|
8
|
+
const job = claim.job || null;
|
|
9
|
+
|
|
10
|
+
if (job === null) {
|
|
11
|
+
console.log('No queued local_mcp jobs.');
|
|
12
|
+
return { claimed: false };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
console.log(`Claimed job #${job.id} (${job.jobKind}).`);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
await client.heartbeat(config.runnerToken, {
|
|
19
|
+
jobId: job.id,
|
|
20
|
+
progressPercent: 10,
|
|
21
|
+
progressStage: 'local_agent_started',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const result = await runAgent(config, job, repoPath);
|
|
25
|
+
|
|
26
|
+
await client.heartbeat(config.runnerToken, {
|
|
27
|
+
jobId: job.id,
|
|
28
|
+
progressPercent: 90,
|
|
29
|
+
progressStage: 'local_agent_reporting',
|
|
30
|
+
});
|
|
31
|
+
await client.complete(config.runnerToken, {
|
|
32
|
+
jobId: job.id,
|
|
33
|
+
resultSummary: result.summary,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
console.log(`Completed job #${job.id}.`);
|
|
37
|
+
return { claimed: true, jobId: job.id };
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
40
|
+
|
|
41
|
+
await client.fail(config.runnerToken, {
|
|
42
|
+
jobId: job.id,
|
|
43
|
+
errorMessage: message,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function startLoop(config, repoPath, intervalSeconds) {
|
|
51
|
+
console.log(`Local agent started. Poll interval: ${intervalSeconds}s.`);
|
|
52
|
+
|
|
53
|
+
while (true) {
|
|
54
|
+
await runOnce(config, repoPath);
|
|
55
|
+
await delay(intervalSeconds * 1000);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { redactSecrets } from './redact.js';
|
|
2
|
+
|
|
3
|
+
export class TrackerClient {
|
|
4
|
+
constructor(serverUrl) {
|
|
5
|
+
this.serverUrl = normalizeServerUrl(serverUrl);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async register({ setupToken, projectId, label }) {
|
|
9
|
+
return this.postJson('/tracker/local-runners/register', setupToken, {
|
|
10
|
+
projectId,
|
|
11
|
+
label,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async claim(runnerToken) {
|
|
16
|
+
return this.postJson('/tracker/local-runners/claim', runnerToken, {});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async heartbeat(runnerToken, payload) {
|
|
20
|
+
return this.postJson('/tracker/local-runners/heartbeat', runnerToken, payload);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async complete(runnerToken, payload) {
|
|
24
|
+
return this.postJson('/tracker/local-runners/complete', runnerToken, payload);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async fail(runnerToken, payload) {
|
|
28
|
+
return this.postJson('/tracker/local-runners/fail', runnerToken, payload);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async postJson(path, bearerToken, payload) {
|
|
32
|
+
const response = await fetch(`${this.serverUrl}${path}`, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: {
|
|
35
|
+
Authorization: `Bearer ${bearerToken}`,
|
|
36
|
+
'Content-Type': 'application/json',
|
|
37
|
+
Accept: 'application/json',
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify(payload),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const text = await response.text();
|
|
43
|
+
const data = text.trim() !== '' ? JSON.parse(text) : {};
|
|
44
|
+
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
const message = typeof data.error === 'string' ? data.error : text;
|
|
47
|
+
throw new Error(redactSecrets(`Tracker request failed: ${response.status} ${message}`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return data;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeServerUrl(serverUrl) {
|
|
55
|
+
const normalized = String(serverUrl || '').trim().replace(/\/+$/, '');
|
|
56
|
+
|
|
57
|
+
if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
|
|
58
|
+
throw new Error('--server must start with http:// or https://.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|