@engineeros/connector 0.1.0 → 0.2.1

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 CHANGED
@@ -1,25 +1,29 @@
1
1
  # EngineerOS Connector
2
2
 
3
- The connector keeps an outbound WebSocket open from a repository-owning machine to EngineerOS and executes assigned Goals with Codex CLI.
3
+ Connect a local Codex CLI workspace to EngineerOS through an outbound WebSocket.
4
4
 
5
- ## Requirements
5
+ ## Onboard a workspace
6
6
 
7
- - Node.js 22 or newer
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
- ## Pair once
9
+ ```sh
10
+ npx --yes @engineeros/connector@latest pair PAIRING-CODE --url https://your-engineeros.example --workspace . --onboard
11
+ ```
12
12
 
13
- Open the repository, choose **Connect a machine** in the EngineerOS Goal screen, and run the generated `npx @engineeros/connector pair ...` command. The one-time code expires after 10 minutes. A workspace-scoped connector token is stored below `~/.engineeros/connectors` with owner-only permissions where the operating system supports them.
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, compiled binaries, files larger than 5 MB, agent-tool caches, Git metadata, and connector state before upload.
14
14
 
15
- ```shell
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
- ```shell
22
- npx --yes @engineeros/connector@latest start --workspace /path/to/repository
19
+ ```sh
20
+ npx @engineeros/connector start --workspace .
23
21
  ```
24
22
 
25
- Each Goal runs in an isolated worktree below `~/.engineeros/runs`. Cancellation stops Codex. The connector returns the changed paths, bounded diff, and repository ZIP to EngineerOS; a human still performs independent attestation.
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 { executeAssignment, stopProcess } from "../src/runner.mjs";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@engineeros/connector",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Connect a local Codex CLI workspace to EngineerOS over an outbound WebSocket.",
5
5
  "private": false,
6
6
  "type": "module",
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,78 @@ 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_SHAREABLE_FILE_BYTES = 5 * 1024 * 1024;
12
+ const MAX_WORKSPACE_FILES = 5_000;
13
+ const EXCLUDED_DIRECTORIES = new Set([
14
+ ".agents",
15
+ ".claude",
16
+ ".codex",
17
+ ".engineeros",
18
+ ".forge",
19
+ ".gemini",
20
+ ".git",
21
+ ".next",
22
+ ".pytest_cache",
23
+ ".ruff_cache",
24
+ ".venv",
25
+ "__pycache__",
26
+ "build",
27
+ "coverage",
28
+ "dist",
29
+ "node_modules",
30
+ "target",
31
+ "vendor",
32
+ ]);
33
+ const EXCLUDED_PATH_PREFIXES = [".github/skills/"];
34
+ const EXCLUDED_FILE_EXTENSIONS = new Set([
35
+ ".7z",
36
+ ".a",
37
+ ".bin",
38
+ ".class",
39
+ ".dll",
40
+ ".dylib",
41
+ ".exe",
42
+ ".gz",
43
+ ".jar",
44
+ ".lib",
45
+ ".o",
46
+ ".obj",
47
+ ".pyc",
48
+ ".pyo",
49
+ ".rar",
50
+ ".so",
51
+ ".tar",
52
+ ".tgz",
53
+ ".war",
54
+ ".zip",
55
+ ]);
56
+ const CODE_EXTENSIONS = new Set([
57
+ ".c",
58
+ ".cpp",
59
+ ".cs",
60
+ ".go",
61
+ ".h",
62
+ ".java",
63
+ ".js",
64
+ ".jsx",
65
+ ".mjs",
66
+ ".php",
67
+ ".py",
68
+ ".rb",
69
+ ".rs",
70
+ ".sql",
71
+ ".ts",
72
+ ".tsx",
73
+ ]);
74
+ const CODE_MARKERS = new Set([
75
+ "cargo.toml",
76
+ "composer.json",
77
+ "go.mod",
78
+ "package.json",
79
+ "pom.xml",
80
+ "pyproject.toml",
81
+ "requirements.txt",
82
+ ]);
11
83
 
