@michelj/context-guard 0.4.4 → 0.6.2

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 (101) hide show
  1. package/Coordinator.md +88 -0
  2. package/Executor.md +53 -0
  3. package/README.md +72 -102
  4. package/README.zh-CN.md +72 -102
  5. package/SKILL.md +26 -33
  6. package/THIRD_PARTY_NOTICES.md +47 -0
  7. package/Tester.md +53 -0
  8. package/bin/build-runtime.mjs +96 -0
  9. package/bin/context-guard-skill.js +287 -69
  10. package/bin/postinstall.js +1 -1
  11. package/hooks.json +80 -4
  12. package/licenses/JSONParse-MIT.txt +24 -0
  13. package/licenses/Marked-MIT.txt +44 -0
  14. package/licenses/Portless-Apache-2.0.txt +201 -0
  15. package/package.json +31 -5
  16. package/prototype/LICENSES/Marked-MIT.txt +44 -0
  17. package/prototype/LICENSES/Ready-redistribution.txt +14 -0
  18. package/prototype/attachments.mjs +75 -0
  19. package/prototype/coordinator-markdown.mjs +283 -0
  20. package/prototype/coordinator-working-blot.mjs +124 -0
  21. package/prototype/vendor/marked.mjs +2189 -0
  22. package/prototype/workbench-app.js +5197 -0
  23. package/prototype/workbench-data.js +33 -0
  24. package/prototype/workbench-sync.mjs +898 -0
  25. package/prototype/workbench.css +1050 -0
  26. package/prototype/workbench.html +139 -4861
  27. package/prototype/working-blot-atlas.png +0 -0
  28. package/references/agent-handoff.md +40 -0
  29. package/references/claude-runtime.md +120 -0
  30. package/references/cloud-sync-interface.md +66 -0
  31. package/references/design-current.md +14 -0
  32. package/references/map-mount.md +41 -0
  33. package/references/map-read.md +50 -0
  34. package/references/memory-definition.md +120 -0
  35. package/references/memory-filesystem-v2/Bug.en.md +162 -0
  36. package/references/memory-filesystem-v2/Bug.md +162 -0
  37. package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
  38. package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
  39. package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
  40. package/references/memory-filesystem-v2/Idea.en.md +36 -0
  41. package/references/memory-filesystem-v2/Idea.md +36 -0
  42. package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
  43. package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
  44. package/references/memory-filesystem-v2/README.md +60 -0
  45. package/references/memory-filesystem-v2/Todo.en.md +137 -0
  46. package/references/memory-filesystem-v2/Todo.md +137 -0
  47. package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
  48. package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
  49. package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
  50. package/references/named-workbench.md +124 -0
  51. package/references/plan-review.md +12 -0
  52. package/references/server-memory.md +276 -0
  53. package/references/test-check.md +7 -0
  54. package/references/user-reply.md +38 -0
  55. package/references/workbench-interface.md +531 -0
  56. package/roles.md +13 -0
  57. package/scripts/context_guard.py +1163 -321
  58. package/scripts/context_guard_hook.py +1864 -63
  59. package/scripts/map_owns.py +68 -138
  60. package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
  61. package/scripts/shared/filesystem-v2.mjs +430 -0
  62. package/scripts/shared/io.mjs +117 -0
  63. package/scripts/shared/map-model.mjs +506 -0
  64. package/scripts/shared/memory-schema.mjs +13 -0
  65. package/scripts/shared/protocol-blobs.mjs +112 -0
  66. package/scripts/shared/protocol-map.mjs +146 -0
  67. package/scripts/shared/protocol-snapshots.mjs +84 -0
  68. package/scripts/shared/protocol-store.mjs +624 -0
  69. package/scripts/shared/protocol-workflow.mjs +226 -0
  70. package/scripts/shared/protocol.mjs +125 -0
  71. package/scripts/shared/vendor/jsonparse.cjs +413 -0
  72. package/scripts/workbench/access.mjs +496 -0
  73. package/scripts/workbench/attachments.mjs +92 -0
  74. package/scripts/workbench/browser-login.mjs +78 -0
  75. package/scripts/workbench/claude-runtime.mjs +372 -0
  76. package/scripts/workbench/cli.mjs +980 -0
  77. package/scripts/workbench/device-heartbeat.mjs +72 -0
  78. package/scripts/workbench/hook-status.mjs +38 -0
  79. package/scripts/workbench/inbox.mjs +155 -0
  80. package/scripts/workbench/journal.mjs +56 -0
  81. package/scripts/workbench/memory-merge.mjs +65 -0
  82. package/scripts/workbench/memory.mjs +252 -0
  83. package/scripts/workbench/named-proxy.mjs +108 -0
  84. package/scripts/workbench/named.mjs +152 -0
  85. package/scripts/workbench/portless-routes.mjs +51 -0
  86. package/scripts/workbench/project.mjs +327 -0
  87. package/scripts/workbench/projections.mjs +68 -0
  88. package/scripts/workbench/protocol-client.mjs +165 -0
  89. package/scripts/workbench/protocol-delivery.mjs +133 -0
  90. package/scripts/workbench/protocol-device.mjs +316 -0
  91. package/scripts/workbench/protocol-events.mjs +53 -0
  92. package/scripts/workbench/protocol-repository.mjs +58 -0
  93. package/scripts/workbench/reconcile.mjs +244 -0
  94. package/scripts/workbench/registry.mjs +111 -0
  95. package/scripts/workbench/runtime.mjs +54 -0
  96. package/scripts/workbench/server.mjs +1171 -0
  97. package/scripts/workbench/store.mjs +243 -0
  98. package/scripts/workbench/sync-coordinator.mjs +518 -0
  99. package/scripts/workbench/sync.mjs +86 -0
  100. package/references/bug-record-template.md +0 -37
  101. package/references/context-template.md +0 -19
