@nurix/nustack 0.10.0-dev.4
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 +77 -0
- package/dist/commands/add.js +130 -0
- package/dist/commands/agent.js +100 -0
- package/dist/commands/bootstrap.js +54 -0
- package/dist/commands/describe.js +34 -0
- package/dist/commands/device.js +159 -0
- package/dist/commands/docs.js +119 -0
- package/dist/commands/doctor.js +284 -0
- package/dist/commands/graph.js +34 -0
- package/dist/commands/handoff.js +108 -0
- package/dist/commands/init.js +280 -0
- package/dist/commands/lane.js +12 -0
- package/dist/commands/list.js +20 -0
- package/dist/commands/login.js +134 -0
- package/dist/commands/logout.js +44 -0
- package/dist/commands/open.js +54 -0
- package/dist/commands/organization.js +114 -0
- package/dist/commands/review.js +152 -0
- package/dist/commands/search.js +57 -0
- package/dist/commands/sessions.js +62 -0
- package/dist/commands/sync.js +85 -0
- package/dist/commands/telemetry.js +112 -0
- package/dist/commands/toolkit.js +167 -0
- package/dist/commands/validate.js +67 -0
- package/dist/commands/workspace-sync.js +645 -0
- package/dist/commands/workspace.js +479 -0
- package/dist/index.js +357 -0
- package/dist/lib/agent/claudeHooks.js +77 -0
- package/dist/lib/auth_grant.js +398 -0
- package/dist/lib/auth_recovery.js +49 -0
- package/dist/lib/browser_opener.js +24 -0
- package/dist/lib/device_identity.js +28 -0
- package/dist/lib/device_keychain.js +101 -0
- package/dist/lib/env_file.js +75 -0
- package/dist/lib/etna/manager.js +43 -0
- package/dist/lib/etna/profile.js +41 -0
- package/dist/lib/etna/reconcile.js +77 -0
- package/dist/lib/filebeat_installer.js +246 -0
- package/dist/lib/git-sync/core/commit_range.js +37 -0
- package/dist/lib/git-sync/core/engine.js +263 -0
- package/dist/lib/git-sync/core/git_runner.js +137 -0
- package/dist/lib/git-sync/core/operation_state.js +22 -0
- package/dist/lib/git-sync/core/path_normalizer.js +98 -0
- package/dist/lib/git-sync/core/reconciliation_lock.js +103 -0
- package/dist/lib/git-sync/core/repository.js +49 -0
- package/dist/lib/git-sync/core/watch_stream.js +86 -0
- package/dist/lib/git-sync/core/watcher.js +61 -0
- package/dist/lib/git-sync/inbound/applier.js +131 -0
- package/dist/lib/git-sync/inbound/apply_commit.js +225 -0
- package/dist/lib/git-sync/inbound/approval.js +117 -0
- package/dist/lib/git-sync/inbound/content_decision.js +148 -0
- package/dist/lib/git-sync/inbound/download.js +223 -0
- package/dist/lib/git-sync/inbound/event_sync.js +133 -0
- package/dist/lib/git-sync/inbound/index.js +10 -0
- package/dist/lib/git-sync/inbound/notification.js +39 -0
- package/dist/lib/git-sync/inbound/state_fingerprint.js +32 -0
- package/dist/lib/git-sync/inbound/technical_wait.js +39 -0
- package/dist/lib/git-sync/inbound/undo.js +198 -0
- package/dist/lib/git-sync/outbound/extraction.js +107 -0
- package/dist/lib/git-sync/outbound/history_decision.js +112 -0
- package/dist/lib/git-sync/outbound/index.js +26 -0
- package/dist/lib/git-sync/outbound/manifest_builder.js +58 -0
- package/dist/lib/git-sync/outbound/publisher.js +51 -0
- package/dist/lib/git-sync/outbound/queue.js +123 -0
- package/dist/lib/git-sync/outbound/state.js +12 -0
- package/dist/lib/git-sync/outbound/upload_client.js +141 -0
- package/dist/lib/git-sync/recovery/bundle.js +56 -0
- package/dist/lib/git-sync/recovery/crypto.js +114 -0
- package/dist/lib/git-sync/recovery/index.js +3 -0
- package/dist/lib/git-sync/recovery/store.js +55 -0
- package/dist/lib/git-sync/whitelist/index.js +4 -0
- package/dist/lib/git-sync/whitelist/matcher.js +62 -0
- package/dist/lib/git-sync/whitelist/store.js +99 -0
- package/dist/lib/git-sync/whitelist/types.js +46 -0
- package/dist/lib/git-sync/whitelist/validation.js +60 -0
- package/dist/lib/graph/answer.js +171 -0
- package/dist/lib/graph/freshness.js +29 -0
- package/dist/lib/graph/library.js +19 -0
- package/dist/lib/guards.js +106 -0
- package/dist/lib/interactive.js +12 -0
- package/dist/lib/json_output.js +8 -0
- package/dist/lib/lanes/claudeAdapter.js +135 -0
- package/dist/lib/lanes/frames.js +73 -0
- package/dist/lib/lanes/supervisor.js +247 -0
- package/dist/lib/lanes/worktree.js +33 -0
- package/dist/lib/local_store/accepted_uploads.js +49 -0
- package/dist/lib/local_store/bindings.js +163 -0
- package/dist/lib/local_store/commit_origins.js +25 -0
- package/dist/lib/local_store/database.js +71 -0
- package/dist/lib/local_store/device_state.js +72 -0
- package/dist/lib/local_store/filebeat_state.js +133 -0
- package/dist/lib/local_store/handoff_applications.js +166 -0
- package/dist/lib/local_store/history_decisions.js +48 -0
- package/dist/lib/local_store/index.js +14 -0
- package/dist/lib/local_store/migrations.js +240 -0
- package/dist/lib/local_store/paths.js +24 -0
- package/dist/lib/local_store/pending_uploads.js +76 -0
- package/dist/lib/local_store/recovery_bundles.js +66 -0
- package/dist/lib/local_store/sync_vocabulary.js +33 -0
- package/dist/lib/local_store/workspace_events_cache.js +54 -0
- package/dist/lib/loopback.js +78 -0
- package/dist/lib/manifest.js +83 -0
- package/dist/lib/nustack_client.js +547 -0
- package/dist/lib/nustack_store.js +17 -0
- package/dist/lib/onboarding/attach_existing_git.js +65 -0
- package/dist/lib/onboarding/binding.js +91 -0
- package/dist/lib/onboarding/folder_state.js +99 -0
- package/dist/lib/onboarding/git_init.js +10 -0
- package/dist/lib/onboarding/handoff_apply.js +68 -0
- package/dist/lib/onboarding/init_fresh_folder.js +100 -0
- package/dist/lib/onboarding/manifest_writer.js +161 -0
- package/dist/lib/onboarding/web_first_attach.js +133 -0
- package/dist/lib/package_manager.js +23 -0
- package/dist/lib/review/client.js +66 -0
- package/dist/lib/schema_validator.js +24 -0
- package/dist/lib/secure_file.js +10 -0
- package/dist/lib/session_telemetry.js +263 -0
- package/dist/lib/sessions/client.js +48 -0
- package/dist/lib/sessions/outsideSessions.js +54 -0
- package/dist/lib/sse_framing.js +48 -0
- package/dist/lib/telemetry/config_renderer.js +175 -0
- package/dist/lib/telemetry/health_report.js +173 -0
- package/dist/lib/telemetry/provider_paths.js +38 -0
- package/dist/lib/telemetry/redaction_policy.js +90 -0
- package/dist/lib/telemetry/reload.js +84 -0
- package/dist/lib/workspace_context.js +121 -0
- package/dist/spec_schema.json +422 -0
- package/dist/wire/auth.js +1 -0
- package/dist/wire/sync-paths.js +25 -0
- package/dist/wire/telemetry.js +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { updateBindingStatus } from "../../local_store/bindings.js";
|
|
2
|
+
import { enqueueUpload, getUpload, recordUploadAttempt, deleteUpload, } from "../../local_store/pending_uploads.js";
|
|
3
|
+
import { recordAcceptedUpload } from "../../local_store/accepted_uploads.js";
|
|
4
|
+
import { fetchWhitelist, pinWhitelist } from "../whitelist/store.js";
|
|
5
|
+
import { mediaTypeForPath } from "../whitelist/validation.js";
|
|
6
|
+
import { extractCommitDelta } from "./extraction.js";
|
|
7
|
+
import { buildGitHandoffManifest } from "./manifest_builder.js";
|
|
8
|
+
import { UploadRequestError } from "./upload_client.js";
|
|
9
|
+
const MIN_BACKOFF_MS = 60_000;
|
|
10
|
+
const MAX_BACKOFF_MS = 30 * 60_000;
|
|
11
|
+
const MAX_STALE_REPINS = 2;
|
|
12
|
+
export function backoffMs(attempts) {
|
|
13
|
+
const exponential = MIN_BACKOFF_MS * 2 ** Math.max(0, attempts - 1);
|
|
14
|
+
return Math.min(exponential, MAX_BACKOFF_MS);
|
|
15
|
+
}
|
|
16
|
+
export function enqueueCommit(store, binding, commitId, idempotencyKey, whitelistManifestSha256, manifestSha256) {
|
|
17
|
+
enqueueUpload(store, { bindingId: binding.id, commitId, idempotencyKey, whitelistManifestSha256, manifestSha256 });
|
|
18
|
+
}
|
|
19
|
+
export async function submitHead(ctx, commitId) {
|
|
20
|
+
const existing = getUpload(ctx.store, ctx.binding.id, commitId);
|
|
21
|
+
if (existing?.nextRetryAtMs != null && existing.nextRetryAtMs > ctx.now()) {
|
|
22
|
+
return { kind: "not_due" };
|
|
23
|
+
}
|
|
24
|
+
return attempt(ctx, commitId, 0);
|
|
25
|
+
}
|
|
26
|
+
async function attempt(ctx, commitId, staleDepth) {
|
|
27
|
+
const extraction = extractCommitDelta(ctx.repo, commitId, ctx.whitelistRef.current, ctx.workspaceId);
|
|
28
|
+
if (!extraction.ok) {
|
|
29
|
+
return blockBinding(ctx, commitId, extraction.code, { path: extraction.path });
|
|
30
|
+
}
|
|
31
|
+
if (extraction.delta.operations.length === 0) {
|
|
32
|
+
deleteUpload(ctx.store, ctx.binding.id, commitId);
|
|
33
|
+
return { kind: "no_op" };
|
|
34
|
+
}
|
|
35
|
+
const result = await pushOperations(ctx, commitId, extraction.delta.parentCommitId, extraction.delta.operations);
|
|
36
|
+
if (result.kind === "stale") {
|
|
37
|
+
if (staleDepth < MAX_STALE_REPINS) {
|
|
38
|
+
const repinned = await repinWhitelist(ctx);
|
|
39
|
+
if (!repinned.ok)
|
|
40
|
+
return handleFailure(ctx, commitId, repinned.error);
|
|
41
|
+
return attempt(ctx, commitId, staleDepth + 1);
|
|
42
|
+
}
|
|
43
|
+
return handleFailure(ctx, commitId, { kind: "retryable", code: "SYNC_WHITELIST_STALE" });
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
export async function pushOperations(ctx, commitId, parentCommitId, operations) {
|
|
48
|
+
const built = buildGitHandoffManifest({
|
|
49
|
+
binding: ctx.binding,
|
|
50
|
+
commitId,
|
|
51
|
+
parentCommitId,
|
|
52
|
+
whitelist: ctx.whitelistRef.current,
|
|
53
|
+
operations,
|
|
54
|
+
});
|
|
55
|
+
enqueueCommit(ctx.store, ctx.binding, commitId, built.idempotencyKey, ctx.whitelistRef.current.manifestSha256, built.manifestSha256);
|
|
56
|
+
const upserts = operations.filter((op) => op.operation === "upsert");
|
|
57
|
+
const check = await ctx.client.checkMissingBlobs(ctx.binding.id, upserts.map((op) => op.contentSha256));
|
|
58
|
+
if (!check.ok)
|
|
59
|
+
return handleFailure(ctx, commitId, check.error);
|
|
60
|
+
const absent = new Set(check.value);
|
|
61
|
+
for (const op of upserts) {
|
|
62
|
+
if (!absent.has(op.contentSha256))
|
|
63
|
+
continue;
|
|
64
|
+
const upload = await ctx.client.uploadBlob(ctx.binding.id, {
|
|
65
|
+
bytes: op.bytes,
|
|
66
|
+
sha256: op.contentSha256,
|
|
67
|
+
byteSize: op.byteSize,
|
|
68
|
+
mediaType: mediaTypeForPath(op.path) ?? "application/octet-stream",
|
|
69
|
+
});
|
|
70
|
+
if (!upload.ok)
|
|
71
|
+
return handleFailure(ctx, commitId, upload.error);
|
|
72
|
+
}
|
|
73
|
+
const submit = await ctx.client.submitGitHandoff(ctx.binding.id, built.manifest, built.idempotencyKey);
|
|
74
|
+
if (!submit.ok) {
|
|
75
|
+
if (submit.error.kind === "stale_whitelist")
|
|
76
|
+
return { kind: "stale" };
|
|
77
|
+
return handleFailure(ctx, commitId, submit.error);
|
|
78
|
+
}
|
|
79
|
+
const outcome = submit.value;
|
|
80
|
+
if ("noOp" in outcome) {
|
|
81
|
+
deleteUpload(ctx.store, ctx.binding.id, commitId);
|
|
82
|
+
return { kind: "no_op" };
|
|
83
|
+
}
|
|
84
|
+
recordAcceptedUpload(ctx.store, {
|
|
85
|
+
bindingId: ctx.binding.id,
|
|
86
|
+
commitId,
|
|
87
|
+
handoffId: outcome.accepted.handoffId,
|
|
88
|
+
workspaceSequence: outcome.accepted.workspaceSequence,
|
|
89
|
+
manifestSha256: built.manifestSha256,
|
|
90
|
+
});
|
|
91
|
+
deleteUpload(ctx.store, ctx.binding.id, commitId);
|
|
92
|
+
return { kind: "accepted", handoffId: outcome.accepted.handoffId, workspaceSequence: outcome.accepted.workspaceSequence };
|
|
93
|
+
}
|
|
94
|
+
async function repinWhitelist(ctx) {
|
|
95
|
+
try {
|
|
96
|
+
const fresh = await fetchWhitelist(ctx.client, ctx.binding.id);
|
|
97
|
+
await pinWhitelist(ctx.store, ctx.binding, fresh);
|
|
98
|
+
ctx.whitelistRef.current = fresh;
|
|
99
|
+
return { ok: true };
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error instanceof UploadRequestError)
|
|
103
|
+
return { ok: false, error: error.failure };
|
|
104
|
+
return { ok: false, error: { kind: "retryable", code: "WHITELIST_REFETCH_FAILED" } };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function handleFailure(ctx, commitId, error) {
|
|
108
|
+
if (error.kind === "terminal")
|
|
109
|
+
return blockBinding(ctx, commitId, error.code, { detail: error.detail });
|
|
110
|
+
const current = getUpload(ctx.store, ctx.binding.id, commitId);
|
|
111
|
+
const nextAttempts = (current?.attempts ?? 0) + 1;
|
|
112
|
+
recordUploadAttempt(ctx.store, ctx.binding.id, commitId, {
|
|
113
|
+
nextRetryAtMs: ctx.now() + backoffMs(nextAttempts),
|
|
114
|
+
lastErrorCode: error.code,
|
|
115
|
+
lastErrorDetail: error.detail ?? null,
|
|
116
|
+
});
|
|
117
|
+
return { kind: "retry_scheduled", code: error.code };
|
|
118
|
+
}
|
|
119
|
+
function blockBinding(ctx, commitId, code, info = {}) {
|
|
120
|
+
updateBindingStatus(ctx.store, ctx.binding.id, "failed", { code, detail: info.detail ?? info.path ?? null });
|
|
121
|
+
deleteUpload(ctx.store, ctx.binding.id, commitId);
|
|
122
|
+
return { kind: "blocked", code, ...(info.path ? { path: info.path } : {}) };
|
|
123
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
2
|
+
export class UploadRequestError extends Error {
|
|
3
|
+
failure;
|
|
4
|
+
constructor(failure) {
|
|
5
|
+
super(`${failure.kind}:${failure.code}`);
|
|
6
|
+
this.name = "UploadRequestError";
|
|
7
|
+
this.failure = failure;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const TERMINAL_CODES = new Set([
|
|
11
|
+
"CONTENT_NOT_ALLOWED",
|
|
12
|
+
"INVALID_PATH",
|
|
13
|
+
"PROTECTED_PATH",
|
|
14
|
+
"VALIDATION_FAILED",
|
|
15
|
+
"BLOB_TOO_LARGE",
|
|
16
|
+
"HANDOFF_TOO_LARGE",
|
|
17
|
+
"UNSUPPORTED_PROTOCOL",
|
|
18
|
+
"INVALID_STATE",
|
|
19
|
+
"IDEMPOTENCY_CONFLICT",
|
|
20
|
+
"HANDOFF_SUPERSEDED",
|
|
21
|
+
"WORKSPACE_NOT_FOUND",
|
|
22
|
+
"DOCUMENT_NOT_FOUND",
|
|
23
|
+
]);
|
|
24
|
+
const AUTH_CODES = new Set(["AUTH_REQUIRED", "ACCESS_TOKEN_EXPIRED", "DEVICE_CREDENTIAL_INVALID"]);
|
|
25
|
+
export function classifyFailure(status, code) {
|
|
26
|
+
if (status === 409 && code === "SYNC_WHITELIST_STALE")
|
|
27
|
+
return { kind: "stale_whitelist", code };
|
|
28
|
+
if (status === 401 || AUTH_CODES.has(code))
|
|
29
|
+
return { kind: "auth", code };
|
|
30
|
+
if (status === 429 || status >= 500)
|
|
31
|
+
return { kind: "retryable", code: code || "SERVER_ERROR" };
|
|
32
|
+
if (TERMINAL_CODES.has(code))
|
|
33
|
+
return { kind: "terminal", code };
|
|
34
|
+
return { kind: "terminal", code: code || `HTTP_${status}` };
|
|
35
|
+
}
|
|
36
|
+
export function createUploadClient(deps) {
|
|
37
|
+
const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
38
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
39
|
+
async function request(method, path, init = {}) {
|
|
40
|
+
let response = await send(method, path, init, false);
|
|
41
|
+
if (response.status === 401) {
|
|
42
|
+
response = await send(method, path, init, true);
|
|
43
|
+
}
|
|
44
|
+
return response;
|
|
45
|
+
}
|
|
46
|
+
async function send(method, path, init, forceRefresh) {
|
|
47
|
+
const token = await deps.getAccessToken(forceRefresh);
|
|
48
|
+
const headers = { ...init.headers };
|
|
49
|
+
if (token)
|
|
50
|
+
headers.Authorization = `Bearer ${token}`;
|
|
51
|
+
const res = await doFetch(`${deps.baseUrl}${path}`, {
|
|
52
|
+
method,
|
|
53
|
+
headers,
|
|
54
|
+
body: init.body,
|
|
55
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
56
|
+
});
|
|
57
|
+
const body = (await res.json().catch(() => null));
|
|
58
|
+
return { status: res.status, body };
|
|
59
|
+
}
|
|
60
|
+
function failureOf(response) {
|
|
61
|
+
if (response.status >= 200 && response.status < 300)
|
|
62
|
+
return null;
|
|
63
|
+
const code = response.body?.error?.code ?? "";
|
|
64
|
+
return { ...classifyFailure(response.status, code), detail: response.body?.error?.message };
|
|
65
|
+
}
|
|
66
|
+
async function guarded(run) {
|
|
67
|
+
try {
|
|
68
|
+
return await run();
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
72
|
+
return { ok: false, error: { kind: "retryable", code: "NETWORK", detail } };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
async getSyncWhitelist(bindingId) {
|
|
77
|
+
const response = await request("GET", `/api/v2/bindings/${encodeURIComponent(bindingId)}/sync-whitelist`);
|
|
78
|
+
const failure = failureOf(response);
|
|
79
|
+
if (failure)
|
|
80
|
+
throw new UploadRequestError(failure);
|
|
81
|
+
return response.body?.data;
|
|
82
|
+
},
|
|
83
|
+
checkMissingBlobs(bindingId, hashes) {
|
|
84
|
+
return guarded(async () => {
|
|
85
|
+
const response = await request("POST", `/api/v2/content/blobs/check`, {
|
|
86
|
+
headers: { "Content-Type": "application/json" },
|
|
87
|
+
body: JSON.stringify({ bindingId, hashes }),
|
|
88
|
+
});
|
|
89
|
+
const failure = failureOf(response);
|
|
90
|
+
if (failure)
|
|
91
|
+
return { ok: false, error: failure };
|
|
92
|
+
const absent = response.body?.data?.absent;
|
|
93
|
+
return { ok: true, value: Array.isArray(absent) ? absent : [] };
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
uploadBlob(bindingId, input) {
|
|
97
|
+
return guarded(async () => {
|
|
98
|
+
const response = await request("POST", `/api/v2/content/blobs`, {
|
|
99
|
+
headers: {
|
|
100
|
+
"Content-Type": input.mediaType,
|
|
101
|
+
"X-NuStack-Binding-Id": bindingId,
|
|
102
|
+
"X-NuStack-Content-Sha256": input.sha256,
|
|
103
|
+
"X-NuStack-Byte-Size": String(input.byteSize),
|
|
104
|
+
},
|
|
105
|
+
body: input.bytes,
|
|
106
|
+
});
|
|
107
|
+
const failure = failureOf(response);
|
|
108
|
+
return failure ? { ok: false, error: failure } : { ok: true, value: undefined };
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
submitGitHandoff(bindingId, manifest, idempotencyKey) {
|
|
112
|
+
return guarded(async () => {
|
|
113
|
+
const response = await request("POST", `/api/v2/bindings/${encodeURIComponent(bindingId)}/git-handoffs`, {
|
|
114
|
+
headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey },
|
|
115
|
+
body: JSON.stringify(manifest),
|
|
116
|
+
});
|
|
117
|
+
const failure = failureOf(response);
|
|
118
|
+
if (failure)
|
|
119
|
+
return { ok: false, error: failure };
|
|
120
|
+
const data = (response.body?.data ?? {});
|
|
121
|
+
if (data.noOp)
|
|
122
|
+
return { ok: true, value: { noOp: true } };
|
|
123
|
+
const workspaceSequence = data.workspaceSequence ?? response.body?.meta?.workspaceSequence;
|
|
124
|
+
if (!data.handoffId || typeof workspaceSequence !== "number") {
|
|
125
|
+
return { ok: false, error: { kind: "terminal", code: "MALFORMED_ACCEPTANCE" } };
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, value: { accepted: { handoffId: data.handoffId, workspaceSequence } } };
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
reportHealth(bindingId, report) {
|
|
131
|
+
return guarded(async () => {
|
|
132
|
+
const response = await request("POST", `/api/v2/bindings/${encodeURIComponent(bindingId)}/health`, {
|
|
133
|
+
headers: { "Content-Type": "application/json" },
|
|
134
|
+
body: JSON.stringify(report),
|
|
135
|
+
});
|
|
136
|
+
const failure = failureOf(response);
|
|
137
|
+
return failure ? { ok: false, error: failure } : { ok: true, value: undefined };
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { open, mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { recoveryDir } from "../../local_store/paths.js";
|
|
4
|
+
import { mkdirSecure, writeFileSecure } from "../../secure_file.js";
|
|
5
|
+
import { encryptBundle, decryptBundle } from "./crypto.js";
|
|
6
|
+
export function bundlePathFor(bindingId, bundleId) {
|
|
7
|
+
return path.join(recoveryDir(), bindingId, `${bundleId}.bin`);
|
|
8
|
+
}
|
|
9
|
+
export function serializeBundle(content) {
|
|
10
|
+
return Buffer.from(JSON.stringify(content), "utf8");
|
|
11
|
+
}
|
|
12
|
+
export class RecoveryVerifyError extends Error {
|
|
13
|
+
constructor() {
|
|
14
|
+
super("recovery bundle failed its post-write decrypt-verify round-trip");
|
|
15
|
+
this.name = "RecoveryVerifyError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function createRecoveryBundle(input) {
|
|
19
|
+
const plaintext = serializeBundle(input.content);
|
|
20
|
+
const sealed = encryptBundle(input.key.key, plaintext);
|
|
21
|
+
const bundlePath = bundlePathFor(input.bindingId, input.bundleId);
|
|
22
|
+
await mkdirSecure(path.dirname(bundlePath));
|
|
23
|
+
const handle = await open(bundlePath, "w", 0o600);
|
|
24
|
+
try {
|
|
25
|
+
await handle.write(sealed);
|
|
26
|
+
await handle.sync();
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await handle.close();
|
|
30
|
+
}
|
|
31
|
+
const readBack = await readFile(bundlePath);
|
|
32
|
+
const roundTrip = decryptBundle(input.key.key, readBack);
|
|
33
|
+
if (!roundTrip.equals(plaintext))
|
|
34
|
+
throw new RecoveryVerifyError();
|
|
35
|
+
return { bundlePath };
|
|
36
|
+
}
|
|
37
|
+
export async function readRecoveryBundle(key, bundlePath) {
|
|
38
|
+
const sealed = await readFile(bundlePath);
|
|
39
|
+
const plaintext = decryptBundle(key.key, sealed);
|
|
40
|
+
return JSON.parse(plaintext.toString("utf8"));
|
|
41
|
+
}
|
|
42
|
+
export async function extractRecoveryBundle(key, bundleId, bundlePath, destDir) {
|
|
43
|
+
const content = await readRecoveryBundle(key, bundlePath);
|
|
44
|
+
const dir = destDir ?? path.join(recoveryDir(), "extracted", bundleId);
|
|
45
|
+
await mkdir(dir, { recursive: true });
|
|
46
|
+
const files = [];
|
|
47
|
+
for (const target of content.targets) {
|
|
48
|
+
if (target.workingContentBase64 === null)
|
|
49
|
+
continue;
|
|
50
|
+
const outPath = path.join(dir, target.path);
|
|
51
|
+
await mkdir(path.dirname(outPath), { recursive: true });
|
|
52
|
+
await writeFileSecure(outPath, Buffer.from(target.workingContentBase64, "base64").toString("utf8"));
|
|
53
|
+
files.push(target.path);
|
|
54
|
+
}
|
|
55
|
+
return { dir, files };
|
|
56
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { recoveryDir } from "../../local_store/paths.js";
|
|
8
|
+
import { mkdirSecure, writeFileSecure } from "../../secure_file.js";
|
|
9
|
+
const runFile = promisify(execFile);
|
|
10
|
+
const DEFAULT_SECURITY_BIN = "/usr/bin/security";
|
|
11
|
+
const RECOVERY_SERVICE = "nustack-recovery";
|
|
12
|
+
const KEY_BYTES = 32;
|
|
13
|
+
const IV_BYTES = 12;
|
|
14
|
+
const TAG_BYTES = 16;
|
|
15
|
+
function securityBin() {
|
|
16
|
+
return process.env.NUSTACK_KEYCHAIN_BIN?.trim() || DEFAULT_SECURITY_BIN;
|
|
17
|
+
}
|
|
18
|
+
export function isRecoveryKeychainAvailable() {
|
|
19
|
+
if (process.env.NUSTACK_KEYCHAIN_BIN)
|
|
20
|
+
return true;
|
|
21
|
+
return process.platform === "darwin" && existsSync(DEFAULT_SECURITY_BIN);
|
|
22
|
+
}
|
|
23
|
+
function keyFilePath() {
|
|
24
|
+
return path.join(recoveryDir(), "recovery.key");
|
|
25
|
+
}
|
|
26
|
+
async function readKeychainKey(account) {
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await runFile(securityBin(), [
|
|
29
|
+
"find-generic-password",
|
|
30
|
+
"-s",
|
|
31
|
+
RECOVERY_SERVICE,
|
|
32
|
+
"-a",
|
|
33
|
+
account,
|
|
34
|
+
"-w",
|
|
35
|
+
]);
|
|
36
|
+
const decoded = Buffer.from(stdout.trim(), "base64");
|
|
37
|
+
return decoded.length === KEY_BYTES ? decoded : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function writeKeychainKey(account, key) {
|
|
44
|
+
await runFile(securityBin(), [
|
|
45
|
+
"add-generic-password",
|
|
46
|
+
"-U",
|
|
47
|
+
"-s",
|
|
48
|
+
RECOVERY_SERVICE,
|
|
49
|
+
"-a",
|
|
50
|
+
account,
|
|
51
|
+
"-w",
|
|
52
|
+
key.toString("base64"),
|
|
53
|
+
]);
|
|
54
|
+
}
|
|
55
|
+
async function readKeyFile() {
|
|
56
|
+
try {
|
|
57
|
+
const decoded = Buffer.from((await readFile(keyFilePath(), "utf8")).trim(), "base64");
|
|
58
|
+
return decoded.length === KEY_BYTES ? decoded : null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function ensureRecoveryKey(deviceRef) {
|
|
65
|
+
if (isRecoveryKeychainAvailable()) {
|
|
66
|
+
const existing = await readKeychainKey(deviceRef);
|
|
67
|
+
if (existing)
|
|
68
|
+
return { key: existing, source: "keychain" };
|
|
69
|
+
const minted = randomBytes(KEY_BYTES);
|
|
70
|
+
await writeKeychainKey(deviceRef, minted);
|
|
71
|
+
return { key: minted, source: "keychain" };
|
|
72
|
+
}
|
|
73
|
+
const existing = await readKeyFile();
|
|
74
|
+
if (existing)
|
|
75
|
+
return { key: existing, source: "keyfile" };
|
|
76
|
+
await mkdirSecure(recoveryDir());
|
|
77
|
+
const minted = randomBytes(KEY_BYTES);
|
|
78
|
+
await writeFileSecure(keyFilePath(), minted.toString("base64"));
|
|
79
|
+
return { key: minted, source: "keyfile" };
|
|
80
|
+
}
|
|
81
|
+
export async function inspectRecoveryKey(deviceRef) {
|
|
82
|
+
if (isRecoveryKeychainAvailable()) {
|
|
83
|
+
return (await readKeychainKey(deviceRef)) ? "keychain" : null;
|
|
84
|
+
}
|
|
85
|
+
return (await readKeyFile()) ? "keyfile" : null;
|
|
86
|
+
}
|
|
87
|
+
export function encryptBundle(key, plaintext) {
|
|
88
|
+
const iv = randomBytes(IV_BYTES);
|
|
89
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
90
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
91
|
+
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
|
|
92
|
+
}
|
|
93
|
+
export class RecoveryDecryptError extends Error {
|
|
94
|
+
constructor(detail) {
|
|
95
|
+
super(`recovery bundle could not be decrypted: ${detail}`);
|
|
96
|
+
this.name = "RecoveryDecryptError";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function decryptBundle(key, sealed) {
|
|
100
|
+
if (sealed.length < IV_BYTES + TAG_BYTES) {
|
|
101
|
+
throw new RecoveryDecryptError("sealed blob is too short");
|
|
102
|
+
}
|
|
103
|
+
const iv = sealed.subarray(0, IV_BYTES);
|
|
104
|
+
const tag = sealed.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
|
|
105
|
+
const ciphertext = sealed.subarray(IV_BYTES + TAG_BYTES);
|
|
106
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
107
|
+
decipher.setAuthTag(tag);
|
|
108
|
+
try {
|
|
109
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
throw new RecoveryDecryptError(error instanceof Error ? error.message : "auth failed");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { ensureRecoveryKey, inspectRecoveryKey, isRecoveryKeychainAvailable, encryptBundle, decryptBundle, RecoveryDecryptError, } from "./crypto.js";
|
|
2
|
+
export { createRecoveryBundle, readRecoveryBundle, extractRecoveryBundle, serializeBundle, bundlePathFor, RecoveryVerifyError, } from "./bundle.js";
|
|
3
|
+
export { persistRecoveryBundle, listBundles, restoreBundle, deleteBundle, sweepExpiredBundles, } from "./store.js";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { unlink } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { insertRecoveryBundle, getRecoveryBundle, listRecoveryBundles, markRecoveryBundleState, listExpiredRecoveryBundles, } from "../../local_store/recovery_bundles.js";
|
|
4
|
+
import { ensureRecoveryKey } from "./crypto.js";
|
|
5
|
+
import { createRecoveryBundle, extractRecoveryBundle, } from "./bundle.js";
|
|
6
|
+
export async function persistRecoveryBundle(input) {
|
|
7
|
+
const key = await ensureRecoveryKey(input.deviceRef);
|
|
8
|
+
const bundleId = randomUUID();
|
|
9
|
+
const { bundlePath } = await createRecoveryBundle({
|
|
10
|
+
key,
|
|
11
|
+
bindingId: input.bindingId,
|
|
12
|
+
bundleId,
|
|
13
|
+
content: input.content,
|
|
14
|
+
});
|
|
15
|
+
const bundle = insertRecoveryBundle(input.store, {
|
|
16
|
+
id: bundleId,
|
|
17
|
+
bindingId: input.bindingId,
|
|
18
|
+
handoffId: input.handoffId,
|
|
19
|
+
bundlePath,
|
|
20
|
+
manifestSha256: input.manifestSha256,
|
|
21
|
+
});
|
|
22
|
+
return { bundle, keySource: key.source };
|
|
23
|
+
}
|
|
24
|
+
export function listBundles(store, bindingId) {
|
|
25
|
+
return listRecoveryBundles(store, bindingId);
|
|
26
|
+
}
|
|
27
|
+
export async function restoreBundle(store, deviceRef, bundleId, destDir) {
|
|
28
|
+
const bundle = getRecoveryBundle(store, bundleId);
|
|
29
|
+
if (!bundle)
|
|
30
|
+
return { ok: false, code: "not_found" };
|
|
31
|
+
if (bundle.state !== "active")
|
|
32
|
+
return { ok: false, code: "not_active" };
|
|
33
|
+
const key = await ensureRecoveryKey(deviceRef);
|
|
34
|
+
const { dir, files } = await extractRecoveryBundle(key, bundleId, bundle.bundlePath, destDir);
|
|
35
|
+
const updated = markRecoveryBundleState(store, bundleId, "restored") ?? bundle;
|
|
36
|
+
return { ok: true, bundle: updated, dir, files };
|
|
37
|
+
}
|
|
38
|
+
export async function deleteBundle(store, bundleId) {
|
|
39
|
+
const bundle = getRecoveryBundle(store, bundleId);
|
|
40
|
+
if (!bundle)
|
|
41
|
+
return { ok: false, code: "not_found" };
|
|
42
|
+
await unlink(bundle.bundlePath).catch(() => { });
|
|
43
|
+
const updated = markRecoveryBundleState(store, bundleId, "deleted") ?? bundle;
|
|
44
|
+
return { ok: true, bundle: updated };
|
|
45
|
+
}
|
|
46
|
+
export async function sweepExpiredBundles(store, nowMs = Date.now()) {
|
|
47
|
+
const expired = listExpiredRecoveryBundles(store, nowMs);
|
|
48
|
+
const swept = [];
|
|
49
|
+
for (const bundle of expired) {
|
|
50
|
+
await unlink(bundle.bundlePath).catch(() => { });
|
|
51
|
+
markRecoveryBundleState(store, bundle.id, "expired");
|
|
52
|
+
swept.push(bundle.id);
|
|
53
|
+
}
|
|
54
|
+
return swept;
|
|
55
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { SYNC_PROTOCOL_VERSION, isWhitelistManifest, } from "./types.js";
|
|
2
|
+
export { matchPath, isProtectedPath, globMatch } from "./matcher.js";
|
|
3
|
+
export { validateOperationContent, mediaTypeForPath, frontmatterWorkspaceId, } from "./validation.js";
|
|
4
|
+
export { fetchWhitelist, pinWhitelist, loadPinnedWhitelist, canonicalWhitelistSha256, whitelistCacheFile, WhitelistError, } from "./store.js";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export function isProtectedPath(path) {
|
|
2
|
+
const segments = path.split("/");
|
|
3
|
+
if (segments.some((segment) => /^\.env/.test(segment)))
|
|
4
|
+
return true;
|
|
5
|
+
if (segments.includes(".git"))
|
|
6
|
+
return true;
|
|
7
|
+
for (let i = 0; i + 1 < segments.length; i++) {
|
|
8
|
+
if (segments[i] === ".nustack" && segments[i + 1] === "recovery")
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
function permitsDirection(entry, direction) {
|
|
14
|
+
return entry.direction === "bidirectional" || entry.direction === direction;
|
|
15
|
+
}
|
|
16
|
+
export function matchPath(manifest, normalizedPath, direction) {
|
|
17
|
+
if (isProtectedPath(normalizedPath))
|
|
18
|
+
return { allowed: false, reason: "protected" };
|
|
19
|
+
let globEntry = null;
|
|
20
|
+
for (const entry of manifest.entries) {
|
|
21
|
+
if (entry.kind === "exact") {
|
|
22
|
+
if (entry.pattern === normalizedPath) {
|
|
23
|
+
return permitsDirection(entry, direction)
|
|
24
|
+
? { allowed: true, entry, matchKind: "exact" }
|
|
25
|
+
: { allowed: false, reason: "direction" };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else if (globEntry === null && globMatch(entry.pattern, normalizedPath)) {
|
|
29
|
+
globEntry = entry;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (globEntry === null)
|
|
33
|
+
return { allowed: false, reason: "unlisted" };
|
|
34
|
+
return permitsDirection(globEntry, direction)
|
|
35
|
+
? { allowed: true, entry: globEntry, matchKind: "glob" }
|
|
36
|
+
: { allowed: false, reason: "direction" };
|
|
37
|
+
}
|
|
38
|
+
export function globMatch(pattern, path) {
|
|
39
|
+
return matchSegments(pattern.split("/"), 0, path.split("/"), 0);
|
|
40
|
+
}
|
|
41
|
+
function matchSegments(pattern, pi, target, ti) {
|
|
42
|
+
if (pi === pattern.length)
|
|
43
|
+
return ti === target.length;
|
|
44
|
+
if (pattern[pi] === "**") {
|
|
45
|
+
for (let k = ti; k <= target.length; k++) {
|
|
46
|
+
if (matchSegments(pattern, pi + 1, target, k))
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (ti === target.length)
|
|
52
|
+
return false;
|
|
53
|
+
if (segmentMatch(pattern[pi], target[ti]))
|
|
54
|
+
return matchSegments(pattern, pi + 1, target, ti + 1);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
function segmentMatch(pattern, segment) {
|
|
58
|
+
if (!pattern.includes("*"))
|
|
59
|
+
return pattern === segment;
|
|
60
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
|
|
61
|
+
return new RegExp(`^${escaped}$`).test(segment);
|
|
62
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { updateBindingWhitelistCache, } from "../../local_store/bindings.js";
|
|
5
|
+
import { cacheDir } from "../../local_store/paths.js";
|
|
6
|
+
import { mkdirSecure, writeFileSecure } from "../../secure_file.js";
|
|
7
|
+
import { isWhitelistManifest } from "./types.js";
|
|
8
|
+
export class WhitelistError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "WhitelistError";
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function whitelistCacheFile(bindingId, manifestSha256) {
|
|
17
|
+
return path.join(cacheDir(), "whitelists", bindingId, `${manifestSha256}.json`);
|
|
18
|
+
}
|
|
19
|
+
function sortKeys(value) {
|
|
20
|
+
if (Array.isArray(value))
|
|
21
|
+
return value.map(sortKeys);
|
|
22
|
+
if (value && typeof value === "object") {
|
|
23
|
+
const source = value;
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const key of Object.keys(source).sort())
|
|
26
|
+
out[key] = sortKeys(source[key]);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
export function canonicalWhitelistSha256(manifest) {
|
|
32
|
+
const core = {
|
|
33
|
+
protocolVersion: manifest.protocolVersion,
|
|
34
|
+
protectedPatterns: manifest.protectedPatterns,
|
|
35
|
+
limits: manifest.limits,
|
|
36
|
+
entries: manifest.entries.map((entry) => ({
|
|
37
|
+
pattern: entry.pattern,
|
|
38
|
+
kind: entry.kind,
|
|
39
|
+
direction: entry.direction,
|
|
40
|
+
maxBytes: entry.maxBytes,
|
|
41
|
+
mediaTypes: entry.mediaTypes,
|
|
42
|
+
allowsSupplemental: entry.allowsSupplemental ?? false,
|
|
43
|
+
})),
|
|
44
|
+
};
|
|
45
|
+
return createHash("sha256").update(JSON.stringify(sortKeys(core)), "utf8").digest("hex");
|
|
46
|
+
}
|
|
47
|
+
export async function fetchWhitelist(client, bindingId) {
|
|
48
|
+
const raw = await client.getSyncWhitelist(bindingId);
|
|
49
|
+
if (!isWhitelistManifest(raw)) {
|
|
50
|
+
throw new WhitelistError("malformed_manifest", "the Sync Whitelist response is not a valid manifest");
|
|
51
|
+
}
|
|
52
|
+
assertManifestIntegrity(raw);
|
|
53
|
+
return raw;
|
|
54
|
+
}
|
|
55
|
+
function assertManifestIntegrity(manifest) {
|
|
56
|
+
if (canonicalWhitelistSha256(manifest) !== manifest.manifestSha256) {
|
|
57
|
+
throw new WhitelistError("hash_mismatch", "the Sync Whitelist manifest hash does not match its content");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function pinWhitelist(store, binding, manifest) {
|
|
61
|
+
const cachePath = whitelistCacheFile(binding.id, manifest.manifestSha256);
|
|
62
|
+
await mkdirSecure(path.dirname(cachePath));
|
|
63
|
+
await writeFileSecure(cachePath, JSON.stringify(manifest));
|
|
64
|
+
updateBindingWhitelistCache(store, binding.id, {
|
|
65
|
+
whitelistManifestId: manifest.manifestId,
|
|
66
|
+
whitelistManifestSha256: manifest.manifestSha256,
|
|
67
|
+
whitelistCachePath: cachePath,
|
|
68
|
+
});
|
|
69
|
+
return { manifest, cachePath };
|
|
70
|
+
}
|
|
71
|
+
export async function loadPinnedWhitelist(store, binding, client) {
|
|
72
|
+
const cached = await readPinnedCache(binding);
|
|
73
|
+
if (cached)
|
|
74
|
+
return cached;
|
|
75
|
+
if (!client) {
|
|
76
|
+
throw new WhitelistError("not_pinned", "no pinned Sync Whitelist is available for this binding");
|
|
77
|
+
}
|
|
78
|
+
const fresh = await fetchWhitelist(client, binding.id);
|
|
79
|
+
await pinWhitelist(store, binding, fresh);
|
|
80
|
+
return fresh;
|
|
81
|
+
}
|
|
82
|
+
async function readPinnedCache(binding) {
|
|
83
|
+
if (!binding.whitelistCachePath || !binding.whitelistManifestSha256)
|
|
84
|
+
return null;
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(await readFile(binding.whitelistCachePath, "utf8"));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (!isWhitelistManifest(parsed))
|
|
93
|
+
return null;
|
|
94
|
+
if (parsed.manifestSha256 !== binding.whitelistManifestSha256)
|
|
95
|
+
return null;
|
|
96
|
+
if (canonicalWhitelistSha256(parsed) !== parsed.manifestSha256)
|
|
97
|
+
return null;
|
|
98
|
+
return parsed;
|
|
99
|
+
}
|