@agentpulselabs/cli-plugin-pulse 0.2.0 → 1.1.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.
- package/README.md +115 -32
- package/package.json +14 -19
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +39 -0
- package/src/commands/pulse/doctor.js +15 -0
- package/src/commands/pulse/hub-write.js +26 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/install.js +14 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/login.js +20 -0
- package/src/commands/pulse/mirror.js +27 -0
- package/src/commands/pulse/pair.js +38 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/restart.js +14 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/commands/pulse/start.js +6 -18
- package/src/commands/pulse/status.js +6 -6
- package/src/commands/pulse/stop.js +24 -0
- package/src/daemon/main.js +43 -0
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/BaseCommand.js +23 -23
- package/src/lib/agentSessions.js +279 -0
- package/src/lib/application.js +704 -0
- package/src/lib/backoff.js +8 -0
- package/src/lib/budgets.js +85 -0
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/cliDefaults.js +20 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudAuth.js +189 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +2 -0
- package/src/lib/cutover.js +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonComposition.js +338 -0
- package/src/lib/daemonLock.js +57 -0
- package/src/lib/daemonLoop.js +72 -0
- package/src/lib/daemonProcess.js +380 -0
- package/src/lib/daemonRuntime.js +828 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/drivers.js +280 -0
- package/src/lib/errors.js +33 -0
- package/src/lib/graphWindows.js +149 -0
- package/src/lib/http.js +84 -0
- package/src/lib/hubActions.js +74 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/identity.js +102 -0
- package/src/lib/intents.js +590 -0
- package/src/lib/ledger.js +535 -0
- package/src/lib/lifecycle.js +123 -0
- package/src/lib/localHub.js +217 -0
- package/src/lib/localHubWriter.js +69 -0
- package/src/lib/mirror-contract.json +4299 -0
- package/src/lib/mirrorContract.js +513 -0
- package/src/lib/mirrorDaemon.js +127 -0
- package/src/lib/mirrorService.js +365 -0
- package/src/lib/mirrorSource.js +430 -0
- package/src/lib/mirrorSync.js +353 -0
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/oauthLogin.js +132 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/ownerLock.js +99 -0
- package/src/lib/pairedConfig.js +40 -0
- package/src/lib/pairingLifecycle.js +179 -0
- package/src/lib/pathRedaction.js +89 -0
- package/src/lib/planContract.js +141 -0
- package/src/lib/priorityLane.js +80 -0
- package/src/lib/processOwnership.js +252 -0
- package/src/lib/processSupervisor.js +376 -0
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/redaction.js +128 -0
- package/src/lib/rootPolicy.js +372 -0
- package/src/lib/rulePacks.js +162 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/secretStore.js +169 -0
- package/src/lib/selfRestart.js +261 -0
- package/src/lib/serviceManager.js +419 -0
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/storage.js +131 -0
- package/src/lib/subprocess.js +45 -0
- package/src/lib/tokenStore.js +136 -0
- package/src/lib/transport.js +334 -0
- package/src/lib/transportContract.js +40 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +140 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
- package/LICENSE +0 -21
- package/src/commands/pulse/gate.js +0 -25
- package/src/commands/pulse/next.js +0 -14
- package/src/commands/pulse/submit.js +0 -21
- package/src/commands/pulse/verify.js +0 -21
- package/src/runPulse.js +0 -58
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { HubError } from './errors.js';
|
|
5
|
+
import {
|
|
6
|
+
ensureOwnerOnlyDirectory,
|
|
7
|
+
readOwnerOnlyJson,
|
|
8
|
+
writeOwnerOnlyJson,
|
|
9
|
+
} from './storage.js';
|
|
10
|
+
import { execFileBounded as execFile } from './subprocess.js';
|
|
11
|
+
|
|
12
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{2,79}$/;
|
|
13
|
+
const SAFE_REF = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,179}$/;
|
|
14
|
+
|
|
15
|
+
function isContained(root, candidate) {
|
|
16
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function exists(file) {
|
|
20
|
+
try {
|
|
21
|
+
await lstat(file);
|
|
22
|
+
return true;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function gitDirectory(root) {
|
|
30
|
+
const dotGit = path.join(root, '.git');
|
|
31
|
+
const info = await lstat(dotGit);
|
|
32
|
+
if (info.isDirectory()) return realpath(dotGit);
|
|
33
|
+
if (!info.isFile()) {
|
|
34
|
+
throw new HubError('HUB_NOT_REPOSITORY', `Unsupported .git entry under ${root}`);
|
|
35
|
+
}
|
|
36
|
+
const contents = await readFile(dotGit, 'utf8');
|
|
37
|
+
const match = contents.match(/^gitdir:\s*(.+)\s*$/m);
|
|
38
|
+
if (!match) throw new HubError('HUB_NOT_REPOSITORY', `Invalid .git file under ${root}`);
|
|
39
|
+
return realpath(path.resolve(root, match[1]));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function repositoryIdentity(canonicalRoot) {
|
|
43
|
+
let gitDir;
|
|
44
|
+
try {
|
|
45
|
+
gitDir = await gitDirectory(canonicalRoot);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error.code === 'ENOENT') {
|
|
48
|
+
throw new HubError('HUB_NOT_REPOSITORY', `Approved root is not a Git repository: ${canonicalRoot}`);
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
const rootInfo = await lstat(canonicalRoot);
|
|
53
|
+
const gitInfo = await lstat(gitDir);
|
|
54
|
+
return createHash('sha256')
|
|
55
|
+
.update(
|
|
56
|
+
[
|
|
57
|
+
canonicalRoot,
|
|
58
|
+
rootInfo.dev,
|
|
59
|
+
rootInfo.ino,
|
|
60
|
+
gitDir,
|
|
61
|
+
gitInfo.dev,
|
|
62
|
+
gitInfo.ino,
|
|
63
|
+
].join('\0')
|
|
64
|
+
)
|
|
65
|
+
.digest('hex');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class WorktreeRegistry {
|
|
69
|
+
#queue = Promise.resolve();
|
|
70
|
+
|
|
71
|
+
static async open(directory) {
|
|
72
|
+
await ensureOwnerOnlyDirectory(directory);
|
|
73
|
+
const file = path.join(directory, 'worktrees.json');
|
|
74
|
+
const data = await readOwnerOnlyJson(file, { schemaVersion: 1, worktrees: {} });
|
|
75
|
+
if (data.schemaVersion !== 1 || !data.worktrees || typeof data.worktrees !== 'object') {
|
|
76
|
+
throw new HubError('HUB_WORKTREE_REGISTRY_INVALID', 'Worktree registry is invalid');
|
|
77
|
+
}
|
|
78
|
+
return new WorktreeRegistry(file, data);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
constructor(file, data) {
|
|
82
|
+
this.file = file;
|
|
83
|
+
this.data = data;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async #mutate(operation) {
|
|
87
|
+
const pending = this.#queue.then(async () => {
|
|
88
|
+
const draft = structuredClone(this.data);
|
|
89
|
+
const result = operation(draft);
|
|
90
|
+
await writeOwnerOnlyJson(this.file, draft);
|
|
91
|
+
this.data = draft;
|
|
92
|
+
return structuredClone(result);
|
|
93
|
+
});
|
|
94
|
+
this.#queue = pending.catch(() => {});
|
|
95
|
+
return pending;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get(worktreeId) {
|
|
99
|
+
const record = Object.hasOwn(this.data.worktrees, worktreeId)
|
|
100
|
+
? this.data.worktrees[worktreeId]
|
|
101
|
+
: null;
|
|
102
|
+
return record ? structuredClone(record) : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
list(rootId = null) {
|
|
106
|
+
return Object.values(this.data.worktrees)
|
|
107
|
+
.filter((record) => rootId === null || record.rootId === rootId)
|
|
108
|
+
.map((record) => structuredClone(record));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async add(record) {
|
|
112
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{5,160}$/.test(record.worktreeId)) {
|
|
113
|
+
throw new HubError('HUB_INVALID_WORKTREE_ID', 'Worktree record id is invalid');
|
|
114
|
+
}
|
|
115
|
+
return this.#mutate((draft) => {
|
|
116
|
+
if (Object.hasOwn(draft.worktrees, record.worktreeId)) {
|
|
117
|
+
throw new HubError('HUB_WORKTREE_EXISTS', `Worktree record already exists: ${record.worktreeId}`);
|
|
118
|
+
}
|
|
119
|
+
draft.worktrees[record.worktreeId] = structuredClone(record);
|
|
120
|
+
return record;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function captureRootGrant({ rootId, root, repoWrite = false, worktreeBase }) {
|
|
126
|
+
if (!SAFE_ID.test(rootId)) {
|
|
127
|
+
throw new HubError('HUB_INVALID_ROOT_ID', 'Root id must be opaque and path-free');
|
|
128
|
+
}
|
|
129
|
+
let canonicalRoot;
|
|
130
|
+
try {
|
|
131
|
+
canonicalRoot = await realpath(root);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
|
|
134
|
+
throw new HubError('HUB_ROOT_DENIED', `Root "${rootId}" does not exist: ${root}`);
|
|
135
|
+
}
|
|
136
|
+
const info = await lstat(canonicalRoot);
|
|
137
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
138
|
+
throw new HubError('HUB_ROOT_DENIED', 'Approved root must be a real directory');
|
|
139
|
+
}
|
|
140
|
+
if (!(await exists(path.join(canonicalRoot, '.git')))) {
|
|
141
|
+
throw new HubError('HUB_NOT_REPOSITORY', `Root "${rootId}" must be a git repository: ${canonicalRoot} has no .git`);
|
|
142
|
+
}
|
|
143
|
+
const configuredWorktreeBase =
|
|
144
|
+
worktreeBase ||
|
|
145
|
+
path.join(path.dirname(canonicalRoot), '.agentpulse-worktrees', rootId);
|
|
146
|
+
await ensureOwnerOnlyDirectory(configuredWorktreeBase);
|
|
147
|
+
const head = await readFile(path.join(await gitDirectory(canonicalRoot), 'HEAD'), 'utf8');
|
|
148
|
+
const branch = head.match(/^ref:\s*refs\/heads\/(.+)\s*$/)?.[1];
|
|
149
|
+
if (!branch) {
|
|
150
|
+
throw new HubError('HUB_INVALID_GIT_REF', 'Approved root must have a local branch checked out');
|
|
151
|
+
}
|
|
152
|
+
validateBaseRef(branch);
|
|
153
|
+
return {
|
|
154
|
+
rootId,
|
|
155
|
+
canonicalRoot,
|
|
156
|
+
repositoryIdentity: await repositoryIdentity(canonicalRoot),
|
|
157
|
+
worktreeBase: await realpath(configuredWorktreeBase),
|
|
158
|
+
baseRef: branch,
|
|
159
|
+
repoWrite: Boolean(repoWrite),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function validateRootGrant(grant) {
|
|
164
|
+
let currentRoot;
|
|
165
|
+
try {
|
|
166
|
+
currentRoot = await realpath(grant.canonicalRoot);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw new HubError('HUB_ROOT_REPLACED', 'Approved root no longer exists', { cause: error });
|
|
169
|
+
}
|
|
170
|
+
const identity = await repositoryIdentity(currentRoot);
|
|
171
|
+
if (currentRoot !== grant.canonicalRoot || identity !== grant.repositoryIdentity) {
|
|
172
|
+
throw new HubError('HUB_ROOT_REPLACED', 'Approved root or repository identity changed');
|
|
173
|
+
}
|
|
174
|
+
return currentRoot;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function nearestExisting(candidate, boundary) {
|
|
178
|
+
let current = candidate;
|
|
179
|
+
while (!await exists(current)) {
|
|
180
|
+
const parent = path.dirname(current);
|
|
181
|
+
if (parent === current || !isContained(boundary, parent)) {
|
|
182
|
+
throw new HubError('HUB_ROOT_ESCAPE', 'Target has no existing parent inside the approved root');
|
|
183
|
+
}
|
|
184
|
+
current = parent;
|
|
185
|
+
}
|
|
186
|
+
return current;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function rejectNestedRepository(root, target) {
|
|
190
|
+
let current = target;
|
|
191
|
+
const relative = path.relative(root, target);
|
|
192
|
+
const parts = relative ? relative.split(path.sep) : [];
|
|
193
|
+
current = root;
|
|
194
|
+
for (const part of parts) {
|
|
195
|
+
current = path.join(current, part);
|
|
196
|
+
const currentInfo = await lstat(current);
|
|
197
|
+
if (current !== root && currentInfo.isDirectory() && await exists(path.join(current, '.git'))) {
|
|
198
|
+
throw new HubError('HUB_NESTED_REPOSITORY', `Nested repository is not separately approved: ${current}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function resolveApprovedPath(
|
|
204
|
+
grant,
|
|
205
|
+
relativePath,
|
|
206
|
+
{ access = 'read', worktreeId = null, registry = null } = {}
|
|
207
|
+
) {
|
|
208
|
+
await validateRootGrant(grant);
|
|
209
|
+
if (
|
|
210
|
+
typeof relativePath !== 'string' ||
|
|
211
|
+
relativePath.length === 0 ||
|
|
212
|
+
relativePath.includes('\0') ||
|
|
213
|
+
relativePath.includes('\\') ||
|
|
214
|
+
path.isAbsolute(relativePath)
|
|
215
|
+
) {
|
|
216
|
+
throw new HubError('HUB_PATH_DENIED', 'Target must be a portable relative path');
|
|
217
|
+
}
|
|
218
|
+
const normalized = path.posix.normalize(relativePath);
|
|
219
|
+
if (normalized !== relativePath || normalized === '..' || normalized.startsWith('../')) {
|
|
220
|
+
throw new HubError('HUB_PATH_DENIED', 'Path traversal or non-canonical path is denied');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let boundary = grant.canonicalRoot;
|
|
224
|
+
if (access === 'write') {
|
|
225
|
+
if (!grant.repoWrite) {
|
|
226
|
+
throw new HubError('HUB_REPO_WRITE_DENIED', 'Approved root does not allow repository writes');
|
|
227
|
+
}
|
|
228
|
+
if (!worktreeId || !registry) {
|
|
229
|
+
throw new HubError('HUB_WRITE_REQUIRES_WORKTREE', 'Writes require a connector-owned worktree');
|
|
230
|
+
}
|
|
231
|
+
const record = registry.get(worktreeId);
|
|
232
|
+
if (!record || record.rootId !== grant.rootId) {
|
|
233
|
+
throw new HubError('HUB_WRITE_REQUIRES_WORKTREE', 'Worktree is not registered for the approved root');
|
|
234
|
+
}
|
|
235
|
+
boundary = await realpath(record.canonicalPath);
|
|
236
|
+
const boundaryInfo = await lstat(boundary);
|
|
237
|
+
const configuredBase = await realpath(grant.worktreeBase);
|
|
238
|
+
if (
|
|
239
|
+
!isContained(configuredBase, boundary) ||
|
|
240
|
+
path.dirname(boundary) !== configuredBase
|
|
241
|
+
) {
|
|
242
|
+
throw new HubError(
|
|
243
|
+
'HUB_WRITE_REQUIRES_WORKTREE',
|
|
244
|
+
'Writes require a direct child of the connector-owned worktree directory'
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const mainGit = await gitDirectory(grant.canonicalRoot);
|
|
249
|
+
const linkedGit = await gitDirectory(boundary);
|
|
250
|
+
if (
|
|
251
|
+
!isContained(path.join(mainGit, 'worktrees'), linkedGit) ||
|
|
252
|
+
linkedGit !== record.gitDirectory ||
|
|
253
|
+
boundary !== record.canonicalPath ||
|
|
254
|
+
boundaryInfo.dev !== record.pathDevice ||
|
|
255
|
+
boundaryInfo.ino !== record.pathInode ||
|
|
256
|
+
record.repositoryIdentity !== grant.repositoryIdentity
|
|
257
|
+
) {
|
|
258
|
+
throw new HubError(
|
|
259
|
+
'HUB_WRITE_REQUIRES_WORKTREE',
|
|
260
|
+
'Write target is not a worktree of the approved repository'
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (error instanceof HubError && error.code === 'HUB_WRITE_REQUIRES_WORKTREE') throw error;
|
|
265
|
+
throw new HubError(
|
|
266
|
+
'HUB_WRITE_REQUIRES_WORKTREE',
|
|
267
|
+
'Write target is not a valid connector-owned worktree',
|
|
268
|
+
{ cause: error }
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
} else if (access !== 'read') {
|
|
272
|
+
throw new HubError('HUB_PATH_DENIED', `Unsupported access mode: ${access}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const candidate = path.resolve(boundary, relativePath);
|
|
276
|
+
if (!isContained(boundary, candidate)) {
|
|
277
|
+
throw new HubError('HUB_ROOT_ESCAPE', 'Target leaves the approved boundary');
|
|
278
|
+
}
|
|
279
|
+
const existing = await nearestExisting(candidate, boundary);
|
|
280
|
+
const canonicalExisting = await realpath(existing);
|
|
281
|
+
if (!isContained(boundary, canonicalExisting)) {
|
|
282
|
+
throw new HubError('HUB_ROOT_ESCAPE', 'Symlink resolves outside the approved boundary');
|
|
283
|
+
}
|
|
284
|
+
await rejectNestedRepository(boundary, existing);
|
|
285
|
+
return candidate;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function validateWorktreeIdentifier(value) {
|
|
289
|
+
if (!SAFE_ID.test(value)) {
|
|
290
|
+
throw new HubError('HUB_INVALID_WORKTREE_ID', 'Worktree identifiers must be opaque');
|
|
291
|
+
}
|
|
292
|
+
return value;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function validateBaseRef(value) {
|
|
296
|
+
if (
|
|
297
|
+
!SAFE_REF.test(value) ||
|
|
298
|
+
value.startsWith('-') ||
|
|
299
|
+
value.includes('..') ||
|
|
300
|
+
value.includes('@{') ||
|
|
301
|
+
value.endsWith('.lock')
|
|
302
|
+
) {
|
|
303
|
+
throw new HubError('HUB_INVALID_GIT_REF', 'Base ref is not safe');
|
|
304
|
+
}
|
|
305
|
+
return value;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function createOwnedWorktree({
|
|
309
|
+
grant,
|
|
310
|
+
deliveryId,
|
|
311
|
+
streamId,
|
|
312
|
+
baseRef,
|
|
313
|
+
registry,
|
|
314
|
+
execute = execFile,
|
|
315
|
+
}) {
|
|
316
|
+
await validateRootGrant(grant);
|
|
317
|
+
if (!grant.repoWrite) {
|
|
318
|
+
throw new HubError('HUB_REPO_WRITE_DENIED', 'Approved root does not allow repository writes');
|
|
319
|
+
}
|
|
320
|
+
if (!registry) throw new HubError('HUB_WORKTREE_REGISTRY_REQUIRED', 'Worktree registry is required');
|
|
321
|
+
validateWorktreeIdentifier(deliveryId);
|
|
322
|
+
validateWorktreeIdentifier(streamId);
|
|
323
|
+
validateBaseRef(baseRef);
|
|
324
|
+
const worktreeBase = grant.worktreeBase;
|
|
325
|
+
await ensureOwnerOnlyDirectory(worktreeBase);
|
|
326
|
+
const name = `${deliveryId}-${streamId}`;
|
|
327
|
+
const destination = path.join(worktreeBase, name);
|
|
328
|
+
if (!isContained(worktreeBase, destination)) {
|
|
329
|
+
throw new HubError('HUB_ROOT_ESCAPE', 'Generated worktree path escaped its owner directory');
|
|
330
|
+
}
|
|
331
|
+
if (await exists(destination)) {
|
|
332
|
+
throw new HubError('HUB_WORKTREE_EXISTS', `Connector worktree already exists: ${name}`);
|
|
333
|
+
}
|
|
334
|
+
const branch = `pulse/${deliveryId}/${streamId}`;
|
|
335
|
+
await execute(
|
|
336
|
+
'git',
|
|
337
|
+
['-c', 'core.hooksPath=/dev/null', 'worktree', 'add', '-b', branch, destination, baseRef],
|
|
338
|
+
{
|
|
339
|
+
cwd: grant.canonicalRoot,
|
|
340
|
+
timeout: 60_000,
|
|
341
|
+
maxBuffer: 1024 * 1024,
|
|
342
|
+
env: {
|
|
343
|
+
PATH: process.env.PATH || '/usr/bin:/bin',
|
|
344
|
+
LC_ALL: 'C',
|
|
345
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
346
|
+
GIT_CONFIG_SYSTEM: '/dev/null',
|
|
347
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
348
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
const canonicalPath = await realpath(destination);
|
|
353
|
+
const pathInfo = await lstat(canonicalPath);
|
|
354
|
+
const linkedGit = await gitDirectory(canonicalPath);
|
|
355
|
+
const mainGit = await gitDirectory(grant.canonicalRoot);
|
|
356
|
+
if (!isContained(path.join(mainGit, 'worktrees'), linkedGit)) {
|
|
357
|
+
throw new HubError('HUB_WORKTREE_INVALID', 'Created worktree is not linked to the approved repository');
|
|
358
|
+
}
|
|
359
|
+
return registry.add({
|
|
360
|
+
worktreeId: name,
|
|
361
|
+
rootId: grant.rootId,
|
|
362
|
+
deliveryId,
|
|
363
|
+
streamId,
|
|
364
|
+
branch,
|
|
365
|
+
canonicalPath,
|
|
366
|
+
pathDevice: pathInfo.dev,
|
|
367
|
+
pathInode: pathInfo.ino,
|
|
368
|
+
gitDirectory: linkedGit,
|
|
369
|
+
repositoryIdentity: grant.repositoryIdentity,
|
|
370
|
+
createdAt: new Date().toISOString(),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DENIED_NAME = /^\.env|secret|token/i;
|
|
5
|
+
const REPO_DOCS = ['AGENTS.md', 'CLAUDE.md'];
|
|
6
|
+
const MAX_DEPTH = 4;
|
|
7
|
+
export const RULES_SUBDIR = path.join('.cursor', 'rules');
|
|
8
|
+
|
|
9
|
+
function unquote(value) {
|
|
10
|
+
const trimmed = value.trim();
|
|
11
|
+
if (/^(["']).*\1$/.test(trimmed)) return trimmed.slice(1, -1);
|
|
12
|
+
return trimmed;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function listValue(value) {
|
|
16
|
+
const trimmed = value.trim();
|
|
17
|
+
const inner = trimmed.startsWith('[') && trimmed.endsWith(']') ? trimmed.slice(1, -1) : trimmed;
|
|
18
|
+
return inner.split(',').map(unquote).filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseRuleFile(text) {
|
|
22
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
23
|
+
const fields = {};
|
|
24
|
+
if (match) {
|
|
25
|
+
let listKey = null;
|
|
26
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
27
|
+
const item = line.match(/^\s+-\s+(.*)$/);
|
|
28
|
+
if (item && listKey) {
|
|
29
|
+
fields[listKey].push(unquote(item[1]));
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const pair = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/);
|
|
33
|
+
if (!pair) continue;
|
|
34
|
+
listKey = pair[2].trim() === '' ? pair[1] : null;
|
|
35
|
+
fields[pair[1]] = listKey ? [] : pair[2];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const globs = fields.globs;
|
|
39
|
+
return {
|
|
40
|
+
description: typeof fields.description === 'string' && fields.description.trim() ? unquote(fields.description) : null,
|
|
41
|
+
alwaysApply: typeof fields.alwaysApply === 'string' && fields.alwaysApply.trim() === 'true',
|
|
42
|
+
globs: Array.isArray(globs) ? globs.filter(Boolean) : typeof globs === 'string' ? listValue(globs) : [],
|
|
43
|
+
body: match ? text.slice(match[0].length) : text,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function canonical(target) {
|
|
48
|
+
try {
|
|
49
|
+
return await realpath(target);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function contained(root, candidate) {
|
|
56
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class PackBuilder {
|
|
60
|
+
constructor({ id, name, source, repo, maxRulesPerPack, maxRuleBytes, skipped }) {
|
|
61
|
+
this.pack = { id, name, source, ...(repo ? { repo } : {}), rules: [] };
|
|
62
|
+
this.maxRulesPerPack = maxRulesPerPack;
|
|
63
|
+
this.maxRuleBytes = maxRuleBytes;
|
|
64
|
+
this.skipped = skipped;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
skip(file, reason) {
|
|
68
|
+
this.skipped.push({ pack: this.pack.id, file, reason });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async add({ file, absolute, boundary, alwaysApply = null }) {
|
|
72
|
+
if (DENIED_NAME.test(path.basename(file))) return this.skip(file, 'denied_name');
|
|
73
|
+
const info = await lstat(absolute);
|
|
74
|
+
let target = absolute;
|
|
75
|
+
if (info.isSymbolicLink()) {
|
|
76
|
+
target = await canonical(absolute);
|
|
77
|
+
if (!target || !contained(boundary, target)) return this.skip(file, 'outside_pack');
|
|
78
|
+
}
|
|
79
|
+
const targetInfo = await stat(target);
|
|
80
|
+
if (!targetInfo.isFile()) return this.skip(file, 'not_a_rule');
|
|
81
|
+
if (targetInfo.size > this.maxRuleBytes) return this.skip(file, 'too_large');
|
|
82
|
+
if (this.pack.rules.length >= this.maxRulesPerPack) return this.skip(file, 'pack_full');
|
|
83
|
+
const parsed = parseRuleFile(await readFile(target, 'utf8'));
|
|
84
|
+
this.pack.rules.push({
|
|
85
|
+
name: file.endsWith('.mdc') ? path.basename(file, '.mdc') : path.basename(file),
|
|
86
|
+
file,
|
|
87
|
+
description: parsed.description,
|
|
88
|
+
alwaysApply: alwaysApply ?? parsed.alwaysApply,
|
|
89
|
+
globs: parsed.globs,
|
|
90
|
+
body: parsed.body,
|
|
91
|
+
});
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async addRulesDirectory(rulesDir) {
|
|
96
|
+
const boundary = await canonical(rulesDir);
|
|
97
|
+
if (!boundary) return;
|
|
98
|
+
const walk = async (directory, relative, depth) => {
|
|
99
|
+
const entries = (await readdir(directory, { withFileTypes: true })).sort((left, right) =>
|
|
100
|
+
left.name < right.name ? -1 : left.name > right.name ? 1 : 0
|
|
101
|
+
);
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
const file = relative ? `${relative}/${entry.name}` : entry.name;
|
|
104
|
+
const absolute = path.join(directory, entry.name);
|
|
105
|
+
if (entry.isDirectory()) {
|
|
106
|
+
if (DENIED_NAME.test(entry.name)) this.skip(file, 'denied_name');
|
|
107
|
+
else if (depth < MAX_DEPTH) await walk(absolute, file, depth + 1);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (DENIED_NAME.test(entry.name)) this.skip(file, 'denied_name');
|
|
111
|
+
else if (!entry.name.endsWith('.mdc')) this.skip(file, 'not_a_rule');
|
|
112
|
+
else await this.add({ file, absolute, boundary });
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
await walk(boundary, '', 0);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function readRulePacks({
|
|
120
|
+
workspace,
|
|
121
|
+
home,
|
|
122
|
+
repos = [],
|
|
123
|
+
maxRulesPerPack = 100,
|
|
124
|
+
maxRuleBytes = 64 * 1024,
|
|
125
|
+
rulesSubdir = RULES_SUBDIR,
|
|
126
|
+
}) {
|
|
127
|
+
const skipped = [];
|
|
128
|
+
const packs = [];
|
|
129
|
+
const options = { maxRulesPerPack, maxRuleBytes, skipped };
|
|
130
|
+
const finish = (builder) => {
|
|
131
|
+
if (builder.pack.rules.length > 0) packs.push(builder.pack);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (workspace) {
|
|
135
|
+
const builder = new PackBuilder({ id: 'workspace', name: 'Workspace rules', source: 'workspace', ...options });
|
|
136
|
+
await builder.addRulesDirectory(path.join(workspace, rulesSubdir));
|
|
137
|
+
finish(builder);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const seen = new Set([await canonical(workspace || '')]);
|
|
141
|
+
const repoPacks = [];
|
|
142
|
+
for (const repo of repos) {
|
|
143
|
+
const root = await canonical(repo.root);
|
|
144
|
+
if (!root || seen.has(root)) continue;
|
|
145
|
+
seen.add(root);
|
|
146
|
+
const builder = new PackBuilder({ id: `repo:${repo.name}`, name: repo.name, source: 'repo', repo: repo.name, ...options });
|
|
147
|
+
await builder.addRulesDirectory(path.join(root, rulesSubdir));
|
|
148
|
+
for (const doc of REPO_DOCS) {
|
|
149
|
+
const absolute = path.join(root, doc);
|
|
150
|
+
if (await canonical(absolute)) await builder.add({ file: doc, absolute, boundary: root, alwaysApply: true });
|
|
151
|
+
}
|
|
152
|
+
repoPacks.push(builder);
|
|
153
|
+
}
|
|
154
|
+
repoPacks.sort((left, right) => (left.pack.id < right.pack.id ? -1 : 1)).forEach(finish);
|
|
155
|
+
|
|
156
|
+
if (home) {
|
|
157
|
+
const builder = new PackBuilder({ id: 'user', name: 'User rules', source: 'user', ...options });
|
|
158
|
+
await builder.addRulesDirectory(path.join(home, rulesSubdir));
|
|
159
|
+
finish(builder);
|
|
160
|
+
}
|
|
161
|
+
return { packs, skipped };
|
|
162
|
+
}
|