@agentconnect.md/daemon 1.0.0-rc.62 → 1.0.0-rc.64

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/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { EventEmitter } from "node:events";
4
4
  import childProcess, { execFile, spawn } from "node:child_process";
5
5
  import * as sp from "node:path";
6
6
  import path, { delimiter, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
7
- import fs, { accessSync, chmodSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, stat, unlinkSync, unwatchFile, watch, watchFile, writeFileSync } from "node:fs";
7
+ import fs, { accessSync, chmodSync, constants, existsSync, mkdirSync, mkdtempSync, promises, readFileSync, readdirSync, rmSync, stat, unlinkSync, unwatchFile, watch, watchFile, writeFileSync } from "node:fs";
8
8
  import process$1 from "node:process";
9
9
  import { stripVTControlCharacters } from "node:util";
10
10
  import { fileURLToPath } from "node:url";
@@ -13254,6 +13254,72 @@ const SessionHistoryPage = object({
13254
13254
  nextCursor: string().optional()
13255
13255
  });
13256
13256
  //#endregion
13257
+ //#region ../protocol/dist/frames/workspace.js
13258
+ /**
13259
+ * Agent workspace file browsing (C→D REQ → REP) — the console's on-demand pulls.
13260
+ *
13261
+ * The CP stores NO workspace data — directory listings and file bytes are pulled
13262
+ * live from the owning daemon and proxied to the console, never persisted
13263
+ * (body-locality). Missing paths are DATA (`exists:false`), not errors; only
13264
+ * path-containment violations (`BAD_PAYLOAD`) and unexpected fs failures
13265
+ * (`INTERNAL`) come back as `error` frames.
13266
+ *
13267
+ * - `workspace/list`: one cursor-paginated page of a directory's entries.
13268
+ * - `workspace/read`: one byte slice of a file. `limit` is only a ceiling — the
13269
+ * daemon returns FEWER bytes when needed to keep the JSON-escaped REP under the
13270
+ * 256 KiB frame cap (control bytes escape to `\uXXXX`, a 6× blowup), and always
13271
+ * ends the slice on a UTF-8 character boundary. `nextOffset` is the authoritative
13272
+ * byte offset to request next — callers must NOT recompute it from the decoded
13273
+ * `content` (a split multi-byte char would make the byte count drift).
13274
+ */
13275
+ /** One directory entry in a workspace listing (name-only; not a full path). */
13276
+ const WorkspaceEntry = object({
13277
+ name: string(),
13278
+ type: _enum([
13279
+ "dir",
13280
+ "file",
13281
+ "symlink",
13282
+ "other"
13283
+ ]),
13284
+ size: number().int().nonnegative().optional(),
13285
+ mtime: string().optional()
13286
+ });
13287
+ /** C→D REQ: list one page of a directory in the agent's workspace. */
13288
+ const WorkspaceListReq = object({
13289
+ agentId: string().min(1),
13290
+ path: string().default(""),
13291
+ cursor: string().optional(),
13292
+ limit: number().int().positive().max(500).default(200)
13293
+ });
13294
+ /** D→C REP (corr = the req id): a page of entries + the cursor for the next page. */
13295
+ const WorkspaceListPage = object({
13296
+ agentId: string(),
13297
+ path: string(),
13298
+ exists: boolean(),
13299
+ entries: array(WorkspaceEntry),
13300
+ nextCursor: string().optional()
13301
+ });
13302
+ /** C→D REQ: read one byte slice of a file in the agent's workspace. */
13303
+ const WorkspaceReadReq = object({
13304
+ agentId: string().min(1),
13305
+ path: string().min(1),
13306
+ offset: number().int().nonnegative().default(0),
13307
+ limit: number().int().positive().max(65536).default(65536)
13308
+ });
13309
+ /** D→C REP (corr = the req id): the file slice (or `exists:false` / binary-detected). */
13310
+ const WorkspaceReadContent = object({
13311
+ agentId: string(),
13312
+ path: string(),
13313
+ exists: boolean(),
13314
+ size: number().int().nonnegative().optional(),
13315
+ mtime: string().optional(),
13316
+ encoding: _enum(["utf8", "none"]).optional(),
13317
+ content: string().optional(),
13318
+ offset: number().int().nonnegative().optional(),
13319
+ nextOffset: number().int().nonnegative().optional(),
13320
+ truncated: boolean().optional()
13321
+ });
13322
+ //#endregion
13257
13323
  //#region ../protocol/dist/frames/telemetry.js
13258
13324
  /**
13259
13325
  * Telemetry & facts (D→C) — protocol §7.
@@ -13373,6 +13439,10 @@ const FRAME_SCHEMAS = {
13373
13439
  "session/list/page": SessionListPage,
13374
13440
  "session/history": SessionHistoryReq,
13375
13441
  "session/history/page": SessionHistoryPage,
13442
+ "workspace/list": WorkspaceListReq,
13443
+ "workspace/list/page": WorkspaceListPage,
13444
+ "workspace/read": WorkspaceReadReq,
13445
+ "workspace/read/content": WorkspaceReadContent,
13376
13446
  "config/push": object({ keys: record(string(), unknown()) }),
13377
13447
  "daemon/restart": object({
13378
13448
  reason: string(),
@@ -13468,6 +13538,10 @@ discriminatedUnion("type", [
13468
13538
  frame("session/list/page", FRAME_SCHEMAS["session/list/page"]),
13469
13539
  frame("session/history", FRAME_SCHEMAS["session/history"]),
13470
13540
  frame("session/history/page", FRAME_SCHEMAS["session/history/page"]),
13541
+ frame("workspace/list", FRAME_SCHEMAS["workspace/list"]),
13542
+ frame("workspace/list/page", FRAME_SCHEMAS["workspace/list/page"]),
13543
+ frame("workspace/read", FRAME_SCHEMAS["workspace/read"]),
13544
+ frame("workspace/read/content", FRAME_SCHEMAS["workspace/read/content"]),
13471
13545
  frame("config/push", FRAME_SCHEMAS["config/push"]),
13472
13546
  frame("daemon/restart", FRAME_SCHEMAS["daemon/restart"]),
13473
13547
  frame("daemon/upgrade", FRAME_SCHEMAS["daemon/upgrade"]),
@@ -81991,6 +82065,237 @@ function makeLogger(level = "info") {
81991
82065
  };
81992
82066
  }
81993
82067
  //#endregion
82068
+ //#region src/cp/workspace-reader.ts
82069
+ /**
82070
+ * `WorkspaceReader` — the read-only seam answering the CP's `workspace/list` and
82071
+ * `workspace/read` REQs from the agent's local workspace directory. File bytes
82072
+ * live only on the daemon (§1/§12); this streams single pages/slices to the CP,
82073
+ * never whole trees or files.
82074
+ *
82075
+ * Containment is to `workspace.path` EXACTLY — the parent directory holds
82076
+ * `agent.json` and `.env` secrets, so escaping even one level is a secret leak.
82077
+ * Every request path is (1) rejected if absolute, (2) lexically resolved against
82078
+ * the root and prefix-checked, then (3) canonicalised with `realpath` and
82079
+ * re-verified so symlinks (including an intermediate directory component swapped
82080
+ * to a symlink after the check) cannot smuggle a target outside the root; all
82081
+ * I/O runs on the canonical path. Violations throw `WorkspaceViolationError`,
82082
+ * which the dispatcher maps to a `BAD_PAYLOAD` error frame; missing paths are
82083
+ * data (`exists:false`), not errors. Never log file contents.
82084
+ *
82085
+ * Frame-size safety: every REP must serialise under the 256 KiB wire cap. JSON
82086
+ * escaping of control bytes is a 6× blowup, so `read` bounds the slice by the
82087
+ * *encoded* reply size (not the raw byte `limit`) and `list` stops a page early
82088
+ * when the encoded entries would overflow — both leave headroom for the envelope.
82089
+ */
82090
+ /** Bytes sniffed from the head of a file for binary (NUL byte) detection. */
82091
+ const SNIFF_BYTES = 8192;
82092
+ /** Encoded-payload ceiling, leaving headroom under MAX_FRAME_BYTES for the
82093
+ * envelope (id/ts/type/corr + fencing ext, well under 4 KiB). */
82094
+ const REPLY_BUDGET = MAX_FRAME_BYTES - 4096;
82095
+ /** Path-containment / bad-request violation → `BAD_PAYLOAD` on the wire. */
82096
+ var WorkspaceViolationError = class extends Error {
82097
+ constructor(message) {
82098
+ super(message);
82099
+ this.name = "WorkspaceViolationError";
82100
+ }
82101
+ };
82102
+ function isErrno(err, code) {
82103
+ return err?.code === code;
82104
+ }
82105
+ function under(root, p) {
82106
+ return p === root || p.startsWith(root + sp.sep);
82107
+ }
82108
+ /** Largest index ≤ `len` that lands on a UTF-8 character boundary — i.e. never
82109
+ * cuts a multi-byte sequence. If the slice's trailing char is incomplete, the
82110
+ * boundary is placed before that char (its bytes belong to the next slice). */
82111
+ function utf8Boundary(buf, len) {
82112
+ if (len >= buf.length) len = buf.length;
82113
+ if (len <= 0) return 0;
82114
+ let start = len - 1;
82115
+ while (start > 0 && ((buf[start] ?? 0) & 192) === 128) start--;
82116
+ const lead = buf[start] ?? 0;
82117
+ const seqLen = lead < 128 ? 1 : (lead & 224) === 192 ? 2 : (lead & 240) === 224 ? 3 : (lead & 248) === 240 ? 4 : 1;
82118
+ return start + seqLen <= len ? len : start;
82119
+ }
82120
+ /** The encoded size of the payload the wire will carry (JSON.stringify matches
82121
+ * the codec's `encode`), measured in bytes. */
82122
+ function encodedBytes(payload) {
82123
+ return Buffer.byteLength(JSON.stringify(payload));
82124
+ }
82125
+ function createWorkspaceReader(workspaceRootByAgent) {
82126
+ function rootFor(agentId) {
82127
+ const root = workspaceRootByAgent(agentId);
82128
+ if (!root) throw new WorkspaceViolationError(`unknown agent "${agentId}"`);
82129
+ return root;
82130
+ }
82131
+ /**
82132
+ * Lexical containment. Rejects absolute paths and `..` escapes; returns the
82133
+ * lexically-resolved path plus the canonical (realpath'd) root, or `null` for
82134
+ * `realRoot` when the root does not exist yet (callers report `exists:false`).
82135
+ */
82136
+ async function resolveContained(root, relPath) {
82137
+ if (sp.isAbsolute(relPath)) throw new WorkspaceViolationError("absolute paths are not allowed");
82138
+ const resolved = sp.resolve(root, relPath);
82139
+ if (!under(root, resolved)) throw new WorkspaceViolationError("path escapes the workspace root");
82140
+ try {
82141
+ return {
82142
+ resolved,
82143
+ realRoot: await promises.realpath(root)
82144
+ };
82145
+ } catch (err) {
82146
+ if (isErrno(err, "ENOENT")) return {
82147
+ resolved,
82148
+ realRoot: null
82149
+ };
82150
+ throw err;
82151
+ }
82152
+ }
82153
+ /** Canonicalise an existing target and re-verify containment on the REAL path,
82154
+ * closing the check-vs-use gap (a symlinked intermediate component resolves
82155
+ * out and is rejected here). Returns the canonical path used for I/O. */
82156
+ async function canonicalUnder(realRoot, abs) {
82157
+ const canon = await promises.realpath(abs);
82158
+ if (!under(realRoot, canon)) throw new WorkspaceViolationError("path resolves outside the workspace root");
82159
+ return canon;
82160
+ }
82161
+ return {
82162
+ async list(req) {
82163
+ const { resolved, realRoot } = await resolveContained(rootFor(req.agentId), req.path);
82164
+ const notFound = {
82165
+ agentId: req.agentId,
82166
+ path: req.path,
82167
+ exists: false,
82168
+ entries: []
82169
+ };
82170
+ if (realRoot === null) return notFound;
82171
+ let target;
82172
+ try {
82173
+ target = await canonicalUnder(realRoot, resolved);
82174
+ } catch (err) {
82175
+ if (isErrno(err, "ENOENT")) return notFound;
82176
+ throw err;
82177
+ }
82178
+ let dirents;
82179
+ try {
82180
+ dirents = await promises.readdir(target, { withFileTypes: true });
82181
+ } catch (err) {
82182
+ if (isErrno(err, "ENOENT") || isErrno(err, "ENOTDIR")) return notFound;
82183
+ throw err;
82184
+ }
82185
+ const entries = [];
82186
+ for (const d of dirents) {
82187
+ if (d.name === ".git") continue;
82188
+ const type = d.isDirectory() ? "dir" : d.isFile() ? "file" : d.isSymbolicLink() ? "symlink" : "other";
82189
+ const entry = {
82190
+ name: d.name,
82191
+ type
82192
+ };
82193
+ if (type === "file") try {
82194
+ const st = await promises.lstat(sp.join(target, d.name));
82195
+ entry.size = st.size;
82196
+ entry.mtime = st.mtime.toISOString();
82197
+ } catch {}
82198
+ entries.push(entry);
82199
+ }
82200
+ entries.sort(compareEntries);
82201
+ const start = req.cursor !== void 0 && /^\d+$/.test(req.cursor) ? Number(req.cursor) : 0;
82202
+ const page = [];
82203
+ let acc = 0;
82204
+ for (let i = start; i < entries.length && page.length < req.limit; i++) {
82205
+ const e = entries[i];
82206
+ const enc = encodedBytes(e) + 1;
82207
+ if (page.length > 0 && acc + enc > REPLY_BUDGET) break;
82208
+ page.push(e);
82209
+ acc += enc;
82210
+ }
82211
+ const consumed = start + page.length;
82212
+ return {
82213
+ agentId: req.agentId,
82214
+ path: req.path,
82215
+ exists: true,
82216
+ entries: page,
82217
+ ...consumed < entries.length ? { nextCursor: String(consumed) } : {}
82218
+ };
82219
+ },
82220
+ async read(req) {
82221
+ const { resolved, realRoot } = await resolveContained(rootFor(req.agentId), req.path);
82222
+ const notFound = {
82223
+ agentId: req.agentId,
82224
+ path: req.path,
82225
+ exists: false
82226
+ };
82227
+ if (realRoot === null) return notFound;
82228
+ let st;
82229
+ try {
82230
+ st = await promises.lstat(resolved);
82231
+ } catch (err) {
82232
+ if (isErrno(err, "ENOENT")) return notFound;
82233
+ throw err;
82234
+ }
82235
+ if (!st.isFile()) throw new WorkspaceViolationError("not a regular file");
82236
+ const target = await canonicalUnder(realRoot, resolved);
82237
+ const size = st.size;
82238
+ const mtime = st.mtime.toISOString();
82239
+ const fh = await promises.open(target, "r");
82240
+ try {
82241
+ const sniffLen = Math.min(SNIFF_BYTES, size);
82242
+ if (sniffLen > 0) {
82243
+ const sniff = Buffer.alloc(sniffLen);
82244
+ const { bytesRead } = await fh.read(sniff, 0, sniffLen, 0);
82245
+ if (sniff.subarray(0, bytesRead).includes(0)) return {
82246
+ agentId: req.agentId,
82247
+ path: req.path,
82248
+ exists: true,
82249
+ size,
82250
+ mtime,
82251
+ encoding: "none"
82252
+ };
82253
+ }
82254
+ const want = Math.min(req.limit, Math.max(0, size - req.offset));
82255
+ let slice = Buffer.alloc(0);
82256
+ if (want > 0) {
82257
+ const buf = Buffer.alloc(want);
82258
+ const { bytesRead } = await fh.read(buf, 0, want, req.offset);
82259
+ slice = buf.subarray(0, bytesRead);
82260
+ }
82261
+ let end = utf8Boundary(slice, slice.length);
82262
+ let content = slice.toString("utf8", 0, end);
82263
+ while (end > 0 && encodedBytes(content) > REPLY_BUDGET) {
82264
+ const factor = REPLY_BUDGET / encodedBytes(content);
82265
+ const shrunk = Math.max(1, Math.floor(end * factor * .9));
82266
+ end = utf8Boundary(slice, Math.min(shrunk, end - 1));
82267
+ content = slice.toString("utf8", 0, end);
82268
+ }
82269
+ const nextOffset = req.offset + end;
82270
+ return {
82271
+ agentId: req.agentId,
82272
+ path: req.path,
82273
+ exists: true,
82274
+ size,
82275
+ mtime,
82276
+ encoding: "utf8",
82277
+ content,
82278
+ offset: req.offset,
82279
+ nextOffset,
82280
+ truncated: nextOffset < size
82281
+ };
82282
+ } finally {
82283
+ await fh.close();
82284
+ }
82285
+ }
82286
+ };
82287
+ }
82288
+ /** dirs first, then case-insensitive alphabetical (stable within the page). */
82289
+ function compareEntries(a, b) {
82290
+ const aDir = a.type === "dir" ? 0 : 1;
82291
+ const bDir = b.type === "dir" ? 0 : 1;
82292
+ if (aDir !== bDir) return aDir - bDir;
82293
+ const al = a.name.toLowerCase();
82294
+ const bl = b.name.toLowerCase();
82295
+ if (al !== bl) return al < bl ? -1 : 1;
82296
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
82297
+ }
82298
+ //#endregion
81994
82299
  //#region src/cp/correlator.ts
81995
82300
  /** Transport-free representation of a typed wire `error` REP. */
81996
82301
  var WireError = class extends Error {
@@ -82356,6 +82661,12 @@ var CpClient = class {
82356
82661
  this.sendError(frame.id, "INTERNAL", `session/history failed: ${err.message}`, false);
82357
82662
  }
82358
82663
  return;
82664
+ case "workspace/list":
82665
+ this.deps.workspaceRead.list(frame.payload).then((page) => this.reply(frame, "workspace/list/page", page)).catch((err) => this.workspaceError(frame.id, "workspace/list", err));
82666
+ return;
82667
+ case "workspace/read":
82668
+ this.deps.workspaceRead.read(frame.payload).then((content) => this.reply(frame, "workspace/read/content", content)).catch((err) => this.workspaceError(frame.id, "workspace/read", err));
82669
+ return;
82359
82670
  case "agent/prompt":
82360
82671
  this.sendError(frame.id, "INTERNAL", "agent/prompt not implemented", false);
82361
82672
  return;
@@ -82364,6 +82675,18 @@ var CpClient = class {
82364
82675
  return;
82365
82676
  }
82366
82677
  }
82678
+ /** Map a workspace read failure onto the wire: containment/bad-request
82679
+ * violations → BAD_PAYLOAD (their messages are hand-written and path-free);
82680
+ * anything else → INTERNAL with a GENERIC message — raw fs errors (ELOOP,
82681
+ * EACCES, …) embed absolute host paths that must not leak to the CP/UI. */
82682
+ workspaceError(corr, op, err) {
82683
+ if (err instanceof WorkspaceViolationError) {
82684
+ this.sendError(corr, "BAD_PAYLOAD", `${op} failed: ${err.message}`, false);
82685
+ return;
82686
+ }
82687
+ this.deps.log.warn(`cp: ${op} failed: ${err?.message}`);
82688
+ this.sendError(corr, "INTERNAL", `${op} failed`, false);
82689
+ }
82367
82690
  reply(req, type, payload) {
82368
82691
  this.transport?.send(encode(buildEnvelope(type, payload, { corr: req.id })));
82369
82692
  }
@@ -83926,6 +84249,7 @@ var Daemon = class Daemon {
83926
84249
  degradedScopes: () => this.cpDegradedScopes(),
83927
84250
  configApply: this.cpConfigApply(),
83928
84251
  sessionRead: createSessionReader(this.store),
84252
+ workspaceRead: createWorkspaceReader((id) => this.agents.get(id)?.workspace.path),
83929
84253
  clock: systemClock,
83930
84254
  connect: () => ClientTransport.dial(url),
83931
84255
  log: this.log