vigthoria-cli 1.13.18 → 1.13.22
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/completions/_vigthoria +49 -0
- package/completions/vigthoria.bash +6 -0
- package/completions/vigthoria.fish +41 -0
- package/dist/commands/auth.d.ts +1 -0
- package/dist/commands/auth.js +84 -135
- package/dist/commands/background.d.ts +0 -2
- package/dist/commands/background.js +26 -34
- package/dist/commands/bridge.js +45 -41
- package/dist/commands/cancel.d.ts +1 -0
- package/dist/commands/cancel.js +22 -16
- package/dist/commands/chat.d.ts +5 -42
- package/dist/commands/chat.js +462 -1122
- package/dist/commands/coding-registration.d.ts +4 -0
- package/dist/commands/coding-registration.js +24 -0
- package/dist/commands/config.js +32 -42
- package/dist/commands/conversation-registration.d.ts +4 -0
- package/dist/commands/conversation-registration.js +122 -0
- package/dist/commands/deploy.d.ts +8 -2
- package/dist/commands/deploy.js +92 -90
- package/dist/commands/device.d.ts +1 -2
- package/dist/commands/device.js +37 -21
- package/dist/commands/doctor-registration.d.ts +4 -0
- package/dist/commands/doctor-registration.js +200 -0
- package/dist/commands/edit.js +22 -33
- package/dist/commands/explain.js +7 -10
- package/dist/commands/fork.d.ts +1 -2
- package/dist/commands/fork.js +72 -22
- package/dist/commands/game.d.ts +1 -1
- package/dist/commands/game.js +17 -6
- package/dist/commands/generate.js +7 -9
- package/dist/commands/history.js +11 -7
- package/dist/commands/hub.d.ts +2 -2
- package/dist/commands/hub.js +34 -35
- package/dist/commands/legion.d.ts +0 -1
- package/dist/commands/legion.js +62 -79
- package/dist/commands/music.d.ts +1 -2
- package/dist/commands/music.js +62 -27
- package/dist/commands/platform-registration.d.ts +4 -0
- package/dist/commands/platform-registration.js +45 -0
- package/dist/commands/preview.d.ts +0 -1
- package/dist/commands/preview.js +82 -35
- package/dist/commands/product-run-registration.d.ts +4 -0
- package/dist/commands/product-run-registration.js +490 -0
- package/dist/commands/replay.d.ts +1 -2
- package/dist/commands/replay.js +15 -11
- package/dist/commands/repo.d.ts +14 -7
- package/dist/commands/repo.js +245 -196
- package/dist/commands/review.js +9 -12
- package/dist/commands/security.d.ts +1 -2
- package/dist/commands/security.js +12 -6
- package/dist/commands/update-registration.d.ts +3 -0
- package/dist/commands/update-registration.js +338 -0
- package/dist/commands/v4-registration.d.ts +4 -0
- package/dist/commands/v4-registration.js +24 -0
- package/dist/commands/v4.d.ts +1 -1
- package/dist/commands/v4.js +46 -8
- package/dist/commands/wallet.d.ts +1 -3
- package/dist/commands/wallet.js +18 -45
- package/dist/commands/workflow.d.ts +2 -0
- package/dist/commands/workflow.js +52 -39
- package/dist/index.js +122 -1498
- package/dist/utils/agent-stream-state.d.ts +27 -0
- package/dist/utils/agent-stream-state.js +93 -0
- package/dist/utils/agentRoute.d.ts +2 -1
- package/dist/utils/agentRoute.js +22 -9
- package/dist/utils/agentRunOutcome.d.ts +16 -0
- package/dist/utils/agentRunOutcome.js +37 -2
- package/dist/utils/api-client-factory.d.ts +8 -0
- package/dist/utils/api-client-factory.js +8 -0
- package/dist/utils/api.d.ts +45 -269
- package/dist/utils/api.js +664 -3210
- package/dist/utils/auth-session.d.ts +41 -0
- package/dist/utils/auth-session.js +224 -0
- package/dist/utils/brain-hub-client.d.ts +0 -4
- package/dist/utils/brain-hub-client.js +6 -3
- package/dist/utils/bridge-client.js +2 -0
- package/dist/utils/capability-health-service.d.ts +48 -0
- package/dist/utils/capability-health-service.js +64 -0
- package/dist/utils/chat-prompt-policy.d.ts +18 -0
- package/dist/utils/chat-prompt-policy.js +61 -0
- package/dist/utils/chat-tool-call-parser.d.ts +11 -0
- package/dist/utils/chat-tool-call-parser.js +71 -0
- package/dist/utils/cli-state.d.ts +22 -4
- package/dist/utils/cli-state.js +105 -47
- package/dist/utils/code-operations-service.d.ts +104 -0
- package/dist/utils/code-operations-service.js +837 -0
- package/dist/utils/codebase-indexer.d.ts +1 -0
- package/dist/utils/codebase-indexer.js +28 -8
- package/dist/utils/command-contract.d.ts +61 -0
- package/dist/utils/command-contract.js +149 -0
- package/dist/utils/command-policy.d.ts +10 -0
- package/dist/utils/command-policy.js +54 -0
- package/dist/utils/config.d.ts +13 -5
- package/dist/utils/config.js +227 -99
- package/dist/utils/context-ranker.js +11 -2
- package/dist/utils/context-state.d.ts +39 -0
- package/dist/utils/context-state.js +52 -0
- package/dist/utils/deckEvents.d.ts +0 -5
- package/dist/utils/deckEvents.js +3 -2
- package/dist/utils/desktop-bridge-client.js +2 -0
- package/dist/utils/direct-output-policy.d.ts +3 -0
- package/dist/utils/direct-output-policy.js +76 -0
- package/dist/utils/durable-json.d.ts +57 -0
- package/dist/utils/durable-json.js +319 -0
- package/dist/utils/fastAgentRouter.d.ts +6 -7
- package/dist/utils/fastAgentRouter.js +21 -30
- package/dist/utils/files.js +21 -13
- package/dist/utils/frontend-preview-service.d.ts +76 -0
- package/dist/utils/frontend-preview-service.js +482 -0
- package/dist/utils/goaEvents.d.ts +0 -4
- package/dist/utils/goaEvents.js +5 -2
- package/dist/utils/logger.d.ts +2 -1
- package/dist/utils/logger.js +27 -20
- package/dist/utils/mcp-context-client.d.ts +24 -0
- package/dist/utils/mcp-context-client.js +58 -0
- package/dist/utils/model-governance.d.ts +18 -0
- package/dist/utils/model-governance.js +75 -0
- package/dist/utils/model-transport-service.d.ts +44 -0
- package/dist/utils/model-transport-service.js +111 -0
- package/dist/utils/mutation-journal.d.ts +42 -0
- package/dist/utils/mutation-journal.js +387 -0
- package/dist/utils/network-policy.d.ts +24 -0
- package/dist/utils/network-policy.js +202 -0
- package/dist/utils/operator-client.d.ts +45 -0
- package/dist/utils/operator-client.js +159 -0
- package/dist/utils/post-write-validator.js +7 -9
- package/dist/utils/preview-screenshot-adapter.d.ts +13 -0
- package/dist/utils/preview-screenshot-adapter.js +36 -0
- package/dist/utils/process-policy.d.ts +23 -0
- package/dist/utils/process-policy.js +284 -0
- package/dist/utils/project-memory.d.ts +13 -3
- package/dist/utils/project-memory.js +70 -17
- package/dist/utils/prompt-workspace-resolver.d.ts +14 -0
- package/dist/utils/prompt-workspace-resolver.js +64 -0
- package/dist/utils/release-install.d.ts +11 -0
- package/dist/utils/release-install.js +155 -0
- package/dist/utils/release-policy.d.ts +64 -0
- package/dist/utils/release-policy.js +293 -0
- package/dist/utils/requestIntent.js +22 -3
- package/dist/utils/runtime-capability.d.ts +8 -0
- package/dist/utils/runtime-capability.js +16 -0
- package/dist/utils/secret-policy.d.ts +20 -0
- package/dist/utils/secret-policy.js +160 -0
- package/dist/utils/session.d.ts +34 -2
- package/dist/utils/session.js +354 -110
- package/dist/utils/tool-approval-service.d.ts +17 -0
- package/dist/utils/tool-approval-service.js +78 -0
- package/dist/utils/tool-capability-providers.d.ts +57 -0
- package/dist/utils/tool-capability-providers.js +57 -0
- package/dist/utils/tools.d.ts +2 -23
- package/dist/utils/tools.js +216 -508
- package/dist/utils/update-policy.d.ts +18 -0
- package/dist/utils/update-policy.js +137 -0
- package/dist/utils/v3-agent-client.d.ts +40 -0
- package/dist/utils/v3-agent-client.js +165 -0
- package/dist/utils/v3-workspace-service.d.ts +28 -0
- package/dist/utils/v3-workspace-service.js +279 -0
- package/dist/utils/vigflow-client.d.ts +95 -0
- package/dist/utils/vigflow-client.js +105 -0
- package/dist/utils/workspace-boundary.d.ts +45 -0
- package/dist/utils/workspace-boundary.js +377 -0
- package/dist/utils/workspace-brain-service.d.ts +2 -0
- package/dist/utils/workspace-brain-service.js +11 -1
- package/dist/utils/workspace-cache.js +37 -15
- package/dist/utils/workspace-stream.js +35 -10
- package/install.ps1 +86 -143
- package/install.sh +76 -81
- package/package.json +44 -9
- package/release-policy.json +43 -0
- package/scripts/release/LOCAL_MACHINE_USER_VERIFICATION.md +1 -1
- package/scripts/release/generate-completions.mjs +24 -0
- package/scripts/release/install-release.mjs +10 -0
- package/scripts/release/publish-cli-release.mjs +116 -0
- package/scripts/release/publish-cli-release.sh +2 -70
- package/scripts/release/resolve-release-manifest.mjs +26 -0
- package/scripts/release/test-balanced-model-live.sh +42 -0
- package/scripts/release/validate-live-service-gates.sh +90 -0
- package/scripts/release/validate-no-go-gates.sh +135 -172
- package/scripts/release/verify-runtime-consistency.mjs +4 -1
- package/dist/commands/index.d.ts +0 -12
- package/dist/commands/index.js +0 -194
- package/dist/commands/update.d.ts +0 -9
- package/dist/commands/update.js +0 -301
- package/dist/utils/templateInstantPath.d.ts +0 -18
- package/dist/utils/templateInstantPath.js +0 -228
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type AxiosRequestConfig } from 'axios';
|
|
2
|
+
export interface VigFlowTemplateSummary {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
category: string;
|
|
7
|
+
icon?: string;
|
|
8
|
+
tags: string[];
|
|
9
|
+
isBuiltin: boolean;
|
|
10
|
+
source: string;
|
|
11
|
+
createdAt?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface VigFlowWorkflowSummary {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
isActive: boolean;
|
|
18
|
+
version: string;
|
|
19
|
+
tags: string[];
|
|
20
|
+
nodeCount: number;
|
|
21
|
+
executionCount: number;
|
|
22
|
+
lastExecutedAt?: string;
|
|
23
|
+
createdAt?: string;
|
|
24
|
+
updatedAt?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface VigFlowWorkflowCreation {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
fromTemplate?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface ResolvedVigFlowWorkflowTarget {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
selector: string;
|
|
35
|
+
matchedBy: 'id' | 'name' | 'search';
|
|
36
|
+
}
|
|
37
|
+
export interface VigFlowExecutionResult {
|
|
38
|
+
executionId: string;
|
|
39
|
+
status: string;
|
|
40
|
+
result?: unknown;
|
|
41
|
+
error?: string;
|
|
42
|
+
nodesExecuted?: number;
|
|
43
|
+
}
|
|
44
|
+
export interface VigFlowExecutionStatus {
|
|
45
|
+
id: string;
|
|
46
|
+
workflowId: string;
|
|
47
|
+
status: string;
|
|
48
|
+
startedAt?: string;
|
|
49
|
+
completedAt?: string;
|
|
50
|
+
nodesExecuted?: number;
|
|
51
|
+
totalNodes?: number;
|
|
52
|
+
result?: unknown;
|
|
53
|
+
error?: string;
|
|
54
|
+
progress?: Record<string, unknown>;
|
|
55
|
+
duration?: number;
|
|
56
|
+
}
|
|
57
|
+
export interface VigFlowTransport {
|
|
58
|
+
get<T>(url: string, config?: AxiosRequestConfig): Promise<{
|
|
59
|
+
data: T;
|
|
60
|
+
}>;
|
|
61
|
+
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<{
|
|
62
|
+
data: T;
|
|
63
|
+
}>;
|
|
64
|
+
}
|
|
65
|
+
export interface VigFlowClientDependencies {
|
|
66
|
+
getBaseUrls(): string[];
|
|
67
|
+
getAccessToken(): string | null;
|
|
68
|
+
debug(message: string, detail?: string): void;
|
|
69
|
+
transport?: VigFlowTransport;
|
|
70
|
+
}
|
|
71
|
+
export declare class VigFlowClient {
|
|
72
|
+
private readonly dependencies;
|
|
73
|
+
private readonly tokens;
|
|
74
|
+
private readonly transport;
|
|
75
|
+
constructor(dependencies: VigFlowClientDependencies);
|
|
76
|
+
clearCredentials(): void;
|
|
77
|
+
private accessToken;
|
|
78
|
+
private withBackend;
|
|
79
|
+
private endpoint;
|
|
80
|
+
listTemplates(options?: {
|
|
81
|
+
category?: string;
|
|
82
|
+
search?: string;
|
|
83
|
+
}): Promise<VigFlowTemplateSummary[]>;
|
|
84
|
+
listWorkflows(): Promise<VigFlowWorkflowSummary[]>;
|
|
85
|
+
resolveWorkflow(selector: string): Promise<ResolvedVigFlowWorkflowTarget>;
|
|
86
|
+
useTemplate(templateId: string, options?: {
|
|
87
|
+
name?: string;
|
|
88
|
+
variables?: Record<string, unknown>;
|
|
89
|
+
}): Promise<VigFlowWorkflowCreation>;
|
|
90
|
+
runWorkflow(workflowId: string, options?: {
|
|
91
|
+
data?: Record<string, unknown>;
|
|
92
|
+
executionOptions?: Record<string, unknown>;
|
|
93
|
+
}): Promise<VigFlowExecutionResult>;
|
|
94
|
+
executionStatus(executionId: string): Promise<VigFlowExecutionStatus>;
|
|
95
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { scanOutboundContext } from './secret-policy.js';
|
|
3
|
+
export class VigFlowClient {
|
|
4
|
+
dependencies;
|
|
5
|
+
tokens = new Map();
|
|
6
|
+
transport;
|
|
7
|
+
constructor(dependencies) {
|
|
8
|
+
this.dependencies = dependencies;
|
|
9
|
+
this.transport = dependencies.transport || axios;
|
|
10
|
+
}
|
|
11
|
+
clearCredentials() { this.tokens.clear(); }
|
|
12
|
+
async accessToken(baseUrl) {
|
|
13
|
+
const cached = this.tokens.get(baseUrl);
|
|
14
|
+
if (cached)
|
|
15
|
+
return cached;
|
|
16
|
+
const authToken = this.dependencies.getAccessToken();
|
|
17
|
+
if (!authToken)
|
|
18
|
+
throw new Error('Not authenticated. Run vigthoria login first.');
|
|
19
|
+
const response = await this.transport.post(`${baseUrl}/api/auth/sso`, { token: authToken }, {
|
|
20
|
+
headers: { 'Content-Type': 'application/json' }, timeout: 30_000,
|
|
21
|
+
});
|
|
22
|
+
const token = String(response.data.token || '').trim();
|
|
23
|
+
if (!token)
|
|
24
|
+
throw new Error('VigFlow SSO response did not include a token.');
|
|
25
|
+
this.tokens.set(baseUrl, token);
|
|
26
|
+
return token;
|
|
27
|
+
}
|
|
28
|
+
async withBackend(operation, action) {
|
|
29
|
+
for (const baseUrl of this.dependencies.getBaseUrls()) {
|
|
30
|
+
try {
|
|
31
|
+
const token = await this.accessToken(baseUrl);
|
|
32
|
+
return await action(baseUrl, { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` });
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
this.dependencies.debug(`VigFlow ${operation} via ${baseUrl} failed:`, error instanceof Error ? error.message : String(error));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`No VigFlow backend available for ${operation}. The workflow service is not deployed or not reachable.`);
|
|
39
|
+
}
|
|
40
|
+
endpoint(baseUrl, subPath) {
|
|
41
|
+
return /\/api\/vigflow\/?$/i.test(baseUrl) ? `${baseUrl.replace(/\/$/, '')}${subPath}` : `${baseUrl}/api${subPath}`;
|
|
42
|
+
}
|
|
43
|
+
listTemplates(options = {}) {
|
|
44
|
+
return this.withBackend('list templates', async (baseUrl, headers) => {
|
|
45
|
+
const query = new URLSearchParams();
|
|
46
|
+
if (options.category)
|
|
47
|
+
query.set('category', options.category);
|
|
48
|
+
if (options.search)
|
|
49
|
+
query.set('search', options.search);
|
|
50
|
+
const response = await this.transport.get(`${this.endpoint(baseUrl, '/templates')}${query.size ? `?${query}` : ''}`, { headers, timeout: 30_000 });
|
|
51
|
+
return Array.isArray(response.data.templates) ? response.data.templates : [];
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
listWorkflows() {
|
|
55
|
+
return this.withBackend('list workflows', async (baseUrl, headers) => {
|
|
56
|
+
const response = await this.transport.get(this.endpoint(baseUrl, '/workflows'), { headers, timeout: 30_000 });
|
|
57
|
+
return Array.isArray(response.data.workflows) ? response.data.workflows : [];
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async resolveWorkflow(selector) {
|
|
61
|
+
const normalized = String(selector || '').trim();
|
|
62
|
+
if (!normalized)
|
|
63
|
+
throw new Error('Workflow selector is required. Provide a workflow id or name.');
|
|
64
|
+
const workflows = await this.listWorkflows();
|
|
65
|
+
const byId = workflows.find((workflow) => workflow.id === normalized);
|
|
66
|
+
if (byId)
|
|
67
|
+
return { id: byId.id, name: byId.name, selector: normalized, matchedBy: 'id' };
|
|
68
|
+
const lower = normalized.toLowerCase();
|
|
69
|
+
const exact = workflows.filter((workflow) => String(workflow.name || '').trim().toLowerCase() === lower);
|
|
70
|
+
if (exact.length === 1)
|
|
71
|
+
return { id: exact[0].id, name: exact[0].name, selector: normalized, matchedBy: 'name' };
|
|
72
|
+
if (exact.length > 1)
|
|
73
|
+
throw new Error(`Multiple workflows matched the name "${normalized}". Use the workflow id instead.`);
|
|
74
|
+
const partial = workflows.filter((workflow) => String(workflow.name || '').toLowerCase().includes(lower));
|
|
75
|
+
if (partial.length === 1)
|
|
76
|
+
return { id: partial[0].id, name: partial[0].name, selector: normalized, matchedBy: 'search' };
|
|
77
|
+
if (partial.length > 1)
|
|
78
|
+
throw new Error(`Multiple workflows partially matched "${normalized}". Use a full workflow name or id.`);
|
|
79
|
+
throw new Error(`No VigFlow workflow matched "${normalized}".`);
|
|
80
|
+
}
|
|
81
|
+
useTemplate(templateId, options = {}) {
|
|
82
|
+
return this.withBackend('use template', async (baseUrl, headers) => {
|
|
83
|
+
const response = await this.transport.post(this.endpoint(baseUrl, `/templates/${encodeURIComponent(templateId)}/use`), scanOutboundContext({ name: options.name, variables: options.variables || {} }).value, { headers, timeout: 30_000 });
|
|
84
|
+
if (!response.data.workflow?.id)
|
|
85
|
+
throw new Error('VigFlow use-template response did not include a workflow id.');
|
|
86
|
+
return response.data.workflow;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
runWorkflow(workflowId, options = {}) {
|
|
90
|
+
return this.withBackend('run workflow', async (baseUrl, headers) => {
|
|
91
|
+
const response = await this.transport.post(this.endpoint(baseUrl, `/executions/run/${encodeURIComponent(workflowId)}`), scanOutboundContext({ data: options.data || {}, options: options.executionOptions || {} }).value, { headers, timeout: 60_000 });
|
|
92
|
+
if (!response.data.executionId)
|
|
93
|
+
throw new Error('VigFlow run response did not include an execution id.');
|
|
94
|
+
return response.data;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
executionStatus(executionId) {
|
|
98
|
+
return this.withBackend('execution status', async (baseUrl, headers) => {
|
|
99
|
+
const response = await this.transport.get(this.endpoint(baseUrl, `/executions/${encodeURIComponent(executionId)}`), { headers, timeout: 30_000 });
|
|
100
|
+
if (!response.data.execution?.id)
|
|
101
|
+
throw new Error('VigFlow execution response did not include execution details.');
|
|
102
|
+
return response.data.execution;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export declare class WorkspaceBoundaryError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
constructor(message: string, code?: string);
|
|
4
|
+
}
|
|
5
|
+
export interface WorkspacePathOptions {
|
|
6
|
+
allowMissing?: boolean;
|
|
7
|
+
allowRoot?: boolean;
|
|
8
|
+
allowAbsoluteInsideRoot?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface ZipInspectionLimits {
|
|
11
|
+
maxEntries?: number;
|
|
12
|
+
maxCompressedBytes?: number;
|
|
13
|
+
maxEntryUncompressedBytes?: number;
|
|
14
|
+
maxTotalUncompressedBytes?: number;
|
|
15
|
+
maxCompressionRatio?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ZipEntryInspection {
|
|
18
|
+
path: string;
|
|
19
|
+
compressedBytes: number;
|
|
20
|
+
uncompressedBytes: number;
|
|
21
|
+
directory: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Validate a path supplied by an archive, service, or model as strictly relative. */
|
|
24
|
+
export declare function assertSafeRelativePath(value: string): string;
|
|
25
|
+
/** Resolve an untrusted relative path beneath a real workspace root. */
|
|
26
|
+
export declare function resolveWorkspacePath(rootPath: string, inputPath: string, options?: WorkspacePathOptions): string;
|
|
27
|
+
export declare function assertContainedAbsolutePath(rootPath: string, candidatePath: string, options?: Omit<WorkspacePathOptions, 'allowAbsoluteInsideRoot'>): string;
|
|
28
|
+
/** Reject a destination whose existing path components include a link. */
|
|
29
|
+
export declare function assertSafeDestinationPath(candidatePath: string): string;
|
|
30
|
+
/** Inspect ZIP metadata before any external extractor sees the archive. */
|
|
31
|
+
export declare function inspectZipArchive(buffer: Buffer, limits?: ZipInspectionLimits): ZipEntryInspection[];
|
|
32
|
+
/** Verify the extractor did not create links, devices, or paths outside destination. */
|
|
33
|
+
export declare function validateExtractedWorkspace(rootPath: string, options?: {
|
|
34
|
+
ignoreNames?: Iterable<string>;
|
|
35
|
+
}): void;
|
|
36
|
+
/**
|
|
37
|
+
* Merge an already extracted, validated staging tree into a destination.
|
|
38
|
+
*
|
|
39
|
+
* External archive tools must never receive the user destination directly:
|
|
40
|
+
* they may follow links that appear safe in ZIP metadata. This routine rejects
|
|
41
|
+
* links in both trees, preflights every destination component, and installs
|
|
42
|
+
* files through a temporary sibling followed by rename so a final-component
|
|
43
|
+
* link is replaced rather than followed.
|
|
44
|
+
*/
|
|
45
|
+
export declare function mergeValidatedWorkspace(sourcePath: string, destinationPath: string): void;
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { inflateRawSync } from 'node:zlib';
|
|
5
|
+
export class WorkspaceBoundaryError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(message, code = 'WORKSPACE_BOUNDARY_VIOLATION') {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = 'WorkspaceBoundaryError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const DEFAULT_ZIP_LIMITS = {
|
|
14
|
+
maxEntries: 10_000,
|
|
15
|
+
maxCompressedBytes: 128 * 1024 * 1024,
|
|
16
|
+
maxEntryUncompressedBytes: 64 * 1024 * 1024,
|
|
17
|
+
maxTotalUncompressedBytes: 256 * 1024 * 1024,
|
|
18
|
+
maxCompressionRatio: 100,
|
|
19
|
+
};
|
|
20
|
+
function isWithin(root, candidate) {
|
|
21
|
+
const relative = path.relative(root, candidate);
|
|
22
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
23
|
+
}
|
|
24
|
+
function rejectWindowsNamespace(value) {
|
|
25
|
+
const normalized = String(value || '').replace(/\//g, '\\');
|
|
26
|
+
if (/^[\\]{2}/.test(normalized)
|
|
27
|
+
|| /^[a-zA-Z]:/.test(normalized)
|
|
28
|
+
|| /^[\\]{2}[?.][\\]/.test(normalized)
|
|
29
|
+
|| /^GLOBALROOT[\\]/i.test(normalized)) {
|
|
30
|
+
throw new WorkspaceBoundaryError(`Windows absolute, UNC, and device paths are not allowed: ${value}`, 'UNSAFE_WINDOWS_PATH');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Validate a path supplied by an archive, service, or model as strictly relative. */
|
|
34
|
+
export function assertSafeRelativePath(value) {
|
|
35
|
+
const input = String(value || '');
|
|
36
|
+
if (!input || /[\0\r\n]/.test(input)) {
|
|
37
|
+
throw new WorkspaceBoundaryError('Path is empty or contains control characters', 'UNSAFE_RELATIVE_PATH');
|
|
38
|
+
}
|
|
39
|
+
rejectWindowsNamespace(input);
|
|
40
|
+
if (input.startsWith('/') || path.posix.isAbsolute(input)) {
|
|
41
|
+
throw new WorkspaceBoundaryError(`Absolute paths are not allowed: ${value}`, 'ABSOLUTE_PATH_REJECTED');
|
|
42
|
+
}
|
|
43
|
+
const slashPath = input.replace(/\\/g, '/');
|
|
44
|
+
const segments = slashPath.split('/');
|
|
45
|
+
if (segments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
|
46
|
+
throw new WorkspaceBoundaryError(`Traversal or empty path segment is not allowed: ${value}`, 'PATH_TRAVERSAL');
|
|
47
|
+
}
|
|
48
|
+
if (segments.some((segment) => segment.includes(':'))) {
|
|
49
|
+
throw new WorkspaceBoundaryError(`Alternate streams and drive-qualified paths are not allowed: ${value}`, 'UNSAFE_WINDOWS_PATH');
|
|
50
|
+
}
|
|
51
|
+
if (segments.some((segment) => /[. ]$/.test(segment) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(segment))) {
|
|
52
|
+
throw new WorkspaceBoundaryError(`Windows device or normalization-sensitive path is not allowed: ${value}`, 'UNSAFE_WINDOWS_PATH');
|
|
53
|
+
}
|
|
54
|
+
return segments.join('/');
|
|
55
|
+
}
|
|
56
|
+
function resolveExistingAncestor(candidate) {
|
|
57
|
+
let current = candidate;
|
|
58
|
+
while (!fs.existsSync(current)) {
|
|
59
|
+
const parent = path.dirname(current);
|
|
60
|
+
if (parent === current)
|
|
61
|
+
break;
|
|
62
|
+
current = parent;
|
|
63
|
+
}
|
|
64
|
+
return current;
|
|
65
|
+
}
|
|
66
|
+
function assertNoLinkComponents(rootReal, candidate) {
|
|
67
|
+
const relative = path.relative(rootReal, candidate);
|
|
68
|
+
if (!relative)
|
|
69
|
+
return;
|
|
70
|
+
let current = rootReal;
|
|
71
|
+
for (const segment of relative.split(path.sep)) {
|
|
72
|
+
current = path.join(current, segment);
|
|
73
|
+
if (!fs.existsSync(current))
|
|
74
|
+
break;
|
|
75
|
+
const stat = fs.lstatSync(current);
|
|
76
|
+
if (stat.isSymbolicLink()) {
|
|
77
|
+
throw new WorkspaceBoundaryError(`Symbolic link or junction boundary is not allowed: ${current}`, 'WORKSPACE_LINK_ESCAPE');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Resolve an untrusted relative path beneath a real workspace root. */
|
|
82
|
+
export function resolveWorkspacePath(rootPath, inputPath, options = {}) {
|
|
83
|
+
if (!rootPath)
|
|
84
|
+
throw new WorkspaceBoundaryError('Workspace root is required');
|
|
85
|
+
const rootReal = fs.realpathSync(path.resolve(rootPath));
|
|
86
|
+
const raw = String(inputPath || '');
|
|
87
|
+
let candidate;
|
|
88
|
+
if (path.isAbsolute(raw)) {
|
|
89
|
+
if (!options.allowAbsoluteInsideRoot) {
|
|
90
|
+
throw new WorkspaceBoundaryError(`Absolute paths are not allowed: ${raw}`, 'ABSOLUTE_PATH_REJECTED');
|
|
91
|
+
}
|
|
92
|
+
const windowsAbsolute = raw.replace(/\//g, '\\');
|
|
93
|
+
if (/^[\\]{2}/.test(windowsAbsolute) || /^[\\]{2}[?.][\\]/.test(windowsAbsolute)) {
|
|
94
|
+
throw new WorkspaceBoundaryError(`UNC and device paths are not allowed: ${raw}`, 'UNSAFE_WINDOWS_PATH');
|
|
95
|
+
}
|
|
96
|
+
candidate = path.resolve(raw);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const safeRelative = assertSafeRelativePath(raw);
|
|
100
|
+
candidate = path.resolve(rootReal, ...safeRelative.split('/'));
|
|
101
|
+
}
|
|
102
|
+
if (!isWithin(rootReal, candidate) || (!options.allowRoot && candidate === rootReal)) {
|
|
103
|
+
throw new WorkspaceBoundaryError(`Path escapes workspace: ${inputPath}`, 'WORKSPACE_ESCAPE');
|
|
104
|
+
}
|
|
105
|
+
assertNoLinkComponents(rootReal, candidate);
|
|
106
|
+
if (fs.existsSync(candidate)) {
|
|
107
|
+
const candidateReal = fs.realpathSync(candidate);
|
|
108
|
+
if (!isWithin(rootReal, candidateReal)) {
|
|
109
|
+
throw new WorkspaceBoundaryError(`Resolved path escapes workspace: ${inputPath}`, 'WORKSPACE_LINK_ESCAPE');
|
|
110
|
+
}
|
|
111
|
+
return candidateReal;
|
|
112
|
+
}
|
|
113
|
+
if (!options.allowMissing) {
|
|
114
|
+
throw new WorkspaceBoundaryError(`Workspace path does not exist: ${inputPath}`, 'WORKSPACE_PATH_MISSING');
|
|
115
|
+
}
|
|
116
|
+
const ancestorReal = fs.realpathSync(resolveExistingAncestor(candidate));
|
|
117
|
+
if (!isWithin(rootReal, ancestorReal)) {
|
|
118
|
+
throw new WorkspaceBoundaryError(`Parent path escapes workspace: ${inputPath}`, 'WORKSPACE_LINK_ESCAPE');
|
|
119
|
+
}
|
|
120
|
+
return candidate;
|
|
121
|
+
}
|
|
122
|
+
export function assertContainedAbsolutePath(rootPath, candidatePath, options = {}) {
|
|
123
|
+
return resolveWorkspacePath(rootPath, candidatePath, { ...options, allowAbsoluteInsideRoot: true });
|
|
124
|
+
}
|
|
125
|
+
/** Reject a destination whose existing path components include a link. */
|
|
126
|
+
export function assertSafeDestinationPath(candidatePath) {
|
|
127
|
+
const absolute = path.resolve(candidatePath);
|
|
128
|
+
const parsed = path.parse(absolute);
|
|
129
|
+
let current = parsed.root;
|
|
130
|
+
for (const segment of absolute.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
|
|
131
|
+
current = path.join(current, segment);
|
|
132
|
+
if (!fs.existsSync(current))
|
|
133
|
+
break;
|
|
134
|
+
const stat = fs.lstatSync(current);
|
|
135
|
+
if (stat.isSymbolicLink()) {
|
|
136
|
+
throw new WorkspaceBoundaryError(`Destination contains a symbolic link or junction: ${current}`, 'WORKSPACE_LINK_ESCAPE');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return absolute;
|
|
140
|
+
}
|
|
141
|
+
function decodeZipName(buffer, offset, length, utf8) {
|
|
142
|
+
const bytes = buffer.subarray(offset, offset + length);
|
|
143
|
+
// CP437 non-ASCII names are deliberately rejected. Ambiguous decoding can
|
|
144
|
+
// make validator and extractor disagree about the destination path.
|
|
145
|
+
if (!utf8 && bytes.some((byte) => byte > 0x7f)) {
|
|
146
|
+
throw new WorkspaceBoundaryError('ZIP entry name must be UTF-8 or ASCII', 'ZIP_AMBIGUOUS_NAME');
|
|
147
|
+
}
|
|
148
|
+
const name = bytes.toString('utf8');
|
|
149
|
+
if (Buffer.from(name, 'utf8').compare(bytes) !== 0 && utf8) {
|
|
150
|
+
throw new WorkspaceBoundaryError('ZIP entry contains invalid UTF-8', 'ZIP_AMBIGUOUS_NAME');
|
|
151
|
+
}
|
|
152
|
+
return name;
|
|
153
|
+
}
|
|
154
|
+
function findEndOfCentralDirectory(buffer) {
|
|
155
|
+
const minimum = Math.max(0, buffer.length - 65_557);
|
|
156
|
+
for (let offset = buffer.length - 22; offset >= minimum; offset -= 1) {
|
|
157
|
+
if (buffer.readUInt32LE(offset) === 0x06054b50)
|
|
158
|
+
return offset;
|
|
159
|
+
}
|
|
160
|
+
throw new WorkspaceBoundaryError('ZIP end-of-central-directory record is missing', 'ZIP_MALFORMED');
|
|
161
|
+
}
|
|
162
|
+
/** Inspect ZIP metadata before any external extractor sees the archive. */
|
|
163
|
+
export function inspectZipArchive(buffer, limits = {}) {
|
|
164
|
+
const policy = { ...DEFAULT_ZIP_LIMITS, ...limits };
|
|
165
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 22 || buffer.length > policy.maxCompressedBytes) {
|
|
166
|
+
throw new WorkspaceBoundaryError('ZIP archive is empty, malformed, or exceeds the compressed-size limit', 'ZIP_SIZE_LIMIT');
|
|
167
|
+
}
|
|
168
|
+
const eocd = findEndOfCentralDirectory(buffer);
|
|
169
|
+
const disk = buffer.readUInt16LE(eocd + 4);
|
|
170
|
+
const centralDisk = buffer.readUInt16LE(eocd + 6);
|
|
171
|
+
const diskEntries = buffer.readUInt16LE(eocd + 8);
|
|
172
|
+
const totalEntries = buffer.readUInt16LE(eocd + 10);
|
|
173
|
+
const centralSize = buffer.readUInt32LE(eocd + 12);
|
|
174
|
+
const centralOffset = buffer.readUInt32LE(eocd + 16);
|
|
175
|
+
if (disk !== 0 || centralDisk !== 0 || diskEntries !== totalEntries || totalEntries === 0 || totalEntries > policy.maxEntries) {
|
|
176
|
+
throw new WorkspaceBoundaryError('Multi-disk, empty, ZIP64, or excessive-entry archives are not allowed', 'ZIP_ENTRY_LIMIT');
|
|
177
|
+
}
|
|
178
|
+
if (centralOffset + centralSize > eocd || centralOffset + centralSize > buffer.length) {
|
|
179
|
+
throw new WorkspaceBoundaryError('ZIP central directory is outside the archive', 'ZIP_MALFORMED');
|
|
180
|
+
}
|
|
181
|
+
const entries = [];
|
|
182
|
+
const seen = new Map();
|
|
183
|
+
const localRanges = [];
|
|
184
|
+
let totalUncompressed = 0;
|
|
185
|
+
let offset = centralOffset;
|
|
186
|
+
for (let index = 0; index < totalEntries; index += 1) {
|
|
187
|
+
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) {
|
|
188
|
+
throw new WorkspaceBoundaryError('ZIP central-directory entry is malformed', 'ZIP_MALFORMED');
|
|
189
|
+
}
|
|
190
|
+
const flags = buffer.readUInt16LE(offset + 8);
|
|
191
|
+
const method = buffer.readUInt16LE(offset + 10);
|
|
192
|
+
const compressedBytes = buffer.readUInt32LE(offset + 20);
|
|
193
|
+
const uncompressedBytes = buffer.readUInt32LE(offset + 24);
|
|
194
|
+
const nameLength = buffer.readUInt16LE(offset + 28);
|
|
195
|
+
const extraLength = buffer.readUInt16LE(offset + 30);
|
|
196
|
+
const commentLength = buffer.readUInt16LE(offset + 32);
|
|
197
|
+
const externalAttributes = buffer.readUInt32LE(offset + 38);
|
|
198
|
+
const localOffset = buffer.readUInt32LE(offset + 42);
|
|
199
|
+
const entryEnd = offset + 46 + nameLength + extraLength + commentLength;
|
|
200
|
+
if (entryEnd > buffer.length || nameLength === 0 || [compressedBytes, uncompressedBytes, localOffset].includes(0xffffffff)) {
|
|
201
|
+
throw new WorkspaceBoundaryError('ZIP64 or truncated entries are not allowed', 'ZIP_MALFORMED');
|
|
202
|
+
}
|
|
203
|
+
if ((flags & 0x1) !== 0 || ![0, 8].includes(method)) {
|
|
204
|
+
throw new WorkspaceBoundaryError('Encrypted or unsupported ZIP entries are not allowed', 'ZIP_UNSUPPORTED_ENTRY');
|
|
205
|
+
}
|
|
206
|
+
const rawName = decodeZipName(buffer, offset + 46, nameLength, (flags & 0x800) !== 0);
|
|
207
|
+
const directory = rawName.endsWith('/');
|
|
208
|
+
const name = directory ? rawName.slice(0, -1) : rawName;
|
|
209
|
+
const safeName = assertSafeRelativePath(name);
|
|
210
|
+
const collisionKey = safeName.normalize('NFC').toLocaleLowerCase('en-US');
|
|
211
|
+
if (seen.has(collisionKey)) {
|
|
212
|
+
throw new WorkspaceBoundaryError(`ZIP contains duplicate or case-colliding paths: ${seen.get(collisionKey)} and ${safeName}`, 'ZIP_PATH_COLLISION');
|
|
213
|
+
}
|
|
214
|
+
seen.set(collisionKey, safeName);
|
|
215
|
+
const unixType = (externalAttributes >>> 16) & 0o170000;
|
|
216
|
+
if (unixType && unixType !== 0o100000 && unixType !== 0o040000) {
|
|
217
|
+
throw new WorkspaceBoundaryError(`ZIP links and device entries are not allowed: ${safeName}`, 'ZIP_LINK_ENTRY');
|
|
218
|
+
}
|
|
219
|
+
if (uncompressedBytes > policy.maxEntryUncompressedBytes) {
|
|
220
|
+
throw new WorkspaceBoundaryError(`ZIP entry exceeds uncompressed-size limit: ${safeName}`, 'ZIP_BOMB');
|
|
221
|
+
}
|
|
222
|
+
totalUncompressed += uncompressedBytes;
|
|
223
|
+
if (totalUncompressed > policy.maxTotalUncompressedBytes) {
|
|
224
|
+
throw new WorkspaceBoundaryError('ZIP exceeds total uncompressed-size limit', 'ZIP_BOMB');
|
|
225
|
+
}
|
|
226
|
+
if (uncompressedBytes > 0 && (compressedBytes === 0 || uncompressedBytes / compressedBytes > policy.maxCompressionRatio)) {
|
|
227
|
+
throw new WorkspaceBoundaryError(`ZIP entry exceeds compression-ratio limit: ${safeName}`, 'ZIP_BOMB');
|
|
228
|
+
}
|
|
229
|
+
if (localOffset + 30 > buffer.length || buffer.readUInt32LE(localOffset) !== 0x04034b50) {
|
|
230
|
+
throw new WorkspaceBoundaryError(`ZIP local header is missing: ${safeName}`, 'ZIP_MALFORMED');
|
|
231
|
+
}
|
|
232
|
+
const localFlags = buffer.readUInt16LE(localOffset + 6);
|
|
233
|
+
const localNameLength = buffer.readUInt16LE(localOffset + 26);
|
|
234
|
+
const localExtraLength = buffer.readUInt16LE(localOffset + 28);
|
|
235
|
+
if (localOffset + 30 + localNameLength + localExtraLength > centralOffset) {
|
|
236
|
+
throw new WorkspaceBoundaryError(`ZIP local entry overlaps metadata: ${safeName}`, 'ZIP_MALFORMED');
|
|
237
|
+
}
|
|
238
|
+
const localName = decodeZipName(buffer, localOffset + 30, localNameLength, (localFlags & 0x800) !== 0);
|
|
239
|
+
if (localName !== rawName || localFlags !== flags) {
|
|
240
|
+
throw new WorkspaceBoundaryError(`ZIP local and central entry names disagree: ${safeName}`, 'ZIP_NAME_MISMATCH');
|
|
241
|
+
}
|
|
242
|
+
const localEnd = localOffset + 30 + localNameLength + localExtraLength + compressedBytes;
|
|
243
|
+
if (localEnd > centralOffset) {
|
|
244
|
+
throw new WorkspaceBoundaryError(`ZIP local entry data overlaps metadata: ${safeName}`, 'ZIP_MALFORMED');
|
|
245
|
+
}
|
|
246
|
+
const compressedData = buffer.subarray(localOffset + 30 + localNameLength + localExtraLength, localEnd);
|
|
247
|
+
let actualUncompressedBytes;
|
|
248
|
+
if (method === 0) {
|
|
249
|
+
actualUncompressedBytes = compressedData.length;
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
try {
|
|
253
|
+
actualUncompressedBytes = inflateRawSync(compressedData, {
|
|
254
|
+
maxOutputLength: policy.maxEntryUncompressedBytes + 1,
|
|
255
|
+
}).length;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (error?.code === 'ERR_BUFFER_TOO_LARGE' || /larger than|output length/i.test(String(error?.message || ''))) {
|
|
259
|
+
throw new WorkspaceBoundaryError(`ZIP entry expands beyond the enforced limit: ${safeName}`, 'ZIP_BOMB');
|
|
260
|
+
}
|
|
261
|
+
throw new WorkspaceBoundaryError(`ZIP entry compressed data is malformed: ${safeName}`, 'ZIP_MALFORMED');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (actualUncompressedBytes > policy.maxEntryUncompressedBytes) {
|
|
265
|
+
throw new WorkspaceBoundaryError(`ZIP entry expands beyond the enforced limit: ${safeName}`, 'ZIP_BOMB');
|
|
266
|
+
}
|
|
267
|
+
if (actualUncompressedBytes !== uncompressedBytes) {
|
|
268
|
+
throw new WorkspaceBoundaryError(`ZIP entry size metadata does not match compressed data: ${safeName}`, 'ZIP_MALFORMED');
|
|
269
|
+
}
|
|
270
|
+
localRanges.push({ start: localOffset, end: localEnd, path: safeName });
|
|
271
|
+
entries.push({ path: safeName, compressedBytes, uncompressedBytes, directory });
|
|
272
|
+
offset = entryEnd;
|
|
273
|
+
}
|
|
274
|
+
if (offset !== centralOffset + centralSize) {
|
|
275
|
+
throw new WorkspaceBoundaryError('ZIP central-directory size does not match its entries', 'ZIP_MALFORMED');
|
|
276
|
+
}
|
|
277
|
+
localRanges.sort((left, right) => left.start - right.start);
|
|
278
|
+
for (let index = 1; index < localRanges.length; index += 1) {
|
|
279
|
+
if (localRanges[index].start < localRanges[index - 1].end) {
|
|
280
|
+
throw new WorkspaceBoundaryError(`ZIP local entries overlap: ${localRanges[index - 1].path} and ${localRanges[index].path}`, 'ZIP_MALFORMED');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return entries;
|
|
284
|
+
}
|
|
285
|
+
/** Verify the extractor did not create links, devices, or paths outside destination. */
|
|
286
|
+
export function validateExtractedWorkspace(rootPath, options = {}) {
|
|
287
|
+
const rootAbsolute = path.resolve(rootPath);
|
|
288
|
+
const rootStat = fs.lstatSync(rootAbsolute);
|
|
289
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
290
|
+
throw new WorkspaceBoundaryError(`Workspace destination must be a real directory: ${rootAbsolute}`, 'WORKSPACE_LINK_ESCAPE');
|
|
291
|
+
}
|
|
292
|
+
const rootReal = fs.realpathSync(rootAbsolute);
|
|
293
|
+
const ignoreNames = new Set(options.ignoreNames || []);
|
|
294
|
+
const walk = (directory) => {
|
|
295
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
296
|
+
if (ignoreNames.has(entry.name))
|
|
297
|
+
continue;
|
|
298
|
+
const candidate = path.join(directory, entry.name);
|
|
299
|
+
const stat = fs.lstatSync(candidate);
|
|
300
|
+
if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) {
|
|
301
|
+
throw new WorkspaceBoundaryError(`Extracted archive contains a link or special file: ${candidate}`, 'ZIP_LINK_ENTRY');
|
|
302
|
+
}
|
|
303
|
+
const real = fs.realpathSync(candidate);
|
|
304
|
+
if (!isWithin(rootReal, real)) {
|
|
305
|
+
throw new WorkspaceBoundaryError(`Extracted path escapes destination: ${candidate}`, 'WORKSPACE_LINK_ESCAPE');
|
|
306
|
+
}
|
|
307
|
+
if (stat.isDirectory())
|
|
308
|
+
walk(candidate);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
walk(rootReal);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Merge an already extracted, validated staging tree into a destination.
|
|
315
|
+
*
|
|
316
|
+
* External archive tools must never receive the user destination directly:
|
|
317
|
+
* they may follow links that appear safe in ZIP metadata. This routine rejects
|
|
318
|
+
* links in both trees, preflights every destination component, and installs
|
|
319
|
+
* files through a temporary sibling followed by rename so a final-component
|
|
320
|
+
* link is replaced rather than followed.
|
|
321
|
+
*/
|
|
322
|
+
export function mergeValidatedWorkspace(sourcePath, destinationPath) {
|
|
323
|
+
validateExtractedWorkspace(sourcePath);
|
|
324
|
+
const destinationAbsolute = assertSafeDestinationPath(destinationPath);
|
|
325
|
+
if (fs.existsSync(destinationAbsolute)) {
|
|
326
|
+
validateExtractedWorkspace(destinationAbsolute);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
fs.mkdirSync(destinationAbsolute, { recursive: true, mode: 0o700 });
|
|
330
|
+
validateExtractedWorkspace(destinationAbsolute);
|
|
331
|
+
}
|
|
332
|
+
const sourceRoot = fs.realpathSync(path.resolve(sourcePath));
|
|
333
|
+
const destinationRoot = fs.realpathSync(destinationAbsolute);
|
|
334
|
+
const entries = [];
|
|
335
|
+
const walk = (directory) => {
|
|
336
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
337
|
+
const sourceEntry = path.join(directory, entry.name);
|
|
338
|
+
const relativePath = assertSafeRelativePath(path.relative(sourceRoot, sourceEntry).replace(/\\/g, '/'));
|
|
339
|
+
if (entry.isSymbolicLink() || (!entry.isFile() && !entry.isDirectory())) {
|
|
340
|
+
throw new WorkspaceBoundaryError(`Staging tree contains a link or special file: ${relativePath}`, 'ZIP_LINK_ENTRY');
|
|
341
|
+
}
|
|
342
|
+
const destinationEntry = resolveWorkspacePath(destinationRoot, relativePath, { allowMissing: true });
|
|
343
|
+
if (fs.existsSync(destinationEntry)) {
|
|
344
|
+
const destinationStat = fs.lstatSync(destinationEntry);
|
|
345
|
+
if (destinationStat.isSymbolicLink() || (entry.isDirectory() && !destinationStat.isDirectory()) || (entry.isFile() && !destinationStat.isFile())) {
|
|
346
|
+
throw new WorkspaceBoundaryError(`Archive entry collides with an unsafe destination: ${relativePath}`, 'ZIP_DESTINATION_COLLISION');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
entries.push({ relativePath, sourcePath: sourceEntry, destinationPath: destinationEntry, directory: entry.isDirectory() });
|
|
350
|
+
if (entry.isDirectory())
|
|
351
|
+
walk(sourceEntry);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
walk(sourceRoot);
|
|
355
|
+
for (const entry of entries.filter((candidate) => candidate.directory).sort((left, right) => left.relativePath.length - right.relativePath.length)) {
|
|
356
|
+
fs.mkdirSync(entry.destinationPath, { recursive: true, mode: fs.statSync(entry.sourcePath).mode & 0o777 });
|
|
357
|
+
}
|
|
358
|
+
for (const entry of entries.filter((candidate) => !candidate.directory)) {
|
|
359
|
+
const parent = path.dirname(resolveWorkspacePath(destinationRoot, entry.relativePath, { allowMissing: true }));
|
|
360
|
+
const temporary = path.join(parent, `.vigthoria-archive-${randomUUID()}.tmp`);
|
|
361
|
+
try {
|
|
362
|
+
fs.copyFileSync(entry.sourcePath, temporary, fs.constants.COPYFILE_EXCL);
|
|
363
|
+
fs.chmodSync(temporary, fs.statSync(entry.sourcePath).mode & 0o777);
|
|
364
|
+
fs.renameSync(temporary, entry.destinationPath);
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
try {
|
|
368
|
+
fs.unlinkSync(temporary);
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
if (error?.code !== 'ENOENT')
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
validateExtractedWorkspace(destinationRoot);
|
|
377
|
+
}
|
|
@@ -22,7 +22,9 @@ export declare class WorkspaceBrainService {
|
|
|
22
22
|
private autoIndex;
|
|
23
23
|
private brainSyncEnabled;
|
|
24
24
|
private accountContextCache;
|
|
25
|
+
private unsubscribeAuthInvalidation;
|
|
25
26
|
constructor(options: WorkspaceBrainOptions);
|
|
27
|
+
destroy(): void;
|
|
26
28
|
getIndexer(): CodebaseIndexer;
|
|
27
29
|
getStatus(): {
|
|
28
30
|
workspacePath: string;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
import { BrainHubClient } from './brain-hub-client.js';
|
|
6
6
|
import { CodebaseIndexer } from './codebase-indexer.js';
|
|
7
|
+
import { subscribeAuthInvalidation } from './auth-session.js';
|
|
7
8
|
export class WorkspaceBrainService {
|
|
8
9
|
workspacePath;
|
|
9
10
|
indexer;
|
|
@@ -11,6 +12,7 @@ export class WorkspaceBrainService {
|
|
|
11
12
|
autoIndex;
|
|
12
13
|
brainSyncEnabled;
|
|
13
14
|
accountContextCache = null;
|
|
15
|
+
unsubscribeAuthInvalidation = null;
|
|
14
16
|
constructor(options) {
|
|
15
17
|
this.workspacePath = path.resolve(options.workspacePath);
|
|
16
18
|
this.indexer = new CodebaseIndexer(this.workspacePath);
|
|
@@ -20,6 +22,14 @@ export class WorkspaceBrainService {
|
|
|
20
22
|
});
|
|
21
23
|
this.autoIndex = options.autoIndex ?? !/^(1|true|yes)$/i.test(String(process.env.VIGTHORIA_NO_AUTO_INDEX || ''));
|
|
22
24
|
this.brainSyncEnabled = options.brainSyncEnabled ?? !/^(1|true|yes)$/i.test(String(process.env.VIGTHORIA_NO_BRAIN_SYNC || ''));
|
|
25
|
+
this.unsubscribeAuthInvalidation = subscribeAuthInvalidation(() => {
|
|
26
|
+
this.accountContextCache = null;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
destroy() {
|
|
30
|
+
this.unsubscribeAuthInvalidation?.();
|
|
31
|
+
this.unsubscribeAuthInvalidation = null;
|
|
32
|
+
this.accountContextCache = null;
|
|
23
33
|
}
|
|
24
34
|
getIndexer() {
|
|
25
35
|
return this.indexer;
|
|
@@ -110,7 +120,7 @@ export class WorkspaceBrainService {
|
|
|
110
120
|
const summary = `Workspace "${wsName}" indexed in Vigthoria CLI: ${meta.indexedFileCount} files, ${meta.totalChunks} chunks. Key paths: ${meta.topFiles.slice(0, 6).join(', ')}`;
|
|
111
121
|
await this.brainClient.syncWorkspaceIndex({
|
|
112
122
|
workspaceName: wsName,
|
|
113
|
-
workspacePath:
|
|
123
|
+
workspacePath: `vigthoria://workspace/${encodeURIComponent(wsName)}`,
|
|
114
124
|
fileCount: meta.indexedFileCount,
|
|
115
125
|
chunkCount: meta.totalChunks,
|
|
116
126
|
topFiles: meta.topFiles,
|