@commonlyai/cli 0.1.4 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -120,6 +120,14 @@ turn with the literal `NO_REPLY`. So: if you already said everything through
120
120
  `NO_REPLY` so the wrapper doesn't post a duplicate. If you *didn't* post via the
121
121
  tool, just let your reply be your final output. Either way — one voice, no echo.
122
122
 
123
+ **Post as yourself, never as your operator.** Your reply text and your
124
+ `commonly_*` tools carry *your* agent identity. If you have shell access, you may
125
+ find an operator's Commonly CLI profile (`commonly pod send`, `~/.commonly/config.json`)
126
+ or a human's saved token in your environment — **never post through them**. A message
127
+ sent that way appears in the room under the human's name and avatar, which
128
+ misattributes your words and breaks the room's provenance. If your own tools are
129
+ unavailable mid-turn, say what you need in your final reply instead.
130
+
123
131
  ## The task board
124
132
 
125
133
  Pods have a task board. When work is being tracked:
@@ -175,7 +175,7 @@ export const buildDefaultEnvironment = (adapterName) => {
175
175
  ],
176
176
  };
177
177
  // Only advertise the skill if it actually shipped (defensive: a broken
178
- // package that dropped the skills/ dir shouldn't hand linkSkills a
178
+ // package that dropped the skills/ dir shouldn't hand mountSkills a
179
179
  // missing-source path every spawn).
