@michelj/context-guard 0.4.3 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Coordinator.md +88 -0
- package/Executor.md +53 -0
- package/README.md +89 -224
- package/README.zh-CN.md +89 -224
- package/SKILL.md +26 -684
- package/THIRD_PARTY_NOTICES.md +47 -0
- package/Tester.md +53 -0
- package/agents/openai.yaml +2 -2
- package/bin/build-runtime.mjs +96 -0
- package/bin/context-guard-skill.js +399 -78
- package/bin/postinstall.js +2 -2
- package/hooks.json +89 -13
- package/licenses/JSONParse-MIT.txt +24 -0
- package/licenses/Marked-MIT.txt +44 -0
- package/licenses/Portless-Apache-2.0.txt +201 -0
- package/package.json +35 -6
- package/prototype/LICENSES/Marked-MIT.txt +44 -0
- package/prototype/LICENSES/Ready-redistribution.txt +14 -0
- package/prototype/attachments.mjs +75 -0
- package/prototype/coordinator-markdown.mjs +283 -0
- package/prototype/coordinator-working-blot.mjs +124 -0
- package/prototype/vendor/marked.mjs +2189 -0
- package/prototype/workbench-app.js +5197 -0
- package/prototype/workbench-data.js +33 -0
- package/prototype/workbench-sync.mjs +898 -0
- package/prototype/workbench.css +1050 -0
- package/prototype/workbench.html +211 -0
- package/prototype/working-blot-atlas.png +0 -0
- package/references/agent-handoff.md +40 -0
- package/references/claude-runtime.md +120 -0
- package/references/cloud-sync-interface.md +66 -0
- package/references/design-current.md +14 -0
- package/references/map-mount.md +41 -0
- package/references/map-read.md +50 -0
- package/references/memory-definition.md +120 -0
- package/references/memory-filesystem-v2/Bug.en.md +162 -0
- package/references/memory-filesystem-v2/Bug.md +162 -0
- package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
- package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
- package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
- package/references/memory-filesystem-v2/Idea.en.md +36 -0
- package/references/memory-filesystem-v2/Idea.md +36 -0
- package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
- package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
- package/references/memory-filesystem-v2/README.md +60 -0
- package/references/memory-filesystem-v2/Todo.en.md +137 -0
- package/references/memory-filesystem-v2/Todo.md +137 -0
- package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
- package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
- package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
- package/references/named-workbench.md +124 -0
- package/references/plan-review.md +12 -0
- package/references/server-memory.md +276 -0
- package/references/test-check.md +7 -0
- package/references/user-reply.md +38 -0
- package/references/workbench-interface.md +531 -0
- package/roles.md +13 -0
- package/scripts/context_guard.py +1366 -7602
- package/scripts/context_guard_hook.py +1960 -711
- package/scripts/map_owns.py +699 -0
- package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
- package/scripts/shared/filesystem-v2.mjs +430 -0
- package/scripts/shared/io.mjs +117 -0
- package/scripts/shared/map-model.mjs +506 -0
- package/scripts/shared/memory-schema.mjs +13 -0
- package/scripts/shared/protocol-blobs.mjs +112 -0
- package/scripts/shared/protocol-map.mjs +146 -0
- package/scripts/shared/protocol-snapshots.mjs +84 -0
- package/scripts/shared/protocol-store.mjs +624 -0
- package/scripts/shared/protocol-workflow.mjs +226 -0
- package/scripts/shared/protocol.mjs +125 -0
- package/scripts/shared/vendor/jsonparse.cjs +413 -0
- package/scripts/workbench/access.mjs +496 -0
- package/scripts/workbench/attachments.mjs +92 -0
- package/scripts/workbench/browser-login.mjs +78 -0
- package/scripts/workbench/claude-runtime.mjs +372 -0
- package/scripts/workbench/cli.mjs +980 -0
- package/scripts/workbench/device-heartbeat.mjs +72 -0
- package/scripts/workbench/hook-status.mjs +38 -0
- package/scripts/workbench/inbox.mjs +155 -0
- package/scripts/workbench/journal.mjs +56 -0
- package/scripts/workbench/memory-merge.mjs +65 -0
- package/scripts/workbench/memory.mjs +252 -0
- package/scripts/workbench/named-proxy.mjs +108 -0
- package/scripts/workbench/named.mjs +152 -0
- package/scripts/workbench/portless-routes.mjs +51 -0
- package/scripts/workbench/project.mjs +327 -0
- package/scripts/workbench/projections.mjs +68 -0
- package/scripts/workbench/protocol-client.mjs +165 -0
- package/scripts/workbench/protocol-delivery.mjs +133 -0
- package/scripts/workbench/protocol-device.mjs +316 -0
- package/scripts/workbench/protocol-events.mjs +53 -0
- package/scripts/workbench/protocol-repository.mjs +58 -0
- package/scripts/workbench/reconcile.mjs +244 -0
- package/scripts/workbench/registry.mjs +111 -0
- package/scripts/workbench/runtime.mjs +54 -0
- package/scripts/workbench/server.mjs +1171 -0
- package/scripts/workbench/store.mjs +243 -0
- package/scripts/workbench/sync-coordinator.mjs +518 -0
- package/scripts/workbench/sync.mjs +86 -0
- package/references/context-template.md +0 -341
- package/references/feature-chain-methodology.md +0 -228
- package/references/register-template.md +0 -85
- package/references/task-case-template.md +0 -63
- package/tests/BC-20260618-063.sh +0 -116
- package/tests/BC-20260618-065.sh +0 -66
- package/tests/BC-20260626-080.sh +0 -48
- package/tests/BC-20260626-081.sh +0 -40
- package/tests/BC-20260626-082.sh +0 -32
- package/tests/BC-20260626-083.sh +0 -66
- package/tests/BC-20260627-084.sh +0 -74
- package/tests/BC-20260630-086.sh +0 -50
- package/tests/BC-20260630-087.sh +0 -103
- package/tests/BC-20260630-088.sh +0 -32
- package/tests/BC-20260630-089.sh +0 -63
- package/tests/BC-20260701-090.sh +0 -84
- package/tests/BC-20260702-096.sh +0 -48
- package/tests/BC-20260706-098.sh +0 -66
- package/tests/BC-20260707-099.sh +0 -47
- package/tests/BC-20260707-100.sh +0 -46
- package/tests/BC-20260707-101.sh +0 -47
- package/tests/BC-20260707-102.sh +0 -68
- package/tests/BC-20260707-103.sh +0 -59
- package/tests/BC-20260707-104.sh +0 -103
- package/tests/BC-20260707-105.sh +0 -109
- package/tests/BC-20260707-106.sh +0 -80
- package/tests/BC-20260707-107.sh +0 -74
- package/tests/BC-20260707-108.sh +0 -48
- package/tests/BC-20260707-109.sh +0 -56
- package/tests/BC-20260707-110.sh +0 -71
- package/tests/BC-20260707-111.sh +0 -70
- package/tests/BC-20260707-112.sh +0 -45
- package/tests/BC-20260707-113.sh +0 -73
- package/tests/BC-20260707-115.sh +0 -77
- package/tests/BC-20260707-116.sh +0 -77
- package/tests/BC-20260707-118.sh +0 -115
- package/tests/BC-20260707-119.sh +0 -47
- package/tests/BC-20260707-120.sh +0 -60
- package/tests/BC-20260707-121.sh +0 -66
- package/tests/BC-20260707-122.sh +0 -48
- package/tests/BC-20260707-123.sh +0 -43
- package/tests/BC-20260707-124.sh +0 -56
- package/tests/BC-20260707-125.sh +0 -64
- package/tests/BC-20260707-126.sh +0 -80
- package/tests/BC-20260707-127.sh +0 -88
- package/tests/BC-20260707-129.sh +0 -59
- package/tests/BC-20260707-130.sh +0 -69
- package/tests/BC-20260707-131.sh +0 -140
- package/tests/BC-20260707-132.sh +0 -150
- package/tests/BC-20260707-133.sh +0 -70
- package/tests/BC-20260708-136.sh +0 -210
- package/tests/BC-20260708-137.sh +0 -106
- package/tests/BC-20260708-138.sh +0 -168
- package/tests/BC-20260708-139.sh +0 -79
- package/tests/BC-20260709-002.sh +0 -63
- package/tests/BC-20260709-003.sh +0 -239
- package/tests/BC-20260709-006.sh +0 -76
- package/tests/BC-20260709-008.sh +0 -168
- package/tests/BC-20260710-001.sh +0 -61
- package/tests/BC-20260710-002.sh +0 -111
- package/tests/npm-install-smoke.sh +0 -53
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { watchFile, unwatchFile } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { entries, MapError, filterNodeAccess } from '../shared/map-model.mjs';
|
|
9
|
+
import { atomicWrite, encode, readJSON } from '../shared/io.mjs';
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
export const token = () => randomBytes(32).toString('base64url');
|
|
12
|
+
let nodeSqlite;
|
|
13
|
+
|
|
14
|
+
function hasBuiltInSqlite(version = process.versions.node) {
|
|
15
|
+
const [major = 0, minor = 0] = String(version).split('.').map(Number);
|
|
16
|
+
return major > 22 || (major === 22 && minor >= 5);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function queryWithNodeSqlite(database, sql) {
|
|
20
|
+
if (nodeSqlite === undefined) nodeSqlite = await import('node:sqlite').catch(() => null);
|
|
21
|
+
if (!nodeSqlite?.DatabaseSync) return null;
|
|
22
|
+
const connection = new nodeSqlite.DatabaseSync(database, { readOnly: true });
|
|
23
|
+
try { return connection.prepare(sql).all(); }
|
|
24
|
+
finally { connection.close(); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ACTIVE_TASK_EVENTS = new Set(['task_started', 'turn_started', 'response.started']);
|
|
28
|
+
const STOPPED_TASK_EVENTS = new Set([
|
|
29
|
+
'task_complete', 'task_completed', 'turn_complete', 'turn_completed', 'response.completed',
|
|
30
|
+
'task_cancelled', 'task_canceled', 'task_failed', 'turn_aborted', 'turn_cancelled', 'turn_canceled',
|
|
31
|
+
]);
|
|
32
|
+
const UNKNOWN_ACTIVITY = { status: 'unknown', lastEvent: 'unknown', lastSeen: '' };
|
|
33
|
+
|
|
34
|
+
function taskEvent(line) {
|
|
35
|
+
try {
|
|
36
|
+
const event = JSON.parse(line);
|
|
37
|
+
const candidates = [
|
|
38
|
+
event?.type === 'event_msg' ? event.payload?.type : null,
|
|
39
|
+
event?.type,
|
|
40
|
+
event?.payload?.event?.type,
|
|
41
|
+
event?.event?.type,
|
|
42
|
+
].filter(type => typeof type === 'string');
|
|
43
|
+
const type = candidates.find(candidate => ACTIVE_TASK_EVENTS.has(candidate) || STOPPED_TASK_EVENTS.has(candidate));
|
|
44
|
+
if (!type) return null;
|
|
45
|
+
return {
|
|
46
|
+
status: ACTIVE_TASK_EVENTS.has(type) ? 'active' : 'stopped',
|
|
47
|
+
lastEvent: type,
|
|
48
|
+
lastSeen: event.timestamp || event.created_at || event.payload?.timestamp || '',
|
|
49
|
+
};
|
|
50
|
+
} catch { return null; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function rolloutTaskStatus(text, initial = UNKNOWN_ACTIVITY) {
|
|
54
|
+
let state = { ...initial };
|
|
55
|
+
for (const line of text.split('\n')) {
|
|
56
|
+
const event = taskEvent(line);
|
|
57
|
+
if (event) state = event;
|
|
58
|
+
}
|
|
59
|
+
return state;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function activityOf(item) {
|
|
63
|
+
const status = ['active', 'stopped'].includes(item?.status) ? item.status : 'unknown';
|
|
64
|
+
const statusSeen = item?.statusSeen || (status === 'unknown' ? '' : item?.lastSeen) || '';
|
|
65
|
+
return { status, statusSeen, lastEvent: item?.lastEvent || 'unknown', statusSource: item?.statusSource || item?.platform || 'unknown' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function latestActivity(previous, discovered) {
|
|
69
|
+
const before = activityOf(previous), after = activityOf(discovered);
|
|
70
|
+
if (after.statusSeen > before.statusSeen) return after;
|
|
71
|
+
if (after.statusSeen < before.statusSeen) return before;
|
|
72
|
+
const rank = { unknown: 0, active: 1, stopped: 2 };
|
|
73
|
+
return rank[after.status] > rank[before.status] ? after : before;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isoTime(value) {
|
|
77
|
+
if (typeof value === 'number' && Number.isFinite(value)) return new Date(value * 1000).toISOString();
|
|
78
|
+
if (typeof value === 'string' && value) return value;
|
|
79
|
+
return new Date(0).toISOString();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function hostAttestedPlatform(sessionId, env = process.env) {
|
|
83
|
+
const id = String(sessionId || '').trim();
|
|
84
|
+
if (!id) return '';
|
|
85
|
+
if (String(env.CODEX_THREAD_ID || '').trim() === id) return 'codex';
|
|
86
|
+
if (String(env.CLAUDE_SESSION_ID || '').trim() === id) return 'claude';
|
|
87
|
+
if (String(env.CURSOR_SESSION_ID || '').trim() === id) return 'cursor';
|
|
88
|
+
return '';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function normalizeHostPath(value) {
|
|
92
|
+
const text = String(value || '');
|
|
93
|
+
if (!text) return '';
|
|
94
|
+
const normalized = path.normalize(text).replace(/[\\/]+$/, '');
|
|
95
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function recordHostAttestedSession(root, sessionId, env = process.env) {
|
|
99
|
+
const id = String(sessionId || '').trim();
|
|
100
|
+
const platform = hostAttestedPlatform(id, env);
|
|
101
|
+
if (!platform) return false;
|
|
102
|
+
const file = path.join(root, '.codex/context/sessions.jsonl');
|
|
103
|
+
const text = await fs.readFile(file, 'utf8').catch(e => e.code === 'ENOENT' ? '' : Promise.reject(e));
|
|
104
|
+
for (const line of text.split('\n').filter(Boolean)) {
|
|
105
|
+
try {
|
|
106
|
+
const event = JSON.parse(line);
|
|
107
|
+
if (typeof event.session_id === 'string' && event.session_id.trim() === id) return false;
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
111
|
+
await fs.appendFile(file, `${JSON.stringify({
|
|
112
|
+
at: new Date().toISOString(),
|
|
113
|
+
event: 'session-start',
|
|
114
|
+
platform,
|
|
115
|
+
session_id: id,
|
|
116
|
+
source: 'host-environment',
|
|
117
|
+
worktree_root: root,
|
|
118
|
+
})}\n`);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function rootPathAliases(root) {
|
|
123
|
+
const values = [root];
|
|
124
|
+
const real = await fs.realpath(root).catch(() => '');
|
|
125
|
+
if (real && real !== root) values.push(real);
|
|
126
|
+
const aliases = [];
|
|
127
|
+
for (const value of values.filter(Boolean)) {
|
|
128
|
+
aliases.push(value, path.toNamespacedPath(value));
|
|
129
|
+
const stripped = value.replace(/[\\/]+$/, '');
|
|
130
|
+
if (stripped && stripped !== value) aliases.push(stripped);
|
|
131
|
+
if (!value.endsWith('/') && !value.endsWith('\\')) aliases.push(value + path.sep);
|
|
132
|
+
}
|
|
133
|
+
return [...new Set(aliases.filter(Boolean))];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class Access {
|
|
137
|
+
constructor(root, options = {}) {
|
|
138
|
+
this.root = root;
|
|
139
|
+
this.ctx = path.join(root, '.codex/context');
|
|
140
|
+
this.file = options.file || path.join(this.ctx, 'sessions/workbench-access.json');
|
|
141
|
+
this.bindingsFile = options.bindingsFile || path.join(this.ctx, 'sessions/workbench-bindings.json');
|
|
142
|
+
this.sessionsFile = path.join(this.ctx, 'sessions.jsonl');
|
|
143
|
+
this.codexHome = options.codexHome || process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
144
|
+
this.sqliteHome = options.sqliteHome || process.env.CODEX_SQLITE_HOME || null;
|
|
145
|
+
this.codexDb = options.codexDb || null;
|
|
146
|
+
this.sqliteCommand = options.sqliteCommand || 'sqlite3';
|
|
147
|
+
this.execFile = options.execFile || execFileAsync;
|
|
148
|
+
this.querySqlite = options.querySqlite || queryWithNodeSqlite;
|
|
149
|
+
this.allowExternalSqlite = options.allowExternalSqlite ?? !hasBuiltInSqlite(options.nodeVersion);
|
|
150
|
+
this.codexSessions = options.codexSessions || null;
|
|
151
|
+
this.codexRows = null;
|
|
152
|
+
this.codexQuery = null;
|
|
153
|
+
this.rolloutStates = new Map();
|
|
154
|
+
this.queue = Promise.resolve();
|
|
155
|
+
}
|
|
156
|
+
async init() {
|
|
157
|
+
this.data = await readJSON(this.file, { sessions: {} });
|
|
158
|
+
this.bindings = await readJSON(this.bindingsFile, { sessions: {} });
|
|
159
|
+
// Older releases represented the default full Session scope by copying the
|
|
160
|
+
// then-current node IDs. That snapshot silently locked every node created
|
|
161
|
+
// later. A non-empty mode-less record is therefore the legacy form of the
|
|
162
|
+
// dynamic default. Explicitly narrowed records written by current releases
|
|
163
|
+
// carry mode:"explicit" and remain untouched.
|
|
164
|
+
let migrated = false;
|
|
165
|
+
for (const record of Object.values(this.data.sessions || {})) {
|
|
166
|
+
if (!record?.mode && Array.isArray(record?.nodes) && record.nodes.length) {
|
|
167
|
+
record.mode = 'all';
|
|
168
|
+
record.nodes = [];
|
|
169
|
+
record.changedAt = new Date().toISOString();
|
|
170
|
+
migrated = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (migrated) await atomicWrite(this.file, encode(this.data));
|
|
174
|
+
return this;
|
|
175
|
+
}
|
|
176
|
+
binding(sessionId) { return this.bindings.sessions[sessionId] || null; }
|
|
177
|
+
bindingRoots() { return [...new Set([this.root, ...Object.values(this.bindings.sessions).map(item => item?.worktreeRoot).filter(Boolean)])]; }
|
|
178
|
+
async hookSessionRegistry(root = this.root) {
|
|
179
|
+
const sessionsFile = path.join(root, '.codex/context/sessions.jsonl');
|
|
180
|
+
const text = await fs.readFile(sessionsFile, 'utf8').catch(e => e.code === 'ENOENT' ? '' : Promise.reject(e));
|
|
181
|
+
const sessions = new Map();
|
|
182
|
+
for (const line of text.split('\n').filter(Boolean)) {
|
|
183
|
+
try {
|
|
184
|
+
const event = JSON.parse(line);
|
|
185
|
+
const id = typeof event.session_id === 'string' ? event.session_id.trim() : '';
|
|
186
|
+
if (!id || event.event === 'maintenance' || id.startsWith('maintenance-')) continue;
|
|
187
|
+
const at = typeof event.at === 'string' && event.at ? event.at : new Date(0).toISOString();
|
|
188
|
+
const previous = sessions.get(id);
|
|
189
|
+
// A blocked Stop means governance is unfinished, not that the Agent is
|
|
190
|
+
// still generating a response. Keep those two states separate so stale
|
|
191
|
+
// bindings never render as a permanently spinning Session.
|
|
192
|
+
const stopped = ['stop', 'stop-blocked', 'subagent-stop', 'session-end', 'stop-failure'].includes(event.event);
|
|
193
|
+
const activated = ['session-start', 'subagent-start', 'user-prompt-submit'].includes(event.event);
|
|
194
|
+
const status = stopped ? 'stopped' : activated ? 'active' : previous?.status || 'unknown';
|
|
195
|
+
sessions.set(id, {
|
|
196
|
+
id,
|
|
197
|
+
name: typeof event.thread_name === 'string' && event.thread_name.trim() ? event.thread_name.trim() : previous?.name || '',
|
|
198
|
+
platform: typeof event.platform === 'string' && event.platform ? event.platform : previous?.platform || 'unknown',
|
|
199
|
+
status,
|
|
200
|
+
statusSeen: stopped || activated ? at : previous?.statusSeen || '',
|
|
201
|
+
statusSource: 'hook',
|
|
202
|
+
firstSeen: previous?.firstSeen || at,
|
|
203
|
+
lastSeen: at,
|
|
204
|
+
lastEvent: typeof event.event === 'string' ? event.event : previous?.lastEvent || 'unknown',
|
|
205
|
+
worktreeRoot: root,
|
|
206
|
+
});
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
return [...sessions.values()];
|
|
210
|
+
}
|
|
211
|
+
async stateDatabase() {
|
|
212
|
+
if (this.codexDb) return this.codexDb;
|
|
213
|
+
const homes = [...new Set([
|
|
214
|
+
this.sqliteHome,
|
|
215
|
+
this.codexHome && path.join(this.codexHome, 'sqlite'),
|
|
216
|
+
this.codexHome,
|
|
217
|
+
].filter(Boolean).map(home => path.resolve(home)))];
|
|
218
|
+
const candidates = [];
|
|
219
|
+
for (const home of homes) {
|
|
220
|
+
const files = await fs.readdir(home, { withFileTypes: true }).catch(() => []);
|
|
221
|
+
for (const item of files) {
|
|
222
|
+
if (!item.isFile() || !/^state(?:_\d+)?\.sqlite$/.test(item.name)) continue;
|
|
223
|
+
const file = path.join(home, item.name);
|
|
224
|
+
candidates.push({ file, mtime: (await fs.stat(file).catch(() => ({ mtimeMs: 0 }))).mtimeMs });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
228
|
+
if (candidates[0]) this.codexDb = candidates[0].file;
|
|
229
|
+
return this.codexDb;
|
|
230
|
+
}
|
|
231
|
+
async readRange(file, start, length) {
|
|
232
|
+
if (length <= 0) return '';
|
|
233
|
+
const handle = await fs.open(file, 'r');
|
|
234
|
+
try {
|
|
235
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
236
|
+
let offset = 0;
|
|
237
|
+
while (offset < length) {
|
|
238
|
+
const { bytesRead } = await handle.read(buffer, offset, length - offset, start + offset);
|
|
239
|
+
if (!bytesRead) break;
|
|
240
|
+
offset += bytesRead;
|
|
241
|
+
}
|
|
242
|
+
return buffer.subarray(0, offset).toString('utf8');
|
|
243
|
+
} finally { await handle.close(); }
|
|
244
|
+
}
|
|
245
|
+
async initialRolloutState(file, size) {
|
|
246
|
+
const block = 256 * 1024;
|
|
247
|
+
let end = size, suffix = '';
|
|
248
|
+
while (end > 0) {
|
|
249
|
+
const start = Math.max(0, end - block);
|
|
250
|
+
const text = await this.readRange(file, start, end - start) + suffix;
|
|
251
|
+
const lines = text.split('\n');
|
|
252
|
+
suffix = start > 0 ? lines.shift() : '';
|
|
253
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
254
|
+
const event = taskEvent(lines[i]);
|
|
255
|
+
if (event) return event;
|
|
256
|
+
}
|
|
257
|
+
end = start;
|
|
258
|
+
}
|
|
259
|
+
return { ...UNKNOWN_ACTIVITY };
|
|
260
|
+
}
|
|
261
|
+
async rolloutState(file) {
|
|
262
|
+
if (!file) return { ...UNKNOWN_ACTIVITY };
|
|
263
|
+
const stat = await fs.stat(file).catch(() => null);
|
|
264
|
+
if (!stat?.isFile()) return { ...UNKNOWN_ACTIVITY };
|
|
265
|
+
const previous = this.rolloutStates.get(file);
|
|
266
|
+
if (previous && stat.size === previous.size) return previous;
|
|
267
|
+
let state;
|
|
268
|
+
if (previous && stat.size > previous.size) {
|
|
269
|
+
const appended = await this.readRange(file, previous.size, stat.size - previous.size);
|
|
270
|
+
state = rolloutTaskStatus(appended, previous);
|
|
271
|
+
} else state = await this.initialRolloutState(file, stat.size);
|
|
272
|
+
const next = { ...state, size: stat.size };
|
|
273
|
+
this.rolloutStates.set(file, next);
|
|
274
|
+
return next;
|
|
275
|
+
}
|
|
276
|
+
async databaseSignature(database) {
|
|
277
|
+
const signatures = await Promise.all([database, `${database}-wal`].map(async file => {
|
|
278
|
+
const stat = await fs.stat(file).catch(() => null);
|
|
279
|
+
return stat ? `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}` : '-';
|
|
280
|
+
}));
|
|
281
|
+
return `${database}:${signatures.join(':')}`;
|
|
282
|
+
}
|
|
283
|
+
async loadCodexRows(database, sql) {
|
|
284
|
+
const signature = `${await this.databaseSignature(database)}:${sql}`;
|
|
285
|
+
if (this.codexRows?.signature === signature) return this.codexRows.rows;
|
|
286
|
+
if (this.codexQuery?.signature === signature) return this.codexQuery.promise;
|
|
287
|
+
const promise = (async () => {
|
|
288
|
+
let rows = await this.querySqlite(database, sql);
|
|
289
|
+
if (rows === null) {
|
|
290
|
+
if (!this.allowExternalSqlite) throw new Error('Built-in SQLite is unavailable on a supported Node.js runtime');
|
|
291
|
+
const { stdout } = await this.execFile(this.sqliteCommand, ['-readonly', '-json', database, sql], {
|
|
292
|
+
encoding: 'utf8', timeout: 1500, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
|
|
293
|
+
});
|
|
294
|
+
rows = stdout.trim() ? JSON.parse(stdout) : [];
|
|
295
|
+
}
|
|
296
|
+
this.codexRows = { signature, rows };
|
|
297
|
+
return rows;
|
|
298
|
+
})();
|
|
299
|
+
this.codexQuery = { signature, promise };
|
|
300
|
+
try { return await promise; }
|
|
301
|
+
finally { if (this.codexQuery?.promise === promise) this.codexQuery = null; }
|
|
302
|
+
}
|
|
303
|
+
mapCodexRow(row, state) {
|
|
304
|
+
return {
|
|
305
|
+
id: String(row.id),
|
|
306
|
+
name: String(row.name || row.title || '').trim(),
|
|
307
|
+
platform: 'codex',
|
|
308
|
+
status: state.status,
|
|
309
|
+
statusSeen: state.lastSeen,
|
|
310
|
+
statusSource: 'codex-rollout',
|
|
311
|
+
firstSeen: isoTime(row.created_at),
|
|
312
|
+
lastSeen: [isoTime(row.updated_at), state.lastSeen].filter(Boolean).sort().at(-1),
|
|
313
|
+
lastEvent: state.lastEvent,
|
|
314
|
+
worktreeRoot: String(row.cwd || ''),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
async discoverCodexSessions(roots = [this.root]) {
|
|
318
|
+
if (this.codexSessions) {
|
|
319
|
+
const batches = await Promise.all(roots.map(root => this.codexSessions(root)));
|
|
320
|
+
return batches.flat();
|
|
321
|
+
}
|
|
322
|
+
const database = await this.stateDatabase();
|
|
323
|
+
if (!database) return [];
|
|
324
|
+
// Codex can persist Win32 extended-length paths, trailing slashes, or a
|
|
325
|
+
// realpath while Git bindings use the ordinary worktree spelling.
|
|
326
|
+
const aliases = [...new Set((await Promise.all(roots.map(root => rootPathAliases(root)))).flat())];
|
|
327
|
+
const escaped = aliases.map(root => `'${root.replaceAll("'", "''")}'`).join(',');
|
|
328
|
+
const sql = `select id, name, title, cwd, created_at, updated_at, rollout_path from threads where cwd in (${escaped}) and thread_source='user' and archived=0 order by updated_at desc limit 100`;
|
|
329
|
+
try {
|
|
330
|
+
const rows = await this.loadCodexRows(database, sql);
|
|
331
|
+
return await Promise.all(rows.map(async row => this.mapCodexRow(row, await this.rolloutState(row.rollout_path))));
|
|
332
|
+
} catch { return []; }
|
|
333
|
+
}
|
|
334
|
+
async discoverCodexThread(sessionId, root = this.root) {
|
|
335
|
+
const id = String(sessionId || '').trim();
|
|
336
|
+
if (!id) return null;
|
|
337
|
+
if (this.codexSessions) {
|
|
338
|
+
return (await this.codexSessions(root)).find(item => item?.id === id) || null;
|
|
339
|
+
}
|
|
340
|
+
const database = await this.stateDatabase();
|
|
341
|
+
if (!database) return null;
|
|
342
|
+
const sql = `select id, name, title, cwd, created_at, updated_at, rollout_path from threads where id='${id.replaceAll("'", "''")}' and archived=0 limit 1`;
|
|
343
|
+
try {
|
|
344
|
+
const [row] = await this.loadCodexRows(database, sql);
|
|
345
|
+
if (!row) return null;
|
|
346
|
+
const aliases = new Set((await rootPathAliases(root)).map(normalizeHostPath));
|
|
347
|
+
if (!aliases.has(normalizeHostPath(row.cwd))) {
|
|
348
|
+
const realCwd = await fs.realpath(row.cwd || '').catch(() => '');
|
|
349
|
+
if (!realCwd || !aliases.has(normalizeHostPath(realCwd))) return null;
|
|
350
|
+
}
|
|
351
|
+
return this.mapCodexRow(row, await this.rolloutState(row.rollout_path));
|
|
352
|
+
} catch { return null; }
|
|
353
|
+
}
|
|
354
|
+
async sessionExists(sessionId, root = this.root) {
|
|
355
|
+
const id = String(sessionId || '').trim();
|
|
356
|
+
if (!id) return false;
|
|
357
|
+
if ((await this.knownSessions(root)).includes(id)) return true;
|
|
358
|
+
return !!(await this.discoverCodexThread(id, root));
|
|
359
|
+
}
|
|
360
|
+
async sessionRegistry() {
|
|
361
|
+
const roots = this.bindingRoots();
|
|
362
|
+
const [hookBatches, codexSessions] = await Promise.all([Promise.all(roots.map(root => this.hookSessionRegistry(root))), this.discoverCodexSessions(roots)]);
|
|
363
|
+
const hookSessions = hookBatches.flat();
|
|
364
|
+
const sessions = new Map(Object.entries(this.bindings.sessions).filter(([id]) => !id.startsWith('maintenance-')).map(([id, binding]) => [id, {
|
|
365
|
+
id, name: '', platform: 'unknown', status: 'unknown', statusSeen: '', statusSource: 'binding', firstSeen: binding.updatedAt, lastSeen: binding.updatedAt, lastEvent: 'bound', ...binding,
|
|
366
|
+
}]));
|
|
367
|
+
for (const discovered of [...hookSessions, ...codexSessions]) {
|
|
368
|
+
if (!discovered?.id) continue;
|
|
369
|
+
if (!this.binding(discovered.id)) continue;
|
|
370
|
+
const stored = sessions.get(discovered.id);
|
|
371
|
+
const previous = stored?.lastEvent === 'bound' ? { ...stored, firstSeen: '', lastSeen: '' } : stored;
|
|
372
|
+
const activity = latestActivity(previous, discovered);
|
|
373
|
+
sessions.set(discovered.id, {
|
|
374
|
+
...previous,
|
|
375
|
+
...discovered,
|
|
376
|
+
name: discovered.name || previous?.name || '',
|
|
377
|
+
firstSeen: [previous?.firstSeen, discovered.firstSeen].filter(Boolean).sort()[0] || new Date(0).toISOString(),
|
|
378
|
+
lastSeen: [previous?.lastSeen, discovered.lastSeen].filter(Boolean).sort().at(-1) || new Date(0).toISOString(),
|
|
379
|
+
...activity,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
const validated = await Promise.all([...sessions.values()].map(async item => {
|
|
383
|
+
const binding = this.binding(item.id) || {};
|
|
384
|
+
const target = binding.worktreeRoot ? await fs.realpath(binding.worktreeRoot).catch(() => null) : null;
|
|
385
|
+
return {
|
|
386
|
+
...item,
|
|
387
|
+
...binding,
|
|
388
|
+
bindingState: target ? 'bound' : 'stale',
|
|
389
|
+
worktreeName: target ? path.basename(target) : path.basename(binding.worktreeRoot || ''),
|
|
390
|
+
};
|
|
391
|
+
}));
|
|
392
|
+
return validated.sort((a, b) => b.lastSeen.localeCompare(a.lastSeen));
|
|
393
|
+
}
|
|
394
|
+
async recordedSessionIds() {
|
|
395
|
+
const text = await fs.readFile(this.sessionsFile, 'utf8').catch(e => e.code === 'ENOENT' ? '' : Promise.reject(e));
|
|
396
|
+
const ids = new Set();
|
|
397
|
+
for (const line of text.split('\n').filter(Boolean)) {
|
|
398
|
+
try { const event = JSON.parse(line); if (typeof event.session_id === 'string' && event.session_id.trim()) ids.add(event.session_id.trim()); } catch {}
|
|
399
|
+
}
|
|
400
|
+
return [...ids];
|
|
401
|
+
}
|
|
402
|
+
accessRecord(sessionId) {
|
|
403
|
+
const record = this.data.sessions[sessionId];
|
|
404
|
+
if (!record) return null;
|
|
405
|
+
return { mode: record.mode === 'all' ? 'all' : 'explicit', nodes: Array.isArray(record.nodes) ? record.nodes : [] };
|
|
406
|
+
}
|
|
407
|
+
grants(sessionId, document = null, mode = 'write') {
|
|
408
|
+
const record = this.accessRecord(sessionId);
|
|
409
|
+
if (!record) return [];
|
|
410
|
+
if (!document?.root) return [...record.nodes];
|
|
411
|
+
const nodes = record.mode === 'all' ? [...entries(document.root).keys()] : [...record.nodes];
|
|
412
|
+
return filterNodeAccess(document, nodes, sessionId, mode);
|
|
413
|
+
}
|
|
414
|
+
async promoteLegacyFullGrant(sessionId, document) {
|
|
415
|
+
const record = this.data.sessions[sessionId];
|
|
416
|
+
if (!record || record.mode || !document?.root || !Array.isArray(record.nodes)) return false;
|
|
417
|
+
const current = [...entries(document.root).keys()];
|
|
418
|
+
if (record.nodes.length !== current.length || current.some(id => !record.nodes.includes(id))) return false;
|
|
419
|
+
await this.grant(sessionId, [], record.version || null, 'all');
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
async snapshot(document = null, currentSessionId = null) {
|
|
423
|
+
const sessions = await this.sessionRegistry();
|
|
424
|
+
const grants = {};
|
|
425
|
+
for (const session of sessions) {
|
|
426
|
+
const record = this.accessRecord(session.id) || { mode: 'all', nodes: [] };
|
|
427
|
+
grants[session.id] = { ...this.data.sessions[session.id], mode: record.mode, nodes: this.grants(session.id, document) };
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
sessions,
|
|
431
|
+
currentSessionId: sessions.some(item => item.id === currentSessionId) ? currentSessionId : null,
|
|
432
|
+
grants,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
async knownSessions(root = null) {
|
|
436
|
+
if (!root) return (await this.sessionRegistry()).map(item => item.id);
|
|
437
|
+
const [hook, codex] = await Promise.all([this.hookSessionRegistry(root), this.discoverCodexSessions([root])]);
|
|
438
|
+
return [...new Set([...hook, ...codex].map(item => item.id))];
|
|
439
|
+
}
|
|
440
|
+
watch(onChange) {
|
|
441
|
+
let timer, checking = false, signature;
|
|
442
|
+
const listener = () => {
|
|
443
|
+
clearTimeout(timer);
|
|
444
|
+
timer = setTimeout(onChange, 30);
|
|
445
|
+
timer.unref?.();
|
|
446
|
+
};
|
|
447
|
+
const inspect = async () => {
|
|
448
|
+
if (checking) return;
|
|
449
|
+
checking = true;
|
|
450
|
+
try {
|
|
451
|
+
const sessions = await this.sessionRegistry();
|
|
452
|
+
const next = JSON.stringify(sessions.map(({ id, name, status, lastSeen }) => [id, name, status, lastSeen]));
|
|
453
|
+
if (signature !== undefined && signature !== next) listener();
|
|
454
|
+
signature = next;
|
|
455
|
+
} finally { checking = false; }
|
|
456
|
+
};
|
|
457
|
+
watchFile(this.sessionsFile, { persistent: false, interval: 200 }, inspect);
|
|
458
|
+
void inspect();
|
|
459
|
+
const interval = setInterval(inspect, 750); interval.unref?.();
|
|
460
|
+
return () => { clearTimeout(timer); clearInterval(interval); unwatchFile(this.sessionsFile, inspect); };
|
|
461
|
+
}
|
|
462
|
+
async register(sessionId, binding = {}) {
|
|
463
|
+
if (!Object.keys(binding).length && !this.binding(sessionId)) throw new MapError('SESSION_BINDING_REQUIRED', 'Bind this Session explicitly before granting scope', 409);
|
|
464
|
+
const worktreeRoot = binding.worktreeRoot || this.binding(sessionId)?.worktreeRoot || this.root;
|
|
465
|
+
if (!(await this.sessionExists(sessionId, worktreeRoot))) throw new MapError('UNKNOWN_SESSION', 'Session must first be recorded by a lifecycle hook or discovered in this Context Guard worktree', 403);
|
|
466
|
+
const stored = {
|
|
467
|
+
...(this.binding(sessionId) || {}),
|
|
468
|
+
...binding,
|
|
469
|
+
sessionId,
|
|
470
|
+
worktreeRoot,
|
|
471
|
+
updatedAt: new Date().toISOString(),
|
|
472
|
+
};
|
|
473
|
+
const next = this.queue.then(async () => {
|
|
474
|
+
this.bindings.sessions[sessionId] = stored;
|
|
475
|
+
const data = structuredClone(this.data);
|
|
476
|
+
if (!data.sessions[sessionId]) data.sessions[sessionId] = { mode: 'all', nodes: [], version: null, changedAt: new Date().toISOString() };
|
|
477
|
+
await Promise.all([
|
|
478
|
+
atomicWrite(this.bindingsFile, encode(this.bindings)),
|
|
479
|
+
atomicWrite(this.file, encode(data)),
|
|
480
|
+
]);
|
|
481
|
+
this.data = data;
|
|
482
|
+
});
|
|
483
|
+
this.queue = next.catch(() => {}); await next;
|
|
484
|
+
return { kind: 'agent', sessionId, ...(stored.projectId ? { projectId: stored.projectId } : {}), ...(stored.worktreeId ? { worktreeId: stored.worktreeId } : {}) };
|
|
485
|
+
}
|
|
486
|
+
async grant(sessionId, nodes, version, mode = 'explicit') {
|
|
487
|
+
await this.register(sessionId);
|
|
488
|
+
if (!['all', 'explicit'].includes(mode)) throw new MapError('INVALID_SCOPE', 'Use all or explicit Session scope');
|
|
489
|
+
const next = this.queue.then(async () => {
|
|
490
|
+
const data = structuredClone(this.data);
|
|
491
|
+
data.sessions[sessionId] = { mode, nodes: mode === 'all' ? [] : [...new Set(nodes)], version, changedAt: new Date().toISOString() };
|
|
492
|
+
await atomicWrite(this.file, encode(data)); this.data = data;
|
|
493
|
+
});
|
|
494
|
+
this.queue = next.catch(() => {}); await next;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { MapError, entries } from '../shared/map-model.mjs';
|
|
5
|
+
import { hash, atomicWrite, encode, readJSON } from '../shared/io.mjs';
|
|
6
|
+
|
|
7
|
+
const limit = 8 * 1024 * 1024;
|
|
8
|
+
const inside = (root, file) => file.startsWith(root + path.sep);
|
|
9
|
+
|
|
10
|
+
export class Attachments {
|
|
11
|
+
constructor(root) { this.root = root; this.tail = Promise.resolve(); }
|
|
12
|
+
|
|
13
|
+
save(input, doc) {
|
|
14
|
+
const next = this.tail.then(() => this.write(input, doc));
|
|
15
|
+
this.tail = next.catch(() => {});
|
|
16
|
+
return next;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async write({ uploadId, nodeId, name, base64 }, doc) {
|
|
20
|
+
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(uploadId || '') || typeof name !== 'string' || !name || name.length > 255 || typeof base64 !== 'string') {
|
|
21
|
+
throw new MapError('INVALID_ATTACHMENT', 'Invalid upload ID, filename or base64');
|
|
22
|
+
}
|
|
23
|
+
if (base64.length > Math.ceil(limit / 3) * 4) throw new MapError('ATTACHMENT_TOO_LARGE', '附件最大为 8 MiB', 413);
|
|
24
|
+
if (base64.length % 4 || /[^A-Za-z0-9+/=]/.test(base64)) throw new MapError('INVALID_ATTACHMENT', 'Invalid base64');
|
|
25
|
+
if (!doc?.root || !entries(doc.root).has(nodeId)) throw new MapError('NOT_FOUND', 'Attachment node no longer exists', 404);
|
|
26
|
+
const bytes = Buffer.from(base64, 'base64');
|
|
27
|
+
if (bytes.toString('base64') !== base64) throw new MapError('INVALID_ATTACHMENT', 'Invalid base64');
|
|
28
|
+
if (bytes.length > limit) throw new MapError('ATTACHMENT_TOO_LARGE', '附件最大为 8 MiB', 413);
|
|
29
|
+
|
|
30
|
+
const cleanName = path.basename(name.replace(/\\/g, '/')).replace(/[^a-zA-Z0-9._-]/g, '-').slice(-90) || 'attachment.bin';
|
|
31
|
+
const dir = path.join(this.root, 'docs/shots');
|
|
32
|
+
for (const rel of ['docs', 'docs/shots']) {
|
|
33
|
+
const target = path.join(this.root, rel);
|
|
34
|
+
try {
|
|
35
|
+
if (!inside(this.root, await fs.realpath(target))) throw new MapError('INVALID_PATH', 'Attachment directory escapes project', 403);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code !== 'ENOENT') throw error;
|
|
38
|
+
await fs.mkdir(target);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
uploadId = uploadId.toLowerCase();
|
|
43
|
+
const rel = `docs/shots/${uploadId}-${cleanName}`;
|
|
44
|
+
const dest = path.join(this.root, rel);
|
|
45
|
+
const receiptFile = path.join(this.root, '.codex/context/private/sync/uploads', `${uploadId}.json`);
|
|
46
|
+
const identity = { uploadId, nodeId, name, path: rel, digest: hash(bytes) };
|
|
47
|
+
const previous = await readJSON(receiptFile, null);
|
|
48
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(identity)) {
|
|
49
|
+
throw new MapError('UPLOAD_ID_REUSED', 'Upload ID belongs to a different attachment; existing file preserved', 409);
|
|
50
|
+
}
|
|
51
|
+
if (!previous) await atomicWrite(receiptFile, encode(identity));
|
|
52
|
+
|
|
53
|
+
const temp = path.join(dir, `.${uploadId}-${randomUUID()}.tmp`);
|
|
54
|
+
let handle;
|
|
55
|
+
try {
|
|
56
|
+
handle = await fs.open(temp, 'wx', 0o600);
|
|
57
|
+
await handle.writeFile(bytes);
|
|
58
|
+
await handle.sync();
|
|
59
|
+
await handle.close();
|
|
60
|
+
handle = null;
|
|
61
|
+
try {
|
|
62
|
+
await fs.link(temp, dest);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error.code !== 'EEXIST') throw error;
|
|
65
|
+
if (!inside(this.root, await fs.realpath(dest)) || hash(await fs.readFile(dest)) !== hash(bytes)) {
|
|
66
|
+
throw new MapError('UPLOAD_ID_REUSED', 'Upload ID already has different content; existing file preserved', 409);
|
|
67
|
+
}
|
|
68
|
+
return { saved: true, duplicate: true, path: rel, name, uploadId };
|
|
69
|
+
}
|
|
70
|
+
return { saved: true, path: rel, name, uploadId };
|
|
71
|
+
} finally {
|
|
72
|
+
if (handle) await handle.close();
|
|
73
|
+
await fs.unlink(temp).catch(() => {});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async read(rel, doc) {
|
|
78
|
+
if (typeof rel !== 'string' || !rel || rel.includes('\\') || rel.includes(':') || path.isAbsolute(rel) || rel.split('/').some(part => !part || part === '.' || part === '..')) {
|
|
79
|
+
throw new MapError('INVALID_PATH', 'Use a project-relative attachment path', 403);
|
|
80
|
+
}
|
|
81
|
+
const referenced = doc?.root && [...entries(doc.root).values()].some(({ node }) =>
|
|
82
|
+
[node, ...(node.memories || []), ...(node.ideas || []), ...(node.bugs || []), ...(node.dormant || [])]
|
|
83
|
+
.some(owner => (owner.files || []).some(file => (typeof file === 'string' ? file : file.path) === rel))
|
|
84
|
+
);
|
|
85
|
+
if (!referenced) throw new MapError('NOT_FOUND', 'File is not referenced by the current map', 404);
|
|
86
|
+
const target = await fs.realpath(path.join(this.root, rel));
|
|
87
|
+
if (!inside(this.root, target)) throw new MapError('INVALID_PATH', 'Attachment escapes project', 403);
|
|
88
|
+
const stat = await fs.stat(target);
|
|
89
|
+
if (!stat.isFile() || stat.size > limit) throw new MapError('ATTACHMENT_TOO_LARGE', '附件预览最大为 8 MiB', 413);
|
|
90
|
+
return fs.readFile(target);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
4
|
+
import { atomicWrite, encode, readJSON, withFileLock, pause } from '../shared/io.mjs';
|
|
5
|
+
import { fail } from '../shared/protocol.mjs';
|
|
6
|
+
|
|
7
|
+
async function request(origin, route, input, fetcher) {
|
|
8
|
+
let response;
|
|
9
|
+
try {
|
|
10
|
+
response = await fetcher(new URL(route, origin), { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000),
|
|
11
|
+
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) });
|
|
12
|
+
} catch { fail('UNAVAILABLE', 'Cloud authorization is unavailable; retry the same connection command'); }
|
|
13
|
+
if ([404, 405, 426].includes(response.status)) fail('UPGRADE_REQUIRED', 'Cloud does not support browser authorization; upgrade Cloud or use the explicit private-input login');
|
|
14
|
+
if (!response.headers.get('content-type')?.includes('application/json')) fail('UNAVAILABLE', 'Cloud returned an invalid authorization reply');
|
|
15
|
+
let result;
|
|
16
|
+
try {
|
|
17
|
+
const chunks = []; let size = 0;
|
|
18
|
+
for await (const chunk of response.body) { size += chunk.length; if (size > 16384) fail('TOO_LARGE', 'Authorization reply is too large'); chunks.push(chunk); }
|
|
19
|
+
result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
20
|
+
} catch (error) { if (error.code === 'TOO_LARGE') throw error; fail('UNAVAILABLE', 'Cloud authorization reply was interrupted'); }
|
|
21
|
+
if (!response.ok || result?.ok !== true) fail(result?.error?.code || 'UNAVAILABLE', result?.error?.message || 'Cloud authorization failed');
|
|
22
|
+
if (!result.data || typeof result.data !== 'object') fail('UNAVAILABLE', 'Cloud returned an invalid authorization reply');
|
|
23
|
+
return { data: result.data, credential: response.headers.get('x-context-guard-credential') };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The model sees only the verification URL/code. The backend keeps the claim
|
|
27
|
+
// secret and final credential private, including across a stopped CLI invocation.
|
|
28
|
+
export async function browserLogin(device, { repository, repositoryId, wait = false, onPending = () => {}, fetcher = fetch, label = os.hostname() }) {
|
|
29
|
+
const origin = new URL(device.origin);
|
|
30
|
+
if (origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash || origin.protocol !== 'https:' && !(device.allowLoopback && origin.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(origin.hostname))) fail('FORBIDDEN', 'Cloud authorization requires an HTTPS origin');
|
|
31
|
+
const file = path.join(device.directory, 'browser-login.json');
|
|
32
|
+
let first = true;
|
|
33
|
+
for (;;) {
|
|
34
|
+
const outcome = await withFileLock(file + '.lock', async () => {
|
|
35
|
+
const connected = await readJSON(device.file, null);
|
|
36
|
+
if (connected?.origin === device.origin && connected.repositoryId === repositoryId && connected.credential) {
|
|
37
|
+
try {
|
|
38
|
+
await device.transmit({ v: 2, id: randomUUID(), type: 'sync.heartbeat', payload: { sessions: [] } });
|
|
39
|
+
const { credential: _secret, origin: _origin, ...data } = connected;
|
|
40
|
+
return { connected: true, ...data };
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code !== 'UNAUTHORIZED') throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
let pending = await readJSON(file, null);
|
|
46
|
+
if (!pending?.deviceCode || pending.origin !== device.origin || pending.repository !== repository || pending.repositoryId !== repositoryId || Date.parse(pending.expiresAt) <= Date.now()) {
|
|
47
|
+
pending = { origin: device.origin, repository, repositoryId, deviceCode: randomBytes(32).toString('base64url'), label: String(label).slice(0, 80),
|
|
48
|
+
expiresAt: new Date(Date.now() + 600000).toISOString() };
|
|
49
|
+
await atomicWrite(file, encode(pending));
|
|
50
|
+
}
|
|
51
|
+
if (!pending.verificationUrl) {
|
|
52
|
+
const identity = await device.identity();
|
|
53
|
+
const { data } = await request(device.origin, '/api/auth/device/start', { repository, clientId: identity.clientId, deviceCode: pending.deviceCode, label: pending.label }, fetcher);
|
|
54
|
+
const verification = new URL(data.verificationPath, device.origin);
|
|
55
|
+
if (verification.origin !== device.origin || verification.pathname !== '/connect' || !/^[A-F0-9]{4}-[A-F0-9]{4}$/.test(data.userCode) || verification.searchParams.get('code') !== data.userCode || !Number.isFinite(Date.parse(data.expiresAt)) || !Number.isInteger(data.expiresIn) || data.expiresIn <= 0 || data.expiresIn > 600) fail('UNAVAILABLE', 'Cloud returned an invalid authorization link');
|
|
56
|
+
pending = { ...pending, verificationUrl: verification.href, userCode: data.userCode, expiresAt: new Date(Date.now() + data.expiresIn * 1000).toISOString() };
|
|
57
|
+
await atomicWrite(file, encode(pending));
|
|
58
|
+
}
|
|
59
|
+
const safe = { connected: false, authorizationRequired: true, verificationUrl: pending.verificationUrl, userCode: pending.userCode, expiresAt: pending.expiresAt };
|
|
60
|
+
if (first) await onPending(safe);
|
|
61
|
+
let result;
|
|
62
|
+
try { result = await request(device.origin, '/api/auth/device/poll', { deviceCode: pending.deviceCode }, fetcher); }
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (['UNAUTHORIZED', 'FORBIDDEN'].includes(error.code)) await atomicWrite(file, encode({ status: error.code }));
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
if (result.data.status === 'pending') return safe;
|
|
68
|
+
if (!result.credential || result.credential.length < 32 || !result.data.projectId || !result.data.capabilities?.includes('device-memory')) fail('UNAVAILABLE', 'Cloud did not issue a project device connection');
|
|
69
|
+
await device.saveConnection(result.data, result.credential, { repositoryId });
|
|
70
|
+
await atomicWrite(file, encode({ status: 'connected' }));
|
|
71
|
+
return { connected: true, ...result.data };
|
|
72
|
+
});
|
|
73
|
+
if (outcome.connected || !wait) return outcome;
|
|
74
|
+
if (Date.now() >= Date.parse(outcome.expiresAt)) fail('UNAUTHORIZED', 'Browser authorization expired; retry the connection command');
|
|
75
|
+
first = false;
|
|
76
|
+
await pause(5000);
|
|
77
|
+
}
|
|
78
|
+
}
|