groove-dev 0.27.77 → 0.27.78

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 (79) hide show
  1. package/CLAUDE.md +0 -7
  2. package/MOE_TRAINING_PIPELINE.md +216 -12
  3. package/moe-training/DEPLOY_CENTRAL_COMMAND.md +413 -0
  4. package/moe-training/client/consent.js +96 -0
  5. package/moe-training/client/envelope-builder.js +56 -0
  6. package/moe-training/client/index.js +10 -0
  7. package/moe-training/client/parsers/claude-code.js +110 -0
  8. package/moe-training/client/parsers/codex.js +80 -0
  9. package/moe-training/client/parsers/gemini.js +80 -0
  10. package/moe-training/client/parsers/grok.js +16 -0
  11. package/moe-training/client/parsers/index.js +20 -0
  12. package/moe-training/client/scrubber.js +126 -0
  13. package/moe-training/client/session-attestation.js +114 -0
  14. package/moe-training/client/step-classifier.js +51 -0
  15. package/moe-training/client/trajectory-capture.js +227 -0
  16. package/moe-training/client/transmission-queue.js +93 -0
  17. package/moe-training/package-lock.json +1266 -0
  18. package/moe-training/package.json +20 -0
  19. package/moe-training/server/enrichment.js +24 -0
  20. package/moe-training/server/index.js +119 -0
  21. package/moe-training/server/ledger.js +110 -0
  22. package/moe-training/server/routes/ingest.js +96 -0
  23. package/moe-training/server/routes/sessions.js +43 -0
  24. package/moe-training/server/routes/stats.js +31 -0
  25. package/moe-training/server/scoring.js +63 -0
  26. package/moe-training/server/session-registry.js +156 -0
  27. package/moe-training/server/stats.js +129 -0
  28. package/moe-training/server/stitcher.js +69 -0
  29. package/moe-training/server/storage.js +147 -0
  30. package/moe-training/server/verifier.js +102 -0
  31. package/moe-training/shared/constants.js +30 -0
  32. package/moe-training/shared/crypto.js +45 -0
  33. package/moe-training/shared/envelope-schema.js +220 -0
  34. package/moe-training/test/client/consent.test.js +121 -0
  35. package/moe-training/test/client/envelope-builder.test.js +107 -0
  36. package/moe-training/test/client/parsers/claude-code.test.js +119 -0
  37. package/moe-training/test/client/parsers/codex.test.js +83 -0
  38. package/moe-training/test/client/parsers/gemini.test.js +99 -0
  39. package/moe-training/test/client/scrubber.test.js +133 -0
  40. package/moe-training/test/client/session-attestation-security.test.js +95 -0
  41. package/moe-training/test/client/step-classifier.test.js +88 -0
  42. package/moe-training/test/integration/handshake.test.js +260 -0
  43. package/moe-training/test/server/ingest-security.test.js +166 -0
  44. package/moe-training/test/server/ledger.test.js +131 -0
  45. package/moe-training/test/server/scoring.test.js +242 -0
  46. package/moe-training/test/server/session-registry.test.js +125 -0
  47. package/moe-training/test/server/stitcher.test.js +157 -0
  48. package/moe-training/test/server/verifier.test.js +232 -0
  49. package/moe-training/test/shared/crypto.test.js +87 -0
  50. package/moe-training/test/shared/envelope-schema.test.js +351 -0
  51. package/node_modules/@groove-dev/cli/package.json +1 -1
  52. package/node_modules/@groove-dev/daemon/package.json +1 -1
  53. package/node_modules/@groove-dev/daemon/src/agent-loop.js +48 -5
  54. package/node_modules/@groove-dev/daemon/src/api.js +77 -0
  55. package/node_modules/@groove-dev/daemon/src/index.js +61 -0
  56. package/node_modules/@groove-dev/daemon/src/journalist.js +64 -21
  57. package/node_modules/@groove-dev/daemon/src/process.js +199 -0
  58. package/node_modules/@groove-dev/daemon/src/providers/grok.js +15 -0
  59. package/node_modules/@groove-dev/daemon/src/state.js +20 -1
  60. package/node_modules/@groove-dev/gui/dist/assets/{index-BbmPDhuW.js → index-BJgEJ9lZ.js} +1677 -1677
  61. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  62. package/node_modules/@groove-dev/gui/package.json +1 -1
  63. package/node_modules/@groove-dev/gui/src/stores/groove.js +32 -0
  64. package/node_modules/@groove-dev/gui/src/views/settings.jsx +167 -1
  65. package/package.json +1 -1
  66. package/packages/cli/package.json +1 -1
  67. package/packages/daemon/package.json +1 -1
  68. package/packages/daemon/src/agent-loop.js +48 -5
  69. package/packages/daemon/src/api.js +77 -0
  70. package/packages/daemon/src/index.js +61 -0
  71. package/packages/daemon/src/journalist.js +64 -21
  72. package/packages/daemon/src/process.js +199 -0
  73. package/packages/daemon/src/providers/grok.js +15 -0
  74. package/packages/daemon/src/state.js +20 -1
  75. package/packages/gui/dist/assets/{index-BbmPDhuW.js → index-BJgEJ9lZ.js} +1677 -1677
  76. package/packages/gui/dist/index.html +1 -1
  77. package/packages/gui/package.json +1 -1
  78. package/packages/gui/src/stores/groove.js +32 -0
  79. package/packages/gui/src/views/settings.jsx +167 -1
