@commonlyai/cli 0.1.1 → 0.1.4

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,8 +1,8 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "license": "Apache-2.0",
5
- "description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast",
5
+ "description": "The Commonly CLI connect agents, manage pods, iterate fast",
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
8
8
  "bin": {
@@ -21,12 +21,19 @@ import { getToken, resolveInstanceUrl } from '../lib/config.js';
21
21
  import { startPoller } from '../lib/poller.js';
22
22
  import { startWebhookServer, forwardToLocalWebhook } from '../lib/webhook-server.js';
23
23
  import { getAdapter, listAdapterNames } from '../lib/adapters/index.js';
24
- import { getSession, setSession, clearSessions } from '../lib/session-store.js';
24
+ import {
25
+ getSession,
26
+ setSession,
27
+ clearSessions,
28
+ wasEventHandled,
29
+ recordHandledEvent,
30
+ } from '../lib/session-store.js';
25
31
  import { readLongTerm, syncBack } from '../lib/memory-bridge.js';
26
32
  import { detectMemorySources, composeImport, importMemory } from '../lib/memory-import.js';
27
33
  import { detectSkills, importSkills } from '../lib/skills-import.js';
28
34
  import { parseEnvironmentFile, resolveWorkspace } from '../lib/environment.js';
29
35
  import { detectBwrap } from '../lib/sandbox/bwrap.js';
36
+ import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
30
37
 
31
38
  // ── Token file I/O — ~/.commonly/tokens/<name>.json (ADR-005) ───────────────
32
39
 
@@ -100,20 +107,41 @@ export const listLocalAgents = () => {
100
107
  .filter(Boolean);
101
108
  };
102
109
 
103
- // Event types that carry a human/agent-authored prompt the wrapper should
104
- // forward to the CLI. Other event types (heartbeat, delivery, etc.) are acked
105
- // as no_action even if they happen to carry `content` in their payload.
106
- const CHAT_EVENT_TYPES = new Set(['chat.mention', 'message.posted', 'dm.message']);
110
+ // Event types that carry a prompt the wrapper should forward to the CLI.
111
+ // Other event types (heartbeat, delivery, etc.) are acked as no_action even
112
+ // if they happen to carry `content` in their payload.
113
+ const PROMPT_EVENT_TYPES = new Set([
114
+ 'chat.mention',
115
+ 'message.posted',
116
+ 'dm.message',
117
+ 'first_contact',
118
+ ]);
107
119
 
108
120
  // ── default environment for adapters that benefit from auto-MCP wiring ─────
109
121
 
110
122
  // Adapters that can consume `mcp[]` from the resolved environment spec.
111
- // `claude` reads it via --mcp-config (see adapters/claude.js). `codex` and
112
- // `stub` do not (codex uses ~/.codex/config.toml at boot, set up server-side
113
- // for cloud-codex; the local codex wrapper doesn't ship MCP wiring yet —
114
- // follow-up after #440). Returning null means "no default" the wrapper
115
- // proceeds with environment=null exactly like before this change.
116
- const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude']);
123
+ // `claude` reads it via --mcp-config (see adapters/claude.js); `codex` via
124
+ // `-c mcp_servers.*` config overrides (see adapters/codex.js added after
125
+ // the 2026-07-22 as-operator attribution incident, where an MCP-less codex
126
+ // agent posted through the operator's CLI profile because it had no
127
+ // commonly_* tools of its own). `stub` does not. Returning null means "no
128
+ // default" — the wrapper proceeds with environment=null exactly like before.
129
+ const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex']);
130
+ const CODEX_PERMISSION_PROFILE_MIN_VERSION = [0, 138, 0];
131
+
132
+ const versionAtLeast = (version, minimum) => {
133
+ const parts = String(version || '')
134
+ .match(/\d+(?:\.\d+){1,2}/)?.[0]
135
+ .split('.')
136
+ .map(Number);
137
+ if (!parts) return false;
138
+ for (let i = 0; i < minimum.length; i += 1) {
139
+ const current = parts[i] || 0;
140
+ if (current > minimum[i]) return true;
141
+ if (current < minimum[i]) return false;
142
+ }
143
+ return true;
144
+ };
117
145
 
118
146
  // The commonly behavior skill ships inside this package (cli/skills/commonly)
119
147
  // so a fresh attach can mount it into the agent without a network fetch.
@@ -193,6 +221,12 @@ export const performAttach = async ({
193
221
  log(`workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
194
222
 
195
223
  const sandboxMode = environment.sandbox?.mode || 'none';
224
+ const sandboxTrust = environment.sandbox?.trust;
225
+ if (sandboxTrust === 'public' && sandboxMode === 'none') {
226
+ throw new Error(
227
+ 'sandbox.trust=public requires an enforced sandbox mode; refusing to attach unsandboxed',
228
+ );
229
+ }
196
230
  if (sandboxMode === 'bwrap') {
197
231
  const bwrap = detectBwrap();
198
232
  if (!bwrap.available) {
@@ -204,10 +238,41 @@ export const performAttach = async ({
204
238
  + 'are advisory in Phase 1. Use sandbox.mode=container for enforced policy.',
205
239
  );
206
240
  }
241
+ } else if (sandboxMode === 'workspace' || sandboxMode === 'read-only') {
242
+ if (sandboxTrust !== 'public' || !['codex', 'claude'].includes(adapterName)) {
243
+ throw new Error(
244
+ `sandbox.mode=${sandboxMode} is currently implemented only for public `
245
+ + `codex or Claude adapters`,
246
+ );
247
+ }
248
+ if (adapterName === 'codex') {
249
+ if (!versionAtLeast(detected.version, CODEX_PERMISSION_PROFILE_MIN_VERSION)) {
250
+ throw new Error(
251
+ `public codex sandbox requires Codex >=0.138.0 permission profiles; `
252
+ + `detected ${detected.version || 'unknown'}`,
253
+ );
254
+ }
255
+ log(
256
+ `sandbox: public ${sandboxMode} via Codex native permission profile `
257
+ + '(deny-by-default filesystem + network)',
258
+ );
259
+ } else {
260
+ const seatbelt = detectSeatbelt();
261
+ if (!seatbelt.available) {
262
+ throw new Error(
263
+ `public Claude sandbox.mode=${sandboxMode} requires macOS Seatbelt: `
264
+ + `${seatbelt.error}. On Linux use sandbox.mode=bwrap.`,
265
+ );
266
+ }
267
+ log(
268
+ `sandbox: public ${sandboxMode} via macOS Seatbelt `
269
+ + '(deny-by-default host filesystem; Claude/MCP network retained)',
270
+ );
271
+ }
207
272
  } else if (sandboxMode !== 'none' && sandboxMode !== undefined) {
208
273
  throw new Error(
209
274
  `sandbox.mode=${sandboxMode} is not yet implemented in the local-CLI driver. `
210
- + `Phase 1 supports: none, bwrap.`,
275
+ + `Supported locally: none, bwrap, and public codex/Claude workspace/read-only.`,
211
276
  );
212
277
  }
213
278
  } else {
@@ -366,7 +431,7 @@ export const runMemoryImport = async ({
366
431
  // ── run: local-CLI wrapper loop (ADR-005) ────────────────────────────────────
367
432
 
368
433
  const extractPrompt = (event) => {
369
- if (!CHAT_EVENT_TYPES.has(event.type)) return null;
434
+ if (!PROMPT_EVENT_TYPES.has(event.type)) return null;
370
435
  const p = event.payload || {};
371
436
  return p.content || p.prompt || p.text || null;
372
437
  };
@@ -432,7 +497,7 @@ export const performRun = ({
432
497
  // and (if the adapter returns a summary) patch-sync back after.
433
498
  const memoryLongTerm = await readLongTerm(client, { onError });
434
499
 
435
- // Snapshot the pod's recent message ids so that, after the spawn, we can
500
+ // Snapshot the pod's recent messages so that, after the spawn, we can
436
501
  // tell whether the agent posted itself via commonly_post_message. If it
437
502
  // did, its final CLI text is a narration/log — echoing it would duplicate
438
503
  // the message and re-fire any @mention. This is the wrapper-side guarantee
@@ -440,17 +505,20 @@ export const performRun = ({
440
505
  // without relying on the agent to emit NO_REPLY. (A rare concurrent post by
441
506
  // another member during the spawn window can suppress a genuine wrapper
442
507
  // reply — an acceptable trade against a guaranteed double-post.)
443
- const snapshotMessageIds = async () => {
508
+ const snapshotMessages = async () => {
444
509
  try {
445
510
  const { messages = [] } = await client.get(
446
511
  `/api/agents/runtime/pods/${eventPodId}/messages`, { limit: 10 },
447
512
  );
448
- return new Set(messages.map((m) => String(m._id || m.id)));
513
+ return messages;
449
514
  } catch {
450
515
  return null; // detection unavailable — fall back to posting the reply
451
516
  }
452
517
  };
453
- const preSpawnIds = await snapshotMessageIds();
518
+ const preSpawn = await snapshotMessages();
519
+ const preSpawnIds = preSpawn
520
+ ? new Set(preSpawn.map((m) => String(m._id || m.id)))
521
+ : null;
454
522
 
455
523
  log(`[${event.type}] spawning ${adapter.name}`);
456
524
  const result = await adapter.spawn(prompt, {
@@ -459,11 +527,13 @@ export const performRun = ({
459
527
  env: process.env,
460
528
  memoryLongTerm,
461
529
  environment,
462
- // Runtime context the adapter uses to substitute ${COMMONLY_AGENT_TOKEN}
463
- // / ${COMMONLY_API_URL} placeholders in MCP env values, command args,
464
- // and URLs. Lets users keep tokens out of their checked-in env files.
530
+ // Runtime context the Claude/Codex adapters expose only to their
531
+ // per-spawn MCP environment. Claude keeps ${COMMONLY_*} placeholders
532
+ // literal on disk and lets its native MCP parser expand them, so the
533
+ // bearer token never enters the generated config file.
465
534
  runtimeToken: token,
466
535
  instanceUrl,
536
+ agentName,
467
537
  metadata: { event },
468
538
  });
469
539
 
@@ -482,10 +552,19 @@ export const performRun = ({
482
552
  const replyText = (result.text || '').trim();
483
553
  let agentPostedItself = false;
484
554
  if (preSpawnIds) {
485
- const postSpawnIds = await snapshotMessageIds();
486
- if (postSpawnIds) {
487
- for (const id of postSpawnIds) {
488
- if (!preSpawnIds.has(id)) { agentPostedItself = true; break; }
555
+ const postSpawn = await snapshotMessages();
556
+ if (postSpawn) {
557
+ for (const m of postSpawn) {
558
+ // Only a NEW message from a bot user counts as "the agent posted
559
+ // itself". A new human-authored message must NOT suppress the echo:
560
+ // it is either a human typing mid-turn, or the agent misusing an
561
+ // operator CLI profile / human token (the 2026-07-22 as-operator
562
+ // attribution incident) — in both cases the wrapper still delivers
563
+ // the reply under the agent's own identity.
564
+ if (!preSpawnIds.has(String(m._id || m.id)) && m.isBot) {
565
+ agentPostedItself = true;
566
+ break;
567
+ }
489
568
  }
490
569
  }
491
570
  }
@@ -524,13 +603,23 @@ export const performRun = ({
524
603
  for (const event of events) {
525
604
  if (!running) break;
526
605
  let result;
527
- try {
528
- result = await processEvent(event);
529
- } catch (err) {
530
- // Spawn failed — skip ack, let kernel re-deliver (ADR-005).
531
- log(`[${event.type}] spawn error: ${err.message}`);
532
- onError?.(err);
533
- continue;
606
+ if (wasEventHandled(agentName, event._id)) {
607
+ result = { outcome: 'no_action', reason: 'duplicate-delivery' };
608
+ log(`[${event.type}] duplicate delivery ${event._id} — skipping spawn and re-acking`);
609
+ } else {
610
+ try {
611
+ result = await processEvent(event);
612
+ } catch (err) {
613
+ // Spawn failed — do not record or ack, so the kernel can re-deliver
614
+ // the event after the local runtime recovers (ADR-005).
615
+ log(`[${event.type}] spawn error: ${err.message}`);
616
+ onError?.(err);
617
+ continue;
618
+ }
619
+ // Record after successful processing but before ack. If the ack
620
+ // fails, the next delivery is skipped and re-acked instead of
621
+ // burning a second model turn for work that already completed.
622
+ recordHandledEvent(agentName, event._id);
534
623
  }
535
624
  try {
536
625
  await client.post(`/api/agents/runtime/events/${event._id}/ack`, { result });
@@ -685,7 +774,8 @@ export const performInit = async ({
685
774
  * 7 days after all installs go inactive — we don't force-revoke here
686
775
  * because the token may legitimately still be in use for another pod.
687
776
  * 2. Local token file at ~/.commonly/tokens/<name>.json
688
- * 3. Local session store at ~/.commonly/sessions/<name>.json
777
+ * 3. Local session + handled-event stores at
778
+ * ~/.commonly/sessions/<name>{,.events}.json
689
779
  *
690
780
  * Idempotent: if the backend returns 404 (already uninstalled elsewhere), we
691
781
  * still clean up local files so the CLI state stays in sync with reality.