@engineeros/connector 0.1.0 → 0.2.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 +17 -13
- package/bin/engineeros-connector.mjs +57 -1
- package/package.json +1 -1
- package/src/config.mjs +7 -0
- package/src/runner.mjs +144 -1
package/README.md
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
# EngineerOS Connector
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Connect a local Codex CLI workspace to EngineerOS through an outbound WebSocket.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Onboard a workspace
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- Git
|
|
9
|
-
- Codex CLI installed and authenticated (`codex login`)
|
|
7
|
+
Create a connection command from **Project steering -> Connect workspace**, then run it inside the local folder:
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
```sh
|
|
10
|
+
npx --yes @engineeros/connector@latest pair PAIRING-CODE --url https://your-engineeros.example --workspace . --onboard
|
|
11
|
+
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The connector uploads a bounded ZIP snapshot for assessment, then stays online for rescans and Goal Runs. Onboarding never executes repository code. It excludes known secrets, dependency directories, build output, caches, Git metadata, and connector state before upload.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
npx --yes @engineeros/connector@latest pair PAIRING-CODE --url https://engineeros.example.com --workspace /path/to/repository
|
|
17
|
-
```
|
|
15
|
+
An empty or document-only folder establishes a greenfield baseline. A code-bearing folder is assessed as brownfield. Use **Rescan** in Steering after the local workspace changes.
|
|
18
16
|
|
|
19
17
|
## Reconnect
|
|
20
18
|
|
|
21
|
-
```
|
|
22
|
-
npx
|
|
19
|
+
```sh
|
|
20
|
+
npx @engineeros/connector start --workspace .
|
|
23
21
|
```
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
Credentials are stored per workspace under `~/.engineeros/connectors` with owner-only permissions where supported.
|
|
24
|
+
|
|
25
|
+
## Run Goals
|
|
26
|
+
|
|
27
|
+
Keep the connector online to receive Goals assigned from EngineerOS. Each Goal runs in an isolated worktree below `~/.engineeros/runs`. Cancellation stops Codex. The connector returns changed paths, a bounded diff, and the exact repository ZIP; a human still performs independent attestation.
|
|
28
|
+
|
|
29
|
+
Requirements: Node.js 22 or newer, Git, and an authenticated Codex CLI (`codex login`).
|
|
@@ -6,8 +6,13 @@ import {
|
|
|
6
6
|
resultUrl,
|
|
7
7
|
saveConfig,
|
|
8
8
|
socketUrl,
|
|
9
|
+
workspaceUrl,
|
|
9
10
|
} from "../src/config.mjs";
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
executeAssignment,
|
|
13
|
+
stopProcess,
|
|
14
|
+
workspaceSnapshot,
|
|
15
|
+
} from "../src/runner.mjs";
|
|
11
16
|
|
|
12
17
|
const { command, positional, flags } = parseArgs(process.argv.slice(2));
|
|
13
18
|
|
|
@@ -34,6 +39,8 @@ if (command === "pair") {
|
|
|
34
39
|
config = {
|
|
35
40
|
server_url: socketUrl(url),
|
|
36
41
|
workspace: path.resolve(flags.workspace || process.cwd()),
|
|
42
|
+
onboard: flags.onboard === true,
|
|
43
|
+
onboarding_pending: flags.onboard === true,
|
|
37
44
|
name:
|
|
38
45
|
flags.name ||
|
|
39
46
|
`${os.hostname()} - ${path.basename(path.resolve(flags.workspace || process.cwd()))}`,
|
|
@@ -70,6 +77,7 @@ let socket;
|
|
|
70
77
|
let pingTimer;
|
|
71
78
|
let reconnectDelay = 1_000;
|
|
72
79
|
let connectionRejected = false;
|
|
80
|
+
let snapshotInFlight = false;
|
|
73
81
|
|
|
74
82
|
process.on("SIGINT", async () => {
|
|
75
83
|
stopped = true;
|
|
@@ -104,12 +112,18 @@ async function connect() {
|
|
|
104
112
|
console.log(`Paired. Connector ${config.connector_id} is online.`);
|
|
105
113
|
reconnectDelay = 1_000;
|
|
106
114
|
startPings();
|
|
115
|
+
if (config.onboarding_pending) void submitWorkspaceSnapshot();
|
|
107
116
|
return;
|
|
108
117
|
}
|
|
109
118
|
if (message.type === "authenticated") {
|
|
110
119
|
console.log("Connected and waiting for EngineerOS runs.");
|
|
111
120
|
reconnectDelay = 1_000;
|
|
112
121
|
startPings();
|
|
122
|
+
if (config.onboarding_pending) void submitWorkspaceSnapshot();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (message.type === "workspace.refresh") {
|
|
126
|
+
void submitWorkspaceSnapshot();
|
|
113
127
|
return;
|
|
114
128
|
}
|
|
115
129
|
if (message.type === "run.available") {
|
|
@@ -135,6 +149,7 @@ async function connect() {
|
|
|
135
149
|
}
|
|
136
150
|
if (message.type === "run.error") {
|
|
137
151
|
console.error(`EngineerOS: ${message.message}`);
|
|
152
|
+
if (!message.run_id) snapshotInFlight = false;
|
|
138
153
|
if (active?.runId === message.run_id && !active.child) {
|
|
139
154
|
active = null;
|
|
140
155
|
pump();
|
|
@@ -164,6 +179,46 @@ async function connect() {
|
|
|
164
179
|
socket.addEventListener("error", () => {});
|
|
165
180
|
}
|
|
166
181
|
|
|
182
|
+
async function submitWorkspaceSnapshot() {
|
|
183
|
+
if (snapshotInFlight || socket.readyState !== WebSocket.OPEN) return;
|
|
184
|
+
snapshotInFlight = true;
|
|
185
|
+
console.log("Inspecting the workspace without executing its code.");
|
|
186
|
+
try {
|
|
187
|
+
const snapshot = await workspaceSnapshot(config.workspace);
|
|
188
|
+
const response = await fetch(
|
|
189
|
+
workspaceUrl(config.server_url, config.connector_id),
|
|
190
|
+
{
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: {
|
|
193
|
+
"Content-Type": "application/json",
|
|
194
|
+
Authorization: `Bearer ${config.token}`,
|
|
195
|
+
},
|
|
196
|
+
body: JSON.stringify(snapshot),
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`EngineerOS rejected the workspace (${response.status}): ${await response.text()}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const result = await response.json();
|
|
205
|
+
config = { ...config, onboarding_pending: false };
|
|
206
|
+
await saveConfig(config);
|
|
207
|
+
console.log(
|
|
208
|
+
`Sent ${snapshot.file_count} safe file(s); ${snapshot.excluded_file_count} sensitive or generated path(s) excluded.`,
|
|
209
|
+
);
|
|
210
|
+
console.log(
|
|
211
|
+
`Workspace assessed as ${result.workspace_kind}. EngineerOS System State is current.`,
|
|
212
|
+
);
|
|
213
|
+
snapshotInFlight = false;
|
|
214
|
+
} catch (error) {
|
|
215
|
+
snapshotInFlight = false;
|
|
216
|
+
console.error(
|
|
217
|
+
`Workspace assessment failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
167
222
|
function startPings() {
|
|
168
223
|
clearInterval(pingTimer);
|
|
169
224
|
pingTimer = setInterval(() => {
|
|
@@ -276,6 +331,7 @@ function parseArgs(args) {
|
|
|
276
331
|
while (args.length) {
|
|
277
332
|
const value = args.shift();
|
|
278
333
|
if (!value.startsWith("--")) positional.push(value);
|
|
334
|
+
else if (value === "--onboard") flags.onboard = true;
|
|
279
335
|
else flags[value.slice(2)] = args.shift();
|
|
280
336
|
}
|
|
281
337
|
return { command, positional, flags };
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -31,6 +31,13 @@ export function resultUrl(websocketUrl, connectorId, runId) {
|
|
|
31
31
|
return url.toString();
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export function workspaceUrl(websocketUrl, connectorId) {
|
|
35
|
+
const url = new URL(websocketUrl);
|
|
36
|
+
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
|
37
|
+
url.pathname = `/api/v1/agent-connectors/${connectorId}/workspace`;
|
|
38
|
+
return url.toString();
|
|
39
|
+
}
|
|
40
|
+
|
|
34
41
|
export async function loadConfig(workspace = process.cwd()) {
|
|
35
42
|
try {
|
|
36
43
|
return JSON.parse(await readFile(configPath(workspace), "utf8"));
|
package/src/runner.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { lstat, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { promisify } from "node:util";
|
|
@@ -8,6 +8,49 @@ import { deflateRaw } from "node:zlib";
|
|
|
8
8
|
|
|
9
9
|
const deflate = promisify(deflateRaw);
|
|
10
10
|
const MAX_ARCHIVE_BYTES = 25 * 1024 * 1024;
|
|
11
|
+
const MAX_WORKSPACE_FILES = 5_000;
|
|
12
|
+
const EXCLUDED_DIRECTORIES = new Set([
|
|
13
|
+
".engineeros",
|
|
14
|
+
".git",
|
|
15
|
+
".next",
|
|
16
|
+
".pytest_cache",
|
|
17
|
+
".ruff_cache",
|
|
18
|
+
".venv",
|
|
19
|
+
"__pycache__",
|
|
20
|
+
"build",
|
|
21
|
+
"coverage",
|
|
22
|
+
"dist",
|
|
23
|
+
"node_modules",
|
|
24
|
+
"target",
|
|
25
|
+
"vendor",
|
|
26
|
+
]);
|
|
27
|
+
const CODE_EXTENSIONS = new Set([
|
|
28
|
+
".c",
|
|
29
|
+
".cpp",
|
|
30
|
+
".cs",
|
|
31
|
+
".go",
|
|
32
|
+
".h",
|
|
33
|
+
".java",
|
|
34
|
+
".js",
|
|
35
|
+
".jsx",
|
|
36
|
+
".mjs",
|
|
37
|
+
".php",
|
|
38
|
+
".py",
|
|
39
|
+
".rb",
|
|
40
|
+
".rs",
|
|
41
|
+
".sql",
|
|
42
|
+
".ts",
|
|
43
|
+
".tsx",
|
|
44
|
+
]);
|
|
45
|
+
const CODE_MARKERS = new Set([
|
|
46
|
+
"cargo.toml",
|
|
47
|
+
"composer.json",
|
|
48
|
+
"go.mod",
|
|
49
|
+
"package.json",
|
|
50
|
+
"pom.xml",
|
|
51
|
+
"pyproject.toml",
|
|
52
|
+
"requirements.txt",
|
|
53
|
+
]);
|
|
11
54
|
|
|
12
55
|
export async function executeAssignment(assignment, config, callbacks) {
|
|
13
56
|
const runWorkspace = await prepareRunWorkspace(
|
|
@@ -54,6 +97,62 @@ export async function stopProcess(child) {
|
|
|
54
97
|
}
|
|
55
98
|
}
|
|
56
99
|
|
|
100
|
+
export async function workspaceSnapshot(workspace) {
|
|
101
|
+
const root = path.resolve(workspace);
|
|
102
|
+
const gitFiles = await run(
|
|
103
|
+
"git",
|
|
104
|
+
["ls-files", "-co", "--exclude-standard"],
|
|
105
|
+
root,
|
|
106
|
+
{ allowFailure: true },
|
|
107
|
+
);
|
|
108
|
+
const candidates =
|
|
109
|
+
gitFiles.code === 0
|
|
110
|
+
? gitFiles.stdout.split(/\r?\n/).map(normalizePath).filter(Boolean)
|
|
111
|
+
: await workspaceFiles(root);
|
|
112
|
+
const files = [];
|
|
113
|
+
let excludedFileCount = 0;
|
|
114
|
+
let totalBytes = 0;
|
|
115
|
+
for (const relative of [...new Set(candidates)].sort()) {
|
|
116
|
+
if (!isShareablePath(relative)) {
|
|
117
|
+
excludedFileCount += 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const absolute = path.join(root, relative);
|
|
121
|
+
const details = await lstat(absolute).catch(() => null);
|
|
122
|
+
if (!details?.isFile() || details.isSymbolicLink()) {
|
|
123
|
+
excludedFileCount += 1;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
totalBytes += details.size;
|
|
127
|
+
if (totalBytes > MAX_ARCHIVE_BYTES) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"Shareable workspace files exceed 25 MB. Exclude large generated or data files before onboarding.",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
files.push({ name: relative, data: await readFile(absolute) });
|
|
133
|
+
if (files.length > MAX_WORKSPACE_FILES) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Workspace exceeds the ${MAX_WORKSPACE_FILES.toLocaleString()}-file onboarding limit. Exclude generated files first.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const archive = await createZip(files);
|
|
140
|
+
const head = await run("git", ["rev-parse", "HEAD"], root, {
|
|
141
|
+
allowFailure: true,
|
|
142
|
+
});
|
|
143
|
+
return {
|
|
144
|
+
archive_base64: archive.toString("base64"),
|
|
145
|
+
media_type: "application/zip",
|
|
146
|
+
workspace_name: path.basename(root),
|
|
147
|
+
workspace_kind: files.some(({ name }) => isCodeBearing(name))
|
|
148
|
+
? "brownfield"
|
|
149
|
+
: "greenfield",
|
|
150
|
+
file_count: files.length,
|
|
151
|
+
excluded_file_count: excludedFileCount,
|
|
152
|
+
head_revision: head.code === 0 ? head.stdout.trim().slice(0, 128) : null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
57
156
|
async function prepareRunWorkspace(workspace, runId, baseRevision) {
|
|
58
157
|
const source = path.resolve(workspace);
|
|
59
158
|
const root = path.join(os.homedir(), ".engineeros", "runs");
|
|
@@ -238,6 +337,50 @@ async function repositoryArchive(workspace) {
|
|
|
238
337
|
);
|
|
239
338
|
}
|
|
240
339
|
|
|
340
|
+
async function workspaceFiles(root, current = root) {
|
|
341
|
+
const files = [];
|
|
342
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
343
|
+
const absolute = path.join(current, entry.name);
|
|
344
|
+
const relative = normalizePath(path.relative(root, absolute));
|
|
345
|
+
if (entry.isSymbolicLink()) continue;
|
|
346
|
+
if (entry.isDirectory()) {
|
|
347
|
+
if (!EXCLUDED_DIRECTORIES.has(entry.name.toLowerCase())) {
|
|
348
|
+
files.push(...(await workspaceFiles(root, absolute)));
|
|
349
|
+
}
|
|
350
|
+
} else if (entry.isFile()) {
|
|
351
|
+
files.push(relative);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return files;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isShareablePath(relative) {
|
|
358
|
+
const normalized = normalizePath(relative);
|
|
359
|
+
if (!normalized || normalized === ".." || normalized.startsWith("../") || path.isAbsolute(normalized)) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
const parts = normalized.split("/");
|
|
363
|
+
if (parts.some((part) => EXCLUDED_DIRECTORIES.has(part.toLowerCase()))) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
const name = parts.at(-1)?.toLowerCase() ?? "";
|
|
367
|
+
if (name === ".env.example" || name === ".env.sample") return true;
|
|
368
|
+
if (
|
|
369
|
+
name === ".env" ||
|
|
370
|
+
name.startsWith(".env.") ||
|
|
371
|
+
[".npmrc", ".netrc", "credentials", "credentials.json"].includes(name) ||
|
|
372
|
+
/\.(?:key|pem|p12|pfx)$/i.test(name)
|
|
373
|
+
) {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function isCodeBearing(relative) {
|
|
380
|
+
const name = path.posix.basename(relative).toLowerCase();
|
|
381
|
+
return CODE_MARKERS.has(name) || CODE_EXTENSIONS.has(path.posix.extname(name));
|
|
382
|
+
}
|
|
383
|
+
|
|
241
384
|
async function worktreeBase(workspace, requested) {
|
|
242
385
|
if (!requested || requested.startsWith("greenfield:")) return "HEAD";
|
|
243
386
|
const exists = await run(
|