@cairnkeep/cli 2.25.0 → 2.27.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/docs/README.md +6 -0
  3. package/docs/harness-compatibility.md +5 -2
  4. package/docs/learning/CURRICULUM-MAP.md +3 -3
  5. package/docs/learning/FEATURE-GUIDE.md +13 -1
  6. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  7. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  8. package/docs/learning/lessons/L02-installation.md +2 -2
  9. package/docs/learning/lessons/L03-first-project.md +1 -1
  10. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  11. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  12. package/docs/learning/lessons/L21-context-packs.md +1 -1
  13. package/docs/learning/lessons/L22-native-windows.md +2 -2
  14. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  15. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  16. package/docs/learning/lessons/L25-playbooks.md +1 -1
  17. package/docs/learning/lessons/L26-context-intelligence.md +1 -1
  18. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  19. package/docs/learning/lessons/L28-security-assurance.md +5 -1
  20. package/docs/learning/lessons/L29-team-continuity.md +42 -1
  21. package/docs/learning/tracks/operator.md +5 -0
  22. package/docs/learning/video-scripts/V28-security-assurance.md +4 -0
  23. package/docs/learning/video-scripts/V29-team-continuity.md +29 -1
  24. package/docs/operating.md +10 -0
  25. package/docs/privacy-and-data-flow.md +30 -1
  26. package/docs/provider-data-flow-review.md +48 -0
  27. package/docs/releasing.md +6 -0
  28. package/docs/security-assurance.md +11 -0
  29. package/docs/storage.md +23 -0
  30. package/docs/team-onboarding.md +230 -0
  31. package/docs/team-operations.md +127 -0
  32. package/docs/team-pilot.md +2 -0
  33. package/docs/team.md +21 -4
  34. package/examples/eval/bundled-fake.json +1 -1
  35. package/mcp-memory-server/dist/capability-config.js +12 -1
  36. package/mcp-memory-server/dist/team-audit.js +41 -0
  37. package/mcp-memory-server/dist/team-cli.js +81 -30
  38. package/mcp-memory-server/dist/team-client.js +71 -0
  39. package/mcp-memory-server/dist/team-errors.js +7 -0
  40. package/mcp-memory-server/dist/team-files.js +28 -0
  41. package/mcp-memory-server/dist/team-mcp.js +12 -4
  42. package/mcp-memory-server/dist/team-pages.js +40 -0
  43. package/mcp-memory-server/dist/team-store.js +105 -40
  44. package/mcp-memory-server/package.json +2 -2
  45. package/package.json +1 -1
  46. package/scripts/cairn-cli.mjs +6 -1
  47. package/scripts/completion.sh +16 -3
  48. package/scripts/lib/team-retention.mjs +1 -1
  49. package/scripts/team-connect.mjs +366 -0
  50. package/scripts/test-ci-sharding.sh +6 -1
  51. package/scripts/test-codeql-cohort.mjs +35 -0
  52. package/scripts/test-completion.sh +7 -1
  53. package/scripts/test-package-install.sh +1 -0
  54. package/scripts/test-phase18-capability-lifecycle.sh +2 -2
  55. package/scripts/test-release-cache.mjs +16 -0
  56. package/scripts/test-security-baseline.sh +4 -0
  57. package/scripts/test-smoke-timings.mjs +19 -0
  58. package/scripts/test-team-docs.sh +26 -2
  59. package/scripts/test-team-installed.mjs +62 -0
  60. package/scripts/test-team-lab-env.mjs +9 -0
  61. package/scripts/test-team-operations.mjs +31 -0
  62. package/scripts/test-team-operations.sh +5 -0
  63. package/scripts/test-team-pilot.mjs +43 -1
  64. package/scripts/verify-published-release.mjs +11 -2
  65. package/scripts/verify-security-baseline.mjs +20 -0
  66. package/scripts/windows-platform.mjs +1 -1
