@arcfold/dsh 0.0.0-stage → 0.4.0
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/LICENSE +21 -0
- package/README.md +8 -2
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/cordis.patch.yml +138 -0
- package/package.json +70 -4
- package/patches/deepseek-0.2.2-rc.1-transport.json +65 -0
- package/patches/deepseek-0.2.2-rc.1-transport.patch +61 -0
- package/presets/arcfold.patch.yml +110 -0
- package/runtime/README.md +19 -0
- package/runtime/bootstrap.json +15 -0
- package/runtime/desktop-python.tar.gz +0 -0
- package/runtime/host/package.json +11 -0
- package/runtime/host/pnpm-workspace.yaml +9 -0
- package/runtime/initial-policy.json +94 -0
- package/runtime/profile/pnpm-workspace.yaml +7 -0
- package/scripts/exact-package-patch.mjs +106 -0
- package/scripts/patch-deepseek-transport.mjs +14 -0
- package/src/agent.mjs +56 -0
- package/src/browse-scope.mjs +14 -0
- package/src/budget.mjs +369 -0
- package/src/business.mjs +334 -0
- package/src/context-material.mjs +119 -0
- package/src/deepseek-budget-plugin.mjs +58 -0
- package/src/deepseek-provider.mjs +57 -0
- package/src/deepseek-receipt.mjs +97 -0
- package/src/deepseek-transport.mjs +97 -0
- package/src/desktop/adapter.mjs +16 -0
- package/src/desktop/bootstrap.mjs +84 -0
- package/src/desktop/configuration.mjs +117 -0
- package/src/desktop/provider.mjs +46 -0
- package/src/desktop/python-runtime.mjs +105 -0
- package/src/desktop/setup-service.mjs +81 -0
- package/src/desktop/vendor/LICENSE.deepseek +21 -0
- package/src/desktop/vendor/NOTICE.md +5 -0
- package/src/desktop/vendor/deepseek-0.2.2-rc.1.mjs +2919 -0
- package/src/evidence-ownership.mjs +51 -0
- package/src/evidence.mjs +35 -0
- package/src/goal-context.mjs +52 -0
- package/src/goal-execution-tool.mjs +74 -0
- package/src/goal-execution.mjs +333 -0
- package/src/goal-tool.mjs +56 -0
- package/src/goal-work-control.mjs +63 -0
- package/src/goal-work.mjs +257 -0
- package/src/goals.mjs +578 -0
- package/src/importer.mjs +298 -0
- package/src/index.mjs +53 -0
- package/src/learning-contracts.mjs +220 -0
- package/src/learning-defaults.mjs +98 -0
- package/src/learning-goals.mjs +165 -0
- package/src/learning-plugin.mjs +28 -0
- package/src/learning-runner.mjs +275 -0
- package/src/learning.mjs +534 -0
- package/src/legacy-export.mjs +332 -0
- package/src/legacy-import-source.mjs +356 -0
- package/src/metered-provider-host.mjs +101 -0
- package/src/metered-transport.mjs +435 -0
- package/src/method-context.mjs +57 -0
- package/src/method-lifecycle.mjs +34 -0
- package/src/method-tool.mjs +61 -0
- package/src/methods.mjs +425 -0
- package/src/model-selection.mjs +41 -0
- package/src/native-context-call.mjs +88 -0
- package/src/native-continuation.mjs +217 -0
- package/src/native-request-authority.mjs +90 -0
- package/src/openai-models.mjs +4 -0
- package/src/openai-receipt.mjs +98 -0
- package/src/openai-transport.mjs +167 -0
- package/src/openai-usage.mjs +194 -0
- package/src/opportunities.mjs +295 -0
- package/src/preparation-artifacts.mjs +213 -0
- package/src/preparation-plugin.mjs +290 -0
- package/src/runtime-command.mjs +264 -0
- package/src/runtime-config.mjs +417 -0
- package/src/runtime-guard.mjs +284 -0
- package/src/runtime-host.mjs +212 -0
- package/src/runtime-init.mjs +278 -0
- package/src/runtime-installation.mjs +518 -0
- package/src/runtime-lifecycle.mjs +227 -0
- package/src/runtime-model-settings.mjs +111 -0
- package/src/runtime-readiness.mjs +285 -0
- package/src/runtime-workspace.mjs +18 -0
- package/src/session-bridge.mjs +365 -0
- package/src/store.mjs +394 -0
- package/src/tree/LICENSE +21 -0
- package/src/tree/attribution.mjs +120 -0
- package/src/tree/clusters.mjs +61 -0
- package/src/tree/consolidation.mjs +103 -0
- package/src/tree/context.mjs +211 -0
- package/src/tree/contracts.mjs +142 -0
- package/src/tree/experience.mjs +140 -0
- package/src/tree/experiment-budget.mjs +84 -0
- package/src/tree/hooks.mjs +254 -0
- package/src/tree/host-context.mjs +107 -0
- package/src/tree/host-process.mjs +288 -0
- package/src/tree/host.mjs +37 -0
- package/src/tree/improvement.mjs +106 -0
- package/src/tree/items.mjs +294 -0
- package/src/tree/jobs.mjs +184 -0
- package/src/tree/learning.mjs +202 -0
- package/src/tree/metrics.mjs +203 -0
- package/src/tree/model-owner.mjs +254 -0
- package/src/tree/observations.mjs +73 -0
- package/src/tree/prompts.mjs +500 -0
- package/src/tree/reports.mjs +67 -0
- package/src/tree/repository.mjs +236 -0
- package/src/tree/runtime.mjs +745 -0
- package/src/tree/script-writer.mjs +149 -0
- package/src/tree/scripts.mjs +208 -0
- package/src/tree/signals.mjs +118 -0
- package/src/tree/snapshots.mjs +379 -0
- package/src/tree/token-counter.mjs +75 -0
- package/src/tree/token-counter.py +37 -0
- package/src/tree/tokenizer-resources/LICENSE +21 -0
- package/src/tree/tokenizer-resources/README.md +31 -0
- package/src/tree/tokenizer-resources/deepseek_recipe-0.2.0-cp310-abi3-macosx_11_0_arm64.whl +0 -0
- package/src/tree/tokenizer-resources/manifest.json +61 -0
- package/src/tree/tokenizer-resources/native.pyi +1042 -0
- package/src/tree/tokenizer-resources/tokenizer-NOTICE.md +26 -0
- package/src/tree/tokenizer-resources/tokenizer-v4-NOTICE.md +27 -0
- package/src/tree/tokenizer-resources/tokenizer-v4.json +267359 -0
- package/src/tree/tokenizer-resources/tokenizer.json +267359 -0
- package/src/tree/tools.mjs +99 -0
- package/src/tree/tree.mjs +202 -0
- package/src/tree/ui.mjs +285 -0
- package/src/tree/verification.mjs +143 -0
- package/src/understandings.mjs +309 -0
- package/src/web-budget-recovery.mjs +181 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createParser } from 'eventsource-parser';
|
|
2
|
+
import { DomainError, jsonClone } from './store.mjs';
|
|
3
|
+
|
|
4
|
+
const invalid = () => new DomainError('invalid_provider_receipt', 'The original Messages receipt is incomplete or inconsistent.', 409);
|
|
5
|
+
const count = value => Number.isSafeInteger(value) && value >= 0;
|
|
6
|
+
|
|
7
|
+
/** Billing only. Official DSH remains the sole translator of answer/tool content. */
|
|
8
|
+
export function messagesUsage(raw) {
|
|
9
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid();
|
|
10
|
+
for (const key of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'])
|
|
11
|
+
if (!count(raw[key])) throw invalid();
|
|
12
|
+
// There is no verified tariff for cache creation in this supported route.
|
|
13
|
+
if (raw.cache_creation_input_tokens !== 0) throw invalid();
|
|
14
|
+
const promptTokens = raw.input_tokens + raw.cache_read_input_tokens;
|
|
15
|
+
if (!count(promptTokens)) throw invalid();
|
|
16
|
+
return { promptTokens, completionTokens: raw.output_tokens, cachedTokens: raw.cache_read_input_tokens };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class MessagesReceipt {
|
|
20
|
+
constructor() {
|
|
21
|
+
this.decoder = new TextDecoder('utf-8', { fatal: true });
|
|
22
|
+
this.started = false; this.stopped = false; this.finalUsage = null;
|
|
23
|
+
this.stopReason = null; this.messageId = null; this.model = null;
|
|
24
|
+
this.lineParts = []; this.skipLf = false;
|
|
25
|
+
this.parser = createParser({
|
|
26
|
+
onError: () => { throw invalid(); },
|
|
27
|
+
onEvent: event => this.event(event),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
event(event) {
|
|
32
|
+
let value;
|
|
33
|
+
try { value = JSON.parse(event.data); } catch { throw invalid(); }
|
|
34
|
+
if (!value || typeof value !== 'object' || typeof value.type !== 'string' ||
|
|
35
|
+
event.event && event.event !== value.type || this.stopped) throw invalid();
|
|
36
|
+
if (value.type === 'ping') return;
|
|
37
|
+
if (value.type === 'message_start') {
|
|
38
|
+
if (this.started || typeof value.message?.id !== 'string' || !value.message.id ||
|
|
39
|
+
typeof value.message?.model !== 'string' || !value.message.model) throw invalid();
|
|
40
|
+
this.started = true; this.messageId = value.message.id; this.model = value.message.model;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!this.started) throw invalid();
|
|
44
|
+
if (value.type === 'message_delta') {
|
|
45
|
+
if (this.stopReason !== null) throw invalid();
|
|
46
|
+
if (value.delta?.stop_reason != null) {
|
|
47
|
+
if (typeof value.delta.stop_reason !== 'string' || !value.delta.stop_reason) throw invalid();
|
|
48
|
+
messagesUsage(value.usage);
|
|
49
|
+
this.finalUsage = jsonClone(value.usage); this.stopReason = value.delta.stop_reason;
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (value.type === 'message_stop') {
|
|
54
|
+
if (!this.finalUsage || this.stopReason === null) throw invalid();
|
|
55
|
+
this.stopped = true; return;
|
|
56
|
+
}
|
|
57
|
+
if (!['content_block_start', 'content_block_delta', 'content_block_stop'].includes(value.type)) throw invalid();
|
|
58
|
+
if (this.stopReason !== null) throw invalid();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Feed complete lines to the official SSE framer so its terminal callback
|
|
62
|
+
// identifies the exact boundary, even when a network chunk has extra bytes.
|
|
63
|
+
// After that boundary our supported protocol permits whitespace only.
|
|
64
|
+
feedText(fragment) {
|
|
65
|
+
if (this.stopped) {
|
|
66
|
+
if (!/^[ \t\r\n]*$/.test(fragment)) throw invalid();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
let start = 0;
|
|
70
|
+
for (let index = 0; index < fragment.length; index++) {
|
|
71
|
+
const character = fragment[index];
|
|
72
|
+
if (this.skipLf) {
|
|
73
|
+
this.skipLf = false;
|
|
74
|
+
if (character === '\n') { start = index + 1; continue; }
|
|
75
|
+
}
|
|
76
|
+
if (character !== '\r' && character !== '\n') continue;
|
|
77
|
+
this.lineParts.push(fragment.slice(start, index));
|
|
78
|
+
const line = this.lineParts.join(''); this.lineParts = [];
|
|
79
|
+
this.parser.feed(line + '\n');
|
|
80
|
+
start = index + 1; this.skipLf = character === '\r';
|
|
81
|
+
if (this.stopped) {
|
|
82
|
+
if (!/^[ \t\r\n]*$/.test(fragment.slice(start))) throw invalid();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (start < fragment.length) this.lineParts.push(fragment.slice(start));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
push(bytes) { this.feedText(this.decoder.decode(bytes, { stream: true })); }
|
|
90
|
+
|
|
91
|
+
finish() {
|
|
92
|
+
this.feedText(this.decoder.decode());
|
|
93
|
+
if (!this.started || !this.stopped || !this.finalUsage) throw invalid();
|
|
94
|
+
return { messageId: this.messageId, model: this.model, stopReason: this.stopReason,
|
|
95
|
+
rawUsage: this.finalUsage, usage: messagesUsage(this.finalUsage) };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { MeteredTransport, fail, object, positive, text, keys, hash } from './metered-transport.mjs';
|
|
2
|
+
import { MessagesReceipt } from './deepseek-receipt.mjs';
|
|
3
|
+
const OFFICIAL = 'https://api.deepseek.com/anthropic/v1/messages';
|
|
4
|
+
const VERSION = 'arcfold-messages-transport-v1';
|
|
5
|
+
function wireBlocks(blocks, nested = false) {
|
|
6
|
+
if (!Array.isArray(blocks)) throw fail('unsupported_model_content', 'Messages content must be an array.');
|
|
7
|
+
for (const block of blocks) {
|
|
8
|
+
if (block?.type === 'text') {
|
|
9
|
+
keys(block, ['type', 'text']);
|
|
10
|
+
if (!text(block.text)) throw fail('unsupported_model_content', 'Text content is invalid.');
|
|
11
|
+
} else if (!nested && block?.type === 'thinking') {
|
|
12
|
+
keys(block, ['type', 'thinking', 'signature']);
|
|
13
|
+
if (!text(block.thinking) || block.signature !== undefined && !text(block.signature))
|
|
14
|
+
throw fail('unsupported_model_content', 'Thinking content is invalid.');
|
|
15
|
+
} else if (!nested && block?.type === 'tool_use') {
|
|
16
|
+
keys(block, ['type', 'id', 'name', 'input']);
|
|
17
|
+
if (!text(block.id) || !text(block.name) || !object(block.input))
|
|
18
|
+
throw fail('unsupported_model_content', 'Client tool input is invalid.');
|
|
19
|
+
} else if (!nested && block?.type === 'tool_result') {
|
|
20
|
+
keys(block, ['type', 'tool_use_id', 'content', 'is_error']);
|
|
21
|
+
if (!text(block.tool_use_id) || block.is_error !== undefined && typeof block.is_error !== 'boolean')
|
|
22
|
+
throw fail('unsupported_model_content', 'Client tool result is invalid.');
|
|
23
|
+
wireBlocks(block.content, true);
|
|
24
|
+
} else if (!nested && ['tool_addition', 'tool_removal'].includes(block?.type)) {
|
|
25
|
+
keys(block, ['type', 'tool']); keys(block.tool, ['type', 'name']);
|
|
26
|
+
if (block.tool.type !== 'tool_reference' || !text(block.tool.name))
|
|
27
|
+
throw fail('unsupported_model_content', 'Client tool update is invalid.');
|
|
28
|
+
} else throw fail('unsupported_model_content', 'Images, uploads and server tools are not supported.');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const protocol = Object.freeze({
|
|
33
|
+
version: VERSION, endpoint: OFFICIAL,
|
|
34
|
+
validateEndpoint(endpoint, allowLocalTestEndpoint) {
|
|
35
|
+
const target = new URL(endpoint);
|
|
36
|
+
const local = allowLocalTestEndpoint === true && target.protocol === 'http:' &&
|
|
37
|
+
target.hostname === '127.0.0.1' && target.port && target.pathname === '/anthropic/v1/messages';
|
|
38
|
+
if (endpoint !== OFFICIAL && !local || target.username || target.password || target.search || target.hash)
|
|
39
|
+
throw fail('invalid_transport_configuration', 'Only the fixed official Messages endpoint is supported.');
|
|
40
|
+
},
|
|
41
|
+
validatePolicies(modelPolicies) {
|
|
42
|
+
for (const policy of Object.values(modelPolicies)) {
|
|
43
|
+
if (!object(policy) || !positive(policy.contextWindow) || !positive(policy.maxOutputTokens) ||
|
|
44
|
+
!object(policy.prices) || !text(policy.priceVersion) || !policy.priceVersion ||
|
|
45
|
+
!Array.isArray(policy.reasoningEfforts) || !policy.reasoningEfforts.length ||
|
|
46
|
+
policy.reasoningEfforts.some(effort => !['off', 'low', 'high', 'max'].includes(effort)) ||
|
|
47
|
+
policy.requestMaxTokens !== undefined && (!positive(policy.requestMaxTokens) || policy.requestMaxTokens > policy.maxOutputTokens))
|
|
48
|
+
throw fail('invalid_transport_configuration', 'Model accounting bounds must be explicitly frozen.');
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
createReceipt: () => new MessagesReceipt(),
|
|
52
|
+
reservationSpec: fixed => ({ maxOutputTokens: fixed.policy.maxOutputTokens,
|
|
53
|
+
inputTokenCeiling: fixed.policy.contextWindow, prices: fixed.policy.prices }),
|
|
54
|
+
});
|
|
55
|
+
export class DeepSeekTransport extends MeteredTransport {
|
|
56
|
+
constructor(options = {}) { super({ ...options, protocol }); }
|
|
57
|
+
validateWire(fixed, url, init) {
|
|
58
|
+
if (url !== this.endpoint || init?.method !== 'POST' || init.redirect !== 'error' || typeof init.body !== 'string')
|
|
59
|
+
throw fail('invalid_wire_request', 'Only the frozen single Messages POST is permitted.');
|
|
60
|
+
if (Buffer.byteLength(init.body) > this.maxRequestBytes) throw fail('request_too_large', 'Request evidence capacity exceeded.');
|
|
61
|
+
let body;
|
|
62
|
+
try { body = JSON.parse(init.body); } catch { throw fail('invalid_wire_request', 'Messages body must be JSON.'); }
|
|
63
|
+
keys(body, ['model', 'stream', 'max_tokens', 'messages', 'system', 'thinking', 'output_config', 'temperature', 'stop_sequences', 'tools']);
|
|
64
|
+
if (body.model !== fixed.model || body.stream !== true || body.max_tokens !== fixed.maxTokens ||
|
|
65
|
+
!Array.isArray(body.messages) || !body.messages.length || body.system !== undefined && !text(body.system))
|
|
66
|
+
throw fail('wire_configuration_changed', 'Serialized route and token limit differ from the frozen request.');
|
|
67
|
+
keys(body.thinking, ['type']);
|
|
68
|
+
if (body.thinking.type !== (fixed.reasoningEffort === 'off' ? 'disabled' : 'enabled'))
|
|
69
|
+
throw fail('wire_configuration_changed', 'Serialized reasoning differs from the frozen request.');
|
|
70
|
+
if (fixed.reasoningEffort === 'off') {
|
|
71
|
+
if (body.output_config !== undefined) throw fail('wire_configuration_changed', 'Disabled thinking cannot add an effort.');
|
|
72
|
+
} else {
|
|
73
|
+
keys(body.output_config, ['effort']);
|
|
74
|
+
if (body.output_config.effort !== fixed.reasoningEffort) throw fail('wire_configuration_changed', 'Serialized effort changed.');
|
|
75
|
+
}
|
|
76
|
+
for (const message of body.messages) {
|
|
77
|
+
keys(message, ['role', 'content']);
|
|
78
|
+
if (!['user', 'assistant', 'system'].includes(message.role)) throw fail('unsupported_model_content', 'Unsupported Messages role.');
|
|
79
|
+
wireBlocks(message.content);
|
|
80
|
+
}
|
|
81
|
+
if (body.tools !== undefined) {
|
|
82
|
+
if (!Array.isArray(body.tools)) throw fail('unsupported_model_content', 'Client tools must be an array.');
|
|
83
|
+
for (const tool of body.tools) {
|
|
84
|
+
keys(tool, ['name', 'description', 'input_schema', 'defer_loading']);
|
|
85
|
+
if (!text(tool.name) || !text(tool.description) || !object(tool.input_schema) ||
|
|
86
|
+
tool.defer_loading !== undefined && tool.defer_loading !== true)
|
|
87
|
+
throw fail('unsupported_model_content', 'Only client tools are allowed.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const headers = new Headers(init.headers);
|
|
91
|
+
if (!headers.get('authorization') && !headers.get('x-api-key')) throw fail('missing_provider_auth', 'Provider authentication is required.');
|
|
92
|
+
if (headers.has('host') && headers.get('host') !== new URL(url).host)
|
|
93
|
+
throw fail('invalid_wire_request', 'The HTTP host cannot override the fixed endpoint.');
|
|
94
|
+
return { body, headers: Object.fromEntries(headers), serialized: init.body, bodyHash: hash(init.body) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
// An exact copy of the audited upstream package with the existing transport
|
|
5
|
+
// seam applied. This is owned by Arcfold; it never patches Desktop's packages.
|
|
6
|
+
const url = new URL('./vendor/deepseek-0.2.2-rc.1.mjs', import.meta.url);
|
|
7
|
+
const expected = 'fc27ceacbed59c8d32100f3aa41cf79e1acab403ac0bc12b0e7f807a6274c752';
|
|
8
|
+
export async function loadDesktopDeepSeekAdapter() {
|
|
9
|
+
const actual = createHash('sha256').update(readFileSync(url)).digest('hex');
|
|
10
|
+
if (actual !== expected) throw new Error('The private Desktop DeepSeek adapter does not match its audited source');
|
|
11
|
+
const adapter = await import(url.href);
|
|
12
|
+
if (adapter.registerDeepSeekProvider?.transportSeam !== 'deepseek-messages-create-transport-v1' ||
|
|
13
|
+
typeof adapter.DeepSeekAdapter !== 'function' || typeof adapter.catalogModelInfo !== 'function')
|
|
14
|
+
throw new Error('The private Desktop DeepSeek adapter lacks the required transport interface');
|
|
15
|
+
return adapter;
|
|
16
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
|
|
6
|
+
import { isBundledDesktopPython, prepareBundledDesktopPython } from './python-runtime.mjs';
|
|
7
|
+
import { createDesktopSetup } from './setup-service.mjs';
|
|
8
|
+
|
|
9
|
+
export const name = 'arcfold-desktop-bootstrap';
|
|
10
|
+
export const inject = ['loader'];
|
|
11
|
+
const same = (a, b) => isDeepStrictEqual(a, b, true);
|
|
12
|
+
|
|
13
|
+
export async function activateDesktop(ctx, result, { home, previousPython } = {}) {
|
|
14
|
+
const all = [...ctx.loader.entries()];
|
|
15
|
+
const one = id => {
|
|
16
|
+
const matches = all.filter(row => row.options.id === id);
|
|
17
|
+
if (matches.length !== 1) throw new Error('无法确认 Desktop 配置位置:' + id);
|
|
18
|
+
return matches[0];
|
|
19
|
+
};
|
|
20
|
+
const business = one('arcfold-business'), originalProvider = one('arcfold-metered-provider');
|
|
21
|
+
const businessConfig = business.options.config ?? {};
|
|
22
|
+
const intendedConfig = { goalTreeV3: result.tree };
|
|
23
|
+
const previous = businessConfig.goalTreeV3?.tokenCounter?.python;
|
|
24
|
+
const managedPrevious = typeof previous === 'string' && (previous === previousPython || isBundledDesktopPython(home, previous));
|
|
25
|
+
const previousConfig = { goalTreeV3: { ...result.tree, tokenCounter: { ...result.tree.tokenCounter, python: previous } } };
|
|
26
|
+
if (Object.keys(businessConfig).length && !same(businessConfig, intendedConfig) &&
|
|
27
|
+
!(managedPrevious && same(businessConfig, previousConfig)))
|
|
28
|
+
throw new Error('Arcfold 已有不同的运行设置,不能自动覆盖。');
|
|
29
|
+
if (originalProvider.options.disabled !== true && Object.keys(originalProvider.options.config ?? {}).length)
|
|
30
|
+
throw new Error('Arcfold 已有不同的模型连接设置,不能自动覆盖。');
|
|
31
|
+
const desktopPatch = result.patches.find(row => row.insert).insert[0];
|
|
32
|
+
const existing = all.filter(row => row.options.id === desktopPatch.id);
|
|
33
|
+
if (existing.length > 1 || existing.length && (!same(existing[0].options.config, desktopPatch.config) || existing[0].options.name !== desktopPatch.name))
|
|
34
|
+
throw new Error('Desktop 模型连接与保存的设置不一致。');
|
|
35
|
+
// Each native Loader operation persists through its own owning config tree.
|
|
36
|
+
// No arbitrary profile file is replaced. A partial failure is reported and
|
|
37
|
+
// the saved initialization remains available for an exact retry.
|
|
38
|
+
if (originalProvider.options.disabled !== true)
|
|
39
|
+
await originalProvider.parent.tree.update(originalProvider.options.id, { disabled: true });
|
|
40
|
+
if (!same(businessConfig, intendedConfig))
|
|
41
|
+
await business.parent.tree.update(business.options.id, { config: intendedConfig });
|
|
42
|
+
if (!existing.length) {
|
|
43
|
+
const group = business.parent === business.parent.tree.root ? null : business.parent.ctx.fiber.entry?.id ?? null;
|
|
44
|
+
await business.parent.tree.create(desktopPatch, group);
|
|
45
|
+
}
|
|
46
|
+
const defaultModel = one('agent-default-model'), currentDefault = defaultModel.options.config ?? {};
|
|
47
|
+
const unset = value => value === undefined || value === '' || value && typeof value === 'object' && typeof value.__jsExpr === 'string';
|
|
48
|
+
if (unset(currentDefault.provider) && unset(currentDefault.model)) {
|
|
49
|
+
const main = result.tree.models.main;
|
|
50
|
+
await defaultModel.parent.tree.update(defaultModel.options.id, { config: { provider: main.provider, model: main.model, reasoningEffort: main.reasoningEffort } });
|
|
51
|
+
}
|
|
52
|
+
await ctx.loader.await();
|
|
53
|
+
// Tree UI registration is reactive and can follow the business plugin's
|
|
54
|
+
// own startup. Do not report ready until the browser-facing service exists.
|
|
55
|
+
const deadline = Date.now() + 5000;
|
|
56
|
+
while (!ctx.get('arcfoldTreeWeb') && Date.now() < deadline)
|
|
57
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
58
|
+
if (!ctx.get('arcfoldTreeRuntime') || !ctx.get('arcfoldTreeV3') || !ctx.get('arcfoldTreeWeb') || !ctx.get('arcfoldDeepSeekTransport'))
|
|
59
|
+
throw new Error('Desktop 未能启动 Arcfold,请保留当前设置并检查错误。');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function apply(ctx) {
|
|
63
|
+
const isDesktop = Boolean(process.versions.electron && process.argv.some(value => value.includes('dsh-desktop-host')));
|
|
64
|
+
if (!isDesktop) {
|
|
65
|
+
ctx.provide('arcfoldDesktopSetup', Object.freeze({ status: () => ({ state: 'not_desktop' }) }));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
69
|
+
let setup;
|
|
70
|
+
try {
|
|
71
|
+
const home = realpathSync(resolveDshHome());
|
|
72
|
+
const policy = JSON.parse(readFileSync(join(packageRoot, 'runtime/initial-policy.json'), 'utf8'));
|
|
73
|
+
setup = createDesktopSetup({ home, installationFile: join(packageRoot, 'package.json'), policy,
|
|
74
|
+
preparePython: () => prepareBundledDesktopPython(packageRoot, home),
|
|
75
|
+
activate: (result, previousPython) => activateDesktop(ctx.root, result, { home, previousPython }) });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
setup = Object.freeze({ status: () => ({ state: 'unavailable', activationError: error.message }) });
|
|
78
|
+
}
|
|
79
|
+
ctx.provide('arcfoldDesktopSetup', setup);
|
|
80
|
+
if (setup.status().state === 'configured') {
|
|
81
|
+
// Do not wait for the Loader from inside its own initialization task.
|
|
82
|
+
setImmediate(() => { void ctx.loader.await().then(() => setup.resume()); });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
2
|
+
import { accessSync, closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { canonicalRuntimePath, runtimeProviderDefaults, runtimeTransport, validateRuntimeConfig } from '../runtime-config.mjs';
|
|
5
|
+
import { initializeOwnedWebBudget, preflightWebBudget } from '../web-budget-recovery.mjs';
|
|
6
|
+
import { canonicalJson } from '../store.mjs';
|
|
7
|
+
|
|
8
|
+
const roles = ['main', 'attribution', 'learning', 'consolidation', 'verification', 'improvement', 'script_writer'];
|
|
9
|
+
const disjoint = (a, b) => a !== b && !a.startsWith(b + sep) && !b.startsWith(a + sep);
|
|
10
|
+
const assert = (condition, message) => { if (!condition) throw new Error(message); };
|
|
11
|
+
|
|
12
|
+
/** Project an already authorized Desktop installation into the native host.
|
|
13
|
+
* This function is read-only. In particular it does not create a ledger,
|
|
14
|
+
* adopt another installation's files, obtain credentials or authorize spending. */
|
|
15
|
+
function projectDesktopConfiguration({ local, runtimeRoot, python, userId, models }, checkLedger) {
|
|
16
|
+
const config = validateRuntimeConfig(local);
|
|
17
|
+
const data = canonicalRuntimePath(config.dataRoot), workspace = canonicalRuntimePath(config.workspace);
|
|
18
|
+
const workers = canonicalRuntimePath(runtimeRoot, 'runtimeRoot');
|
|
19
|
+
const interpreter = realpathSync(canonicalRuntimePath(python, 'python'));
|
|
20
|
+
assert(lstatSync(interpreter).isFile(), 'Desktop token counter needs an executable interpreter');
|
|
21
|
+
accessSync(interpreter, constants.X_OK);
|
|
22
|
+
const install = dirname(canonicalRuntimePath(config.installationFile));
|
|
23
|
+
assert(lstatSync(workspace).isDirectory(), 'Desktop workspace must already exist');
|
|
24
|
+
assert(disjoint(data, workspace) && disjoint(workers, data) && disjoint(workers, workspace) &&
|
|
25
|
+
disjoint(data, install) && disjoint(workers, install), 'Desktop data, workspace, processes and installed code must be separate');
|
|
26
|
+
assert(typeof userId === 'string' && userId.trim().length > 0 && userId.length <= 256, 'An explicit Desktop user identity is required');
|
|
27
|
+
const transport = runtimeTransport(config), route = transport.routes['deepseek-official'];
|
|
28
|
+
assert(route?.mode === 'web-only' && route.scope === 'web-unlimited' && route.webAuthorization && route.network?.mode === 'direct',
|
|
29
|
+
'Desktop requires its own explicitly authorized, direct DeepSeek accounting route');
|
|
30
|
+
const evidence = canonicalRuntimePath(transport.artifactDirectory), ledgerPath = canonicalRuntimePath(route.ledgerPath);
|
|
31
|
+
assert(!disjoint(data, evidence) && evidence !== data && evidence.startsWith(data + sep), 'Desktop request evidence must be inside its data directory');
|
|
32
|
+
if (checkLedger) preflightWebBudget({ artifactDirectory: evidence, ledgerPath });
|
|
33
|
+
assert(models && typeof models === 'object' && Object.keys(models).length === roles.length && roles.every(role => Object.hasOwn(models, role)),
|
|
34
|
+
'All seven 4397 model roles must be specified explicitly');
|
|
35
|
+
for (const role of roles) {
|
|
36
|
+
const value = models[role], policy = route.modelPolicies[value?.model];
|
|
37
|
+
assert(value && Object.keys(value).every(k => ['provider', 'model', 'modelVersion', 'reasoningEffort', 'maxTokens'].includes(k)) &&
|
|
38
|
+
value.provider === 'deepseek-official' && policy && typeof value.modelVersion === 'string' && value.modelVersion.length > 0 &&
|
|
39
|
+
policy.reasoningEfforts.includes(value.reasoningEffort) && Number.isSafeInteger(value.maxTokens) && value.maxTokens > 0 &&
|
|
40
|
+
value.maxTokens <= (policy.requestMaxTokens ?? policy.maxOutputTokens), `Invalid explicit Desktop model role: ${role}`);
|
|
41
|
+
}
|
|
42
|
+
const main = models.main;
|
|
43
|
+
assert(['provider', 'model', 'reasoningEffort', 'maxTokens'].every(k => config.main?.[k] === main[k]), 'Desktop main model must match the authorized configuration');
|
|
44
|
+
const defaults = runtimeProviderDefaults(config, 'deepseek-official');
|
|
45
|
+
assert(defaults.available, 'Desktop provider defaults do not fit the accounting policies');
|
|
46
|
+
const tree = { enabled: true, adapter: 'desktop-private-v1', followConversationModel: true, workspace, privateRoot: data, runtimeRoot: workers,
|
|
47
|
+
treeFilename: join(data, 'tree.sqlite'), userId, models: structuredClone(models), stage: 'desktop', tokenCounter: { python: interpreter },
|
|
48
|
+
budget: { mode: 'web-only', scope: 'web-unlimited', webAuthorization: structuredClone(route.webAuthorization),
|
|
49
|
+
ledgerPath, artifactDirectory: evidence, modelPolicies: structuredClone(route.modelPolicies), endpoint: route.endpoint,
|
|
50
|
+
timeoutMs: route.timeoutMs, ...(route.allowLocalTestEndpoint === true ? { allowLocalTestEndpoint: true } : {}) } };
|
|
51
|
+
// The caller supplies the independently verified offline interpreter. No
|
|
52
|
+
// hard-coded developer machine path is substituted here.
|
|
53
|
+
return { tree, patches: [
|
|
54
|
+
{ id: 'arcfold-business', config: { goalTreeV3: tree } },
|
|
55
|
+
{ id: 'arcfold-metered-provider', disabled: true },
|
|
56
|
+
{ insert: [{ id: 'arcfold-desktop-metered-provider', name: '@arcfold/dsh/desktop-deepseek',
|
|
57
|
+
config: { apiKeyRef: route.apiKeyRef, maxTokens: defaults.maxTokens, reasoningEffort: defaults.reasoningEffort } }] },
|
|
58
|
+
{ id: 'agent-default-model', config: { provider: main.provider, model: main.model, reasoningEffort: main.reasoningEffort } },
|
|
59
|
+
{ id: 'working-directory', config: { defaultDirectory: workspace } },
|
|
60
|
+
] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function desktopConfiguration(options) { return projectDesktopConfiguration(options, true); }
|
|
64
|
+
|
|
65
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
66
|
+
function writePrivateNew(path, value) {
|
|
67
|
+
const fd = openSync(path, 'wx', 0o600);
|
|
68
|
+
try { writeFileSync(fd, value); fsyncSync(fd); } finally { closeSync(fd); }
|
|
69
|
+
const directory = openSync(dirname(path), 'r');
|
|
70
|
+
try { fsyncSync(directory); } finally { closeSync(directory); }
|
|
71
|
+
}
|
|
72
|
+
function privateRegular(path) {
|
|
73
|
+
const st = lstatSync(path);
|
|
74
|
+
assert(st.isFile() && !st.isSymbolicLink() && st.nlink === 1 && st.uid === process.getuid() && !(st.mode & 0o077), 'Desktop initialization receipt must remain private and unmodified');
|
|
75
|
+
return st;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Explicit first use for a new, separate Desktop namespace. A completed
|
|
79
|
+
* setup is idempotent only for the exact same inputs and owned ledger. An
|
|
80
|
+
* interrupted setup is retained and refused for manual reconciliation. */
|
|
81
|
+
export async function initializeDesktop(options, consent, runtimePython = options.python) {
|
|
82
|
+
assert(consent?.confirmed === true && Object.keys(consent).length === 1, 'Explicit Desktop model-spending confirmation is required');
|
|
83
|
+
// Only this installation's interpreter changes with a plugin upgrade. The
|
|
84
|
+
// original settings still bind the receipt and the accounting authorization.
|
|
85
|
+
const runtimeOptions = { ...options, python: runtimePython };
|
|
86
|
+
const projection = projectDesktopConfiguration(runtimeOptions, false), tree = projection.tree;
|
|
87
|
+
const root = tree.privateRoot, evidence = tree.budget.artifactDirectory, ledger = tree.budget.ledgerPath;
|
|
88
|
+
assert(evidence === join(root, 'requests') && ledger === join(evidence, 'deepseek-web.sqlite'), 'First use requires the fixed independent Desktop evidence directory');
|
|
89
|
+
assert(resolve(options.local.dataRoot) === root, 'Desktop data directory cannot be an alias');
|
|
90
|
+
const configPath = join(root, 'desktop-configuration.json'), intentPath = join(root, 'desktop-initialization.json'), completedPath = join(root, 'desktop-initialization-completed.json');
|
|
91
|
+
const bytes = canonicalJson({ schema: 'arcfold-desktop-configuration-v1', options }) + '\n', inputHash = hash(bytes);
|
|
92
|
+
if (existsSync(root)) {
|
|
93
|
+
const rootStat = lstatSync(root);
|
|
94
|
+
assert(rootStat.isDirectory() && !rootStat.isSymbolicLink() && rootStat.uid === process.getuid() && !(rootStat.mode & 0o077), 'Desktop data directory must remain private and owned');
|
|
95
|
+
assert(existsSync(completedPath), 'Desktop initialization is interrupted or the directory is not owned; reconcile before retrying');
|
|
96
|
+
privateRegular(intentPath); privateRegular(configPath); privateRegular(completedPath);
|
|
97
|
+
const intent = JSON.parse(readFileSync(intentPath, 'utf8')), completed = JSON.parse(readFileSync(completedPath, 'utf8'));
|
|
98
|
+
assert(intent.schema === 'arcfold-desktop-initialization-v1' && completed.schema === 'arcfold-desktop-initialization-completed-v1', 'Unsupported Desktop initialization receipt');
|
|
99
|
+
assert(intent.inputHash === inputHash && completed.inputHash === inputHash && hash(readFileSync(configPath)) === inputHash,
|
|
100
|
+
'Desktop initialization retry requires the same original configuration');
|
|
101
|
+
const st = privateRegular(ledger);
|
|
102
|
+
assert(st.dev === completed.ledger.dev && st.ino === completed.ledger.ino, 'Desktop accounting ledger has been replaced');
|
|
103
|
+
preflightWebBudget({ artifactDirectory: evidence, ledgerPath: ledger });
|
|
104
|
+
return { created: false, configPath, ...projection };
|
|
105
|
+
}
|
|
106
|
+
assert(runtimePython === options.python, 'Fresh Desktop initialization must record its runtime');
|
|
107
|
+
// mkdir is the ownership claim; it fails instead of adopting any existing
|
|
108
|
+
// directory. Completion is published only after the independent ledger.
|
|
109
|
+
mkdirSync(root, { mode: 0o700 });
|
|
110
|
+
writePrivateNew(intentPath, JSON.stringify({ schema: 'arcfold-desktop-initialization-v1', inputHash }) + '\n');
|
|
111
|
+
writePrivateNew(configPath, bytes);
|
|
112
|
+
const account = await initializeOwnedWebBudget({ artifactDirectory: evidence, ledgerPath: ledger, webAuthorization: tree.budget.webAuthorization });
|
|
113
|
+
assert(account.created && account.ledgerPath === ledger, 'First use cannot adopt an existing accounting ledger');
|
|
114
|
+
const st = privateRegular(ledger);
|
|
115
|
+
writePrivateNew(completedPath, JSON.stringify({ schema: 'arcfold-desktop-initialization-completed-v1', inputHash, ledger: { dev: st.dev, ino: st.ino } }) + '\n');
|
|
116
|
+
return { created: true, configPath, ...desktopConfiguration(runtimeOptions) };
|
|
117
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
import { resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek-api-key';
|
|
3
|
+
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
|
|
4
|
+
import { loadDesktopDeepSeekAdapter } from './adapter.mjs';
|
|
5
|
+
|
|
6
|
+
export const name = 'arcfold-desktop-deepseek-provider';
|
|
7
|
+
export const inject = ['llm', 'credentials', 'arcfoldDeepSeekTransport'];
|
|
8
|
+
export async function apply(ctx, config = {}) {
|
|
9
|
+
const { catalogModelInfo, registerDeepSeekProvider } = await loadDesktopDeepSeekAdapter();
|
|
10
|
+
const service = ctx.arcfoldDeepSeekTransport;
|
|
11
|
+
const { provider, endpoint, modelPolicies } = service.configuration;
|
|
12
|
+
if (provider !== 'deepseek-official' || !Object.keys(modelPolicies).length)
|
|
13
|
+
throw new Error('An explicit metered DeepSeek model catalog is required');
|
|
14
|
+
if (typeof config.apiKeyRef !== 'string' || !config.apiKeyRef ||
|
|
15
|
+
!Number.isSafeInteger(config.maxTokens) || config.maxTokens < 1 ||
|
|
16
|
+
!['off', 'low', 'high', 'max'].includes(config.reasoningEffort))
|
|
17
|
+
throw new Error('A credential reference, output limit and reasoning effort are required');
|
|
18
|
+
const models = Object.entries(modelPolicies).map(([id, policy]) => {
|
|
19
|
+
if (config.maxTokens > (policy.requestMaxTokens ?? policy.maxOutputTokens) ||
|
|
20
|
+
!policy.reasoningEfforts.includes(config.reasoningEffort))
|
|
21
|
+
throw new Error('Provider defaults exceed a frozen model accounting policy');
|
|
22
|
+
return { id, name: id, inputModalities: ['text'], contextWindow: policy.contextWindow,
|
|
23
|
+
maxTokens: config.maxTokens };
|
|
24
|
+
});
|
|
25
|
+
// This deployment generation is fixed. A different route/catalog requires a
|
|
26
|
+
// plugin reload; the user-selected model remains an ordinary DSH option.
|
|
27
|
+
const connection = resolveAdapterOptions({
|
|
28
|
+
baseURL: endpoint.slice(0, -'/v1/messages'.length), apiKeyEnv: config.apiKeyRef,
|
|
29
|
+
models, maxTokens: config.maxTokens, reasoningEffort: config.reasoningEffort,
|
|
30
|
+
retryPolicy: { mode: 'normal', maxRetries: 0 },
|
|
31
|
+
}, launchEnvironmentOf(ctx));
|
|
32
|
+
for (const model of connection.models) Object.freeze(model);
|
|
33
|
+
Object.freeze(connection.models); Object.freeze(connection);
|
|
34
|
+
registerDeepSeekProvider(ctx, provider, {
|
|
35
|
+
providerName: 'DeepSeek', options: () => connection, connect: async () => connection,
|
|
36
|
+
createTransport: options => service.createTransport(options),
|
|
37
|
+
// Resolve secrets only inside the official credential service. They remain
|
|
38
|
+
// in memory for the request and never enter Arcfold config or evidence.
|
|
39
|
+
resolveAuth: async current => {
|
|
40
|
+
const credential = await ctx.credentials.resolve(current.apiKeyEnv);
|
|
41
|
+
if (!credential) throw new LlmError('Configure the DeepSeek credential in the host Models page', 'MISSING_CREDENTIAL');
|
|
42
|
+
return { headers: { 'x-api-key': assertUsableApiKey(credential.value, name, current.apiKeyEnv) } };
|
|
43
|
+
},
|
|
44
|
+
discoverModels: async route => connection.models.map(model => catalogModelInfo(route, model)),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { accessSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join, relative } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const desktopPython = Object.freeze({
|
|
7
|
+
path: 'runtime/desktop-python.tar.gz',
|
|
8
|
+
url: 'https://github.com/astral-sh/python-build-standalone/releases/download/20260901/cpython-3.12.14%2B20260901-aarch64-apple-darwin-install_only_stripped.tar.gz',
|
|
9
|
+
bytes: 24981445,
|
|
10
|
+
sha256: '81a359f1cfadd4da11766534c5913791cea55f26e1bb902cacd2a531bb1e4b2b',
|
|
11
|
+
// runtimeDigest of the pinned archive freshly unpacked by /usr/bin/tar.
|
|
12
|
+
treeSha256: '59df7f39ee074442e75a776aacf8810c51b41a64ef5d37ad5e90da39a2a808d7',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const executable = 'python/bin/python3.12';
|
|
16
|
+
const cache = home => join(home, 'arcfold-desktop-counter');
|
|
17
|
+
|
|
18
|
+
export function readDesktopPythonArchive(file) {
|
|
19
|
+
const stat = lstatSync(file);
|
|
20
|
+
if (!stat.isFile() || stat.size !== desktopPython.bytes)
|
|
21
|
+
throw new Error('Arcfold 文本计数文件不完整,请重新安装此版本。');
|
|
22
|
+
const bytes = readFileSync(file);
|
|
23
|
+
if (createHash('sha256').update(bytes).digest('hex') !== desktopPython.sha256)
|
|
24
|
+
throw new Error('Arcfold 文本计数文件校验失败,请重新安装此版本。');
|
|
25
|
+
return bytes;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isBundledDesktopPython(home, path) {
|
|
29
|
+
return typeof home === 'string' && typeof path === 'string' &&
|
|
30
|
+
/^[a-f0-9]{64}\/python\/bin\/python3\.\d+$/u.test(relative(cache(home), path));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function requirePrivateDirectory(path) {
|
|
34
|
+
const stat = lstatSync(path);
|
|
35
|
+
if (!stat.isDirectory() || stat.uid !== process.getuid() || (stat.mode & 0o077))
|
|
36
|
+
throw new Error('Arcfold 文本计数目录的所有者或权限不符合要求。');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function runtimeDigest(root) {
|
|
40
|
+
const hash = createHash('sha256');
|
|
41
|
+
function visit(directory, prefix) {
|
|
42
|
+
for (const name of readdirSync(directory).sort()) {
|
|
43
|
+
const path = join(directory, name);
|
|
44
|
+
const entry = prefix + name;
|
|
45
|
+
const stat = lstatSync(path);
|
|
46
|
+
let record;
|
|
47
|
+
if (stat.isDirectory()) {
|
|
48
|
+
record = [entry, 'directory'];
|
|
49
|
+
} else if (stat.isSymbolicLink()) {
|
|
50
|
+
record = [entry, 'link', readlinkSync(path)];
|
|
51
|
+
} else if (stat.isFile()) {
|
|
52
|
+
record = [entry, 'file', createHash('sha256').update(readFileSync(path)).digest('hex')];
|
|
53
|
+
} else {
|
|
54
|
+
throw new Error('Arcfold 文本计数缓存校验失败。');
|
|
55
|
+
}
|
|
56
|
+
hash.update(JSON.stringify(record) + '\n');
|
|
57
|
+
if (stat.isDirectory()) visit(path, entry + '/');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
visit(root, '');
|
|
61
|
+
return hash.digest('hex');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function installedPython(path) {
|
|
65
|
+
requirePrivateDirectory(path);
|
|
66
|
+
if (runtimeDigest(path) !== desktopPython.treeSha256)
|
|
67
|
+
throw new Error('Arcfold 文本计数缓存校验失败,请联系维护者修复此配置下的缓存。');
|
|
68
|
+
const file = join(path, executable);
|
|
69
|
+
if (!lstatSync(file).isFile() || realpathSync(file) !== file)
|
|
70
|
+
throw new Error('Arcfold 文本计数程序无效,请检查本机安装。');
|
|
71
|
+
accessSync(file, constants.X_OK);
|
|
72
|
+
return file;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Extract the checked, relocatable runtime into this Desktop profile. */
|
|
76
|
+
export function prepareBundledDesktopPython(packageRoot, home) {
|
|
77
|
+
if (process.platform !== 'darwin' || process.arch !== 'arm64')
|
|
78
|
+
throw new Error('当前 Arcfold Desktop 安装包需要配备 Apple 芯片的 Mac。');
|
|
79
|
+
const archive = join(packageRoot, desktopPython.path);
|
|
80
|
+
if (!existsSync(archive)) throw new Error('当前安装包缺少 Arcfold 文本计数环境,请重新安装此版本。');
|
|
81
|
+
const bytes = readDesktopPythonArchive(archive);
|
|
82
|
+
const directory = cache(realpathSync(home));
|
|
83
|
+
mkdirSync(directory, { mode: 0o700, recursive: true });
|
|
84
|
+
requirePrivateDirectory(directory);
|
|
85
|
+
const destination = join(directory, desktopPython.sha256);
|
|
86
|
+
if (existsSync(destination)) return installedPython(destination);
|
|
87
|
+
const stage = mkdtempSync(join(directory, '.install-'));
|
|
88
|
+
try {
|
|
89
|
+
const stagedArchive = join(stage, 'runtime.tar.gz');
|
|
90
|
+
writeFileSync(stagedArchive, bytes, { mode: 0o600, flag: 'wx' });
|
|
91
|
+
execFileSync('/usr/bin/tar', ['-xzf', stagedArchive, '-C', stage], {
|
|
92
|
+
stdio: ['ignore', 'ignore', 'pipe'], timeout: 60_000,
|
|
93
|
+
});
|
|
94
|
+
rmSync(stagedArchive);
|
|
95
|
+
installedPython(stage);
|
|
96
|
+
try {
|
|
97
|
+
renameSync(stage, destination);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!['EEXIST', 'ENOTEMPTY'].includes(error.code)) throw error;
|
|
100
|
+
}
|
|
101
|
+
return installedPython(destination);
|
|
102
|
+
} finally {
|
|
103
|
+
rmSync(stage, { force: true, recursive: true });
|
|
104
|
+
}
|
|
105
|
+
}
|