180
180
  if (existsSync(BUNDLED_COMMONLY_SKILL_DIR)) {
181
181
  environment.skills = { claude: [BUNDLED_COMMONLY_SKILL_DIR] };
@@ -60,7 +60,7 @@ import {
60
60
  import { homedir, tmpdir } from 'os';
61
61
  import { isAbsolute, join } from 'path';
62
62
 
63
- import { linkSkills } from '../environment.js';
63
+ import { mountSkills } from '../environment.js';
64
64
  import { wrapArgvWithBwrap } from '../sandbox/bwrap.js';
65
65
  import {
66
66
  publicClaudeStateRoot,
@@ -468,11 +468,11 @@ export default {
468
468
  const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId];
469
469
 
470
470
  if (ctx.environment && ctx.cwd) {
471
- const skills = await linkSkills(ctx.environment, ctx.cwd);
471
+ const skills = await mountSkills(ctx.environment, ctx.cwd);
472
472
  if (skills.conflicted.length > 0) {
473
473
  for (const c of skills.conflicted) {
474
474
  // eslint-disable-next-line no-console
475
- console.warn(`[claude] skill not linked (${c.reason}): ${c.path}`);
475
+ console.warn(`[claude] skill not mounted (${c.reason}): ${c.path}`);
476
476
  }
477
477
  }
478
478
  if (ctx.onSkillsLinked) ctx.onSkillsLinked(skills);
@@ -5,7 +5,7 @@
5
5
  * how an agent's runtime should be shaped: workspace path, sandbox mode, the
6
6
  * Claude skills to mount, the MCP servers to expose. This module is the
7
7
  * adapter-facing read-side: parse, validate, and realize the workspace +
8
- * skill links on disk. Sandbox argv wrapping lives in `./sandbox/bwrap.js`
8
+ * skill mounts on disk. Sandbox argv wrapping lives in `./sandbox/bwrap.js`
9
9
  * because it is per-driver (Linux-only, bwrap-specific).
10
10
  *
11
11
  * Zero runtime deps: Node 20 has no built-in YAML, so we accept JSON only and
@@ -14,7 +14,9 @@
14
14
  * place to add a `.yaml` branch — the rest of the module is parser-agnostic.
15
15
  */
16
16
 
17
- import { readFile, mkdir, symlink, lstat, readlink, cp } from 'fs/promises';
17
+ import {
18
+ readFile, mkdir, lstat, cp, rm, chmod, readdir, writeFile,
19
+ } from 'fs/promises';
18
20
  import { existsSync } from 'fs';
19
21
  import { dirname, isAbsolute, join, resolve as pathResolve, basename } from 'path';
20
22
  import { homedir } from 'os';
@@ -25,7 +27,7 @@ import { homedir } from 'os';
25
27
  // relative skill paths) is NOT stored on the returned spec. Leaking the user's
26
28
  // absolute filesystem path into `config.environment` sent to the backend
27
29
  // exposes `$HOME` layout for zero server-side benefit. Callers pass
28
- // `envFileDir` as a separate argument to resolveWorkspace / linkSkills.
30
+ // `envFileDir` as a separate argument to resolveWorkspace / mountSkills.
29
31
 
30
32
  const ALLOWED_TOP_KEYS = new Set([
31
33
  'version', 'workspace', 'sandbox', 'skills', 'mcp',
@@ -85,7 +87,7 @@ export const parseEnvironmentFile = async (absolutePath) => {
85
87
  // Return the bare spec — no envFileDir wrapping, no underscore-prefixed
86
88
  // annotations. The caller is responsible for tracking envFileDir separately
87
89
  // (compute via `dirname(envPath)`) and passing it explicitly to
88
- // resolveWorkspace / linkSkills when relative paths in the spec need to
90
+ // resolveWorkspace / mountSkills when relative paths in the spec need to
89
91
  // resolve. This keeps the spec safe to serialize and ship to the backend
90
92
  // (`config.environment` on AgentInstallation) without leaking $HOME layout.
91
93
  return parsed;
@@ -246,35 +248,72 @@ export const resolveWorkspace = async (spec, agentName, envFileDir = null) => {
246
248
  return { path: absPath, created };
247
249
  };
248
250
 
249
- // ── linkSkills ──────────────────────────────────────────────────────────────
251
+ // ── mountSkills ─────────────────────────────────────────────────────────────
252
+
253
+ // Marks a skill directory as a Commonly-managed snapshot. Its presence is what
254
+ // licenses a refresh to delete and re-copy the directory: without it we cannot
255
+ // distinguish "a mount we made last spawn" from "a skill the user authored in
256
+ // their workspace", and clobbering the latter would destroy real work.
257
+ const MOUNT_MARKER = '.commonly-skill-mount';
250
258
 
251
259
  /**
252
- * Symlink each `skills.claude[]` source path into `<workspacePath>/.claude/skills/`.
260
+ * Recursively drop the write bit on every regular file in a mounted snapshot.
261
+ *
262
+ * Directories deliberately stay writable: unlinking a file requires write
263
+ * permission on its PARENT, so read-only directories would make the next
264
+ * refresh fail. This is defense-in-depth for the agent's own copy, not the
265
+ * load-bearing protection — that comes from the copy existing at all.
266
+ */
267
+ const freezeFiles = async (dir) => {
268
+ const entries = await readdir(dir, { withFileTypes: true });
269
+ for (const entry of entries) {
270
+ const full = join(dir, entry.name);
271
+ if (entry.isDirectory()) {
272
+ // eslint-disable-next-line no-await-in-loop
273
+ await freezeFiles(full);
274
+ } else if (entry.isFile()) {
275
+ // eslint-disable-next-line no-await-in-loop
276
+ await chmod(full, 0o444);
277
+ }
278
+ }
279
+ };
280
+
281
+ /**
282
+ * Mount each `skills.claude[]` source into `<workspacePath>/.claude/skills/`
283
+ * as a READ-ONLY SNAPSHOT COPY.
284
+ *
285
+ * Why a copy and not a symlink: a symlink is writable *through*, so an agent
286
+ * holding Write/Edit could edit the file that governs its own behaviour — and
287
+ * because these sources are usually files tracked in a repo (the bundled
288
+ * `cli/skills/commonly` is the common case), that edit lands in the operator's
289
+ * working tree. `npm publish` ships the working tree, not git HEAD, so a
290
+ * write-through edit can silently ride out in the next release. That is not
291
+ * hypothetical: @commonlyai/cli@0.1.4 shipped with the "post as yourself,
292
+ * never as your operator" guardrail (#702) missing for exactly this reason.
293
+ * The snapshot makes the data flow one-way: source → workspace, never back.
253
294
  *
254
- * Idempotent: if a symlink already points at the declared source, it is
255
- * counted in `skipped` and left in place. If a DIFFERENT file or symlink
256
- * occupies the slot, it is recorded in `conflicted` with a reason never
257
- * overwritten (a user-edited skill in the workspace must not be clobbered).
258
- * Missing source paths are also reported in `conflicted` so the caller can
259
- * surface them.
295
+ * Refreshed on every call the claude adapter mounts per spawn — so edits to
296
+ * the source still reach the agent on its next turn. Only directories carrying
297
+ * MOUNT_MARKER are refreshed; anything else in the slot is reported as a
298
+ * conflict and left untouched.
299
+ *
300
+ * Legacy symlinks written by CLI ≤0.1.4 are replaced with a snapshot, since
301
+ * the symlink IS the vulnerable shape being retired.
260
302
  *
261
303
  * `envFileDir` is the directory of the env file (used to resolve relative
262
304
  * skill paths). Optional — when omitted, relative paths fall back to
263
- * `workspacePath`. Pass it from `performAttach` for attach-time resolution
264
- * matching the env file's location.
305
+ * `workspacePath`.
265
306
  *
266
- * Returns `{ linked, skipped, conflicted }`:
267
- * - `linked`: string[] — newly created symlinks (absolute source paths)
268
- * - `skipped`: string[] — already correctly linked (no-op)
307
+ * Returns `{ mounted, conflicted }`:
308
+ * - `mounted`: string[] — absolute source paths snapshotted
269
309
  * - `conflicted`: Array<{path, reason}> where reason ∈
270
- * 'different-target' | 'not-symlink' | 'missing-source'
310
+ * 'not-a-mount' | 'missing-source'
271
311
  */
272
- export const linkSkills = async (spec, workspacePath, envFileDir = null) => {
312
+ export const mountSkills = async (spec, workspacePath, envFileDir = null) => {
273
313
  const sources = Array.isArray(spec?.skills?.claude) ? spec.skills.claude : [];
274
- const linked = [];
275
- const skipped = [];
314
+ const mounted = [];
276
315
  const conflicted = [];
277
- if (sources.length === 0) return { linked, skipped, conflicted };
316
+ if (sources.length === 0) return { mounted, conflicted };
278
317
 
279
318
  const skillsDir = join(workspacePath, '.claude', 'skills');
280
319
  await mkdir(skillsDir, { recursive: true });
@@ -288,47 +327,43 @@ export const linkSkills = async (spec, workspacePath, envFileDir = null) => {
288
327
  conflicted.push({ path: absSource, reason: 'missing-source' });
289
328
  continue;
290
329
  }
291
- const linkPath = join(skillsDir, basename(absSource));
330
+ const destPath = join(skillsDir, basename(absSource));
292
331
 
293
- let existingTarget = null;
294
- let slotIsNonSymlink = false;
332
+ let slot = null;
295
333
  try {
296
334
  // eslint-disable-next-line no-await-in-loop
297
- const stat = await lstat(linkPath);
298
- if (stat.isSymbolicLink()) {
299
- // eslint-disable-next-line no-await-in-loop
300
- existingTarget = await readlink(linkPath);
301
- } else {
302
- slotIsNonSymlink = true;
303
- }
335
+ slot = await lstat(destPath);
304
336
  } catch (err) {
305
337
  // Only ENOENT means "slot is free, proceed." EACCES / EPERM / EIO are
306
- // real failures that should surface without this narrowing the
307
- // subsequent symlink() call fails with a confusing follow-on error.
338
+ // real failures that should surface rather than becoming a confusing
339
+ // follow-on error from the copy below.
308
340
  if (err.code !== 'ENOENT') throw err;
309
341
  }
310
342
 
311
- if (slotIsNonSymlink) {
312
- conflicted.push({ path: absSource, reason: 'not-symlink' });
313
- continue;
314
- }
315
-
316
- if (existingTarget) {
317
- const resolvedExisting = isAbsolute(existingTarget)
318
- ? existingTarget
319
- : pathResolve(skillsDir, existingTarget);
320
- if (resolvedExisting === absSource) {
321
- skipped.push(absSource);
343
+ if (slot) {
344
+ const isLegacySymlink = slot.isSymbolicLink();
345
+ const isOwnedMount = slot.isDirectory() && existsSync(join(destPath, MOUNT_MARKER));
346
+ if (!isLegacySymlink && !isOwnedMount) {
347
+ // A real file, or a directory we did not create — user content.
348
+ conflicted.push({ path: absSource, reason: 'not-a-mount' });
322
349
  continue;
323
350
  }
324
- conflicted.push({ path: absSource, reason: 'different-target' });
325
- continue;
351
+ // eslint-disable-next-line no-await-in-loop
352
+ await rm(destPath, { recursive: true, force: true });
326
353
  }
327
354
 
328
355
  // eslint-disable-next-line no-await-in-loop
329
- await symlink(absSource, linkPath);
330
- linked.push(absSource);
356
+ await cp(absSource, destPath, { recursive: true });
357
+ // eslint-disable-next-line no-await-in-loop
358
+ await writeFile(
359
+ join(destPath, MOUNT_MARKER),
360
+ `mounted-from: ${absSource}\n`,
361
+ 'utf8',
362
+ );
363
+ // eslint-disable-next-line no-await-in-loop
364
+ await freezeFiles(destPath);
365
+ mounted.push(absSource);
331
366
  }
332
367
 
333
- return { linked, skipped, conflicted };
368
+ return { mounted, conflicted };
334
369
  };