@@ -0,0 +1,316 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { Readable, Transform } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { atomicWrite, encode, hash, readJSON, withFileLock } from '../shared/io.mjs';
7
+ import { canonical, fail, validateMessage, ProtocolError } from '../shared/protocol.mjs';
8
+ import { sendMessage, ProjectMessagePump } from './protocol-client.mjs';
9
+
10
+ const readTypes = new Set(['sync.heartbeat', 'sync.read', 'workbench.read', 'object.read', 'blob.get']);
11
+ const messageScope = message => message.session?.id || message.payload.sessionId || 'project';
12
+ // A single project host owns this connection. Agents never receive its credential.
13
+ export class DeviceConnection {
14
+ constructor({ directory, origin, transport = sendMessage, allowLoopback = false }) {
15
+ this.directory = directory; this.origin = origin; this.transport = transport; this.allowLoopback = allowLoopback;
16
+ this.file = path.join(directory, 'device-connection.json'); this.outbox = path.join(directory, 'outbox'); this.inflight = new Map();
17
+ }
18
+ async connect(message, { repositoryId } = {}) {
19
+ validateMessage(message);
20
+ if (message.type !== 'auth.open') fail('INVALID_ARGUMENT', 'Expected auth.open');
21
+ const identity = await this.identity();
22
+ message = { ...message, payload: { ...message.payload, clientId: identity.clientId } };
23
+ let credential;
24
+ const result = await this.transport(this.origin, '', message, { allowLoopback: this.allowLoopback, receiveCredential: value => { credential = value; } });
25
+ return this.saveConnection(result, credential, { repositoryId });
26
+ }
27
+ async identity() {
28
+ const identityFile = path.join(this.directory, 'device-identity.json');
29
+ return withFileLock(`${identityFile}.lock`, async () => {
30
+ const prior = await readJSON(identityFile, null);
31
+ if (prior) return prior;
32
+ const created = { clientId: randomUUID() };
33
+ await atomicWrite(identityFile, encode(created));
34
+ return created;
35
+ });
36
+ }
37
+ async saveConnection(result, credential, { repositoryId } = {}) {
38
+ if (!credential) fail('UNAVAILABLE', 'Missing backend connection credential');
39
+ if (repositoryId && result.repositoryId !== repositoryId) {
40
+ await this.transport(this.origin, credential, { v: 2, id: randomUUID(), type: 'auth.close', payload: {} }, { allowLoopback: this.allowLoopback }).catch(() => {});
41
+ fail('FORBIDDEN', 'Cloud project does not match the verified GitHub repository');
42
+ }
43
+ const previous = await readJSON(this.file, null);
44
+ if (previous?.repositoryId && previous.repositoryId !== result.repositoryId) {
45
+ await this.transport(this.origin, credential, { v: 2, id: randomUUID(), type: 'auth.close', payload: {} }, { allowLoopback: this.allowLoopback }).catch(() => {});
46
+ fail('FORBIDDEN', 'Repository changed; preserve queues and migrate the project binding explicitly');
47
+ }
48
+ await withFileLock(`${this.file}.lock`, () => atomicWrite(this.file, encode({ origin: this.origin, credential, ...result })));
49
+ return result;
50
+ }
51
+ async transmit(message) {
52
+ const connection = await readJSON(this.file, null);
53
+ if (!connection?.credential || connection.origin !== this.origin) fail('UNAUTHORIZED', 'Local backend must connect first');
54
+ try {
55
+ return await this.transport(connection.origin, connection.credential, message, { allowLoopback: this.allowLoopback });
56
+ } catch (error) {
57
+ if (error?.code === 'UNAUTHORIZED') {
58
+ await withFileLock(`${this.file}.lock`, async () => {
59
+ const current = await readJSON(this.file, null);
60
+ if (current?.credential === connection.credential) await atomicWrite(this.file, encode({
61
+ origin: this.origin, disconnected: true, error: error.code, failedAt: new Date().toISOString(),
62
+ }));
63
+ });
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+ async connected() { const value = await readJSON(this.file, null); return value?.origin === this.origin && !!value.credential; }
69
+ async callCoordinatorTool(body) {
70
+ if (!body || typeof body !== 'object' || Array.isArray(body)) fail('INVALID_ARGUMENT', 'Coordinator tool request must be an object');
71
+ const connection = await readJSON(this.file, null);
72
+ if (!connection?.credential || connection.origin !== this.origin) fail('UNAUTHORIZED', 'Local backend must connect first');
73
+ const base = new URL(this.origin);
74
+ if (base.username || base.password || base.protocol !== 'https:' && !(this.allowLoopback && base.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname))) fail('FORBIDDEN', 'Invalid coordinator tool origin');
75
+ const response = await fetch(new URL('/api/v2/coordinator-tools', base), {
76
+ method: 'POST',
77
+ headers: { Authorization: `Bearer ${connection.credential}`, 'Content-Type': 'application/json' },
78
+ body: JSON.stringify(body),
79
+ redirect: 'error',
80
+ signal: AbortSignal.timeout(40000),
81
+ });
82
+ let value = null;
83
+ try { value = await response.json(); } catch { fail('UNAVAILABLE', 'Coordinator tool response was not JSON'); }
84
+ if (!response.ok || value?.ok !== true) fail(value?.error?.code || 'UNAVAILABLE', value?.error?.message || 'Coordinator tool failed');
85
+ return value.data;
86
+ }
87
+ async recordCreationFailure(id, error) {
88
+ if (!/^[a-f0-9]{64}$/.test(id || '')) fail('INVALID_ARGUMENT', 'Invalid creation identity');
89
+ const file = path.join(this.directory, 'creation-results.json');
90
+ await withFileLock(file + '.lock', async () => {
91
+ const values = await readJSON(file, {});
92
+ values[id] ||= { id, error: /^[A-Z][A-Z0-9_]{0,79}$/.test(error || '') ? error : 'SESSION_CREATION_FAILED' };
93
+ await atomicWrite(file, encode(values));
94
+ });
95
+ }
96
+ async supports(capability) {
97
+ const value = await readJSON(this.file, null);
98
+ return value?.origin === this.origin && !!value.credential && value.capabilities?.includes(capability) === true;
99
+ }
100
+ async proxyBlob(req, res, session, blobId) {
101
+ if (!['GET', 'PUT'].includes(req.method) || !/^[a-f0-9]{64}$/.test(blobId)) fail('INVALID_ARGUMENT', 'Invalid binary request');
102
+ const connection = await readJSON(this.file, null), base = new URL(this.origin);
103
+ if (!connection?.credential || connection.origin !== this.origin) fail('UNAUTHORIZED', 'Backend is disconnected');
104
+ if (base.username || base.password || base.protocol !== 'https:' && !(this.allowLoopback && base.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname))) fail('FORBIDDEN', 'Invalid binary transport origin');
105
+ let body;
106
+ if (req.method === 'PUT') {
107
+ const chunks = []; let size = 0;
108
+ for await (const bytes of req) { size += bytes.length; if (size > 1024 * 1024) fail('TOO_LARGE', 'Chunk exceeds 1 MiB'); chunks.push(bytes); }
109
+ body = Buffer.concat(chunks);
110
+ }
111
+ const headers = { Authorization: `Bearer ${connection.credential}`, 'X-Context-Guard-Session': session.id, 'X-Context-Guard-Generation': String(session.generation) };
112
+ for (const key of ['range', 'content-range']) if (req.headers[key]) headers[key] = req.headers[key];
113
+ const response = await fetch(new URL(`/api/v2/blobs/${blobId}`, base), { method: req.method, headers, body, redirect: 'error', signal: AbortSignal.timeout(30000) });
114
+ const expectedType = response.ok && req.method === 'GET' ? 'application/octet-stream' : 'application/json';
115
+ if (!response.headers.get('content-type')?.startsWith(expectedType)) {
116
+ await response.body?.cancel(); fail('UNAVAILABLE', 'Unexpected binary endpoint response; keep the upload for retry');
117
+ }
118
+ const returnedHeaders = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
119
+ for (const key of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'etag']) if (response.headers.has(key)) returnedHeaders[key] = response.headers.get(key);
120
+ // Never forward cookies/credentials. Range retries use the registered digest,
121
+ // so an unknown upload result can be safely retried with the same bytes.
122
+ res.writeHead(response.status, returnedHeaders);
123
+ let received = 0;
124
+ const bound = new Transform({ transform(chunk, _encoding, callback) {
125
+ received += chunk.length;
126
+ if (received > (response.ok && req.method === 'GET' ? 64 * 1024 * 1024 : 256 * 1024)) callback(new Error('Binary response exceeds limit'));
127
+ else callback(null, chunk);
128
+ } });
129
+ if (!response.body) { res.end(); return; }
130
+ await pipeline(Readable.fromWeb(response.body), bound, res);
131
+ }
132
+ start({ sessions, apply, onError = () => {}, onSession, onSessionCreations = async () => {} }) {
133
+ if (this.runtime) return;
134
+ const pump = new ProjectMessagePump({ sessions, apply, onError: error => onError(error, 'heartbeat'), onSession, send: message => this.send(message) });
135
+ let pending, retrying, creating, settling;
136
+ this.runtime = {
137
+ prepare: async () => {
138
+ const connection = await readJSON(this.file, null);
139
+ if (!connection?.credential || connection.origin !== this.origin) fail('UNAUTHORIZED', 'Backend is disconnected');
140
+ const current = await sessions();
141
+ const creationResults = Object.values(await readJSON(path.join(this.directory, 'creation-results.json'), {})).slice(0, 20);
142
+ pending = { sessions: current, message: pump.request('sync.heartbeat', { sessions: current, ...(creationResults.length ? { creationResults } : {}) }) };
143
+ return { origin: this.origin, credential: connection.credential, message: pending.message };
144
+ },
145
+ accept: reply => {
146
+ if (!pending || reply?.id !== pending.message.id) fail('CONFLICT', 'Heartbeat receipt is no longer current');
147
+ const observed = pending; pending = null;
148
+ if (!reply.ok) { onError(new ProtocolError(reply.error?.code || 'UNAVAILABLE', 'Device heartbeat rejected'), 'heartbeat'); return; }
149
+ if (reply.data?.creationResults?.length) {
150
+ const receipts = reply.data.creationResults;
151
+ settling = Promise.resolve(settling).then(async () => {
152
+ const file = path.join(this.directory, 'creation-results.json');
153
+ await withFileLock(file + '.lock', async () => {
154
+ const values = await readJSON(file, {});
155
+ for (const receipt of receipts) if (typeof receipt.accepted === 'boolean' && values[receipt.id]?.error === receipt.error) {
156
+ if (!receipt.accepted) onError(new ProtocolError(receipt.code, 'Creation result rejected'), 'session-create');
157
+ delete values[receipt.id];
158
+ }
159
+ await atomicWrite(file, encode(values));
160
+ });
161
+ }).catch(onError);
162
+ }
163
+ for (const item of reply.data?.rejected || []) onError(new ProtocolError(item.code, 'Session heartbeat rejected', { sessionId: item.id }), 'heartbeat');
164
+ pump.poll({ sessions: observed.sessions, beat: reply.data }).catch(error => onError(error, 'heartbeat'));
165
+ retrying ||= this.retryPending().catch(onError).finally(() => { retrying = null; });
166
+ if (reply.data?.sessionCreations?.length) creating ||= Promise.resolve().then(() => onSessionCreations(reply.data.sessionCreations))
167
+ .catch(error => onError(error, 'session-create')).finally(() => { creating = null; });
168
+ },
169
+ close: async () => { await pump.close(); await retrying; await creating; await settling; },
170
+ };
171
+ }
172
+ async close() { const runtime = this.runtime; this.runtime = null; await runtime?.close(); await Promise.all([...(this.enrolling?.values() || [])]); }
173
+ async disconnect(message) {
174
+ const result = await this.transmit(message);
175
+ await this.close();
176
+ await withFileLock(`${this.file}.lock`, () => atomicWrite(this.file, encode({ origin: this.origin, disconnected: true })));
177
+ return result;
178
+ }
179
+ async bind(message, localBinding) {
180
+ const file = path.join(this.directory, 'bindings', `${hash(message.payload.sessionId)}.json`);
181
+ return withFileLock(`${file}.lock`, async () => {
182
+ const versions = await readJSON(file, {});
183
+ const remote = await this.send(message, wire => ({ ...wire, payload: { ...wire.payload,
184
+ expectedBindingVersion: versions[wire.payload.expectedBindingVersion] || '',
185
+ } }));
186
+ if (remote.session.id !== localBinding.session.id || remote.session.generation !== localBinding.session.generation) {
187
+ fail('STALE_SESSION', 'Cloud and local binding generations differ');
188
+ }
189
+ versions[localBinding.bindingVersion] = remote.bindingVersion;
190
+ await atomicWrite(file, encode(versions));
191
+ return localBinding;
192
+ });
193
+ }
194
+ async ensureBinding(binding) {
195
+ const versions = await readJSON(path.join(this.directory, 'bindings', `${hash(binding.sessionId)}.json`), {});
196
+ if (versions[binding.version]) return;
197
+ await this.bind({ v: 2, id: `bind:${hash(binding.version)}`, type: 'session.bind', payload: {
198
+ sessionId: binding.sessionId, worktreeId: binding.worktreeId, agentId: binding.agentId,
199
+ expectedBindingVersion: binding.version,
200
+ } }, { session: { id: binding.sessionId, generation: binding.generation }, bindingVersion: binding.version });
201
+ }
202
+ async bindingReady(binding) {
203
+ const versions = await readJSON(path.join(this.directory, 'bindings', `${hash(binding.sessionId)}.json`), {});
204
+ if (versions[binding.version]) return true;
205
+ this.enrolling ||= new Map();
206
+ if (!this.enrolling.has(binding.sessionId) && this.enrolling.size < 4) {
207
+ const job = this.ensureBinding(binding).catch(error => { this.lastError = error.code || 'UNAVAILABLE'; })
208
+ .finally(() => this.enrolling.delete(binding.sessionId));
209
+ this.enrolling.set(binding.sessionId, job);
210
+ }
211
+ return false;
212
+ }
213
+ async send(message, prepare = value => value) {
214
+ validateMessage(message);
215
+ if (message.type.startsWith('auth.')) fail('FORBIDDEN', 'Password and credential operations cannot enter the outbox');
216
+ if (readTypes.has(message.type)) return this.transmit(message);
217
+ const id = hash(message.id);
218
+ if (this.inflight.has(id)) {
219
+ if (this.inflight.get(id).fingerprint !== canonical(message)) fail('ID_REUSED', 'Concurrent request ID differs');
220
+ return this.inflight.get(id).promise;
221
+ }
222
+ const promise = this.deliver(message, prepare).finally(() => this.inflight.delete(id));
223
+ this.inflight.set(id, { fingerprint: canonical(message), promise });
224
+ return promise;
225
+ }
226
+ async savedMessage(id) {
227
+ return ((await readJSON(path.join(this.directory, 'outcomes', `${hash(id)}.json`), null)) || await readJSON(path.join(this.outbox, `${hash(id)}.json`), null))?.message || null;
228
+ }
229
+ async deliver(message, prepare) {
230
+ const file = path.join(this.outbox, `${hash(message.id)}.json`), fingerprint = canonical(message);
231
+ const outcomeFile = path.join(this.directory, 'outcomes', `${hash(message.id)}.json`);
232
+ const outcome = record => {
233
+ if (record.state === 'rejected') throw new ProtocolError(record.error.code, record.error.message, record.error.details);
234
+ return record.result;
235
+ };
236
+ const prior = await withFileLock(`${file}.lock`, async () => {
237
+ const completed = await readJSON(outcomeFile, null);
238
+ if (completed) {
239
+ if (canonical(completed.message) !== fingerprint) fail('ID_REUSED', 'Request ID differs from durable outcome');
240
+ await fs.unlink(file).catch(error => { if (error.code !== 'ENOENT') throw error; });
241
+ return completed;
242
+ }
243
+ const existing = await readJSON(file, null);
244
+ if (existing && canonical(existing.message) !== fingerprint) fail('ID_REUSED', 'Request ID differs from durable outbox');
245
+ if (existing) return existing;
246
+ const pending = { message, wire: prepare(structuredClone(message)), state: 'pending' };
247
+ validateMessage(pending.wire);
248
+ const sequenceFile = path.join(this.directory, 'outbox-sequence.json');
249
+ pending.sequence = await withFileLock(`${sequenceFile}.lock`, async () => {
250
+ const next = (await readJSON(sequenceFile, { value: 0 })).value + 1;
251
+ if (!Number.isSafeInteger(next)) fail('UNAVAILABLE', 'Outbox sequence is exhausted');
252
+ await atomicWrite(sequenceFile, encode({ value: next }));
253
+ return next;
254
+ });
255
+ await atomicWrite(file, encode(pending));
256
+ return pending;
257
+ });
258
+ if (['done', 'rejected'].includes(prior?.state)) return outcome(prior);
259
+ const scope = messageScope(message);
260
+ return withFileLock(path.join(this.directory, 'lanes', `${hash(scope)}.lock`), async () => {
261
+ const completed = await readJSON(outcomeFile, null);
262
+ if (completed) return outcome(completed);
263
+ const current = await readJSON(file);
264
+ if (current.state === 'done') return current.result;
265
+ // A fresh write must not overtake an older uncertain write in this Session.
266
+ for (const name of await fs.readdir(this.outbox)) {
267
+ if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
268
+ const record = await readJSON(path.join(this.outbox, name), null);
269
+ if (!record) continue;
270
+ if (record.state === 'pending' && messageScope(record.message) === scope && (record.sequence || 0) < (current.sequence || 0)) fail('UNAVAILABLE', 'An earlier Session write must be recovered first');
271
+ }
272
+ const save = record => withFileLock(`${file}.lock`, async () => {
273
+ // Persist the retry receipt before removing the pending index entry.
274
+ await atomicWrite(outcomeFile, encode(record));
275
+ await fs.unlink(file).catch(error => { if (error.code !== 'ENOENT') throw error; });
276
+ });
277
+ try {
278
+ const result = await this.transmit(current.wire || message);
279
+ await save({ ...current, state: 'done', result });
280
+ return result;
281
+ } catch (error) {
282
+ // A same-ID retry returns the server's atomic receipt. A confirmed
283
+ // rejection resolves earlier transport uncertainty without claiming success.
284
+ if (error.confirmedRejection) await save({ ...current, state: 'rejected', error: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) } });
285
+ else if (!current.uncertain) await withFileLock(`${file}.lock`, () => atomicWrite(file, encode({ ...current, uncertain: true })));
286
+ throw error;
287
+ }
288
+ });
289
+ }
290
+ async retryPending() {
291
+ const files = await fs.readdir(this.outbox).catch(error => { if (error.code === 'ENOENT') return []; throw error; });
292
+ const groups = new Map();
293
+ for (const name of files.filter(name => /^[a-f0-9]{64}\.json$/.test(name))) {
294
+ const record = await readJSON(path.join(this.outbox, name), null);
295
+ if (!record) continue;
296
+ if (record.state !== 'pending') continue;
297
+ const scope = messageScope(record.message);
298
+ if (!groups.has(scope)) groups.set(scope, []);
299
+ groups.get(scope).push(record);
300
+ }
301
+ const queue = [...groups.values()], results = [];
302
+ await Promise.all(Array.from({ length: Math.min(4, queue.length) }, async () => {
303
+ while (queue.length) {
304
+ const records = queue.shift().sort((a, b) => (a.sequence || 0) - (b.sequence || 0));
305
+ for (const record of records) {
306
+ try { await this.send(record.message); results.push({ id: record.message.id, sent: true }); }
307
+ catch (error) {
308
+ results.push({ id: record.message.id, sent: false, code: error.code || 'UNAVAILABLE' });
309
+ break; // Keep dependent writes behind the failed operation, not behind other Sessions.
310
+ }
311
+ }
312
+ }
313
+ }));
314
+ return results;
315
+ }
316
+ }
@@ -0,0 +1,53 @@
1
+ import { fail, MAX_MESSAGE_BYTES, validateMessage } from '../shared/protocol.mjs';
2
+
3
+ // SSE is only a hint. Independent heartbeats remain responsible for catching gaps.
4
+ export async function readEvents(origin, credential, { signal, onEvent, fetcher = fetch, allowLoopback = false, idleMs = 25000 }) {
5
+ const base = new URL(origin);
6
+ if (base.username || base.password || (base.protocol !== 'https:' && !(allowLoopback && base.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname)))) fail('FORBIDDEN', 'Invalid event transport origin');
7
+ const controller = new AbortController();
8
+ const abort = () => controller.abort();
9
+ signal?.addEventListener('abort', abort, { once: true });
10
+ if (signal?.aborted) abort();
11
+ let timer, reader;
12
+ const renew = () => { clearTimeout(timer); timer = setTimeout(abort, idleMs); timer.unref?.(); };
13
+ try {
14
+ renew();
15
+ const response = await fetcher(new URL('/api/v2/events', base), { redirect: 'error', signal: controller.signal,
16
+ headers: { Authorization: `Bearer ${credential}`, Accept: 'text/event-stream' } });
17
+ if (response.status === 401) fail('UNAUTHORIZED', 'Connection expired');
18
+ if (response.status === 403) fail('FORBIDDEN', 'Connection is not authorized');
19
+ if (!response.ok || !response.headers.get('content-type')?.includes('text/event-stream')) fail('UNAVAILABLE', 'Event stream unavailable');
20
+ reader = response.body.getReader();
21
+ const decoder = new TextDecoder();
22
+ let pending = '', data = [], size = 0;
23
+ while (!controller.signal.aborted) {
24
+ const chunk = await reader.read();
25
+ if (chunk.done) break;
26
+ renew(); pending += decoder.decode(chunk.value, { stream: true });
27
+ let newline;
28
+ while ((newline = pending.indexOf('\n')) >= 0) {
29
+ const line = pending.slice(0, newline).replace(/\r$/, ''); pending = pending.slice(newline + 1);
30
+ if (!line) {
31
+ if (data.length) {
32
+ let message;
33
+ try { message = JSON.parse(data.join('\n')); } catch { fail('UNAVAILABLE', 'Malformed event hint'); }
34
+ validateMessage(message);
35
+ if (message.type !== 'sync.event') fail('UNAVAILABLE', 'Unexpected event hint');
36
+ await onEvent(message);
37
+ }
38
+ data = []; size = 0;
39
+ } else if (line.startsWith('data:')) {
40
+ const value = line.slice(5).replace(/^ /, ''); size += Buffer.byteLength(value);
41
+ if (size > MAX_MESSAGE_BYTES) fail('TOO_LARGE', 'Event hint exceeds limit');
42
+ data.push(value);
43
+ }
44
+ }
45
+ if (Buffer.byteLength(pending) + size > MAX_MESSAGE_BYTES) fail('TOO_LARGE', 'Event hint exceeds limit');
46
+ }
47
+ if (!signal?.aborted) fail('UNAVAILABLE', 'Event stream ended; heartbeat will recover missed changes');
48
+ } finally {
49
+ clearTimeout(timer); controller.abort();
50
+ signal?.removeEventListener('abort', abort);
51
+ await reader?.cancel().catch(() => {});
52
+ }
53
+ }
@@ -0,0 +1,58 @@
1
+ import { fail } from '../shared/protocol.mjs';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ async function keychainToken() {
8
+ try {
9
+ const { stdout } = await execFileAsync('gh', ['auth', 'token'], {
10
+ encoding: 'utf8', windowsHide: true, timeout: 5000, maxBuffer: 64 * 1024,
11
+ });
12
+ const token = stdout.trim();
13
+ return token && token.length <= 4096 ? token : '';
14
+ } catch { return ''; }
15
+ }
16
+
17
+ // GitHub's repository ID survives a repository/branch rename. Branch heads are
18
+ // commits, not repository identities: https://docs.github.com/en/rest/repos/repos
19
+ export async function lookupRepository(slug, {
20
+ fetcher = fetch,
21
+ token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN,
22
+ tokenProvider = keychainToken,
23
+ } = {}) {
24
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(slug) || slug.split('/').some(part => ['.', '..'].includes(part))) fail('INVALID_ARGUMENT', 'Invalid GitHub repository');
25
+ let url = new URL(`https://api.github.com/repos/${slug}`);
26
+ let authorization = token || '', keychainChecked = !!authorization;
27
+ for (let attempt = 0; attempt < 4; attempt++) {
28
+ let response;
29
+ try { response = await fetcher(url, { redirect: 'manual', signal: AbortSignal.timeout(10000),
30
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Context-Guard', ...(authorization ? { Authorization: `Bearer ${authorization}` } : {}) } }); }
31
+ catch { fail('UNAVAILABLE', 'GitHub repository identity could not be verified'); }
32
+ if ([301, 302, 307, 308].includes(response.status)) {
33
+ const location = response.headers.get('location'); await response.body?.cancel();
34
+ if (!location) fail('UNAVAILABLE', 'GitHub redirect is missing');
35
+ const next = new URL(location, url);
36
+ if (next.origin !== 'https://api.github.com' || next.username || next.password || !/^\/(repos\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+|repositories\/\d+)$/.test(next.pathname)) fail('FORBIDDEN', 'GitHub redirected outside the repository API');
37
+ url = next; continue;
38
+ }
39
+ if (!response.ok) {
40
+ await response.body?.cancel();
41
+ if (!authorization && !keychainChecked && [401, 403, 404].includes(response.status)) {
42
+ keychainChecked = true;
43
+ authorization = await tokenProvider();
44
+ if (authorization) { attempt -= 1; continue; }
45
+ }
46
+ fail(response.status === 404 ? 'NOT_FOUND' : [401, 403].includes(response.status) ? 'FORBIDDEN' : 'UNAVAILABLE', 'GitHub repository is unavailable to this backend');
47
+ }
48
+ let value;
49
+ try {
50
+ const chunks = []; let size = 0;
51
+ for await (const chunk of response.body) { size += chunk.length; if (size > 1024 * 1024) fail('TOO_LARGE', 'GitHub response exceeds limit'); chunks.push(chunk); }
52
+ value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
53
+ } catch { fail('UNAVAILABLE', 'GitHub returned an invalid repository identity'); }
54
+ if (!Number.isSafeInteger(value.id) || value.id <= 0 || typeof value.full_name !== 'string') fail('UNAVAILABLE', 'GitHub returned an invalid repository identity');
55
+ return { repositoryId: String(value.id), slug: value.full_name };
56
+ }
57
+ fail('UNAVAILABLE', 'Too many GitHub repository redirects');
58
+ }