@@ -0,0 +1,366 @@
1
+ import { chmodSync, closeSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { dirname, join, resolve, parse, relative, isAbsolute, sep } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createInterface } from 'node:readline/promises';
7
+ import { readStableFile, assertFileUnchanged } from './lib/stable-file.mjs';
8
+ import { selectManyPrompt, selectOnePrompt, supportsTerminalPrompts } from './terminal-prompts.mjs';
9
+ import { readStableJson } from '../mcp-memory-server/dist/stable-file.js';
10
+ import { hardenPrivatePath } from '../mcp-memory-server/dist/platform-security.js';
11
+ import { prepareTeamPrivateDirectory, TeamError } from '../mcp-memory-server/dist/team-store.js';
12
+ import { validateTeamConnection, readTeamConnection, connectionToken, connectionDoctor, TeamClientError } from '../mcp-memory-server/dist/team-client.js';
13
+
14
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
15
+ const NAME = 'cairn-team-memory';
16
+ const MAX = 1024 * 1024;
17
+ // Four before/after 1 MiB files, base64 encoded, plus bounded metadata.
18
+ const MAX_RECEIPT = 16 * MAX;
19
+ const ADAPTERS = Object.freeze({ claude: '.mcp.json', opencode: 'opencode.json', qwen: '.qwen/settings.json', codex: '.codex/config.toml' });
20
+ const HELP = `cairn team connect — opt-in, project-local team MCP (personal memory unchanged)
21
+
22
+ cairn team connect [--project-root PATH] [--url URL --organization ID --project ID --token-file FILE --harness claude,opencode,qwen,codex] [--access read-only|full] [--apply --yes]
23
+ cairn team disconnect [--project-root PATH] [--apply --yes]
24
+ cairn team doctor --client [--project-root PATH]
25
+
26
+ CAIRN_TEAM=1 is required. Without --apply a complete command is a no-write plan.
27
+ Interactive connect asks for missing choices and confirms before applying.
28
+ Credentials remain in owner-only files OUTSIDE source trees. No token values are
29
+ stored in harness config. Existing team entries/unsupported syntax fail closed.
30
+ Doctor checks authenticated reader access, not production admission. Disconnect
31
+ keeps credentials/backups; restart existing harness sessions or ask the operator
32
+ to revoke a credential for immediate denial. See docs/team-onboarding.md.
33
+ `;
34
+ class ConnectionError extends Error {}
35
+ function fail(message) { throw new ConnectionError(message); }
36
+ function enabled() { if (process.env.CAIRN_TEAM !== '1') fail('Team disabled. Opt in explicitly with CAIRN_TEAM=1.'); }
37
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex');
38
+ function info(path) { try { return lstatSync(path); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } }
39
+ function safeAncestors(path) {
40
+ let cursor = resolve(path);
41
+ while (true) {
42
+ const stat = info(cursor);
43
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink()
44
+ || (process.platform !== 'win32' && (stat.mode & 0o022) !== 0 && !(stat.mode & 0o1000)))) fail('Team configuration has an unsafe directory ancestor.');
45
+ if (cursor === dirname(cursor)) break;
46
+ cursor = dirname(cursor);
47
+ }
48
+ }
49
+ function locations(projectRoot, home = homedir()) {
50
+ const target = realpathSync(resolve(projectRoot));
51
+ if (target === parse(target).root || /[\x00-\x1f\x7f]/.test(target)) fail('Invalid team project root.');
52
+ safeAncestors(target);
53
+ const targetInfo = lstatSync(target);
54
+ if (process.platform !== 'win32' && ((targetInfo.mode & 0o022) !== 0 || (typeof process.getuid === 'function' && targetInfo.uid !== process.getuid()))) fail('Team project directory must be owned by this user and not writable by other users.');
55
+ const state = join(realpathSync(resolve(home)), '.cairnkeep', 'team-clients', hash(target));
56
+ safeAncestors(state);
57
+ return { target, state, connectionFile: join(state, 'connection.json'), receiptFile: join(state, 'receipt.json') };
58
+ }
59
+ function snapshot(path, maxBytes = MAX) {
60
+ if (!info(path)) return null;
61
+ const current = readStableFile(path, { label: 'Harness configuration', maxBytes });
62
+ if (process.platform !== 'win32' && (current.stat.mode & 0o022)) fail('Harness configuration must not be writable by other users.');
63
+ return current;
64
+ }
65
+ function object(value) { return value && typeof value === 'object' && !Array.isArray(value); }
66
+ // Refuse duplicate object keys, including escaped spellings. JSON.parse alone
67
+ // would silently discard a preexisting MCP entry hidden behind a duplicate key.
68
+ function parseConfig(text) {
69
+ let offset = 0;
70
+ const ws = () => { while (/\s/.test(text[offset] ?? '') && offset < text.length) offset++; };
71
+ function string() {
72
+ const start = offset++;
73
+ while (offset < text.length) { const ch = text[offset++]; if (ch === '\\') offset++; else if (ch === '"') return JSON.parse(text.slice(start, offset)); }
74
+ fail('Harness configuration contains invalid JSON.');
75
+ }
76
+ function value(depth = 0) {
77
+ if (depth > 100) fail('Harness configuration is nested too deeply.');
78
+ ws();
79
+ if (text[offset] === '"') { string(); return; }
80
+ if (text[offset] === '{') {
81
+ offset++; ws(); const keys = new Set(); if (text[offset] === '}') { offset++; return; }
82
+ while (offset < text.length) {
83
+ ws(); if (text[offset] !== '"') fail('Harness configuration requires strict JSON (not JSONC).');
84
+ const key = string(); if (keys.has(key)) fail('Harness configuration has duplicate JSON keys.'); keys.add(key);
85
+ ws(); if (text[offset++] !== ':') fail('Harness configuration contains invalid JSON.');
86
+ value(depth + 1); ws(); const ch = text[offset++]; if (ch === '}') return; if (ch !== ',') break;
87
+ }
88
+ fail('Harness configuration contains invalid JSON.');
89
+ }
90
+ if (text[offset] === '[') {
91
+ offset++; ws(); if (text[offset] === ']') { offset++; return; }
92
+ while (offset < text.length) { value(depth + 1); ws(); const ch = text[offset++]; if (ch === ']') return; if (ch !== ',') break; }
93
+ fail('Harness configuration contains invalid JSON.');
94
+ }
95
+ const match = /^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/.exec(text.slice(offset));
96
+ if (!match) fail('Harness configuration contains invalid JSON.'); offset += match[0].length;
97
+ }
98
+ value(); ws(); if (offset !== text.length) fail('Harness configuration requires strict JSON.');
99
+ const parsed = JSON.parse(text); if (!object(parsed)) fail('Harness configuration must be a JSON object.'); return parsed;
100
+ }
101
+ function text(bytes) { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
102
+ function entryFor(harness, connectionFile) {
103
+ const args = [join(ROOT, 'bin', 'cairn'), 'team', 'mcp', '--connection', connectionFile];
104
+ return harness === 'opencode'
105
+ ? { type: 'local', command: [process.execPath, ...args], environment: { CAIRN_TEAM: '1' }, enabled: true }
106
+ : { command: process.execPath, args, env: { CAIRN_TEAM: '1' } };
107
+ }
108
+ function jsonAdd(bytes, harness, entry) {
109
+ const parsed = bytes ? parseConfig(text(bytes)) : {};
110
+ const field = harness === 'opencode' ? 'mcp' : 'mcpServers';
111
+ if (Object.hasOwn(parsed, field) && !object(parsed[field])) fail('Harness MCP configuration must be an object.');
112
+ if (Object.hasOwn(parsed[field] ?? {}, NAME)) fail('Existing team MCP entry is not owned by this connection; preserve it and resolve manually.');
113
+ parsed[field] = { ...parsed[field], [NAME]: entry };
114
+ return Buffer.from(`${JSON.stringify(parsed, null, 2)}\n`);
115
+ }
116
+ function tomlBlock(entry, nonce) {
117
+ return `\n# BEGIN cairnkeep team ${nonce}\n[mcp_servers.${NAME}]\ncommand = ${JSON.stringify(entry.command)}\nargs = ${JSON.stringify(entry.args)}\n[mcp_servers.${NAME}.env]\nCAIRN_TEAM = "1"\n# END cairnkeep team ${nonce}\n`;
118
+ }
119
+ function receiptAt(loc) {
120
+ if (!info(loc.receiptFile)) return null;
121
+ const receipt = readStableJson(loc.receiptFile, { label: 'Team connection receipt', maxBytes: MAX_RECEIPT, private: true });
122
+ if (!object(receipt) || receipt.schema_version !== 1 || receipt.project_root !== loc.target
123
+ || typeof receipt.connection_digest !== 'string' || !/^[a-f0-9]{64}$/.test(receipt.connection_digest)
124
+ || !['preparing', 'connected', 'disconnecting'].includes(receipt.status) || !Array.isArray(receipt.files) || receipt.files.length < 1 || receipt.files.length > 4
125
+ || new Set(receipt.files.map(row => row.harness)).size !== receipt.files.length) fail('Invalid team connection receipt.');
126
+ for (const row of receipt.files) {
127
+ if (!object(row) || !Object.hasOwn(ADAPTERS, row.harness) || row.path !== ADAPTERS[row.harness]
128
+ || !(row.before === null || (typeof row.before === 'string' && row.before.length <= 1.4 * MAX))
129
+ || typeof row.after !== 'string' || row.after.length > 1.4 * MAX
130
+ || !Number.isInteger(row.mode) || row.mode < 0 || row.mode > 0o777
131
+ || (row.harness === 'codex' ? typeof row.block !== 'string' : !object(row.entry))) fail('Invalid team connection receipt.');
132
+ }
133
+ return receipt;
134
+ }
135
+ export function planTeamConnection(options) {
136
+ enabled();
137
+ const loc = locations(options.projectRoot ?? process.cwd(), options.home);
138
+ const connection = validateTeamConnection({ schema_version: 1, project_root: loc.target, organization: options.organization,
139
+ project: options.project, url: options.url, token_file: resolve(options.tokenFile ?? ''), access: options.access ?? 'read-only' });
140
+ if (!options.tokenFile) fail('A private token-file reference is required.');
141
+ // Do not embed a credential path from this source tree into project config.
142
+ const tokenRelative = relative(loc.target, realpathSync(connection.token_file));
143
+ if (tokenRelative === '' || (!isAbsolute(tokenRelative) && tokenRelative.split(sep)[0] !== '..')) fail('Keep the credential file outside the project source tree.');
144
+ connectionToken(connection);
145
+ const harnesses = options.harnesses;
146
+ if (!Array.isArray(harnesses) || !harnesses.length || harnesses.length > 4 || new Set(harnesses).size !== harnesses.length
147
+ || harnesses.some(h => !Object.hasOwn(ADAPTERS, h))) fail('Unsupported or duplicate team harness; choose claude, opencode, qwen or codex.');
148
+ const prior = receiptAt(loc);
149
+ if (prior) {
150
+ const currentConnection = readTeamConnection(loc.connectionFile);
151
+ if (prior.status !== 'connected' || JSON.stringify(currentConnection) !== JSON.stringify(connection)
152
+ || [...harnesses].sort().join() !== prior.files.map(r => r.harness).sort().join()) fail('Team connection already exists or is incomplete; doctor/disconnect before changing it.');
153
+ for (const row of prior.files) {
154
+ const current = snapshot(join(loc.target, row.path));
155
+ if (!current || !current.bytes.equals(Buffer.from(row.after, 'base64'))) fail('Team harness configuration changed; resolve drift before reconnecting.');
156
+ }
157
+ return { ...loc, connection, files: prior.files, unchanged: true };
158
+ }
159
+ if (info(loc.connectionFile)) fail('Incomplete team connection; inspect private recovery state before retrying.');
160
+ const nonce = randomUUID();
161
+ const files = harnesses.map(harness => {
162
+ const path = ADAPTERS[harness]; const destination = join(loc.target, path); safeAncestors(dirname(destination));
163
+ if (harness === 'opencode' && info(join(loc.target, 'opencode.jsonc'))) fail('OpenCode JSONC exists; no automatic edits. Use a separate supported configuration.');
164
+ const current = snapshot(destination); const bytes = current?.bytes ?? null;
165
+ const entry = entryFor(harness, loc.connectionFile);
166
+ let after, block;
167
+ if (harness === 'codex') {
168
+ // Do not attempt to reinterpret arbitrary operator TOML or equivalent
169
+ // quoted/inline tables. A conservative collision check preserves it.
170
+ if (bytes && text(bytes).includes(NAME)) fail('Existing team MCP TOML entry must be resolved manually.');
171
+ if (bytes && /(?:^|\n)\s*(?:"mcp_servers"|'mcp_servers'|mcp_servers)\s*=/.test(text(bytes))) fail('Inline MCP TOML configuration is unsupported; preserve it and configure manually.');
172
+ block = tomlBlock(entry, nonce); after = Buffer.concat([bytes ?? Buffer.alloc(0), Buffer.from(block)]);
173
+ } else after = jsonAdd(bytes, harness, entry);
174
+ if (after.length > MAX) fail('Harness configuration exceeds the 1 MiB limit.');
175
+ return { harness, path, before: bytes?.toString('base64') ?? null, after: after.toString('base64'), mode: current ? current.stat.mode & 0o777 : 0o600, ...(block ? { block } : { entry }), expected: current?.stat ?? null };
176
+ });
177
+ return { ...loc, connection, files, unchanged: false };
178
+ }
179
+ function makeParents(target, directory) {
180
+ const segments = directory.slice(target.length).split(/[\\/]/).filter(Boolean);
181
+ let cursor = target;
182
+ for (const segment of segments) { cursor = join(cursor, segment); if (!info(cursor)) { mkdirSync(cursor, { mode: 0o700 }); hardenPrivatePath(cursor); } safeAncestors(cursor); }
183
+ }
184
+ // Like the setup writer, this has a point-in-time drift check, not an OS CAS.
185
+ // The private state lock serializes Cairnkeep writers. Owner-controlled config
186
+ // directories are required; hostile same-identity processes are out of scope.
187
+ function publish(path, bytes, mode, expected = null) {
188
+ safeAncestors(dirname(path));
189
+ const parent = lstatSync(dirname(path));
190
+ const temporary = join(dirname(path), `.cairn-team-${randomUUID()}.tmp`);
191
+ let fd;
192
+ try {
193
+ fd = openSync(temporary, 'wx', mode); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined;
194
+ hardenPrivatePath(temporary);
195
+ if (process.platform !== 'win32') chmodSync(temporary, mode);
196
+ assertFileUnchanged(path, expected, 'Team configuration');
197
+ const now = lstatSync(dirname(path));
198
+ if (parent.dev !== now.dev || parent.ino !== now.ino || now.isSymbolicLink()) fail('Team configuration parent changed.');
199
+ if (expected) renameSync(temporary, path);
200
+ else linkSync(temporary, path); // exclusive destination; never replace a competing new file
201
+ } finally {
202
+ if (fd !== undefined) closeSync(fd);
203
+ if (info(temporary)) unlinkSync(temporary);
204
+ }
205
+ }
206
+ function lockPath(loc) { return `${loc.state}.operation.lock`; }
207
+ function lock(loc) {
208
+ // Keep the lock outside the directory we atomically archive on disconnect.
209
+ // Native Windows cannot rename a directory containing an open lock handle.
210
+ prepareTeamPrivateDirectory(dirname(loc.state), true);
211
+ const path = lockPath(loc);
212
+ let fd;
213
+ try { fd = openSync(path, 'wx', 0o600); }
214
+ catch { fail('Team connection is locked or interrupted. Check running operations before manually clearing operation.lock; see recovery guide.'); }
215
+ try { hardenPrivatePath(path); } catch (error) { closeSync(fd); unlinkSync(path); throw error; }
216
+ try { prepareTeamPrivateDirectory(loc.state, true); }
217
+ catch (error) { closeSync(fd); unlinkSync(path); throw error; }
218
+ let released = false;
219
+ return (location = path) => { if (released) return; released = true; closeSync(fd); unlinkSync(location); };
220
+ }
221
+ export async function applyTeamConnection(plan, hooks = {}) {
222
+ enabled();
223
+ const loc = locations(plan.target, dirname(dirname(dirname(plan.state))));
224
+ if (loc.state !== plan.state || loc.connectionFile !== plan.connectionFile) fail('Invalid team connection plan.');
225
+ if (plan.unchanged) { await connectionDoctor(plan.connection); return { connected: true, unchanged: true, connection_file: loc.connectionFile }; }
226
+ const unlock = lock(loc);
227
+ try {
228
+ if (receiptAt(loc) || info(loc.connectionFile)) fail('Team connection changed; inspect recovery state.');
229
+ const connection = validateTeamConnection(plan.connection); connectionToken(connection);
230
+ // Verify access before writing anything except our private lock directory.
231
+ const identity = await connectionDoctor(connection);
232
+ if (identity.credential_class !== 'workload') fail('Team harness connection requires a workload credential; keep human reviewer credentials outside agent configuration.');
233
+ for (const row of plan.files) assertFileUnchanged(join(loc.target, row.path), row.expected, 'Harness configuration');
234
+ const connectionBytes = Buffer.from(`${JSON.stringify(connection)}\n`);
235
+ const receipt = { schema_version: 1, project_root: loc.target, status: 'preparing', connection_digest: hash(connectionBytes), files: plan.files.map(({ expected, ...row }) => row) };
236
+ publish(loc.receiptFile, Buffer.from(`${JSON.stringify(receipt)}\n`), 0o600);
237
+ publish(loc.connectionFile, connectionBytes, 0o600);
238
+ for (const row of plan.files) {
239
+ await hooks.beforeWrite?.(row);
240
+ const destination = join(loc.target, row.path); makeParents(loc.target, dirname(destination));
241
+ publish(destination, Buffer.from(row.after, 'base64'), row.mode, row.expected);
242
+ }
243
+ const current = snapshot(loc.receiptFile, MAX_RECEIPT);
244
+ publish(loc.receiptFile, Buffer.from(`${JSON.stringify({ ...receipt, status: 'connected' })}\n`), 0o600, current.stat);
245
+ return { connected: true, unchanged: false, connection_file: loc.connectionFile, access: connection.access };
246
+ } finally { unlock(); }
247
+ }
248
+ function removal(row, current, disconnecting = false) {
249
+ const before = row.before === null ? null : Buffer.from(row.before, 'base64');
250
+ if (!current) { if (before === null) return undefined; fail('Team configuration changed or is missing; resolve drift before disconnect.'); }
251
+ if (before?.equals(current.bytes)) return undefined; // interrupted connect did not publish this file
252
+ if (current.bytes.equals(Buffer.from(row.after, 'base64'))) return before;
253
+ if (row.harness === 'codex') {
254
+ const contents = text(current.bytes);
255
+ if (disconnecting && !contents.includes(NAME)) return undefined;
256
+ if (contents.split(row.block).length !== 2) fail('Owned team TOML block changed; resolve drift before disconnect.');
257
+ return Buffer.from(contents.replace(row.block, ''));
258
+ }
259
+ const parsed = parseConfig(text(current.bytes)); const field = row.harness === 'opencode' ? 'mcp' : 'mcpServers';
260
+ if (disconnecting && (!Object.hasOwn(parsed, field) || (object(parsed[field]) && !Object.hasOwn(parsed[field], NAME)))) return undefined;
261
+ if (!object(parsed[field]) || JSON.stringify(parsed[field][NAME]) !== JSON.stringify(row.entry)) fail('Owned team MCP entry changed; resolve drift before disconnect.');
262
+ delete parsed[field][NAME];
263
+ const original = before ? parseConfig(text(before)) : {};
264
+ if (Object.keys(parsed[field]).length === 0 && !Object.hasOwn(original, field)) delete parsed[field];
265
+ return Buffer.from(`${JSON.stringify(parsed, null, 2)}\n`);
266
+ }
267
+ export async function disconnectTeamConnection(options) {
268
+ enabled(); const loc = locations(options.projectRoot ?? process.cwd(), options.home);
269
+ const unlock = options.dryRun ? () => {} : lock(loc);
270
+ try {
271
+ const receipt = receiptAt(loc); if (!receipt) fail('No managed team connection for this project.');
272
+ // Preflight EVERY file before removing any entry. Drift never authorizes
273
+ // wholesale backup restore over newly edited personal/unrelated MCP config.
274
+ const changes = receipt.files.map(row => {
275
+ const destination = join(loc.target, row.path); safeAncestors(dirname(destination));
276
+ const current = snapshot(destination); return { row, destination, current, bytes: removal(row, current, receipt.status === 'disconnecting') };
277
+ });
278
+ if (options.dryRun) return { planned: true, disconnect: true, project_root: loc.target, files: receipt.files.map(row => row.path) };
279
+ const receiptSnapshot = snapshot(loc.receiptFile, MAX_RECEIPT);
280
+ publish(loc.receiptFile, Buffer.from(`${JSON.stringify({ ...receipt, status: 'disconnecting' })}\n`), 0o600, receiptSnapshot.stat);
281
+ for (const { row, destination, current, bytes } of changes) {
282
+ if (bytes === undefined) continue;
283
+ await options.beforeWrite?.(row);
284
+ assertFileUnchanged(destination, current.stat, 'Harness configuration');
285
+ if (bytes === null) unlinkSync(destination);
286
+ else publish(destination, bytes, row.mode, current.stat);
287
+ }
288
+ // Keep private backups, but retire the active connection path so new
289
+ // harness launches cannot reuse it accidentally. Existing processes need
290
+ // restart or server-side revocation; disconnect does not revoke a token.
291
+ const archive = `${loc.state}.disconnected-${randomUUID()}`;
292
+ renameSync(loc.state, archive);
293
+ unlock();
294
+ return { disconnected: true, credentials_retained: true, backups_retained: true };
295
+ } finally { unlock(); }
296
+ }
297
+ function argsFor(args) {
298
+ const flags = {}; const values = new Set(['project-root', 'url', 'organization', 'project', 'token-file', 'harness', 'access']);
299
+ for (let index = 1; index < args.length; index++) {
300
+ const name = args[index].slice(2);
301
+ if (!args[index].startsWith('--') || Object.hasOwn(flags, name)) fail('Unknown or duplicate team connection option.');
302
+ if (['apply', 'yes', 'client'].includes(name)) flags[name] = true;
303
+ else if (values.has(name) && args[index + 1] && !args[index + 1].startsWith('--')) flags[name] = args[++index];
304
+ else fail('Unknown or missing team connection option.');
305
+ }
306
+ return flags;
307
+ }
308
+ export async function runTeamConnection(args, streams = {}) {
309
+ const input = streams.input ?? process.stdin, output = streams.output ?? process.stdout, error = streams.error ?? process.stderr;
310
+ let prompt;
311
+ try {
312
+ if (args.includes('--help') || args.includes('-h')) { output.write(HELP); return 0; }
313
+ enabled();
314
+ const flags = argsFor(args); const command = args[0];
315
+ const allowed = command === 'connect' ? ['project-root', 'url', 'organization', 'project', 'token-file', 'harness', 'access', 'apply', 'yes']
316
+ : command === 'disconnect' ? ['project-root', 'apply', 'yes'] : command === 'doctor' ? ['project-root', 'client'] : [];
317
+ if (!allowed.length || Object.keys(flags).some(key => !allowed.includes(key))) fail('Invalid team connection command or option.');
318
+ const options = { projectRoot: flags['project-root'] ?? process.cwd(), home: streams.home };
319
+ if (command === 'doctor') {
320
+ if (!flags.client) fail('Use team doctor --client for connection diagnostics.');
321
+ const loc = locations(options.projectRoot, options.home); const receipt = receiptAt(loc);
322
+ if (!receipt || receipt.status !== 'connected' || info(lockPath(loc))) fail('No completed, unlocked team connection; inspect recovery state.');
323
+ for (const row of receipt.files) {
324
+ const current = snapshot(join(loc.target, row.path));
325
+ if (!current || removal(row, current) === undefined) fail('Managed team entry is missing; reconnect or inspect recovery state.');
326
+ }
327
+ const connection = readTeamConnection(loc.connectionFile);
328
+ output.write(`${JSON.stringify(await connectionDoctor(connection), null, 2)}\n`); return 0;
329
+ }
330
+ const interactive = Boolean(input.isTTY && output.isTTY);
331
+ const wizard = command === 'connect' && interactive && ['url', 'organization', 'project', 'token-file', 'harness'].some(key => !flags[key]);
332
+ const question = async message => {
333
+ prompt ??= createInterface({ input, output });
334
+ return (await prompt.question(message)).trim();
335
+ };
336
+ const confirm = async () => {
337
+ prompt?.close(); prompt = undefined;
338
+ if (supportsTerminalPrompts(input, output)) return (await selectOnePrompt({ input, output, message: 'Apply team connection plan?', choices: [{ value: 'no', label: 'No, cancel' }, { value: 'yes', label: 'Yes, apply' }], initialValue: 'no' })) === 'yes';
339
+ return ['y', 'yes'].includes((await question('Apply team connection plan? [y/N] ')).toLowerCase());
340
+ };
341
+ let plan;
342
+ if (command === 'connect') {
343
+ const get = async (name, message) => flags[name] ?? (interactive ? await question(message) : fail(`Missing --${name}; use team connect --help.`));
344
+ Object.assign(options, { url: await get('url', 'Team URL (loopback HTTP or HTTPS): '), organization: await get('organization', 'Organization ID: '), project: await get('project', 'Team project ID: '), tokenFile: await get('token-file', 'Private credential file (outside source tree): '), access: flags.access ?? 'read-only' });
345
+ if (flags.harness) options.harnesses = flags.harness.split(',');
346
+ else if (supportsTerminalPrompts(input, output)) {
347
+ prompt?.close(); prompt = undefined;
348
+ options.harnesses = await selectManyPrompt({ input, output, message: 'Add team MCP to project harnesses', choices: Object.keys(ADAPTERS).map(value => ({ value, label: value })) });
349
+ } else options.harnesses = (await get('harness', 'Harnesses (claude,opencode,qwen,codex): ')).split(',');
350
+ prompt?.close(); prompt = undefined;
351
+ plan = planTeamConnection(options);
352
+ output.write(`${JSON.stringify({ planned: true, project_root: plan.target, project: plan.connection.project, organization: plan.connection.organization, access: plan.connection.access, files: plan.files.map(row => row.path), personal_memory: 'unchanged' }, null, 2)}\n`);
353
+ } else output.write(`${JSON.stringify(await disconnectTeamConnection({ ...options, dryRun: true }), null, 2)}\n`);
354
+ const apply = flags.apply || wizard;
355
+ if (!apply) return 0;
356
+ if (!flags.yes && !(interactive && await confirm())) fail('Team connection cancelled. No harness configuration changed.');
357
+ const result = command === 'connect' ? await applyTeamConnection(plan) : await disconnectTeamConnection(options);
358
+ output.write(`${JSON.stringify(result, null, 2)}\n`); return 0;
359
+ } catch (cause) {
360
+ const messages = { unauthorized: 'Team credential rejected (expired, revoked or unknown). Ask the operator to issue a fresh private credential file.', denied: 'Team project access denied. Check organization/project IDs and explicit reader membership with the operator.', integrity: 'Team service unavailable or response invalid. Check the tunnel/TLS, server version and operator status; no personal-memory fallback is used.', invalid: 'Team request rejected. Verify connection fields and upgrade the team API to a version supporting connection-check (2.26 or newer).' };
361
+ const message = cause instanceof TeamError ? (messages[cause.code] ?? cause.message)
362
+ : cause instanceof ConnectionError || cause instanceof TeamClientError ? cause.message
363
+ : 'Team connection failed safely; no credential or configuration content is logged.';
364
+ error.write(`${message}\nRecovery: cairn team doctor --client --project-root PATH; see docs/team-onboarding.md.\n`); return cause?.status ?? 1;
365
+ } finally { prompt?.close(); if (typeof input.pause === 'function') input.pause(); }
366
+ }
@@ -12,12 +12,17 @@ const root = process.argv[2];
12
12
  const { buildShardPlan, smokeChecks } = await import(
13
13
  pathToFileURL(join(root, "mcp-memory-server", "scripts", "run-smoke-suite.mjs")));
14
14
  const plan = buildShardPlan(3);
15
+ const measured = JSON.parse(readFileSync(join(root, 'mcp-memory-server/scripts/fixtures/windows-smoke-timings.json'), 'utf8'));
16
+ assert.equal(measured.schema_version,1);
17
+ for (const [name,seconds] of Object.entries(measured.measurements)) {
18
+ assert.equal(smokeChecks.find(row=>row.name===name)?.weight,seconds,`measured weight drift: ${name}`);
19
+ }
15
20
  const flattened = plan.flatMap(({ checks }) => checks.map(({ name }) => name));
16
21
  assert.equal(flattened.length, smokeChecks.length);
17
22
  assert.equal(new Set(flattened).size, smokeChecks.length);
18
23
  assert.deepEqual([...flattened].sort(), smokeChecks.map(({ name }) => name).sort());
19
24
  assert.ok(plan.every(({ checks }) => checks.length > 0));
20
- const teamContracts = ['team', 'team-review', 'team-http', 'team-mcp', 'team-restore', 'team-cli'];
25
+ const teamContracts = ['team', 'team-review', 'team-http', 'team-mcp', 'team-restore', 'team-cli', 'team-onboarding', 'team-operations'];
21
26
  for (const name of teamContracts) assert.ok(flattened.includes(name), `missing team contract: ${name}`);
22
27
  assert.ok(plan.every(({ checks }) => checks.some(({ name }) => teamContracts.includes(name))),
23
28
  'native ACL-heavy team contracts must be distributed across all Windows shards');
@@ -0,0 +1,35 @@
1
+ import assert from 'node:assert/strict';
2
+ import { validateCodeqlCohort } from './verify-security-baseline.mjs';
3
+
4
+ const sha = 'a'.repeat(40), other = 'b'.repeat(40);
5
+ const workflows = [
6
+ { jobs: { scan: { steps: [{ uses: `github/codeql-action/init@${sha}` }, { uses: `github/codeql-action/analyze@${sha}` }] } } },
7
+ { jobs: { score: { steps: [{ uses: `github/codeql-action/upload-sarif@${sha}` }] } } },
8
+ ];
9
+ const dependabot = { version: 2, updates: [{ 'package-ecosystem': 'github-actions', directory: '/', groups: { 'codeql-action': { patterns: ['github/codeql-action/*'] } } }] };
10
+ validateCodeqlCohort(workflows, dependabot);
11
+ assert.throws(() => validateCodeqlCohort([], dependabot), /cohort/);
12
+ assert.throws(() => validateCodeqlCohort(workflows.slice(0, 1), dependabot), /missing upload-sarif/);
13
+ assert.throws(() => validateCodeqlCohort(workflows, {}), /group/);
14
+ const duplicateUpdates = structuredClone(dependabot);
15
+ duplicateUpdates.updates.push(structuredClone(dependabot.updates[0]));
16
+ assert.throws(() => validateCodeqlCohort(workflows, duplicateUpdates), /group/);
17
+ for (let index = 0; index < 3; index++) {
18
+ const changed = structuredClone(workflows);
19
+ const target = index < 2 ? changed[0].jobs.scan.steps[index] : changed[1].jobs.score.steps[0];
20
+ target.uses = target.uses.replace(sha, other);
21
+ assert.throws(() => validateCodeqlCohort(changed, dependabot), /same.*commit|cohort/);
22
+ }
23
+ const unpinned = structuredClone(workflows);
24
+ unpinned[0].jobs.scan.steps[0].uses = 'github/codeql-action/init@v4';
25
+ assert.throws(() => validateCodeqlCohort(unpinned, dependabot), /pinned|commit/);
26
+ for (const mutate of [
27
+ value => { delete value.updates[0].groups; },
28
+ value => { value.updates[0].groups['codeql-action'].patterns = ['github/codeql-action/init']; },
29
+ value => { value.updates[0].groups['codeql-action']['exclude-patterns'] = ['github/codeql-action/analyze']; },
30
+ value => { value.updates[0].groups['codeql-action']['update-types'] = ['patch']; },
31
+ ]) {
32
+ const changed = structuredClone(dependabot); mutate(changed);
33
+ assert.throws(() => validateCodeqlCohort(workflows, changed), /group|cohort/);
34
+ }
35
+ console.log('PASS: CodeQL same-commit cohort and grouped dependency-update mutation controls');
@@ -19,9 +19,12 @@ for shell in bash zsh fish; do
19
19
  grep -q 'capabilities' "$tmp/$shell"
20
20
  grep -q 'team' "$tmp/$shell"
21
21
  grep -q 'credential create set remove issue revoke' "$tmp/$shell"
22
- for team_command in init project member credential serve mcp list read search history propose proposals proposal-show review audit doctor backup restore memory-delete; do
22
+ for team_command in connect disconnect init project member credential serve mcp list read search history propose proposals proposal-show review audit doctor backup restore memory-delete audit-export audit-verify audit-archive; do
23
23
  grep -qF "$team_command" "$tmp/$shell"
24
24
  done
25
+ for team_flag in connection client url token-file harness access apply yes backup previous read-only page-size cursor; do
26
+ grep -q -- "--$team_flag\|-l $team_flag" "$tmp/$shell"
27
+ done
25
28
  grep -q 'list.*status.*enable.*disable.*reset.*logging\|list status enable disable reset logging' "$tmp/$shell"
26
29
  grep -q -- '--json\|-l json' "$tmp/$shell"
27
30
  for capability_id in $capability_ids; do
@@ -160,6 +163,9 @@ done
160
163
  grep -qF "'team'" "$tmp/powershell"
161
164
  grep -qF "'credential','create','set','remove','issue','revoke'" "$tmp/powershell"
162
165
  grep -qF "'memory-delete'" "$tmp/powershell"
166
+ for value in audit-export audit-verify audit-archive --backup --previous --read-only --page-size --cursor; do
167
+ grep -qF -- "'$value'" "$tmp/powershell"
168
+ done
163
169
  grep -qF "'protocol'" "$tmp/powershell"
164
170
  grep -qF "'--trajectory'" "$tmp/powershell"
165
171
  grep -qF "'--codex-jsonl'" "$tmp/powershell"
@@ -62,6 +62,7 @@ env -u CAIRN_NOTE_DISTILLATION cairn notes --help >/dev/null \
62
62
  installed_root="$tmp/prefix/lib/node_modules/@cairnkeep/cli"
63
63
  cairn team --help >"$tmp/installed-team-help"
64
64
  grep -qF 'experimental token-first team continuity' "$tmp/installed-team-help" || fail "installed package omitted team preview"
65
+ node "$ROOT/scripts/test-team-installed.mjs" "$installed_root" || fail "installed team onboarding failed"
65
66
  CAIRN_TEAM=1 cairn team init --organization demo-org --data "$tmp/installed-team" >"$tmp/team-init.json"
66
67
  CAIRN_TEAM=1 cairn team project create alpha --data "$tmp/installed-team" >/dev/null
67
68
  CAIRN_TEAM=1 cairn team backup --data "$tmp/installed-team" --output "$tmp/team-snapshot.json" >"$tmp/team-backup.json"
@@ -215,8 +215,8 @@ NODE
215
215
  local first_pid=$!
216
216
  run_capability "$project" disable security.audit >"$tmp/concurrent-two.out" 2>&1 &
217
217
  local second_pid=$!
218
- wait "$first_pid" || fail "first concurrent mutation failed"
219
- wait "$second_pid" || fail "second concurrent mutation failed"
218
+ wait "$first_pid" || { cat "$tmp/concurrent-one.out" >&2; fail "first concurrent mutation failed"; }
219
+ wait "$second_pid" || { cat "$tmp/concurrent-two.out" >&2; fail "second concurrent mutation failed"; }
220
220
  node - "$config_path" <<'NODE'
221
221
  const fs = require("fs");
222
222
  const value = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
@@ -0,0 +1,16 @@
1
+ import assert from 'node:assert/strict';
2
+ import { npmVerificationCommands } from './verify-published-release.mjs';
3
+ const commands = npmVerificationCommands('/private/disposable/install', '1.2.3');
4
+ assert.equal(commands.length, 2);
5
+ for (const args of commands) {
6
+ assert.equal(args[args.indexOf('--cache') + 1], '/private/disposable/install/.verification-cache');
7
+ assert(args.includes('--prefer-online'));
8
+ assert(args.includes('--prefer-offline=false'));
9
+ assert(args.includes('--offline=false'));
10
+ assert.equal(args[args.indexOf('--registry') + 1], 'https://registry.npmjs.org');
11
+ }
12
+ assert.equal(commands[0][0], 'install');
13
+ assert(commands[0].includes('@cairnkeep/cli@1.2.3'));
14
+ assert(commands[0].includes('--ignore-scripts'));
15
+ assert.deepEqual(commands[1].slice(0, 2), ['audit', 'signatures']);
16
+ console.log('PASS: disposable online npm verification retains signature audit and exact version');
@@ -2,3 +2,7 @@
2
2
  set -euo pipefail
3
3
  ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
4
4
  node "$ROOT/scripts/verify-security-baseline.mjs"
5
+ node "$ROOT/scripts/test-codeql-cohort.mjs"
6
+ node "$ROOT/scripts/test-release-cache.mjs"
7
+ node "$ROOT/scripts/test-smoke-timings.mjs"
8
+ node "$ROOT/scripts/test-team-lab-env.mjs"
@@ -0,0 +1,19 @@
1
+ import assert from 'node:assert/strict';
2
+ import { runSmokeChecks, smokeChecks } from '../mcp-memory-server/scripts/run-smoke-suite.mjs';
3
+ const output = [], calls = [];
4
+ let tick = 0;
5
+ const result = runSmokeChecks(smokeChecks.slice(0, 3), {
6
+ spawn: (_command, args) => { calls.push(args); return { status: calls.length === 2 ? 9 : 0 }; },
7
+ now: () => (tick += 10), write: value => output.push(value),
8
+ });
9
+ assert.equal(result, 9);
10
+ assert.equal(calls.length, 2);
11
+ const timings = output.filter(value => value.startsWith('SMOKE_TIMING ')).map(value => JSON.parse(value.slice(13)));
12
+ assert.equal(timings.length, 2);
13
+ assert.deepEqual(timings.map(row => row.status), [0, 9]);
14
+ assert(timings.every(row => row.duration_ms === 10 && row.schema_version === 1));
15
+ assert.deepEqual(Object.keys(timings[0]).sort(), ['duration_ms','name','schema_version','status']);
16
+ const errors=[];
17
+ assert.throws(()=>runSmokeChecks(smokeChecks.slice(0,1),{spawn:()=>{throw Error('synthetic spawn failure');},write:value=>errors.push(value),now:()=>0}));
18
+ assert.equal(JSON.parse(errors.find(value=>value.startsWith('SMOKE_TIMING ')).slice(13)).status,1);
19
+ console.log('PASS: smoke timings preserve execution order, early failure, status and payload-free output');
@@ -19,6 +19,15 @@ for doc in docs/team.md "$lesson" "$video"; do
19
19
  fi
20
20
  done
21
21
  [[ -f docs/team-pilot.md ]]
22
+ [[ -f docs/team-onboarding.md ]]
23
+ for doc in docs/team.md docs/team-pilot.md "$lesson" "$video"; do
24
+ grep -qF 'team-onboarding.md' "$doc"
25
+ done
26
+ for heading in 'Choose the right workflow' 'Operator handoff' 'Reader quick-check' 'Contributor and reviewer' 'Deployment readiness'; do
27
+ grep -qF "$heading" docs/team-onboarding.md
28
+ done
29
+ grep -qF 'not production admission' docs/team-onboarding.md
30
+ grep -qF 'no real-data deployment is authorized' docs/team-onboarding.md
22
31
  grep -qF 'team-pilot.md' docs/team.md
23
32
  grep -qF 'team-pilot.md' "$lesson"
24
33
  grep -qF 'team-pilot.md' "$video"
@@ -34,9 +43,24 @@ grep -q '^## Privacy and trust boundary$' "$lesson"
34
43
  grep -qF 'L29-team-continuity.md' docs/learning/README.md
35
44
  grep -qF 'L29-team-continuity.md' docs/learning/tracks/operator.md
36
45
  grep -qF '`cairn team`' docs/learning/CURRICULUM-MAP.md
37
- for command in init project member credential serve mcp proposals proposal-show propose review search read audit doctor backup restore memory-delete; do
46
+ for command in connect disconnect init project member credential serve mcp proposals proposal-show propose review search read audit doctor backup restore memory-delete audit-export audit-verify audit-archive; do
38
47
  node mcp-memory-server/dist/team-cli.js --help | grep -q "cairn team $command"
39
48
  done
49
+ for doc in docs/team.md docs/team-onboarding.md "$lesson" "$video"; do
50
+ grep -qF 'team-operations.md' "$doc"
51
+ done
52
+ for term in '2.27.0' 'audit-verify' 'audit-archive' 'read-only' 'capacity' 'schema' 'synthetic' 'integrity'; do
53
+ grep -qiF "$term" docs/team-operations.md
54
+ done
55
+ if grep -qiE 'next-minor|source-only|next.minor source feature' docs/team-operations.md; then
56
+ echo 'Released team operations must not be described as source-only' >&2
57
+ exit 1
58
+ fi
59
+ if grep -qiE 'no audit archival/compaction command|currently has no pagination' docs/team.md; then
60
+ echo 'Team reference must describe released paging and archival' >&2
61
+ exit 1
62
+ fi
63
+ grep -qF 'One successful segment check is not a claim' docs/team-operations.md
40
64
  node mcp-memory-server/dist/team-cli.js --help | grep -qF 'member remove SUBJECT --project ID'
41
65
  for doc in docs/team.md "$lesson" "$video"; do
42
66
  grep -qF 'cairn team member remove' "$doc"
@@ -44,7 +68,7 @@ done
44
68
  for guide in operating storage privacy-and-data-flow security-assurance harness-compatibility agents; do
45
69
  grep -qF 'team.md' "docs/$guide.md"
46
70
  done
47
- for control in team team-review team-http team-mcp team-restore team-cli; do
71
+ for control in team team-review team-http team-mcp team-restore team-cli team-onboarding; do
48
72
  grep -qF "smoke-$control.mjs" "$lesson"
49
73
  [[ -f "mcp-memory-server/scripts/smoke-$control.mjs" ]]
50
74
  done