12
84
  export async function executeAssignment(assignment, config, callbacks) {
13
85
  const runWorkspace = await prepareRunWorkspace(
@@ -54,6 +126,66 @@ export async function stopProcess(child) {
54
126
  }
55
127
  }
56
128
 
129
+ export async function workspaceSnapshot(workspace) {
130
+ const root = path.resolve(workspace);
131
+ const gitFiles = await run(
132
+ "git",
133
+ ["ls-files", "-co", "--exclude-standard"],
134
+ root,
135
+ { allowFailure: true },
136
+ );
137
+ const candidates =
138
+ gitFiles.code === 0
139
+ ? gitFiles.stdout.split(/\r?\n/).map(normalizePath).filter(Boolean)
140
+ : await workspaceFiles(root);
141
+ const files = [];
142
+ let excludedFileCount = 0;
143
+ let totalBytes = 0;
144
+ for (const relative of [...new Set(candidates)].sort()) {
145
+ if (!isShareablePath(relative)) {
146
+ excludedFileCount += 1;
147
+ continue;
148
+ }
149
+ const absolute = path.join(root, relative);
150
+ const details = await lstat(absolute).catch(() => null);
151
+ if (!details?.isFile() || details.isSymbolicLink()) {
152
+ excludedFileCount += 1;
153
+ continue;
154
+ }
155
+ if (details.size > MAX_SHAREABLE_FILE_BYTES) {
156
+ excludedFileCount += 1;
157
+ continue;
158
+ }
159
+ totalBytes += details.size;
160
+ if (totalBytes > MAX_ARCHIVE_BYTES) {
161
+ throw new Error(
162
+ "Shareable workspace files exceed 25 MB. Exclude large generated or data files before onboarding.",
163
+ );
164
+ }
165
+ files.push({ name: relative, data: await readFile(absolute) });
166
+ if (files.length > MAX_WORKSPACE_FILES) {
167
+ throw new Error(
168
+ `Workspace exceeds the ${MAX_WORKSPACE_FILES.toLocaleString()}-file onboarding limit. Exclude generated files first.`,
169
+ );
170
+ }
171
+ }
172
+ const archive = await createZip(files);
173
+ const head = await run("git", ["rev-parse", "HEAD"], root, {
174
+ allowFailure: true,
175
+ });
176
+ return {
177
+ archive_base64: archive.toString("base64"),
178
+ media_type: "application/zip",
179
+ workspace_name: path.basename(root),
180
+ workspace_kind: files.some(({ name }) => isCodeBearing(name))
181
+ ? "brownfield"
182
+ : "greenfield",
183
+ file_count: files.length,
184
+ excluded_file_count: excludedFileCount,
185
+ head_revision: head.code === 0 ? head.stdout.trim().slice(0, 128) : null,
186
+ };
187
+ }
188
+
57
189
  async function prepareRunWorkspace(workspace, runId, baseRevision) {
58
190
  const source = path.resolve(workspace);
59
191
  const root = path.join(os.homedir(), ".engineeros", "runs");
@@ -238,6 +370,58 @@ async function repositoryArchive(workspace) {
238
370
  );
239
371
  }
240
372
 
373
+ async function workspaceFiles(root, current = root) {
374
+ const files = [];
375
+ for (const entry of await readdir(current, { withFileTypes: true })) {
376
+ const absolute = path.join(current, entry.name);
377
+ const relative = normalizePath(path.relative(root, absolute));
378
+ if (entry.isSymbolicLink()) continue;
379
+ if (entry.isDirectory()) {
380
+ if (!EXCLUDED_DIRECTORIES.has(entry.name.toLowerCase())) {
381
+ files.push(...(await workspaceFiles(root, absolute)));
382
+ }
383
+ } else if (entry.isFile()) {
384
+ files.push(relative);
385
+ }
386
+ }
387
+ return files;
388
+ }
389
+
390
+ function isShareablePath(relative) {
391
+ const normalized = normalizePath(relative);
392
+ if (!normalized || normalized === ".." || normalized.startsWith("../") || path.isAbsolute(normalized)) {
393
+ return false;
394
+ }
395
+ const parts = normalized.split("/");
396
+ if (parts.some((part) => EXCLUDED_DIRECTORIES.has(part.toLowerCase()))) {
397
+ return false;
398
+ }
399
+ if (
400
+ EXCLUDED_PATH_PREFIXES.some((prefix) =>
401
+ normalized.toLowerCase().startsWith(prefix),
402
+ )
403
+ ) {
404
+ return false;
405
+ }
406
+ const name = parts.at(-1)?.toLowerCase() ?? "";
407
+ if (EXCLUDED_FILE_EXTENSIONS.has(path.posix.extname(name))) return false;
408
+ if (name === ".env.example" || name === ".env.sample") return true;
409
+ if (
410
+ name === ".env" ||
411
+ name.startsWith(".env.") ||
412
+ [".npmrc", ".netrc", "credentials", "credentials.json"].includes(name) ||
413
+ /\.(?:key|pem|p12|pfx)$/i.test(name)
414
+ ) {
415
+ return false;
416
+ }
417
+ return true;
418
+ }
419
+
420
+ function isCodeBearing(relative) {
421
+ const name = path.posix.basename(relative).toLowerCase();
422
+ return CODE_MARKERS.has(name) || CODE_EXTENSIONS.has(path.posix.extname(name));
423
+ }
424
+
241
425
  async function worktreeBase(workspace, requested) {
242
426
  if (!requested || requested.startsWith("greenfield:")) return "HEAD";
243
427
  const exists = await run(