@@ -0,0 +1,99 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { describe, it } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { GeminiParser } from '../../../client/parsers/gemini.js';
6
+
7
+ describe('GeminiParser', () => {
8
+ it('parses thought content type as thought', () => {
9
+ const parser = new GeminiParser();
10
+ const result = parser.parseEvent({
11
+ type: 'message',
12
+ role: 'model',
13
+ content: [{ type: 'thought', thought: 'I should check the imports' }],
14
+ });
15
+ assert.equal(result.type, 'thought');
16
+ assert.equal(result.content, 'I should check the imports');
17
+ });
18
+
19
+ it('parses model text message as thought', () => {
20
+ const parser = new GeminiParser();
21
+ const result = parser.parseEvent({
22
+ type: 'message',
23
+ role: 'model',
24
+ content: [{ text: 'Let me look at that file' }],
25
+ });
26
+ assert.equal(result.type, 'thought');
27
+ assert.equal(result.content, 'Let me look at that file');
28
+ });
29
+
30
+ it('skips user messages', () => {
31
+ const parser = new GeminiParser();
32
+ const result = parser.parseEvent({
33
+ type: 'message',
34
+ role: 'user',
35
+ content: [{ text: 'fix the bug' }],
36
+ });
37
+ assert.equal(result, null);
38
+ });
39
+
40
+ it('parses tool_request as action', () => {
41
+ const parser = new GeminiParser();
42
+ const result = parser.parseEvent({
43
+ type: 'tool_request',
44
+ name: 'Grep',
45
+ args: { pattern: 'TODO' },
46
+ });
47
+ assert.equal(result.type, 'action');
48
+ assert.equal(result.tool, 'Grep');
49
+ assert.deepEqual(result.arguments, { pattern: 'TODO' });
50
+ });
51
+
52
+ it('parses tool_response as observation', () => {
53
+ const parser = new GeminiParser();
54
+ const result = parser.parseEvent({
55
+ type: 'tool_response',
56
+ content: 'Found 3 matches',
57
+ });
58
+ assert.equal(result.type, 'observation');
59
+ assert.equal(result.content, 'Found 3 matches');
60
+ });
61
+
62
+ it('parses error event', () => {
63
+ const parser = new GeminiParser();
64
+ const result = parser.parseEvent({
65
+ type: 'error',
66
+ message: 'Rate limit exceeded',
67
+ });
68
+ assert.equal(result.type, 'error');
69
+ assert.equal(result.content, 'Rate limit exceeded');
70
+ });
71
+
72
+ it('parses agent_end as resolution', () => {
73
+ const parser = new GeminiParser();
74
+ const result = parser.parseEvent({ type: 'agent_end' });
75
+ assert.equal(result.type, 'resolution');
76
+ });
77
+
78
+ it('extracts tokens from usage event', () => {
79
+ const parser = new GeminiParser();
80
+ const tokens = parser.extractTokens({
81
+ type: 'usage',
82
+ inputTokens: 300,
83
+ outputTokens: 150,
84
+ cachedTokens: 50,
85
+ });
86
+ assert.deepEqual(tokens, { input: 300, output: 150, cacheRead: 50, cacheCreation: 0 });
87
+ });
88
+
89
+ it('returns null for non-usage token extraction', () => {
90
+ const parser = new GeminiParser();
91
+ assert.equal(parser.extractTokens({ type: 'message' }), null);
92
+ });
93
+
94
+ it('extracts session id from agent_start', () => {
95
+ const parser = new GeminiParser();
96
+ assert.equal(parser.extractSessionId({ type: 'agent_start', streamId: 'stream-456' }), 'stream-456');
97
+ assert.equal(parser.extractSessionId({ type: 'message' }), null);
98
+ });
99
+ });
@@ -0,0 +1,133 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { describe, it } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { PIIScrubber } from '../../client/scrubber.js';
6
+
7
+ const scrubber = new PIIScrubber();
8
+
9
+ describe('PIIScrubber', () => {
10
+ it('scrubs PEM private keys', () => {
11
+ const input = '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIB...\n-----END RSA PRIVATE KEY-----';
12
+ assert.equal(scrubber.scrub(input), '[PRIVATE_KEY]');
13
+ });
14
+
15
+ it('scrubs AWS access keys', () => {
16
+ const input = 'key is AKIAIOSFODNN7EXAMPLE here';
17
+ assert.equal(scrubber.scrub(input), 'key is [AWS_KEY] here');
18
+ });
19
+
20
+ it('scrubs Bearer tokens', () => {
21
+ const input = 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
22
+ assert.equal(scrubber.scrub(input), 'Authorization: [API_KEY]');
23
+ });
24
+
25
+ it('scrubs sk_/pk_ prefixed keys', () => {
26
+ const input = 'apikey: sk_test_51HQdMnAbcDefGhIjKlMn';
27
+ assert.equal(scrubber.scrub(input), 'apikey: [API_KEY]');
28
+ });
29
+
30
+ it('scrubs valid credit cards with Luhn check', () => {
31
+ const input = 'card: 4111 1111 1111 1111';
32
+ assert.equal(scrubber.scrub(input), 'card: [CREDIT_CARD]');
33
+ });
34
+
35
+ it('does not scrub random 16-digit numbers failing Luhn', () => {
36
+ const input = 'number: 1234 5678 9012 3456';
37
+ assert.equal(scrubber.scrub(input), 'number: 1234 5678 9012 3456');
38
+ });
39
+
40
+ it('scrubs SSNs', () => {
41
+ const input = 'ssn: 123-45-6789';
42
+ assert.equal(scrubber.scrub(input), 'ssn: [SSN]');
43
+ });
44
+
45
+ it('scrubs email addresses', () => {
46
+ const input = 'contact user@example.com for info';
47
+ assert.equal(scrubber.scrub(input), 'contact [EMAIL] for info');
48
+ });
49
+
50
+ it('scrubs IPv6 addresses', () => {
51
+ const input = 'server at 2001:0db8:85a3:0000:0000:8a2e:0370:7334';
52
+ assert.equal(scrubber.scrub(input), 'server at [IP]');
53
+ });
54
+
55
+ it('scrubs IPv4 addresses', () => {
56
+ const input = 'connect to 192.168.1.100';
57
+ assert.equal(scrubber.scrub(input), 'connect to [IP]');
58
+ });
59
+
60
+ it('scrubs phone numbers', () => {
61
+ const input = 'call (555) 123-4567';
62
+ assert.equal(scrubber.scrub(input), 'call [PHONE]');
63
+ });
64
+
65
+ it('scrubs URLs with token/key/secret params', () => {
66
+ const input = 'visit https://api.example.com/data?token=abc123&other=val';
67
+ assert.equal(scrubber.scrub(input), 'visit [REDACTED_URL]&other=val');
68
+ });
69
+
70
+ it('scrubs long hex strings (40+ chars)', () => {
71
+ const hex = 'a'.repeat(40);
72
+ const input = `hash: ${hex}`;
73
+ assert.equal(scrubber.scrub(input), 'hash: [API_KEY]');
74
+ });
75
+
76
+ it('scrubs home directory paths', () => {
77
+ const input = 'file at /Users/john/Documents/secret.txt';
78
+ assert.equal(scrubber.scrub(input), 'file at [FILE_PATH]');
79
+ });
80
+
81
+ it('scrubs URL-encoded emails', () => {
82
+ const input = 'param=ryan%40motovizion.com&next=home';
83
+ assert.equal(scrubber.scrub(input), 'param=[EMAIL]&next=home');
84
+ });
85
+
86
+ it('scrubs international phone numbers', () => {
87
+ const input = 'call +44 20 7946 0958 for help';
88
+ assert.equal(scrubber.scrub(input), 'call [PHONE] for help');
89
+ });
90
+
91
+ it('scrubs JWT tokens without Bearer prefix', () => {
92
+ const input = 'token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
93
+ const result = scrubber.scrub(input);
94
+ assert.ok(result.includes('[API_KEY]'));
95
+ assert.ok(!result.includes('eyJhbGciOi'));
96
+ });
97
+
98
+ it('scrubs file paths without trailing slash', () => {
99
+ const input = 'reading /home/alice/project/secret.key now';
100
+ const result = scrubber.scrub(input);
101
+ assert.ok(result.includes('[FILE_PATH]'));
102
+ assert.ok(!result.includes('/home/alice'));
103
+ });
104
+
105
+ it('scrubs base64 encoded secrets', () => {
106
+ const b64 = 'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY3ODk=';
107
+ const input = `secret: ${b64} done`;
108
+ const result = scrubber.scrub(input);
109
+ assert.ok(result.includes('[API_KEY]'));
110
+ assert.ok(!result.includes(b64));
111
+ });
112
+
113
+ it('passes through non-PII content unchanged', () => {
114
+ const input = 'This is a normal sentence about coding in JavaScript.';
115
+ assert.equal(scrubber.scrub(input), input);
116
+ });
117
+
118
+ it('handles null/undefined input gracefully', () => {
119
+ assert.equal(scrubber.scrub(null), null);
120
+ assert.equal(scrubber.scrub(undefined), undefined);
121
+ assert.equal(scrubber.scrub(''), '');
122
+ });
123
+
124
+ it('patterns do not interfere with each other', () => {
125
+ const input = 'user@example.com called 555-123-4567 from 192.168.1.1';
126
+ const result = scrubber.scrub(input);
127
+ assert.ok(result.includes('[EMAIL]'));
128
+ assert.ok(result.includes('[PHONE]'));
129
+ assert.ok(result.includes('[IP]'));
130
+ assert.ok(!result.includes('user@example.com'));
131
+ assert.ok(!result.includes('192.168.1.1'));
132
+ });
133
+ });
@@ -0,0 +1,95 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { describe, it, beforeEach } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { SessionAttestation } from '../../client/session-attestation.js';
6
+ import { verifyEnvelope } from '../../shared/crypto.js';
7
+
8
+ describe('SessionAttestation security', () => {
9
+ let attestation;
10
+
11
+ beforeEach(() => {
12
+ attestation = new SessionAttestation('http://localhost:9999');
13
+ });
14
+
15
+ it('HMAC signs entire envelope — tampering metadata after signing fails verification', async () => {
16
+ const sessionId = 'sess_test_1';
17
+ await attestation.openSession(sessionId, {
18
+ provider: 'claude-code',
19
+ model_engine: 'claude-opus-4-6',
20
+ groove_version: '0.27.0',
21
+ });
22
+
23
+ const session = attestation._sessions.get(sessionId);
24
+ if (session.offline) {
25
+ return;
26
+ }
27
+
28
+ const envelope = {
29
+ envelope_id: 'env_test',
30
+ session_id: sessionId,
31
+ chunk_sequence: 0,
32
+ contributor_id: 'user_1',
33
+ attestation: { session_hmac: '', sequence: 0, app_version_hash: '' },
34
+ metadata: { agent_role: 'backend', agent_id: 'backend-1', provider: 'claude-code' },
35
+ trajectory_log: [{ step: 1, type: 'thought', timestamp: 123 }],
36
+ };
37
+
38
+ const signed = attestation.signEnvelope(sessionId, envelope);
39
+ const hmac = signed.attestation.session_hmac;
40
+ const seq = signed.attestation.sequence - 1;
41
+
42
+ const forVerify = { ...signed };
43
+ delete forVerify.attestation;
44
+ const valid = verifyEnvelope(session.sharedSecret, JSON.stringify(forVerify), seq, hmac);
45
+ assert.equal(valid, true, 'HMAC should verify against the full envelope minus attestation');
46
+
47
+ forVerify.metadata.agent_role = 'hacked';
48
+ const invalid = verifyEnvelope(session.sharedSecret, JSON.stringify(forVerify), seq, hmac);
49
+ assert.equal(invalid, false, 'Tampered metadata should fail HMAC verification');
50
+ });
51
+
52
+ it('offline mode uses OFFLINE marker not empty string', async () => {
53
+ const sessionId = 'sess_offline';
54
+ attestation._sessions.set(sessionId, {
55
+ keypair: null,
56
+ sharedSecret: null,
57
+ sequence: 0,
58
+ appVersionHash: 'test',
59
+ offline: true,
60
+ });
61
+
62
+ const envelope = {
63
+ envelope_id: 'env_off',
64
+ session_id: sessionId,
65
+ attestation: { session_hmac: '', sequence: 0, app_version_hash: '' },
66
+ metadata: {},
67
+ trajectory_log: [],
68
+ };
69
+
70
+ const signed = attestation.signEnvelope(sessionId, envelope);
71
+ assert.equal(signed.attestation.session_hmac, 'OFFLINE');
72
+ assert.notEqual(signed.attestation.session_hmac, '');
73
+ });
74
+
75
+ it('machine fingerprint includes hostname and core count', async () => {
76
+ const os = await import('node:os');
77
+ const fp = SessionAttestation.getMachineFingerprint();
78
+ assert.equal(typeof fp, 'string');
79
+ assert.equal(fp.length, 64);
80
+
81
+ const signals = [
82
+ os.platform(),
83
+ os.arch(),
84
+ os.cpus()[0]?.model || '',
85
+ String(os.totalmem()),
86
+ os.hostname(),
87
+ String(os.cpus().length),
88
+ os.release(),
89
+ os.endianness(),
90
+ ];
91
+ const { createHash } = await import('node:crypto');
92
+ const expected = createHash('sha256').update(signals.join('|')).digest('hex');
93
+ assert.equal(fp, expected);
94
+ });
95
+ });
@@ -0,0 +1,88 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { describe, it } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { StepClassifier } from '../../client/step-classifier.js';
6
+
7
+ describe('StepClassifier', () => {
8
+ it('user message before any action is not a correction', () => {
9
+ const classifier = new StepClassifier();
10
+ const result = classifier.classifyUserMessage('fix the bug');
11
+ assert.equal(result, null);
12
+ });
13
+
14
+ it('user message after action is a correction', () => {
15
+ const classifier = new StepClassifier();
16
+ classifier.onStep({ type: 'action' });
17
+ const result = classifier.classifyUserMessage('no, use exponential backoff');
18
+ assert.equal(result.type, 'correction');
19
+ assert.equal(result.content, 'no, use exponential backoff');
20
+ assert.equal(result.source, 'user');
21
+ });
22
+
23
+ it('classifies coordination event', () => {
24
+ const classifier = new StepClassifier();
25
+ const result = classifier.classifyCoordinationEvent({
26
+ coordination_id: 'coord-1',
27
+ direction: 'outbound',
28
+ target_agent: 'backend-1',
29
+ protocol: 'knock',
30
+ content: 'Requesting lock on src/api.js',
31
+ });
32
+ assert.equal(result.type, 'coordination');
33
+ assert.equal(result.coordination_id, 'coord-1');
34
+ assert.equal(result.direction, 'outbound');
35
+ assert.equal(result.target_agent, 'backend-1');
36
+ assert.equal(result.protocol, 'knock');
37
+ });
38
+
39
+ it('detects error recovery', () => {
40
+ const steps = [
41
+ { type: 'thought', step: 1 },
42
+ { type: 'action', step: 2 },
43
+ { type: 'error', step: 3 },
44
+ { type: 'thought', step: 4 },
45
+ { type: 'action', step: 5 },
46
+ { type: 'resolution', step: 6 },
47
+ ];
48
+ assert.equal(StepClassifier.detectErrorRecovery(steps), true);
49
+ });
50
+
51
+ it('no error recovery when no resolution after error', () => {
52
+ const steps = [
53
+ { type: 'thought', step: 1 },
54
+ { type: 'error', step: 2 },
55
+ { type: 'error', step: 3 },
56
+ ];
57
+ assert.equal(StepClassifier.detectErrorRecovery(steps), false);
58
+ });
59
+
60
+ it('no error recovery when no errors', () => {
61
+ const steps = [
62
+ { type: 'thought', step: 1 },
63
+ { type: 'action', step: 2 },
64
+ { type: 'resolution', step: 3 },
65
+ ];
66
+ assert.equal(StepClassifier.detectErrorRecovery(steps), false);
67
+ });
68
+
69
+ it('counts user interventions', () => {
70
+ const steps = [
71
+ { type: 'thought' },
72
+ { type: 'correction' },
73
+ { type: 'action' },
74
+ { type: 'correction' },
75
+ { type: 'resolution' },
76
+ ];
77
+ assert.equal(StepClassifier.countUserInterventions(steps), 2);
78
+ });
79
+
80
+ it('counts zero interventions when none present', () => {
81
+ const steps = [
82
+ { type: 'thought' },
83
+ { type: 'action' },
84
+ { type: 'resolution' },
85
+ ];
86
+ assert.equal(StepClassifier.countUserInterventions(steps), 0);
87
+ });
88
+ });
@@ -0,0 +1,260 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { describe, it, beforeEach, afterEach } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { mkdtempSync, rmSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { tmpdir } from 'node:os';
8
+ import { generateECDHKeypair, deriveSharedSecret, signEnvelope, verifyEnvelope } from '../../shared/crypto.js';
9
+ import { SessionRegistry } from '../../server/session-registry.js';
10
+ import { EnvelopeVerifier } from '../../server/verifier.js';
11
+ import { SessionAttestation } from '../../client/session-attestation.js';
12
+ import { EnvelopeBuilder } from '../../client/envelope-builder.js';
13
+
14
+ describe('ECDH Handshake End-to-End', () => {
15
+ let tmpDir, registry, verifier;
16
+
17
+ beforeEach(() => {
18
+ tmpDir = mkdtempSync(join(tmpdir(), 'handshake-test-'));
19
+ registry = new SessionRegistry(join(tmpDir, 'sessions.db'));
20
+ verifier = new EnvelopeVerifier(registry);
21
+ });
22
+
23
+ afterEach(() => {
24
+ registry.close();
25
+ rmSync(tmpDir, { recursive: true, force: true });
26
+ });
27
+
28
+ it('client and server derive the same shared secret', () => {
29
+ const clientKeypair = generateECDHKeypair();
30
+ const result = registry.openSession(
31
+ 'sess_hs_001', clientKeypair.publicKey, 'claude-code', 'claude-opus-4-6',
32
+ 'fp_test', 'hash_test', '0.27.77'
33
+ );
34
+
35
+ const serverSession = registry.getSession('sess_hs_001');
36
+ const clientSecret = deriveSharedSecret(clientKeypair.privateKey, result.serverPublicKey);
37
+ assert.equal(clientSecret, serverSession.shared_secret);
38
+ });
39
+
40
+ it('client-signed envelope passes server verification', () => {
41
+ const clientKeypair = generateECDHKeypair();
42
+ const result = registry.openSession(
43
+ 'sess_hs_002', clientKeypair.publicKey, 'claude-code', 'claude-opus-4-6',
44
+ 'fp_test', 'hash_test', '0.27.77'
45
+ );
46
+
47
+ const serverSession = registry.getSession('sess_hs_002');
48
+ const sharedSecret = deriveSharedSecret(clientKeypair.privateKey, result.serverPublicKey);
49
+ assert.equal(sharedSecret, serverSession.shared_secret);
50
+
51
+ const envelope = {
52
+ envelope_id: 'env_hs_001',
53
+ session_id: 'sess_hs_002',
54
+ chunk_sequence: 0,
55
+ contributor_id: 'cccccccccccccccccccccccccccccccc',
56
+ metadata: {
57
+ model_engine: 'claude-opus-4-6',
58
+ provider: 'claude-code',
59
+ agent_role: 'frontend',
60
+ agent_id: 'frontend-1',
61
+ },
62
+ trajectory_log: [
63
+ { step: 1, type: 'thought', timestamp: Date.now() / 1000, content: 'planning', token_count: 10 },
64
+ ],
65
+ attestation: { session_hmac: '', sequence: 0, app_version_hash: '' },
66
+ };
67
+
68
+ const envelopeForHmac = { ...envelope };
69
+ delete envelopeForHmac.attestation;
70
+ const envelopeBytes = JSON.stringify(envelopeForHmac);
71
+ const hmac = signEnvelope(sharedSecret, envelopeBytes, 0);
72
+ envelope.attestation = { session_hmac: hmac, sequence: 0, app_version_hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' };
73
+
74
+ const verifyResult = verifier.verify(envelope);
75
+ assert.equal(verifyResult.valid, true);
76
+ });
77
+
78
+ it('tampered envelope is rejected by server', () => {
79
+ const clientKeypair = generateECDHKeypair();
80
+ const result = registry.openSession(
81
+ 'sess_hs_003', clientKeypair.publicKey, 'claude-code', 'claude-opus-4-6',
82
+ 'fp_test', 'hash_test', '0.27.77'
83
+ );
84
+
85
+ const sharedSecret = deriveSharedSecret(clientKeypair.privateKey, result.serverPublicKey);
86
+
87
+ const envelope = {
88
+ envelope_id: 'env_hs_002',
89
+ session_id: 'sess_hs_003',
90
+ chunk_sequence: 0,
91
+ contributor_id: 'cccccccccccccccccccccccccccccccc',
92
+ metadata: {
93
+ model_engine: 'claude-opus-4-6',
94
+ provider: 'claude-code',
95
+ agent_role: 'frontend',
96
+ agent_id: 'frontend-1',
97
+ },
98
+ trajectory_log: [
99
+ { step: 1, type: 'thought', timestamp: Date.now() / 1000, content: 'original', token_count: 10 },
100
+ ],
101
+ attestation: { session_hmac: '', sequence: 0, app_version_hash: '' },
102
+ };
103
+
104
+ const envelopeForHmac = { ...envelope };
105
+ delete envelopeForHmac.attestation;
106
+ const envelopeBytes = JSON.stringify(envelopeForHmac);
107
+ const hmac = signEnvelope(sharedSecret, envelopeBytes, 0);
108
+ envelope.attestation = { session_hmac: hmac, sequence: 0, app_version_hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' };
109
+
110
+ envelope.trajectory_log.push({ step: 2, type: 'action', timestamp: Date.now() / 1000, content: 'injected' });
111
+
112
+ const verifyResult = verifier.verify(envelope);
113
+ assert.equal(verifyResult.valid, false);
114
+ assert.ok(verifyResult.reason.includes('HMAC'));
115
+ });
116
+
117
+ it('sequence numbers enforce ordering', () => {
118
+ const clientKeypair = generateECDHKeypair();
119
+ const result = registry.openSession(
120
+ 'sess_hs_004', clientKeypair.publicKey, 'claude-code', 'claude-opus-4-6',
121
+ 'fp_test', 'hash_test', '0.27.77'
122
+ );
123
+ const sharedSecret = deriveSharedSecret(clientKeypair.privateKey, result.serverPublicKey);
124
+
125
+ function makeEnv(seq) {
126
+ const env = {
127
+ envelope_id: `env_hs_seq_${seq}`,
128
+ session_id: 'sess_hs_004',
129
+ chunk_sequence: seq,
130
+ contributor_id: 'cccccccccccccccccccccccccccccccc',
131
+ metadata: { model_engine: 'claude-opus-4-6', provider: 'claude-code', agent_role: 'frontend', agent_id: 'frontend-1' },
132
+ trajectory_log: [{ step: seq + 1, type: 'thought', timestamp: Date.now() / 1000, content: `step ${seq}`, token_count: 5 }],
133
+ };
134
+ const bytes = JSON.stringify(env);
135
+ const hmac = signEnvelope(sharedSecret, bytes, seq);
136
+ env.attestation = { session_hmac: hmac, sequence: seq, app_version_hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' };
137
+ return env;
138
+ }
139
+
140
+ const r0 = verifier.verify(makeEnv(0));
141
+ assert.equal(r0.valid, true);
142
+
143
+ const r1 = verifier.verify(makeEnv(1));
144
+ assert.equal(r1.valid, true);
145
+
146
+ const rSkip = verifier.verify(makeEnv(5));
147
+ assert.equal(rSkip.valid, false);
148
+ assert.ok(rSkip.reason.includes('sequence'));
149
+
150
+ const r2 = verifier.verify(makeEnv(2));
151
+ assert.equal(r2.valid, true);
152
+ });
153
+
154
+ it('different client keypairs produce different shared secrets', () => {
155
+ const client1 = generateECDHKeypair();
156
+ const client2 = generateECDHKeypair();
157
+
158
+ registry.openSession('sess_hs_005a', client1.publicKey, 'claude-code', 'claude-opus-4-6', 'fp1', 'h1', '0.27.77');
159
+ registry.openSession('sess_hs_005b', client2.publicKey, 'claude-code', 'claude-opus-4-6', 'fp2', 'h2', '0.27.77');
160
+
161
+ const s1 = registry.getSession('sess_hs_005a');
162
+ const s2 = registry.getSession('sess_hs_005b');
163
+
164
+ assert.notEqual(s1.shared_secret, s2.shared_secret);
165
+ });
166
+
167
+ it('EnvelopeBuilder output is verifiable after client signing', () => {
168
+ const clientKeypair = generateECDHKeypair();
169
+ const result = registry.openSession(
170
+ 'sess_hs_006', clientKeypair.publicKey, 'claude-code', 'claude-opus-4-6',
171
+ 'fp_test', 'hash_test', '0.27.77'
172
+ );
173
+ const sharedSecret = deriveSharedSecret(clientKeypair.privateKey, result.serverPublicKey);
174
+
175
+ const builder = new EnvelopeBuilder('sess_hs_006', 'cccccccccccccccccccccccccccccccc', {
176
+ model_engine: 'claude-opus-4-6',
177
+ provider: 'claude-code',
178
+ agent_role: 'frontend',
179
+ agent_id: 'frontend-1',
180
+ task_complexity: 'medium',
181
+ team_size: 1,
182
+ session_quality: 80,
183
+ groove_version: '0.27.77',
184
+ });
185
+
186
+ builder.addStep({ step: 1, type: 'thought', timestamp: Date.now() / 1000, content: 'test', token_count: 5 });
187
+ const envelope = builder.flush();
188
+ assert.ok(envelope);
189
+
190
+ const envelopeForHmac = { ...envelope };
191
+ delete envelopeForHmac.attestation;
192
+ const envelopeBytes = JSON.stringify(envelopeForHmac);
193
+ const hmac = signEnvelope(sharedSecret, envelopeBytes, 0);
194
+ envelope.attestation = { session_hmac: hmac, sequence: 0, app_version_hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' };
195
+
196
+ const verifyResult = verifier.verify(envelope);
197
+ assert.equal(verifyResult.valid, true);
198
+ });
199
+
200
+ it('SESSION_CLOSE from builder is verifiable', () => {
201
+ const clientKeypair = generateECDHKeypair();
202
+ const result = registry.openSession(
203
+ 'sess_hs_007', clientKeypair.publicKey, 'claude-code', 'claude-opus-4-6',
204
+ 'fp_test', 'hash_test', '0.27.77'
205
+ );
206
+ const sharedSecret = deriveSharedSecret(clientKeypair.privateKey, result.serverPublicKey);
207
+
208
+ const builder = new EnvelopeBuilder('sess_hs_007', 'cccccccccccccccccccccccccccccccc', {
209
+ model_engine: 'claude-opus-4-6',
210
+ provider: 'claude-code',
211
+ agent_role: 'frontend',
212
+ agent_id: 'frontend-1',
213
+ });
214
+
215
+ const closeEnvelope = builder.buildSessionClose({
216
+ status: 'SUCCESS',
217
+ user_interventions: 0,
218
+ total_steps: 10,
219
+ total_chunks: 1,
220
+ total_tokens: 500,
221
+ duration_seconds: 60,
222
+ files_modified: 2,
223
+ errors_encountered: 0,
224
+ errors_recovered: 0,
225
+ coordination_events: 0,
226
+ });
227
+
228
+ const forHmac = { ...closeEnvelope };
229
+ delete forHmac.attestation;
230
+ const bytes = JSON.stringify(forHmac);
231
+ const hmac = signEnvelope(sharedSecret, bytes, 0);
232
+ closeEnvelope.attestation = { session_hmac: hmac, sequence: 0, app_version_hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' };
233
+
234
+ const verifyResult = verifier.verifyClose(closeEnvelope);
235
+ assert.equal(verifyResult.valid, true);
236
+
237
+ const session = registry.getSession('sess_hs_007');
238
+ assert.equal(session.status, 'closed');
239
+ });
240
+
241
+ it('HMAC verification is constant-time (no short-circuit)', () => {
242
+ const clientKeypair = generateECDHKeypair();
243
+ registry.openSession(
244
+ 'sess_hs_008', clientKeypair.publicKey, 'claude-code', 'claude-opus-4-6',
245
+ 'fp_test', 'hash_test', '0.27.77'
246
+ );
247
+ const session = registry.getSession('sess_hs_008');
248
+ const sharedSecret = session.shared_secret;
249
+
250
+ const payload = JSON.stringify({ test: 'data' });
251
+ const validHmac = signEnvelope(sharedSecret, payload, 0);
252
+
253
+ const wrongFirstChar = String.fromCharCode(validHmac.charCodeAt(0) ^ 1) + validHmac.slice(1);
254
+ const wrongLastChar = validHmac.slice(0, -1) + String.fromCharCode(validHmac.charCodeAt(validHmac.length - 1) ^ 1);
255
+
256
+ assert.equal(verifyEnvelope(sharedSecret, payload, 0, wrongFirstChar), false);
257
+ assert.equal(verifyEnvelope(sharedSecret, payload, 0, wrongLastChar), false);
258
+ assert.equal(verifyEnvelope(sharedSecret, payload, 0, validHmac), true);
259
+ });
260
+ });