@awak-app/simy-cli 0.7.16 → 0.7.18
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 +180 -26
- package/package.json +3 -2
- package/src/agent.js +128 -7
- package/src/bounded-local-task-subtask-pool.js +32 -6
- package/src/bounded-local-task-subtasks-contract.js +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +2 -2
- package/src/codex-plugins/simy/hooks/README.md +8 -3
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +261 -67
- package/src/codex-plugins/simy/hooks/hooks.json +1 -1
- package/src/codex-plugins/simy/hooks/loop-kernel/conformance.json +136 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/index.mjs +42 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/outcome-review.mjs +31 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/pin.json +6 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/policy.json +141 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/task-pool.mjs +34 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +132 -0
- package/src/codex-plugins/simy/hooks/loop-kernel.mjs +6 -0
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +15 -3
- package/src/codex-plugins/simy/hooks/purpose-completion-contract.mjs +10 -0
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +8 -6
- package/src/codex-plugins/simy/hooks/result-cycle.mjs +32 -6
- package/src/codex-plugins/simy/hooks/work-frontier.mjs +2 -0
- package/src/codex-plugins/simy/skills/diagnose/SKILL.md +10 -4
- package/src/coding-run-recovery.js +4 -2
- package/src/delivery-hooks/claude-workflow.md +10 -7
- package/src/delivery-hooks/command.js +45 -10
- package/src/delivery-hooks/consult.js +60 -0
- package/src/delivery-hooks/diagnosis.js +2 -2
- package/src/delivery-hooks/legacy-hook-routing.js +46 -8
- package/src/delivery-hooks/local-runtime.js +50 -1
- package/src/delivery-hooks/local-supervisor.js +1 -1
- package/src/delivery-hooks/repair-continuation.js +80 -0
- package/src/delivery-hooks/repair.js +164 -0
- package/src/delivery-hooks/supervisor-service.js +3 -1
- package/src/delivery-hooks/supervisor.js +88 -12
- package/src/desktop-access.js +2 -1
- package/src/desktop-host.js +62 -2
- package/src/durable-local-task-worker.js +372 -58
- package/src/execution-capability-contract.js +15 -1
- package/src/index.js +3 -3
- package/src/local-photos-read.swift +33 -0
- package/src/local-photos.js +47 -0
- package/src/local-task-attachment-store.js +31 -0
- package/src/local-task-destination.js +174 -0
- package/src/local-task.js +119 -7
- package/src/native-delegation-contract.js +68 -0
- package/src/native-provider-identity.js +60 -0
- package/src/orchestrator/budget.js +22 -11
- package/src/orchestrator/independent-audit.js +9 -18
- package/src/orchestrator/loop.js +27 -59
- package/src/orchestrator/recovery.js +32 -2
- package/src/provider-delegation.js +336 -0
- package/src/runner.js +2 -1
- package/src/session-supervision/artifact-observer.js +138 -0
- package/src/session-supervision/claude-sessions.js +5 -3
- package/src/session-supervision/codex-app-server.js +20 -5
- package/src/session-supervision/completion-artifacts.js +44 -0
- package/src/session-supervision/consistency-auditor.js +8 -11
- package/src/session-supervision/continuation-analysis.js +46 -28
- package/src/session-supervision/discovery-window.js +1 -1
- package/src/session-supervision/final-response-recovery.js +5 -3
- package/src/session-supervision/index.js +25 -8
- package/src/session-supervision/purpose-recovery.js +12 -3
- package/src/session-supervision/purpose-verification.js +9 -57
- package/src/session-supervision/recovery-frontier.js +55 -0
- package/src/session-supervision/remote-hosts.js +1 -1
- package/src/session-supervision/stop-notifications.js +8 -8
- package/src/sqm/agent-skill.js +1 -1
- package/src/sqm/checkers.js +12 -6
- package/src/sqm/claude-plugin-update.js +22 -8
- package/src/sqm/claude-plugin.js +4 -0
- package/src/sqm/local-tests.js +2 -2
- package/src/sqm/plugin-cache.js +67 -0
- package/src/sqm/proof.js +25 -0
- package/src/sqm/report.js +1 -0
- package/src/sqm/workflow-onboarding.js +3 -39
- package/src/sqm/workflow-update.js +17 -6
- package/src/team-status-fence-store.js +32 -0
- package/src/team-threads.js +478 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PROVIDER_LINKS } from './provider-links.js';
|
|
2
|
+
import { providerDelegationCoverage } from './provider-delegation.js';
|
|
1
3
|
import { localTaskFileCapabilityMetadata } from "./local-task-file-capabilities.js";
|
|
2
4
|
|
|
3
5
|
export const EXECUTION_CAPABILITY_SCHEMA_VERSION = "execution_capability.v1";
|
|
@@ -8,10 +10,11 @@ export function buildExecutionCapabilityContract({
|
|
|
8
10
|
backendVersions = {},
|
|
9
11
|
providerStatuses,
|
|
10
12
|
authenticated = false,
|
|
13
|
+
platform = process.platform,
|
|
11
14
|
} = {}) {
|
|
12
15
|
const artifactTypes = supportedArtifactTypes();
|
|
13
16
|
return Object.freeze(
|
|
14
|
-
|
|
17
|
+
PROVIDER_LINKS.map((provider) =>
|
|
15
18
|
Object.freeze(
|
|
16
19
|
localExecutionCapability({
|
|
17
20
|
provider,
|
|
@@ -20,6 +23,7 @@ export function buildExecutionCapabilityContract({
|
|
|
20
23
|
authenticated,
|
|
21
24
|
authStatus: providerStatuses?.[provider],
|
|
22
25
|
artifactTypes,
|
|
26
|
+
platform,
|
|
23
27
|
}),
|
|
24
28
|
),
|
|
25
29
|
),
|
|
@@ -33,6 +37,7 @@ function localExecutionCapability({
|
|
|
33
37
|
authenticated,
|
|
34
38
|
authStatus,
|
|
35
39
|
artifactTypes,
|
|
40
|
+
platform,
|
|
36
41
|
}) {
|
|
37
42
|
const binary = normalizeInspection(provider, inspection, legacyAvailable);
|
|
38
43
|
const providerAuth = authStatus?.state === "ready" ? "ready"
|
|
@@ -49,6 +54,7 @@ function localExecutionCapability({
|
|
|
49
54
|
capability_contract_version: EXECUTION_CAPABILITY_CONTRACT_VERSION,
|
|
50
55
|
execution_target: "local",
|
|
51
56
|
provider,
|
|
57
|
+
delegation: providerDelegationCoverage([{ provider }])[0],
|
|
52
58
|
// The CLI does not inspect Provider-managed skills, MCP configuration, or
|
|
53
59
|
// connectors. Unknown is materially different from unavailable.
|
|
54
60
|
skills: { status: "unknown", identifiers: [] },
|
|
@@ -56,6 +62,14 @@ function localExecutionCapability({
|
|
|
56
62
|
connectors: [],
|
|
57
63
|
cli_binaries: [binary.public],
|
|
58
64
|
browser_capability: { status: "unknown", modes: [] },
|
|
65
|
+
device_io: {
|
|
66
|
+
schema_version: "local_device_io.v1",
|
|
67
|
+
native_output_staging: provider === "codex" ? "verified_native_cwd" : "provider_permission_flow",
|
|
68
|
+
local_destination_scopes: provider === "codex" ? ["official_native_writable_roots", "authorized_repository"] : ["authorized_repository"],
|
|
69
|
+
selected_photos_metadata: platform === "darwin" ? "requires_existing_authorization" : "unavailable",
|
|
70
|
+
runtime_authorization_required: true,
|
|
71
|
+
requests_new_permissions: false,
|
|
72
|
+
},
|
|
59
73
|
supported_input_artifact_types: [...artifactTypes],
|
|
60
74
|
supported_output_artifact_types: [...artifactTypes],
|
|
61
75
|
side_effect_classes: {
|
package/src/index.js
CHANGED
|
@@ -68,7 +68,7 @@ if (!configuredWebOrigin) {
|
|
|
68
68
|
} else if (dispatchArgv[0] === "doctor") {
|
|
69
69
|
const { runDoctor } = await import("./doctor.js");
|
|
70
70
|
process.exitCode = await runDoctor({ webOrigin: configuredWebOrigin, cliVersion: packageInfo.version });
|
|
71
|
-
} else if (dispatchArgv[0] === "delivery" && ["activity", "runtime", "supervise", "diagnose"].includes(dispatchArgv[1])) {
|
|
71
|
+
} else if (dispatchArgv[0] === "delivery" && ["activity", "runtime", "supervise", "diagnose", "consult"].includes(dispatchArgv[1])) {
|
|
72
72
|
// A local observation must not install updates or require a network connection.
|
|
73
73
|
await runCommand();
|
|
74
74
|
} else if (dispatchArgv[0] === "update") {
|
|
@@ -160,7 +160,7 @@ Options:
|
|
|
160
160
|
--port <port> Bind a specific localhost port
|
|
161
161
|
--host <url> Connect to a SIMY Web origin (default: ${DEFAULT_WEB_ORIGIN})
|
|
162
162
|
--install-workflows Install or update SIMY Codex/Claude opt-in SQM skills
|
|
163
|
-
--no-workflow-prompt Skip
|
|
163
|
+
--no-workflow-prompt Skip initial workflow preparation and background installation of missing sources
|
|
164
164
|
--no-auto-update Disable update checks and automatic installation
|
|
165
165
|
|
|
166
166
|
${autoUpdateHelpText({ installMode, webOrigin: configuredWebOrigin })}
|
|
@@ -256,7 +256,7 @@ async function runLocalAgent() {
|
|
|
256
256
|
webOrigin,
|
|
257
257
|
installMode: updateManager.snapshot().install_mode,
|
|
258
258
|
});
|
|
259
|
-
stopWorkflowUpdates = startWorkflowUpdates({ enabled: daemon && !updatesDisabled });
|
|
259
|
+
stopWorkflowUpdates = startWorkflowUpdates({ enabled: daemon && !updatesDisabled, installMissing: !args.has('--no-workflow-prompt') });
|
|
260
260
|
updateManager.start({
|
|
261
261
|
registry: agent.registry,
|
|
262
262
|
restartDaemon: async (targetVersion, installation) => {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Photos
|
|
3
|
+
|
|
4
|
+
// Bounded PhotoKit metadata only. Never request permission, enumerate the
|
|
5
|
+
// library, read its database, fetch cloud originals, or perform changes.
|
|
6
|
+
func emit(_ value: [String: Any]) {
|
|
7
|
+
let data = try! JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
|
|
8
|
+
print(String(data: data, encoding: .utf8)!)
|
|
9
|
+
}
|
|
10
|
+
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
|
11
|
+
guard status == .authorized || status == .limited else {
|
|
12
|
+
emit(["status": "permission_required", "authorization": status.rawValue, "assets": []])
|
|
13
|
+
exit(0)
|
|
14
|
+
}
|
|
15
|
+
guard CommandLine.arguments.count == 2,
|
|
16
|
+
let data = CommandLine.arguments[1].data(using: .utf8),
|
|
17
|
+
let request = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
18
|
+
let ids = request["asset_ids"] as? [String], !ids.isEmpty, ids.count <= 20 else {
|
|
19
|
+
emit(["status": "invalid_request", "assets": []]); exit(0)
|
|
20
|
+
}
|
|
21
|
+
let found = PHAsset.fetchAssets(withLocalIdentifiers: ids, options: nil)
|
|
22
|
+
var assets: [[String: Any]] = []
|
|
23
|
+
found.enumerateObjects { asset, _, _ in
|
|
24
|
+
var value: [String: Any] = ["id": asset.localIdentifier, "pixel_width": asset.pixelWidth,
|
|
25
|
+
"pixel_height": asset.pixelHeight, "media_type": asset.mediaType.rawValue]
|
|
26
|
+
if let date = asset.creationDate { value["created_at"] = ISO8601DateFormatter().string(from: date) }
|
|
27
|
+
if let location = asset.location {
|
|
28
|
+
value["latitude"] = location.coordinate.latitude
|
|
29
|
+
value["longitude"] = location.coordinate.longitude
|
|
30
|
+
}
|
|
31
|
+
assets.append(value)
|
|
32
|
+
}
|
|
33
|
+
emit(["status": "ready", "authorization": status.rawValue, "assets": assets])
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
const execute = promisify(execFile);
|
|
5
|
+
const fail = code => Object.assign(new Error(code), { code });
|
|
6
|
+
|
|
7
|
+
export function normalizeLocalPhotoSource(value) {
|
|
8
|
+
if (value === undefined || value === null) return null;
|
|
9
|
+
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
10
|
+
Object.keys(value).some(key => !["device_id", "asset_ids"].includes(key)) ||
|
|
11
|
+
typeof value.device_id !== "string" || !value.device_id ||
|
|
12
|
+
!Array.isArray(value.asset_ids) || !value.asset_ids.length || value.asset_ids.length > 20 ||
|
|
13
|
+
value.asset_ids.some(id => typeof id !== "string" || !id || id.length > 256 || /[\u0000-\u001f]/.test(id)) ||
|
|
14
|
+
new Set(value.asset_ids).size !== value.asset_ids.length) throw fail("local_photos_source_invalid");
|
|
15
|
+
return { device_id: value.device_id, asset_ids: [...value.asset_ids] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readPhotoMetadataWithSwift(request, { run = execute } = {}) {
|
|
19
|
+
// /usr/bin/swift may launch a developer-tools installer on an unprepared Mac.
|
|
20
|
+
// A read-only preflight fails closed instead; this adapter never installs it.
|
|
21
|
+
try { await run("/usr/bin/xcode-select", ["-p"], { timeout: 5_000, maxBuffer: 4096 }); }
|
|
22
|
+
catch { throw fail("local_photos_adapter_unavailable"); }
|
|
23
|
+
return JSON.parse((await run("/usr/bin/swift", [fileURLToPath(new URL("./local-photos-read.swift", import.meta.url)), JSON.stringify(request)],
|
|
24
|
+
{ timeout: 30_000, maxBuffer: 64 * 1024 })).stdout);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function readLocalPhotoSource(value, { deviceId, platform = process.platform,
|
|
28
|
+
read = readPhotoMetadataWithSwift } = {}) {
|
|
29
|
+
const source = normalizeLocalPhotoSource(value);
|
|
30
|
+
if (!source) return null;
|
|
31
|
+
if (source.device_id !== deviceId) throw fail("local_photos_device_mismatch");
|
|
32
|
+
if (platform !== "darwin") throw fail("local_photos_adapter_unavailable");
|
|
33
|
+
const result = await read(source);
|
|
34
|
+
if (result?.status !== "ready" || ![3, 4].includes(result.authorization)) throw fail("local_photos_existing_permission_required");
|
|
35
|
+
if (!Array.isArray(result.assets) || result.assets.length !== source.asset_ids.length ||
|
|
36
|
+
new Set(result.assets.map(asset => asset?.id)).size !== source.asset_ids.length ||
|
|
37
|
+
result.assets.some(asset => !source.asset_ids.includes(asset?.id))) throw fail("local_photos_selection_unavailable");
|
|
38
|
+
const assets = result.assets.map(asset => {
|
|
39
|
+
const record = { id: asset.id };
|
|
40
|
+
for (const key of ["pixel_width", "pixel_height", "media_type", "latitude", "longitude"]) {
|
|
41
|
+
if (Number.isFinite(asset[key])) record[key] = asset[key];
|
|
42
|
+
}
|
|
43
|
+
if (typeof asset.created_at === "string" && Number.isFinite(Date.parse(asset.created_at))) record.created_at = asset.created_at;
|
|
44
|
+
return record;
|
|
45
|
+
});
|
|
46
|
+
return { schema_version: "local_photos_metadata.v1", device_id: deviceId, assets };
|
|
47
|
+
}
|
|
@@ -249,6 +249,37 @@ export function createLocalTaskAttachmentStore({ root }) {
|
|
|
249
249
|
return materialized;
|
|
250
250
|
},
|
|
251
251
|
|
|
252
|
+
// Only the durable worker passes a prior task from its restored registry.
|
|
253
|
+
// Ref IDs, bytes and roots remain verified through the existing input contract.
|
|
254
|
+
async materializeRetained({ refs, deviceId, inputsRoot, retainedTask }) {
|
|
255
|
+
const values = validateRefs(refs, requireDeviceId(deviceId));
|
|
256
|
+
const targetRoot = path.resolve(inputsRoot);
|
|
257
|
+
await mkdir(targetRoot, { recursive: true, mode: 0o700 });
|
|
258
|
+
const materialized = [];
|
|
259
|
+
const parentRoot = retainedTask?.inputs_root;
|
|
260
|
+
if (!parentRoot || !retainedTask.task_root || path.resolve(parentRoot) !== path.join(path.resolve(retainedTask.task_root), 'inputs') ||
|
|
261
|
+
!(await lstat(parentRoot)).isDirectory() || (await lstat(parentRoot)).isSymbolicLink() ||
|
|
262
|
+
(await lstat(retainedTask.task_root)).isSymbolicLink())
|
|
263
|
+
throw storeError('Retained input root is unavailable.', 'local_task_attachment_unsafe_storage', 'reattach_file');
|
|
264
|
+
for (const ref of values) {
|
|
265
|
+
const input = retainedTask.attachments?.find(item => item.id === ref.attachment_id && item.name === ref.name &&
|
|
266
|
+
item.mime_type === ref.mime_type && item.sha256 === ref.sha256 && item.size_bytes === ref.size_bytes);
|
|
267
|
+
if (!input?.local_path || !retainedTask.inputs_root) throw storeError('Retained input is unavailable.', 'local_task_attachment_ref_not_found', 'reattach_file');
|
|
268
|
+
await assertManagedRegularFile(input.local_path, retainedTask.inputs_root);
|
|
269
|
+
const bytes = await readFile(input.local_path);
|
|
270
|
+
const validated = validateLocalTaskFile({ ...ref, bytes }, { direction: 'input', requireBytes: true });
|
|
271
|
+
const localPath = path.join(targetRoot, `${ref.attachment_id}-${validated.name}`);
|
|
272
|
+
try { await writeExclusive(localPath, bytes); }
|
|
273
|
+
catch (error) {
|
|
274
|
+
if (error.code !== 'EEXIST') throw error;
|
|
275
|
+
await assertManagedRegularFile(localPath, targetRoot);
|
|
276
|
+
validateLocalTaskFile({ ...ref, bytes: await readFile(localPath) }, { direction: 'input', requireBytes: true });
|
|
277
|
+
}
|
|
278
|
+
materialized.push({ ...input, local_path: localPath, sha256: validated.sha256, integrity_status: 'verified', executor_handoff_status: 'pending' });
|
|
279
|
+
}
|
|
280
|
+
return materialized;
|
|
281
|
+
},
|
|
282
|
+
|
|
252
283
|
async cleanup({ refs, deviceId }) {
|
|
253
284
|
const normalizedDeviceId = requireDeviceId(deviceId);
|
|
254
285
|
const values = validateRefs(refs, normalizedDeviceId);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open, lstat, realpath, link, unlink, mkdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { localTaskArtifact } from "./local-task.js";
|
|
6
|
+
|
|
7
|
+
function reject(message) {
|
|
8
|
+
return Object.assign(new Error(message), { code: "local_task_destination_unconfirmed" });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Paths from cloud/native requests never grant a writable folder by themselves.
|
|
12
|
+
export function normalizeLocalTaskDestination(value) {
|
|
13
|
+
if (value === undefined || value === null) return null;
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
15
|
+
Object.keys(value).some((key) => !["device_id", "repository", "authorized_root", "relative_directory"].includes(key)) ||
|
|
16
|
+
typeof value.device_id !== "string" || !value.device_id.trim() ||
|
|
17
|
+
((typeof value.repository !== "string" || !value.repository.trim()) &&
|
|
18
|
+
(typeof value.authorized_root !== "string" || !path.isAbsolute(value.authorized_root))) ||
|
|
19
|
+
(value.repository !== undefined && value.authorized_root !== undefined) ||
|
|
20
|
+
typeof value.relative_directory !== "string") throw reject("Invalid local save destination.");
|
|
21
|
+
const relative = value.relative_directory;
|
|
22
|
+
if (relative && (relative.includes("\\") || /[\u0000-\u001f\u007f:]/.test(relative) ||
|
|
23
|
+
relative.split("/").some((part) => !part || part === "." || part === ".."))) {
|
|
24
|
+
throw reject("The save destination must be an existing directory inside the authorized workspace.");
|
|
25
|
+
}
|
|
26
|
+
return { device_id: value.device_id, ...(value.authorized_root !== undefined ? { authorized_root: value.authorized_root } : { repository: value.repository }), relative_directory: relative };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function resolveLocalTaskDestination(spec, { deviceId, resolveRepository, nativeWritableRoots = [] }) {
|
|
30
|
+
const value = normalizeLocalTaskDestination(spec?.local_destination);
|
|
31
|
+
if (!value) return null;
|
|
32
|
+
if (value.device_id !== deviceId || spec.operation !== "execute" ||
|
|
33
|
+
spec.permission_mode !== "workspace_write" ||
|
|
34
|
+
(value.repository && (spec.workspace_mode !== "repository" || value.repository !== spec.repository))) {
|
|
35
|
+
throw reject("The save destination does not match this device and its authorized workspace.");
|
|
36
|
+
}
|
|
37
|
+
const relative = value.relative_directory;
|
|
38
|
+
let root;
|
|
39
|
+
if (value.authorized_root) {
|
|
40
|
+
root = path.resolve(value.authorized_root);
|
|
41
|
+
if (!nativeWritableRoots.some(allowed => typeof allowed === "string" && path.isAbsolute(allowed) &&
|
|
42
|
+
(root === path.resolve(allowed) || root.startsWith(path.resolve(allowed).replace(/\/$/, "") + path.sep)))) {
|
|
43
|
+
throw reject("This folder is outside the existing native session's writable roots. Select it through the original provider's workspace permission flow.");
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
const repository = await resolveRepository(value.repository);
|
|
47
|
+
if (!repository?.local_path) throw reject("The save destination is unavailable on this device.");
|
|
48
|
+
root = path.resolve(repository.local_path);
|
|
49
|
+
}
|
|
50
|
+
const destination = { device_id: deviceId, root, directory: path.join(root, relative), relative };
|
|
51
|
+
try { await assertDirectory(destination); }
|
|
52
|
+
catch (error) { throw destinationFilesystemError(error); }
|
|
53
|
+
return destination;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function assertDirectory(destination) {
|
|
57
|
+
let current = destination.root;
|
|
58
|
+
for (const part of ["", ...destination.relative.split("/").filter(Boolean)]) {
|
|
59
|
+
if (part) current = path.join(current, part);
|
|
60
|
+
const info = await lstat(current);
|
|
61
|
+
if (!info.isDirectory() || info.isSymbolicLink() || await realpath(current) !== current) {
|
|
62
|
+
throw reject("The save destination is no longer a safe local directory.");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Artifact staging is placed inside the already verified native cwd, not the
|
|
68
|
+
// unrelated CLI sandbox. It grants no new root and never relocates the session.
|
|
69
|
+
export async function prepareNativeTaskOutputs(task, nativeCwd, { assertActive = () => {} } = {}) {
|
|
70
|
+
if (!/^local_task_[A-Za-z0-9_-]+$/.test(task?.id || "") || !path.isAbsolute(nativeCwd || "")) {
|
|
71
|
+
throw reject("The native output workspace is unavailable.");
|
|
72
|
+
}
|
|
73
|
+
const root = await realpath(nativeCwd);
|
|
74
|
+
const destination = { root, directory: root, relative: "" };
|
|
75
|
+
await assertDirectory(destination);
|
|
76
|
+
let current = root;
|
|
77
|
+
for (const part of [".simy", "local-tasks", task.id, "outputs"]) {
|
|
78
|
+
await assertActive();
|
|
79
|
+
current = path.join(current, part);
|
|
80
|
+
await mkdir(current, { mode: 0o700 }).catch(error => { if (error.code !== "EEXIST") throw error; });
|
|
81
|
+
const info = await lstat(current);
|
|
82
|
+
if (!info.isDirectory() || info.isSymbolicLink() || await realpath(current) !== current) {
|
|
83
|
+
throw reject("The native output workspace is no longer a safe directory.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await assertActive();
|
|
87
|
+
await assertDirectory(destination);
|
|
88
|
+
task.native_workspace_cwd = root;
|
|
89
|
+
task.outputs_root = current;
|
|
90
|
+
task.outputs_root_expected = current;
|
|
91
|
+
return current;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function saveLocalTaskDestination(options) {
|
|
95
|
+
try { return await saveDestination(options); }
|
|
96
|
+
catch (error) { throw destinationFilesystemError(error); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function destinationFilesystemError(error) {
|
|
100
|
+
if (!error?.syscall) return error;
|
|
101
|
+
return Object.assign(reject("The save destination is no longer a safe local directory."), { cause: error });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function saveDestination({ task, destination, assertActive = () => {}, onReceipt = async () => {} }) {
|
|
105
|
+
if (!destination) return [];
|
|
106
|
+
const receipts = [];
|
|
107
|
+
const names = new Set();
|
|
108
|
+
if (!task.artifacts?.length) throw reject("No verified output is available to save.");
|
|
109
|
+
for (const ref of task.artifacts) {
|
|
110
|
+
if (!ref.name || path.basename(ref.name) !== ref.name || ref.name.includes("\\") ||
|
|
111
|
+
ref.name === "." || ref.name === ".." || names.has(ref.name)) {
|
|
112
|
+
throw reject("The output files have conflicting or unsafe destination names.");
|
|
113
|
+
}
|
|
114
|
+
names.add(ref.name);
|
|
115
|
+
}
|
|
116
|
+
for (const ref of task.artifacts) {
|
|
117
|
+
await assertActive();
|
|
118
|
+
await assertDirectory(destination);
|
|
119
|
+
const verified = await localTaskArtifact(task, ref.id);
|
|
120
|
+
if (!verified) throw reject("The latest output could not be verified before saving.");
|
|
121
|
+
const source = await open(verified.local_path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
122
|
+
let bytes;
|
|
123
|
+
try { bytes = await source.readFile(); } finally { await source.close(); }
|
|
124
|
+
if (bytes.length !== ref.size_bytes || digest(bytes) !== ref.sha256.toLowerCase()) {
|
|
125
|
+
throw reject("The output changed before saving.");
|
|
126
|
+
}
|
|
127
|
+
const localPath = path.join(destination.directory, ref.name);
|
|
128
|
+
let target;
|
|
129
|
+
const temporaryPath = path.join(destination.directory, `.simy-save-${randomUUID()}.tmp`);
|
|
130
|
+
let temporary;
|
|
131
|
+
try {
|
|
132
|
+
temporary = await open(temporaryPath, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
133
|
+
await temporary.writeFile(bytes);
|
|
134
|
+
await temporary.sync();
|
|
135
|
+
await assertActive();
|
|
136
|
+
await assertDirectory(destination);
|
|
137
|
+
try {
|
|
138
|
+
// Publish without replacing an existing file. A stopped/failed copy
|
|
139
|
+
// leaves no truncated destination that could obstruct a safe replay.
|
|
140
|
+
await link(temporaryPath, localPath);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error.code !== "EEXIST") throw error;
|
|
143
|
+
}
|
|
144
|
+
// Replay confirms identical bytes, but never overwrites another revision.
|
|
145
|
+
target = await open(localPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
146
|
+
} finally {
|
|
147
|
+
await temporary?.close();
|
|
148
|
+
await unlink(temporaryPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const info = await target.stat();
|
|
152
|
+
if (!info.isFile()) throw reject("The saved output is not a regular file.");
|
|
153
|
+
const readback = Buffer.alloc(bytes.length + 1);
|
|
154
|
+
const { bytesRead } = await target.read(readback, 0, readback.length, 0);
|
|
155
|
+
if (bytesRead !== bytes.length || digest(readback.subarray(0, bytesRead)) !== ref.sha256.toLowerCase()) {
|
|
156
|
+
throw reject("The destination contains a different revision; the existing file was preserved.");
|
|
157
|
+
}
|
|
158
|
+
await assertDirectory(destination);
|
|
159
|
+
const visible = await lstat(localPath);
|
|
160
|
+
if (visible.isSymbolicLink() || visible.dev !== info.dev || visible.ino !== info.ino) {
|
|
161
|
+
throw reject("The saved path changed during verification.");
|
|
162
|
+
}
|
|
163
|
+
// Stop or authorization loss can arrive while filesystem readback awaits.
|
|
164
|
+
// Preserve any published bytes, but never issue a verified receipt then.
|
|
165
|
+
await assertActive();
|
|
166
|
+
receipts.push({ artifact_id: ref.id, sha256: ref.sha256, size_bytes: bytes.length,
|
|
167
|
+
device_id: destination.device_id, local_path: localPath, verified: true });
|
|
168
|
+
await onReceipt(receipts.at(-1));
|
|
169
|
+
} finally { await target?.close(); }
|
|
170
|
+
}
|
|
171
|
+
return receipts;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function digest(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
|