Compare commits

..
Author SHA1 Message Date
bincxz fe7f5029af fix(ai): use neutral MCP text for silent terminal_execute success
Codex P2 on #2724: ok:true empty serial/raw PTY results no longer fall
back to "Error: Operation failed". Prefer Catty's "Command completed
(no output)" and keep operational failures / non-zero exits unchanged.
Also prefer non-empty OpenCode tool error/output when formatting tool
results.
2026-08-05 11:10:48 +08:00
bincxz 1d0762d250 fix(ai): stop OpenCode turns dying on terminal tool failures
OpenCode MCP terminal_execute treated non-zero exits as bare
"Error: Operation failed" (dropping stdout), and the OpenCode driver
aborted the whole turn on tool status=error. Preserve command output
and surface tool errors as tool results so multi-step agent runs can
continue.

Closes #2718
2026-08-05 10:58:39 +08:00
107 changed files with 727 additions and 12456 deletions

No files matched your search

+67 -367
View File
@@ -254,24 +254,6 @@ jobs:
context,
issueNumber: issue.number,
});
const relatedPull = pull || await auto.findOpenPullForIssue({
github,
context,
issueNumber: issue.number,
includeRelated: true,
});
const refined = auto.refineIssueCommentRoute(decision, {
hasOpenBotPull: Boolean(pull),
hasOpenRelatedPull: Boolean(relatedPull),
body: comment.body,
});
if (refined.kind === 'issue_classify') {
return set('issue_classify', {
issue_number: String(issue.number),
trigger_comment_id: String(comment.id),
reason: refined.reason,
});
}
return set('issue_followup', {
issue_number: String(issue.number),
pull_number: pull ? String(pull.number) : '',
@@ -292,8 +274,9 @@ jobs:
repository: `${context.repo.owner}/${context.repo.repo}`,
});
if (
sameRepo &&
context.payload.action === 'closed' &&
auto.shouldCleanupSourceIssueAfterPull(pr, {
auto.shouldGatePullOnSourceIssueFollowups(pr, {
ownActors,
repository: `${context.repo.owner}/${context.repo.repo}`,
})
@@ -301,7 +284,7 @@ jobs:
return set('source_issue_cleanup', {
issue_number: String(auto.extractSourceIssueNumber(pr)),
pull_number: String(pr.number),
reason: pr.merged ? 'merged PR closed source issue' : 'automation PR closed',
reason: pr.merged ? 'automation PR merged' : 'automation PR closed',
});
}
if (sameRepo) {
@@ -387,16 +370,16 @@ jobs:
core.warning(`PR #${pullNumber} is no longer closed; skipping source cleanup.`);
return;
}
if (!auto.shouldCleanupSourceIssueAfterPull(pull, {
if (!auto.shouldGatePullOnSourceIssueFollowups(pull, {
ownActors: process.env.OWN_ACTORS,
repository: `${context.repo.owner}/${context.repo.repo}`,
})) {
core.warning(`PR #${pullNumber} is not eligible for source cleanup; skipping.`);
core.warning(`PR #${pullNumber} is not a trusted automation pull request; skipping source cleanup.`);
return;
}
const issueNumbers = auto.extractSourceIssueNumbers(pull);
const issueNumber = auto.extractSourceIssueNumber(pull);
const expectedIssueNumber = Number(process.env.SOURCE_ISSUE_NUMBER);
if (!issueNumbers.includes(expectedIssueNumber)) {
if (!issueNumber || issueNumber !== expectedIssueNumber) {
core.warning(`PR #${pullNumber} source issue changed while queued; skipping cleanup.`);
return;
}
@@ -411,161 +394,20 @@ jobs:
if (error.status !== 404) throw error;
}
};
for (const issueNumber of issueNumbers) {
const { data: sourceIssue } = await github.rest.issues.get({
for (const name of ['ready-for-agent', 'needs-info', 'ready-for-human']) {
await removeLabel(issueNumber, name);
}
if (!pull.merged) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issueNumber,
labels: ['ready-for-human'],
});
if (sourceIssue.pull_request) {
core.warning(`#${issueNumber} is a pull request, not a source issue; skipping cleanup.`);
continue;
}
if (pull.merged && sourceIssue.state !== 'closed') {
core.warning(`Merged PR #${pullNumber} does not currently close issue #${issueNumber}; skipping cleanup.`);
continue;
}
for (const name of ['ready-for-agent', 'needs-info', 'ready-for-human']) {
await removeLabel(issueNumber, name);
}
if (!pull.merged) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issueNumber,
labels: ['ready-for-human'],
});
}
}
for (const name of ['automation:codex-loop', 'ready-for-human']) {
await removeLabel(pullNumber, name);
}
reconcile_closed_handoffs:
name: Reconcile handoffs
if: github.event_name == 'schedule' && github.event.schedule == '17 3 * * *'
runs-on: ubuntu-latest
permissions:
actions: write
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout automation helpers
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
- name: Remove stale ready-for-human labels
uses: actions/github-script@v9
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const auto = require(`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`);
const queries = [
`repo:${context.repo.owner}/${context.repo.repo} is:issue is:closed label:"ready-for-human" label:triage`,
`repo:${context.repo.owner}/${context.repo.repo} is:pr is:merged label:"ready-for-human" label:"automation:bot-pr"`,
];
let cleaned = 0;
for (const q of queries) {
const items = await github.paginate(
github.rest.search.issuesAndPullRequests,
{ q, per_page: 100 },
(response) => auto.extractPaginatedItems(response),
);
for (const item of items) {
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: item.number,
name: 'ready-for-human',
});
cleaned += 1;
} catch (error) {
if (error.status !== 404) throw error;
}
}
}
core.summary.addHeading('Closed handoff reconciliation');
core.summary.addRaw(`Removed stale ready-for-human from ${cleaned} closed items.`);
await core.summary.write();
- name: Retry handoffs fixed by this hardening pass
uses: actions/github-script@v9
env:
ISSUE_BOT_LOGINS: ${{ vars.AUTOMATION_ISSUE_BOT_LOGINS || 'netcatty-bot,github-actions[bot]' }}
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const auto = require(`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`);
const recoveryVersion = 'protected-tests-and-media-v1';
const auditedIssueNumbers = new Set([2679, 2697, 2704, 2705, 2708, 2709]);
const repository = `${context.repo.owner}/${context.repo.repo}`;
const issues = await github.paginate(
github.rest.search.issuesAndPullRequests,
{
q: `repo:${repository} is:issue is:open label:"ready-for-human" label:triage`,
per_page: 100,
},
(response) => auto.extractPaginatedItems(response),
);
const pulls = await github.paginate(github.rest.pulls.list, {
...context.repo,
state: 'open',
per_page: 100,
sort: 'updated',
direction: 'desc',
});
let dispatched = 0;
for (const issue of issues) {
if (!auditedIssueNumbers.has(Number(issue.number))) continue;
const comments = await github.paginate(
github.rest.issues.listComments,
{ ...context.repo, issue_number: issue.number, per_page: 100 },
);
if (!auto.shouldRetryIssueHandoff(comments, {
trustedActors: process.env.ISSUE_BOT_LOGINS,
recoveryVersion,
notBefore: '2026-07-31T12:54:37Z',
notAfter: '2026-08-04T08:27:14Z',
})) continue;
if (pulls.some((pull) => auto.isTrustedOpenPullForIssue(pull, issue.number, {
repository,
includeRelated: true,
}))) continue;
const { data: marker } = await github.rest.issues.createComment({
...context.repo,
issue_number: issue.number,
body: [
'<!-- cursor-automation -->',
`<!-- cursor-handoff-recovery:version=${recoveryVersion} -->`,
'',
'此前导致自动处理停止的问题已经修复,现重新进入自动检查。',
].join('\n'),
});
try {
await github.rest.actions.createWorkflowDispatch({
...context.repo,
workflow_id: 'cursor-automation.yml',
ref: context.payload.repository.default_branch,
inputs: { issue_number: String(issue.number) },
});
dispatched += 1;
} catch (error) {
try {
await github.rest.issues.deleteComment({
...context.repo,
comment_id: marker.id,
});
} catch (cleanupError) {
core.warning(`Issue #${issue.number}: could not remove retry marker (${cleanupError.message}).`);
}
throw error;
}
}
core.summary.addHeading('Retry recovered handoffs');
core.summary.addRaw(`Re-dispatched ${dispatched} retryable issues without open related PRs.`);
await core.summary.write();
classify:
name: Classify issue
needs: route
@@ -851,7 +693,6 @@ jobs:
fi
- name: Research external context for classification
id: research
if: steps.prepare.outputs.should_run == 'true'
env:
GITHUB_TOKEN: ''
@@ -961,7 +802,6 @@ jobs:
run: *stage_cursor_api_key
- name: Classify with Cursor CLI
id: classify_agent
if: steps.prepare.outputs.should_run == 'true'
env:
GITHUB_TOKEN: ''
@@ -1017,15 +857,6 @@ jobs:
);
'
- name: Validate classification result
id: validate_classification
if: steps.prepare.outputs.should_run == 'true'
run: |
node -e '
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
auto.parseClassificationFile(".cursor-runtime/classification.json");
'
- name: Apply classification
id: apply
if: steps.prepare.outputs.should_run == 'true'
@@ -1059,20 +890,6 @@ jobs:
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const fs = require('node:fs');
let auto = null;
for (const helper of [
`${process.env.RUNNER_TEMP}/cursor-automation.cjs`,
`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`,
]) {
if (!fs.existsSync(helper)) continue;
try {
auto = require(helper);
break;
} catch (error) {
core.warning(`Could not load automation helper ${helper}: ${error.message}`);
}
}
const issueNumber = Number(process.env.ISSUE_NUMBER);
const commentId = String(process.env.TRIGGER_COMMENT_ID || '');
const { data: issue } = await github.rest.issues.get({
@@ -1116,42 +933,22 @@ jobs:
if (issue.state === 'closed') update.state = 'open';
await github.rest.issues.update(update);
if (!processed) {
const isChinese = /[\u3400-\u9fff]/u.test(
`${issue.title || ''}\n${issue.body || ''}`,
);
const marker = /^[A-Za-z0-9_-]+$/.test(commentId)
? `<!-- cursor-followup:comment-id=${commentId};result=blocked -->`
: '';
const kind = '${{ steps.research.outcome }}' === 'failure'
? 'research_failed'
: '${{ steps.classify_agent.outcome }}' === 'failure'
? 'classification_failed'
: '${{ steps.validate_classification.outcome }}' === 'failure'
? 'classification_failed'
: '${{ steps.apply.outcome }}' === 'failure'
? 'apply_failed'
: 'processing_failed';
const chinese = /[\u3400-\u9fff]/u.test(
`${issue.title || ''}\n${issue.body || ''}`,
);
const failureMessage = auto
? auto.buildClassificationFailureMessage(issue, {
kind,
isFollowup: Boolean(commentId),
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
})
: [
chinese
? '自动分类流程没有正常完成,Issue 已保留并转给维护者继续处理。'
: 'The automatic classification process did not finish normally. The issue was preserved for maintainer review.',
`${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
].join('\n\n');
await github.rest.issues.createComment({
...context.repo,
issue_number: issueNumber,
body: [
'<!-- cursor-automation -->',
`<!-- cursor-classification-failure:kind=${kind};run=${context.runId} -->`,
marker,
'',
failureMessage,
isChinese
? '收到这条补充了,但自动复核没有安全完成,已经转给维护者继续处理。'
: 'We received the additional information, but the automatic review could not finish safely. A maintainer has been notified.',
].filter(Boolean).join('\n'),
});
}
@@ -1963,7 +1760,6 @@ jobs:
changedFiles: process.argv[2].split("\n").filter(Boolean),
nameStatusText: process.argv[3],
});
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -2059,7 +1855,6 @@ jobs:
const hits = auto.hasProtectedChangesInSources({
nameStatusText: process.argv[1],
});
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified after verification:", hits.join(", "));
process.exit(1);
@@ -2289,19 +2084,10 @@ jobs:
...context.repo,
issue_number: issueNumber,
});
const protectedPaths = auto.readProtectedPathReport(
process.env.RUNNER_TEMP + '/protected-paths.json',
const fallback = auto.buildIssueFollowupFallbackReply(
issue,
'processing_failed',
);
const fallback = protectedPaths.length
? auto.buildImplementationFailureMessage(issue, {
kind: 'protected_path',
protectedPaths,
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
})
: auto.buildIssueFollowupFallbackReply(
issue,
'processing_failed',
);
const comments = await github.paginate(
github.rest.issues.listComments,
{ ...context.repo, issue_number: issueNumber, per_page: 100 },
@@ -3027,32 +2813,29 @@ jobs:
cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
cp scripts/compare-ci-test-baseline.cjs "$RUNNER_TEMP/compare-ci-test-baseline.cjs"
- name: Skip if trusted related PR already open
- name: Skip if bot PR already open
id: existing
uses: actions/github-script@v9
with:
script: |
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
const botPull = await auto.findOpenBotPrForIssue({
const existing = await auto.findOpenBotPrForIssue({
github,
context,
issueNumber: process.env.ISSUE_NUMBER,
});
const existing = botPull || await auto.findOpenPullForIssue({
github,
context,
issueNumber: process.env.ISSUE_NUMBER,
includeRelated: true,
});
core.setOutput('exists', existing ? 'true' : 'false');
core.setOutput('url', existing ? existing.html_url : '');
if (existing) {
await auto.markNeedsHuman({
github,
context,
issueNumber: Number(process.env.ISSUE_NUMBER),
message: `A trusted related pull request is already open: ${existing.html_url}`,
dedupeMarker: `<!-- cursor-existing-related-pr:${existing.number} -->`,
await github.rest.issues.createComment({
...context.repo,
issue_number: Number(process.env.ISSUE_NUMBER),
body: [
auto.TRIAGE_MARKER,
auto.DISCLAIMER,
'',
`An automation pull request is already open or was already filed: ${existing.html_url}`,
].join('\n'),
});
}
@@ -3209,7 +2992,6 @@ jobs:
changedFiles: process.argv[2].split("\n").filter(Boolean),
nameStatusText: process.argv[3],
});
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -3332,7 +3114,6 @@ jobs:
node -e '
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
const hits = auto.hasProtectedChangesInSources({ nameStatusText: process.argv[1] });
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -3389,7 +3170,6 @@ jobs:
'
- name: Upload implement patch
id: upload_patch
if: steps.patch.outputs.artifact_ready == 'true'
uses: actions/upload-artifact@v7
with:
@@ -3423,17 +3203,14 @@ jobs:
: `${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`;
const auto = require(helper);
const issueNumber = Number(process.env.ISSUE_NUMBER);
const protectedPaths = auto.readProtectedPathReport(
process.env.RUNNER_TEMP + '/protected-paths.json',
);
const { data: issue } = await github.rest.issues.get({
...context.repo,
issue_number: issueNumber,
});
const protectedFailure = '${{ steps.check_protected.outcome }}' === 'failure';
const artifactReady = '${{ steps.patch.outputs.artifact_ready }}' === 'true';
const artifactUploaded = '${{ steps.upload_patch.outcome }}' === 'success';
const verifyFailed = '${{ steps.verify.outputs.passed }}' === 'false';
const kind = protectedPaths.length
const kind = protectedFailure
? 'protected_path'
: artifactReady && verifyFailed
? 'verification_failed'
@@ -3445,10 +3222,7 @@ jobs:
message: auto.buildImplementationFailureMessage(issue, {
kind,
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
artifactName: artifactReady && artifactUploaded
? `implement-patch-${context.runId}`
: '',
protectedPaths,
artifactName: artifactReady ? `implement-patch-${context.runId}` : '',
}),
dedupeMarker: `<!-- cursor-implement-failure:base=${{ steps.branch.outputs.base_sha }};kind=${kind} -->`,
});
@@ -3484,60 +3258,7 @@ jobs:
name: implement-patch-${{ github.run_id }}
path: patch-in
- name: Checkout trusted helper for publish guard
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
path: helpers
- name: Ensure automation helper present
working-directory: helpers
env:
BOOTSTRAP_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [[ -f scripts/cursor-automation.cjs ]]; then
cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
exit 0
fi
git fetch --depth=1 origin "${BOOTSTRAP_SHA}"
git show "FETCH_HEAD:scripts/cursor-automation.cjs" > "$RUNNER_TEMP/cursor-automation.cjs"
test -s "$RUNNER_TEMP/cursor-automation.cjs"
- name: Skip publish if trusted related PR opened during implementation
id: existing
uses: actions/github-script@v9
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
const issueNumber = Number(process.env.ISSUE_NUMBER);
const botPull = await auto.findOpenBotPrForIssue({
github,
context,
issueNumber,
});
const existing = botPull || await auto.findOpenPullForIssue({
github,
context,
issueNumber,
includeRelated: true,
});
core.setOutput('exists', existing ? 'true' : 'false');
if (existing) {
await auto.markNeedsHuman({
github,
context,
issueNumber,
message: `A trusted related pull request is already open: ${existing.html_url}`,
dedupeMarker: `<!-- cursor-existing-related-pr:${existing.number} -->`,
});
core.notice(`Trusted related pull request ${existing.html_url} opened during implementation; skip publishing a duplicate branch.`);
}
- name: Publish branch from fresh runner
if: steps.existing.outputs.exists != 'true'
id: publish
run: |
set -euo pipefail
@@ -3580,7 +3301,7 @@ jobs:
exit 1
- name: Mark needs human on publish failure
if: failure() && steps.existing.outputs.exists != 'true' && steps.publish.outputs.handoff == 'true'
if: failure() && steps.publish.outputs.handoff == 'true'
uses: actions/github-script@v9
env:
ISSUE_NUMBER: ${{ needs.implement.outputs.issue_number }}
@@ -3608,6 +3329,32 @@ jobs:
});
}
- name: Checkout helper for PR body helpers
if: steps.publish.outputs.published == 'true'
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
path: helpers
- name: Ensure automation helper present
if: steps.publish.outputs.published == 'true'
working-directory: helpers
env:
BOOTSTRAP_SHA: ${{ github.sha }}
EVENT_NAME: ${{ github.event_name }}
HEAD_REPO: ''
BASE_REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [[ -f scripts/cursor-automation.cjs ]]; then
cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
exit 0
fi
git fetch --depth=1 origin "${BOOTSTRAP_SHA}"
git show "FETCH_HEAD:scripts/cursor-automation.cjs" > "$RUNNER_TEMP/cursor-automation.cjs"
test -s "$RUNNER_TEMP/cursor-automation.cjs"
- name: Open draft PR
if: steps.publish.outputs.published == 'true'
id: openpr
@@ -3626,29 +3373,6 @@ jobs:
script: |
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
const fs = require('node:fs');
const issueNumber = Number(process.env.ISSUE_NUMBER);
const botPull = await auto.findOpenBotPrForIssue({
github,
context,
issueNumber,
});
const existing = botPull || await auto.findOpenPullForIssue({
github,
context,
issueNumber,
includeRelated: true,
});
if (existing) {
await auto.markNeedsHuman({
github,
context,
issueNumber,
message: `A trusted related pull request is already open: ${existing.html_url}`,
dedupeMarker: `<!-- cursor-existing-related-pr:${existing.number} -->`,
});
core.notice(`Trusted related pull request ${existing.html_url} opened before PR creation; skip the duplicate.`);
return;
}
let statusText = '';
let agentBody = '';
try {
@@ -4676,7 +4400,6 @@ jobs:
changedFiles: process.argv[2].split("\n").filter(Boolean),
nameStatusText: process.argv[3],
});
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -4775,7 +4498,6 @@ jobs:
node -e '
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
const hits = auto.hasProtectedChangesInSources({ nameStatusText: process.argv[1] });
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -4822,7 +4544,6 @@ jobs:
'
- name: Upload fix patch
id: upload_fixpatch
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.artifact_ready == 'true'
uses: actions/upload-artifact@v7
with:
@@ -4857,12 +4578,9 @@ jobs:
issue_number: pull_number,
body: [
auto.TRIAGE_MARKER,
`<!-- cursor-codex-fix-failure:kind=no_changes;run=${context.runId} -->`,
auto.DISCLAIMER,
'',
auto.buildCodexFixFailureMessage({
kind: 'no_changes',
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
}),
'Automatic fix did not produce additional code changes for the latest Codex findings. Marking for human review.',
].join('\n'),
});
await auto.applyCodexTerminalLabels({
@@ -4886,32 +4604,14 @@ jobs:
: `${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`;
const auto = require(helper);
const pull_number = Number(process.env.PULL_NUMBER);
const protectedPaths = auto.readProtectedPathReport(
process.env.RUNNER_TEMP + '/protected-paths.json',
);
const artifactReady = '${{ steps.fixpatch.outputs.artifact_ready }}' === 'true';
const artifactUploaded = '${{ steps.upload_fixpatch.outcome }}' === 'success';
const verifyFailed = '${{ steps.fixverify.outputs.passed }}' === 'false';
const kind = protectedPaths.length
? 'protected_path'
: artifactReady && verifyFailed
? 'verification_failed'
: 'processing_failed';
await github.rest.issues.createComment({
...context.repo,
issue_number: pull_number,
body: [
auto.TRIAGE_MARKER,
`<!-- cursor-codex-fix-failure:kind=${kind};run=${context.runId} -->`,
auto.DISCLAIMER,
'',
auto.buildCodexFixFailureMessage({
kind,
protectedPaths,
artifactName: artifactReady && artifactUploaded
? `codex-fix-patch-${context.runId}`
: '',
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
}),
'Automatic Codex fix did not finish cleanly (agent, protected paths, or verification failed). Marking for human review.',
].join('\n'),
});
await auto.applyCodexTerminalLabels({
+12 -52
View File
@@ -50,7 +50,7 @@ import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useThemeRuntime, useTerminalAppearanceInjection } from './application/state/useThemeRuntime';
import { useCustomThemes } from './application/state/customThemeStore';
import type { SyncPayload } from './domain/sync';
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from './application/syncPayload';
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
import {
applyProtectedSyncPayload,
ensureVersionChangeBackup,
@@ -489,7 +489,7 @@ function App({ settings }: { settings: SettingsState }) {
[portForwardingRules],
);
const buildCurrentSyncPayload = useCallback(async () => {
const buildCurrentSyncPayload = useCallback(() => {
let effectivePortForwardingRules = portForwardingRulesForSync;
if (effectivePortForwardingRules.length === 0) {
const stored = localStorageAdapter.read<typeof portForwardingRulesForSync>(
@@ -505,7 +505,7 @@ function App({ settings }: { settings: SettingsState }) {
}
}
return buildLocalVaultPayloadAsync(
return buildLocalVaultPayload(
{
hosts,
keys,
@@ -548,10 +548,6 @@ function App({ settings }: { settings: SettingsState }) {
}, [buildCurrentSyncPayload]);
const versionBackupAttemptedRef = useRef(false);
const versionBackupInFlightRef = useRef(false);
// Bumps when plugin contributions settle or the sidecar-host grace timer
// fires so version-change backups can wait for live sidecar collect.
const [pluginSidecarReadyTick, setPluginSidecarReadyTick] = useState(0);
// Two-stage gate: once the vault has initialized we open the auto-sync
// gate immediately — the hook's own hasMeaningfulSyncData guard and
// the cross-window restore barrier prevent an empty-but-not-yet-
@@ -563,25 +559,6 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [isVaultInitialized, startupSyncSafetyReady]);
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get() as {
onPluginContributionsChanged?: (callback: () => void) => () => void;
} | null | undefined;
const unsubscribe = bridge?.onPluginContributionsChanged?.(() => {
setPluginSidecarReadyTick((v) => v + 1);
});
// Plugins stay gated off for many installs; still allow a version
// backup after a short grace so we do not wait forever for host ready.
const graceTimer = window.setTimeout(() => {
setPluginSidecarReadyTick((v) => v + 1);
}, 5_000);
return () => {
unsubscribe?.();
window.clearTimeout(graceTimer);
};
}, [isPeerSessionWindow]);
// Retry the version-change backup as hosts/keys/snippets become
// available. ensureVersionChangeBackup refuses to advance the stored
// version stamp when the observed payload is empty, so running this
@@ -590,47 +567,30 @@ function App({ settings }: { settings: SettingsState }) {
// in which case the effect continues to no-op).
useEffect(() => {
if (isPeerSessionWindow || !isVaultInitialized || versionBackupAttemptedRef.current) return;
// Always wait for contributions or the grace tick — host-ready alone is
// not enough because collect can still return an empty authoritative
// bundle before plugin settings are declared.
if (pluginSidecarReadyTick === 0) return;
if (versionBackupInFlightRef.current) return;
const payload = buildCurrentSyncPayloadRef.current();
if (!hasMeaningfulSyncData(payload)) return;
versionBackupAttemptedRef.current = true;
let cancelled = false;
versionBackupInFlightRef.current = true;
void (async () => {
try {
const payload = await buildCurrentSyncPayloadRef.current();
if (cancelled) return;
if (!hasMeaningfulSyncData(payload)) return;
const info = await netcattyBridge.get()?.getAppInfo?.();
if (cancelled) return;
await ensureVersionChangeBackup(payload, info?.version ?? null);
if (cancelled) return;
// Latch only after a completed (non-cancelled) attempt so effect
// cleanup cannot permanently suppress retries when the in-flight
// call later throws.
versionBackupAttemptedRef.current = true;
} catch (error) {
if (!cancelled) {
console.error('[App] Failed to create version-change backup:', error);
}
} finally {
versionBackupInFlightRef.current = false;
// If hydration re-ran the effect mid-flight, nudge another attempt
// now that the in-flight guard is clear.
if (cancelled && !versionBackupAttemptedRef.current) {
queueMicrotask(() => {
setPluginSidecarReadyTick((v) => v + 1);
});
// Reset the latch so a later data change (or the next mount)
// can retry. ensureVersionChangeBackup already leaves the
// version stamp untouched on failure, so retrying is safe.
versionBackupAttemptedRef.current = false;
}
console.error('[App] Failed to create version-change backup:', error);
}
})();
return () => {
cancelled = true;
};
}, [isPeerSessionWindow, isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts, pluginSidecarReadyTick]);
}, [isPeerSessionWindow, isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts]);
// Memoized "apply a remote payload safely" callback. Stable identity
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
-7
View File
@@ -332,13 +332,6 @@ export const enTerminalMessages: Messages = {
'cloudSync.connect.webdav.failedTitle': 'WebDAV connection failed',
'cloudSync.connect.s3.success': 'S3 connected successfully',
'cloudSync.connect.s3.failedTitle': 'S3 connection failed',
'cloudSync.connect.plugin.success': 'Plugin sync provider connected successfully',
'cloudSync.connect.plugin.failedTitle': 'Plugin sync connection failed',
'cloudSync.pluginConfig.title': 'Configure {name}',
'cloudSync.pluginConfig.desc': 'Enter the JSON configuration required by this plugin sync provider.',
'cloudSync.pluginConfig.label': 'Provider configuration (JSON)',
'cloudSync.pluginConfig.invalidJson': 'Configuration must be valid JSON.',
'cloudSync.pluginConfig.schemaInvalid': 'Configuration does not match the provider schema.',
'cloudSync.lastSync.never': 'Never',
'cloudSync.lastSync.justNow': 'Just now',
'cloudSync.lastSync.minutesAgo': '{minutes} min ago',
-7
View File
@@ -348,13 +348,6 @@ export const ruTerminalMessages: Messages = {
'cloudSync.connect.webdav.failedTitle': 'Ошибка подключения WebDAV',
'cloudSync.connect.s3.success': 'S3 успешно подключён',
'cloudSync.connect.s3.failedTitle': 'Ошибка подключения S3',
'cloudSync.connect.plugin.success': 'Провайдер синхронизации плагина подключен',
'cloudSync.connect.plugin.failedTitle': 'Ошибка подключения синхронизации плагина',
'cloudSync.pluginConfig.title': 'Настройка {name}',
'cloudSync.pluginConfig.desc': 'Введите JSON-конфигурацию, требуемую этим провайдером синхронизации.',
'cloudSync.pluginConfig.label': 'Конфигурация провайдера (JSON)',
'cloudSync.pluginConfig.invalidJson': 'Конфигурация должна быть допустимым JSON.',
'cloudSync.pluginConfig.schemaInvalid': 'Конфигурация не соответствует схеме провайдера.',
'cloudSync.lastSync.never': 'Никогда',
'cloudSync.lastSync.justNow': 'Только что',
'cloudSync.lastSync.minutesAgo': '{minutes} мин назад',
-7
View File
@@ -659,13 +659,6 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.connect.webdav.failedTitle': 'WebDAV 连接失败',
'cloudSync.connect.s3.success': 'S3 已连接',
'cloudSync.connect.s3.failedTitle': 'S3 连接失败',
'cloudSync.connect.plugin.success': '插件同步提供方已连接',
'cloudSync.connect.plugin.failedTitle': '插件同步连接失败',
'cloudSync.pluginConfig.title': '配置 {name}',
'cloudSync.pluginConfig.desc': '请输入该插件同步提供方所需的 JSON 配置。',
'cloudSync.pluginConfig.label': '提供方配置(JSON)',
'cloudSync.pluginConfig.invalidJson': '配置必须是有效的 JSON。',
'cloudSync.pluginConfig.schemaInvalid': '配置不符合提供方声明的结构。',
'cloudSync.lastSync.never': '从未',
'cloudSync.lastSync.justNow': '刚刚',
'cloudSync.lastSync.minutesAgo': '{minutes} 分钟前',
-7
View File
@@ -659,13 +659,6 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.connect.webdav.failedTitle': 'WebDAV 連線失敗',
'cloudSync.connect.s3.success': 'S3 已連線',
'cloudSync.connect.s3.failedTitle': 'S3 連線失敗',
'cloudSync.connect.plugin.success': '外掛同步提供者已連線',
'cloudSync.connect.plugin.failedTitle': '外掛同步連線失敗',
'cloudSync.pluginConfig.title': '設定 {name}',
'cloudSync.pluginConfig.desc': '請輸入此外掛同步提供者所需的 JSON 設定。',
'cloudSync.pluginConfig.label': '提供者設定(JSON)',
'cloudSync.pluginConfig.invalidJson': '設定必須是有效的 JSON。',
'cloudSync.pluginConfig.schemaInvalid': '設定不符合提供者宣告的結構。',
'cloudSync.lastSync.never': '從未',
'cloudSync.lastSync.justNow': '剛剛',
'cloudSync.lastSync.minutesAgo': '{minutes} 分鐘前',
+2 -2
View File
@@ -400,7 +400,7 @@ function writeApplyInProgressSentinel(record: VaultApplyInProgressRecord): void
type ProtectedApplyCallback = () => void | Promise<void>;
export type ApplyProtectedSyncPayloadOptions = {
buildPreApplyPayload: () => SyncPayload | Promise<SyncPayload>;
buildPreApplyPayload: () => SyncPayload;
translateProtectiveBackupFailure: (message: string) => string;
} & (
| { applyPayload: ProtectedApplyCallback; prepareApply?: never }
@@ -415,7 +415,7 @@ export function applyProtectedSyncPayload(
const applyPayload = options.prepareApply
? await options.prepareApply()
: options.applyPayload;
const pre = await buildPreApplyPayload();
const pre = buildPreApplyPayload();
let protectiveBackupId: string | null = null;
try {
const backup = await createRequiredProtectiveLocalVaultBackup(pre);
@@ -1,106 +0,0 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { storePluginSyncSecretsThenConnect } from './pluginSyncConnectWithSecrets.ts';
describe('storePluginSyncSecretsThenConnect', () => {
it('deletes just-created secret keys when connect rejects', async () => {
const putCalls: Array<{ key: string; value: string }> = [];
const deletedKeys: string[][] = [];
let connectCalls = 0;
await assert.rejects(
() => storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [
{ secretKey: 'sync-credential', value: 'bad-password' },
{ secretKey: 'sync-credential:token', value: 'bad-token' },
],
putSecret: async ({ key, value }) => {
putCalls.push({ key, value });
return { kind: 'secret', id: `ref-${key}`, key };
},
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
connect: async () => {
connectCalls += 1;
throw new Error('auth failed');
},
}),
/auth failed/,
);
assert.equal(connectCalls, 1);
assert.deepEqual(putCalls, [
{ key: 'sync-credential', value: 'bad-password' },
{ key: 'sync-credential:token', value: 'bad-token' },
]);
assert.deepEqual(deletedKeys, [['sync-credential', 'sync-credential:token']]);
});
it('does not delete secrets when connect succeeds', async () => {
const deletedKeys: string[][] = [];
await storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [{ secretKey: 'sync-credential', value: 'ok' }],
putSecret: async ({ key }) => ({ kind: 'secret', id: 'ref-1', key }),
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
connect: async () => {},
});
assert.deepEqual(deletedKeys, []);
});
it('does not delete when reusing an existing credential without new secrets', async () => {
const deletedKeys: string[][] = [];
const existing = { kind: 'secret' as const, id: 'existing-ref', key: 'sync-credential' };
await assert.rejects(
() => storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [],
existingCredential: existing,
putSecret: async () => {
throw new Error('put should not run');
},
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
connect: async () => {
throw new Error('network failed');
},
}),
/network failed/,
);
assert.deepEqual(deletedKeys, []);
});
it('deletes secrets that were put before a later put fails', async () => {
const deletedKeys: string[][] = [];
await assert.rejects(
() => storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [
{ secretKey: 'sync-credential', value: 'first' },
{ secretKey: 'sync-credential:token', value: 'second' },
],
putSecret: async ({ key }) => {
if (key === 'sync-credential:token') throw new Error('put failed');
return { kind: 'secret', id: 'ref-1', key };
},
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
connect: async () => {
throw new Error('connect should not run');
},
}),
/put failed/,
);
assert.deepEqual(deletedKeys, [['sync-credential']]);
});
});
@@ -1,65 +0,0 @@
/**
* Persist plugin sync secrets then connect; roll back just-created secrets if
* connect (or a later put) fails so bad passwords are not left in OS storage.
*/
export type PluginSyncSecretRef = { kind: 'secret'; id: string; key: string };
export interface PluginSyncConnectSecretInput {
secretKey: string;
value: string;
}
export interface StorePluginSyncSecretsThenConnectParams {
providerId: string;
secrets: readonly PluginSyncConnectSecretInput[];
/** Reused when `secrets` is empty (edit non-secret fields / reconnect). */
existingCredential?: PluginSyncSecretRef;
putSecret: (params: {
providerId: string;
key: string;
value: string;
}) => Promise<PluginSyncSecretRef>;
deleteSecrets: (params: {
providerId: string;
keys: string[];
}) => Promise<unknown>;
connect: (credential: PluginSyncSecretRef | undefined) => Promise<void>;
}
export async function storePluginSyncSecretsThenConnect(
params: StorePluginSyncSecretsThenConnectParams,
): Promise<void> {
const createdKeys: string[] = [];
let credential: PluginSyncSecretRef | undefined = params.existingCredential;
try {
if (params.secrets.length > 0) {
credential = undefined;
for (const secret of params.secrets) {
const ref = await params.putSecret({
providerId: params.providerId,
key: secret.secretKey,
value: secret.value,
});
createdKeys.push(secret.secretKey);
// SyncConnectPayload.credential carries the primary (first) secret;
// additional secrets remain addressable via secrets.get(key).
if (!credential) credential = ref;
}
}
await params.connect(credential);
} catch (error) {
if (createdKeys.length > 0) {
try {
await params.deleteSecrets({
providerId: params.providerId,
keys: [...createdKeys],
});
} catch {
/* best-effort; surface the original connect/put error */
}
}
throw error;
}
}
-209
View File
@@ -1,209 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
type LocalStorageMock = {
clear(): void;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
};
function installLocalStorage(): LocalStorageMock {
const store = new Map<string, string>();
const localStorage: LocalStorageMock = {
clear() { store.clear(); },
getItem(key) { return store.has(key) ? store.get(key)! : null; },
setItem(key, value) { store.set(key, String(value)); },
removeItem(key) { store.delete(key); },
};
Object.defineProperty(globalThis, 'localStorage', {
value: localStorage,
configurable: true,
});
return localStorage;
}
const localStorage = installLocalStorage();
const { SYNC_STORAGE_KEYS } = await import('../domain/sync.ts');
const { localStorageAdapter } = await import('../infrastructure/persistence/localStorageAdapter.ts');
const {
collectPluginSyncSidecarsFromHost,
commitPluginSidecarsLastKnown,
isPluginSidecarHostReady,
} = await import('./pluginSyncSidecarBridge.ts');
const { hasMeaningfulCloudSyncData } = await import('./syncPayload.ts');
test.beforeEach(() => {
localStorage.clear();
delete (globalThis as { window?: unknown }).window;
});
test('isPluginSidecarHostReady follows pluginHostReady probe when present', () => {
(globalThis as { window: unknown }).window = {
netcatty: {
pluginHostReady: () => true,
async collectPluginSyncSidecars() {
return { version: 1, entries: [] };
},
},
};
assert.equal(isPluginSidecarHostReady(), true);
(globalThis as { window: unknown }).window = {
netcatty: {
pluginHostReady: () => false,
async collectPluginSyncSidecars() {
return null;
},
},
};
assert.equal(isPluginSidecarHostReady(), false);
});
test('collect defers empty last-known until commit (empty-vault guard ignores last-known alone)', async () => {
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
version: 1,
entries: [{
pluginId: 'com.example.p',
kind: 'settings',
key: 'k',
value: 1,
updatedAt: 1,
}],
});
(globalThis as { window: unknown }).window = {
netcatty: {
async collectPluginSyncSidecars() {
return { version: 1, entries: [] };
},
},
};
const collected = await collectPluginSyncSidecarsFromHost();
assert.deepEqual(collected, { version: 1, entries: [] });
// Last-known still holds prior entries until commit, but that alone must not
// bypass the empty-vault upload guard.
const lastKnown = localStorageAdapter.read<{ entries: unknown[] }>(
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
assert.equal(lastKnown?.entries?.length, 1);
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
pluginSidecars: { version: 1, entries: [] },
}),
false,
);
commitPluginSidecarsLastKnown({ version: 1, entries: [] });
const committed = localStorageAdapter.read<{ entries: unknown[] }>(
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
assert.deepEqual(committed?.entries, []);
});
test('liveOnly collect returns null instead of last-known when host is gated off', async () => {
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
version: 1,
entries: [{
pluginId: 'com.example.p',
kind: 'settings',
key: 'k',
value: 1,
updatedAt: 1,
}],
});
(globalThis as { window: unknown }).window = {
netcatty: {
async collectPluginSyncSidecars() {
return null;
},
},
};
assert.equal(await collectPluginSyncSidecarsFromHost({ liveOnly: true }), null);
const fallback = await collectPluginSyncSidecarsFromHost();
assert.equal(fallback?.entries?.length, 1);
});
test('liveOnly collect omits pending remote when replay cannot apply', async () => {
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE, {
version: 1,
entries: [{
pluginId: 'com.example.remote',
kind: 'settings',
key: 'k',
value: 'remote',
updatedAt: 2,
}],
});
(globalThis as { window: unknown }).window = {
netcatty: {
async collectPluginSyncSidecars() {
return { version: 1, entries: [] };
},
// no applyPluginSyncSidecars → pending cannot replay
},
};
assert.equal(await collectPluginSyncSidecarsFromHost({ liveOnly: true }), null);
const uploadPath = await collectPluginSyncSidecarsFromHost();
assert.equal(uploadPath?.entries?.[0]?.value, 'remote');
});
test('commitPluginSidecarsAfterSuccessfulSync prefers merged payload sidecars', async () => {
const { commitPluginSidecarsAfterSuccessfulSync } = await import('./pluginSyncSidecarBridge.ts');
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
version: 1,
entries: [{
pluginId: 'com.example.old',
kind: 'settings',
key: 'k',
value: 'old',
updatedAt: 1,
}],
});
commitPluginSidecarsAfterSuccessfulSync(
{
pluginSidecars: {
version: 1,
entries: [{
pluginId: 'com.example.local',
kind: 'settings',
key: 'k',
value: 'local',
updatedAt: 2,
}],
},
},
[{
success: true,
mergedPayload: {
pluginSidecars: {
version: 1,
entries: [{
pluginId: 'com.example.merged',
kind: 'settings',
key: 'k',
value: 'merged',
updatedAt: 3,
}],
},
},
}],
);
const committed = localStorageAdapter.read<{ entries: Array<{ value: string }> }>(
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
assert.equal(committed?.entries?.[0]?.value, 'merged');
});
-343
View File
@@ -1,343 +0,0 @@
/**
* Renderer bridge for plugin encrypted-sync sidecars.
* Collects/applies sidecars through the main-process non-cascade store when
* the plugin host is available. When the host is offline, last-known sidecars
* are retained so cloud uploads cannot wipe previously synced plugin data.
*/
import type { PluginSyncSidecarBundle } from '../domain/pluginSyncSidecar';
import { SYNC_STORAGE_KEYS } from '../domain/sync';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
/** Ordinary upload fallback when collect cannot reach the host. */
const LAST_KNOWN_SIDECARS_KEY = SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN;
/**
* Remote apply that could not reach the host DB. Distinct from last-known so
* a later collect does not re-apply a stale post-collect snapshot over newer
* local plugin settings.
*/
const PENDING_REMOTE_SIDECARS_KEY = SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE;
const HOST_UNAVAILABLE_MARKER = 'PLUGIN_SIDECAR_HOST_UNAVAILABLE';
export class PluginSidecarHostUnavailableError extends Error {
readonly code = HOST_UNAVAILABLE_MARKER;
constructor(message = 'Plugin sidecar host is unavailable') {
super(message);
this.name = 'PluginSidecarHostUnavailableError';
}
}
export function isPluginSidecarHostUnavailableError(error: unknown): boolean {
if (error instanceof PluginSidecarHostUnavailableError) return true;
if (!error || typeof error !== 'object') return false;
const maybe = error as { code?: unknown; message?: unknown };
if (maybe.code === HOST_UNAVAILABLE_MARKER) return true;
return typeof maybe.message === 'string'
&& maybe.message.includes('Plugin sidecar host is unavailable');
}
type ElectronSidecarApi = {
collectPluginSyncSidecars?: () => Promise<PluginSyncSidecarBundle | null | undefined>;
applyPluginSyncSidecars?: (
bundle: PluginSyncSidecarBundle | null | undefined,
) => Promise<unknown>;
/** True only when main process wired PluginSyncSidecarService. */
pluginHostReady?: () => boolean;
};
function getSidecarApi(): ElectronSidecarApi | null {
if (typeof window === 'undefined') return null;
// Preload exposes the production bridge as window.netcatty only.
const bridge = (window as Window & {
netcatty?: ElectronSidecarApi;
electron?: ElectronSidecarApi;
}).netcatty
?? (window as Window & { electron?: ElectronSidecarApi }).electron;
return bridge ?? null;
}
/**
* Whether the main-process sidecar service is wired. Version-change backups
* should wait for this (or a grace timeout) before latching, otherwise an
* early empty collect permanently skips plugin settings in the upgrade snapshot.
*/
export function isPluginSidecarHostReady(): boolean {
const api = getSidecarApi();
if (!api) return false;
if (typeof api.pluginHostReady === 'function') {
try {
return api.pluginHostReady() === true;
} catch {
return false;
}
}
// Older / test bridges without the probe: collect presence implies ready.
return typeof api.collectPluginSyncSidecars === 'function';
}
function readBundle(key: string): PluginSyncSidecarBundle | null {
const raw = localStorageAdapter.read<PluginSyncSidecarBundle>(key);
if (!raw || !Array.isArray(raw.entries)) return null;
return { version: 1, entries: raw.entries };
}
function writeBundle(key: string, bundle: PluginSyncSidecarBundle | null | undefined): void {
if (!bundle || !Array.isArray(bundle.entries)) {
localStorageAdapter.remove(key);
return;
}
const ok = localStorageAdapter.write(key, {
version: 1,
entries: bundle.entries,
});
if (ok === false) {
throw new Error(
`Failed to persist plugin sidecars (${key}); local storage write was rejected`,
);
}
}
function readLastKnownSidecars(): PluginSyncSidecarBundle | null {
return readBundle(LAST_KNOWN_SIDECARS_KEY);
}
function writeLastKnownSidecars(bundle: PluginSyncSidecarBundle | null | undefined): void {
writeBundle(LAST_KNOWN_SIDECARS_KEY, bundle);
}
/** null means no pending remote apply. Empty entries is a valid pending reset. */
function readPendingRemoteSidecars(): PluginSyncSidecarBundle | null {
const raw = localStorageAdapter.read<PluginSyncSidecarBundle | null>(PENDING_REMOTE_SIDECARS_KEY);
if (raw == null) return null;
if (!Array.isArray(raw.entries)) return null;
return { version: 1, entries: raw.entries };
}
function writePendingRemoteSidecars(bundle: PluginSyncSidecarBundle): void {
const ok = localStorageAdapter.write(PENDING_REMOTE_SIDECARS_KEY, {
version: 1,
entries: bundle.entries,
});
if (ok === false) {
// Operational failure — must not look like host-unavailable (which apply
// swallows). Sync must abort so the remote bundle is not lost.
throw new Error(
'Failed to queue pending remote plugin sidecars; local storage write was rejected',
);
}
}
function clearPendingRemoteSidecars(): void {
localStorageAdapter.remove(PENDING_REMOTE_SIDECARS_KEY);
}
function isAuthoritativeBundle(
value: unknown,
): value is PluginSyncSidecarBundle {
return Boolean(
value
&& typeof value === 'object'
&& Array.isArray((value as PluginSyncSidecarBundle).entries),
);
}
function isSuccessfulApplyResult(result: unknown): boolean {
if (result == null) return false;
if (
typeof result === 'object'
&& result !== null
&& 'applied' in result
&& (result as { applied?: unknown }).applied === false
) {
return false;
}
return true;
}
/**
* Returns:
* - a real bundle (possibly empty entries) when the host collected successfully
* - last-known bundle when the host is unavailable (protect remote from wipe)
* Throws on operational host failure (DB/runtime error) so sync aborts.
*
* Pending remote applies (host was offline during download) are replayed into
* the DB before collection. Ordinary last-known collect snapshots are never
* re-applied, so local settings edits made after the last collect are kept.
*/
export async function collectPluginSyncSidecarsFromHost(options?: {
/**
* When true, never fall back to last-known cache. Returns null if the host is
* gated off / non-authoritative so callers can omit `pluginSidecars` instead
* of applying a stale cache snapshot (e.g. convergent conflict materialize).
*/
liveOnly?: boolean;
} = {}): Promise<PluginSyncSidecarBundle | null> {
const liveOnly = options.liveOnly === true;
const api = getSidecarApi();
if (typeof api?.collectPluginSyncSidecars !== 'function') {
return liveOnly ? null : readLastKnownSidecars();
}
const pendingRemote = readPendingRemoteSidecars();
if (pendingRemote && typeof api.applyPluginSyncSidecars === 'function') {
try {
const replayResult = await api.applyPluginSyncSidecars(pendingRemote);
if (!isSuccessfulApplyResult(replayResult)) {
// Upload path: keep returning pending so we do not push stale local
// over an authoritative remote still waiting to apply.
// liveOnly (conflict materialize): omit instead of attaching the queue.
return liveOnly ? null : pendingRemote;
}
clearPendingRemoteSidecars();
} catch (error) {
// Operational failure: keep pending and surface so upload aborts.
// Host-unavailable during replay: upload keeps pending; liveOnly omits.
if (isPluginSidecarHostUnavailableError(error)) {
return liveOnly ? null : pendingRemote;
}
throw error;
}
} else if (pendingRemote && typeof api.applyPluginSyncSidecars !== 'function') {
return liveOnly ? null : pendingRemote;
}
const bundle = await api.collectPluginSyncSidecars();
// Passive/null means the plugin host is gated off or manager resolution failed.
// Do not treat that as an authoritative empty bundle (would wipe last-known).
if (bundle == null || !isAuthoritativeBundle(bundle)) {
return liveOnly ? null : readLastKnownSidecars();
}
const normalized: PluginSyncSidecarBundle = {
version: 1,
entries: bundle.entries,
};
// Defer overwriting a non-empty last-known with an authoritative empty collect.
// useAutoSync runs hasMeaningfulCloudSyncData() after buildPayload/collect; if
// we erased last-known here, a deliberate plugin-only reset (entries → [])
// would look like an empty vault and be blocked. Commit the empty cache after
// a successful upload via commitPluginSidecarsLastKnown().
const previous = readLastKnownSidecars();
const previousHadEntries = Array.isArray(previous?.entries) && previous.entries.length > 0;
if (!(previousHadEntries && normalized.entries.length === 0)) {
writeLastKnownSidecars(normalized);
}
return normalized;
}
/**
* Persist the sidecar bundle that was just successfully synced (or applied).
* Clears a deferred non-empty last-known after an empty reset upload.
*/
export function commitPluginSidecarsLastKnown(
bundle: PluginSyncSidecarBundle | null | undefined,
): void {
if (!bundle || !Array.isArray(bundle.entries)) {
writeLastKnownSidecars({ version: 1, entries: [] });
return;
}
writeLastKnownSidecars({ version: 1, entries: bundle.entries });
}
/**
* After a successful cloud upload/download round-trip, finalize the deferred
* last-known sidecar cache. Prefer merged/downloaded payload sidecars when
* present so we do not overwrite a remote apply with the pre-sync local collect.
*/
export function commitPluginSidecarsAfterSuccessfulSync(
payload: { pluginSidecars?: PluginSyncSidecarBundle | null },
results: Iterable<{
success?: boolean;
mergedPayload?: { pluginSidecars?: PluginSyncSidecarBundle | null } | null;
}>,
): void {
const resultList = Array.from(results);
if (!resultList.some((result) => result.success === true)) return;
let commitSidecars: PluginSyncSidecarBundle | undefined;
if (Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) {
commitSidecars = {
version: 1,
entries: Array.isArray(payload.pluginSidecars?.entries) ? payload.pluginSidecars.entries : [],
};
}
for (const result of resultList) {
if (
result.mergedPayload
&& Object.prototype.hasOwnProperty.call(result.mergedPayload, 'pluginSidecars')
) {
commitSidecars = {
version: 1,
entries: Array.isArray(result.mergedPayload.pluginSidecars?.entries)
? result.mergedPayload.pluginSidecars.entries
: [],
};
break;
}
}
if (commitSidecars) commitPluginSidecarsLastKnown(commitSidecars);
}
export async function applyPluginSyncSidecarsFromHost(
bundle: PluginSyncSidecarBundle | null | undefined,
): Promise<void> {
// Empty bundle is authoritative (cloud deleted all sidecars) — still apply.
const normalized: PluginSyncSidecarBundle = {
version: 1,
entries: Array.isArray(bundle?.entries) ? bundle.entries : [],
};
const api = getSidecarApi();
if (typeof api?.applyPluginSyncSidecars !== 'function') {
// Host offline: queue remote apply for later DB replay, and keep last-known
// for upload protection so cloud is not wiped with empty collect.
writePendingRemoteSidecars(normalized);
writeLastKnownSidecars(normalized);
throw new PluginSidecarHostUnavailableError(
'Plugin sidecar host is unavailable; cannot apply downloaded sidecars',
);
}
const result = await api.applyPluginSyncSidecars(normalized);
// Passive IPC returns null when the manager is unavailable, or
// { applied: false } when the sidecar service was not wired.
if (!isSuccessfulApplyResult(result)) {
writePendingRemoteSidecars(normalized);
writeLastKnownSidecars(normalized);
throw new PluginSidecarHostUnavailableError(
'Plugin sidecar host is unavailable; cannot apply downloaded sidecars',
);
}
clearPendingRemoteSidecars();
// Prefer merged entries returned by apply (includes missing-plugin rows the
// remote omitted) when refreshing last-known without a successful collect.
const appliedMerged: PluginSyncSidecarBundle | null = Array.isArray(
(result as { entries?: unknown }).entries,
)
? { version: 1, entries: (result as { entries: PluginSyncSidecarBundle['entries'] }).entries }
: null;
const fallbackLastKnown = appliedMerged ?? normalized;
// Prefer a fresh host collect so last-known reflects post-apply host state.
if (typeof api.collectPluginSyncSidecars === 'function') {
try {
const collected = await api.collectPluginSyncSidecars();
if (isAuthoritativeBundle(collected)) {
writeLastKnownSidecars({ version: 1, entries: collected.entries });
return;
}
// Apply already committed; collect shape was non-authoritative. Prefer the
// merged apply result over the raw remote so preserved local rows survive.
writeLastKnownSidecars(fallbackLastKnown);
return;
} catch {
// Apply already committed. Do not fall back to the raw remote bundle —
// that would drop missing-plugin rows applyFromSync preserved.
try {
writeLastKnownSidecars(fallbackLastKnown);
} catch {
// last-known write failure is secondary; apply already succeeded.
}
return;
}
}
// No collect API: use merged apply result when available, else remote.
writeLastKnownSidecars(fallbackLastKnown);
}
+1 -1
View File
@@ -19,7 +19,7 @@ const publishCredentialCatalogIds = (ids: ReadonlyArray<string>) => {
};
export const pluginExtensionBridge = Object.freeze({
async listProviders(kind: "connection" | "authentication" | "importer" | "sync") {
async listProviders(kind: "connection" | "authentication" | "importer") {
return requireBridge().listPluginExtensionProviders?.({ kind }) ?? [];
},
async updateCredentialCatalog(entries: ReadonlyArray<{ id: string; ciphertext: string }>) {
+21 -85
View File
@@ -31,7 +31,6 @@ import {
sanitizePortForwardingRulesForSync,
shouldPromptCloudVaultRecovery,
} from '../syncPayload';
import { commitPluginSidecarsAfterSuccessfulSync } from '../pluginSyncSidecarBridge';
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
@@ -77,21 +76,6 @@ interface AutoSyncConfig {
// Get manager singleton for direct state access
const manager = getCloudSyncManager();
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
/** Prefer built-in order, then any connected namespaced plugin provider. */
function pickConnectedAutoSyncProvider(
providers: Record<string, { status?: string; tokens?: unknown; config?: unknown }>,
): CloudProvider | undefined {
for (const id of AUTO_SYNC_PROVIDER_ORDER) {
const conn = providers[id];
if (conn && isProviderReadyForSync(conn as never)) return id;
}
for (const [id, conn] of Object.entries(providers)) {
if (AUTO_SYNC_PROVIDER_ORDER.includes(id as CloudProvider)) continue;
if (conn && isProviderReadyForSync(conn as never)) return id as CloudProvider;
}
return undefined;
}
const SYNCABLE_SETTING_STORAGE_KEY_SET = new Set<string>(SYNCABLE_SETTING_STORAGE_KEYS);
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
@@ -141,7 +125,6 @@ const getSyncPayloadDataHash = (payload: SyncPayload): string => {
portForwardingRules: sanitizePortForwardingRulesForSync(payload.portForwardingRules),
groupConfigs: payload.groupConfigs,
settings: payload.settings,
pluginSidecars: payload.pluginSidecars,
});
};
@@ -205,21 +188,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
return () => window.removeEventListener('sftp-bookmarks-changed', handler);
}, []);
// Plugin settings live in the main-process DB; contribution change events
// are the renderer signal that sidecar-backed values may have changed.
const [pluginSidecarsVersion, setPluginSidecarsVersion] = useState(0);
useEffect(() => {
const bridge = netcattyBridge.get() as {
onPluginContributionsChanged?: (callback: () => void) => () => void;
} | null | undefined;
const unsubscribe = bridge?.onPluginContributionsChanged?.(() => {
setPluginSidecarsVersion((v) => v + 1);
});
return () => {
unsubscribe?.();
};
}, []);
const [syncableSettingsStorageVersion, setSyncableSettingsStorageVersion] = useState(0);
useEffect(() => {
const bumpIfSyncableSetting = (key: string | null | undefined) => {
@@ -271,24 +239,19 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.groupConfigs,
]);
// Build sync payload (includes plugin sidecars when host is available)
// Build sync payload
const buildPayload = useCallback(async (): Promise<SyncPayload> => {
const { withPluginSyncSidecars } = await import('../syncPayload');
const { collectPluginSyncSidecarsFromHost } = await import('../pluginSyncSidecarBridge');
const base: SyncPayload = {
return {
...getSyncSnapshot(),
settings: await collectCloudSyncableSettings(),
syncedAt: Date.now(),
};
const sidecars = await collectPluginSyncSidecarsFromHost();
return withPluginSyncSidecars(base, sidecars);
}, [getSyncSnapshot]);
// Create a hash of current data for comparison (includes settings + sidecars)
// Create a hash of current data for comparison (includes settings)
const getDataHash = useCallback(async () => {
const payload = await buildPayload();
return getSyncPayloadDataHash(payload);
}, [buildPayload]);
return JSON.stringify({ ...getSyncSnapshot(), settings: await collectCloudSyncableSettings() });
}, [getSyncSnapshot]);
// Sync now handler - get fresh state directly from manager
const syncNow = useCallback(async (options?: SyncNowOptions): Promise<boolean> => {
@@ -383,8 +346,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.autoSync.vaultLocked'));
}
const dataHash = await getDataHash();
const payload = await buildPayload();
const dataHash = getSyncPayloadDataHash(payload);
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (
encryptedCredentialPaths.length > 0
@@ -458,10 +421,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
}
// Commit sidecar last-known after a successful sync (also done inside
// useCloudSync.syncNow; keep here as defense for this path's merge logic).
commitPluginSidecarsAfterSuccessfulSync(payload, resultList);
lastSyncedDataRef.current = dataHash;
manager.setPendingLocalSync(false);
@@ -558,19 +517,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (!remoteCheckDoneRef.current) {
return;
}
try {
const currentHash = await getDataHashRef.current();
const hashDecision = resolveAutoSyncHashDecision({
currentHash,
lastSyncedHash: lastSyncedDataRef.current,
appliedSkipHash: skipNextSyncHashRef.current,
});
manager.setPendingLocalSync(hashDecision === 'sync');
} catch (error) {
// Sidecar collect can throw on host DB/runtime errors; leave the
// pending indicator unchanged rather than surfacing an unhandled rejection.
console.warn('[AutoSync] Failed to refresh pending local sync:', error);
}
const currentHash = await getDataHashRef.current();
const hashDecision = resolveAutoSyncHashDecision({
currentHash,
lastSyncedHash: lastSyncedDataRef.current,
appliedSkipHash: skipNextSyncHashRef.current,
});
manager.setPendingLocalSync(hashDecision === 'sync');
}, [convergentSyncPaused, enabled, sync.hasAnyConnectedProvider, sync.isUnlocked]);
const refreshPendingLocalSyncRef = useRef(refreshPendingLocalSync);
@@ -614,7 +567,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// "nothing to check" early return doesn't leak the lock and wedge
// the retry timer. Any path that takes the lock MUST reach the
// finally-release below.
const connectedProvider = pickConnectedAutoSyncProvider(state.providers) ?? null;
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
isProviderReadyForSync(state.providers[provider]),
) ?? null;
if (!connectedProvider) {
// Nothing to check — mark as done so the auto-sync gate opens.
@@ -760,7 +715,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
conflictActionOverride: 'upload-local',
});
const roundTripResultList = Array.from(roundTripResults.values());
commitPluginSidecarsAfterSuccessfulSync(remotePayload, roundTripResultList);
const wasShrinkBlocked = roundTripResultList.some((result) => result.shrinkBlocked === true);
const roundTripFullySynced = roundTripResultList.length > 0
&& roundTripResultList.every((result) => result.success);
@@ -780,7 +734,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (conflictAction === 'upload-local') {
const pushResults = await manager.syncAllProviders(localPayload);
const results = Array.from(pushResults.values());
commitPluginSidecarsAfterSuccessfulSync(localPayload, results);
const allProvidersSynced = results.length > 0
&& results.every((result) => result.success);
const wasShrinkBlocked = results.some((result) => result.shrinkBlocked === true);
@@ -828,7 +781,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
try {
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
const roundTripResultList = Array.from(roundTripResults.values());
commitPluginSidecarsAfterSuccessfulSync(mergeResult.payload, roundTripResultList);
const wasShrinkBlocked = roundTripResultList.some((r) => r.shrinkBlocked === true);
const roundTripFullySynced = roundTripResultList.length > 0
&& roundTripResultList.every((result) => result.success);
@@ -876,11 +828,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
isInitializedRef.current = true;
}
if (markCurrentDataSynced && (!hadInitialBaseline || inspectedRemoteChange)) {
try {
lastSyncedDataRef.current = await getDataHashRef.current();
} catch (error) {
console.warn('[AutoSync] Failed to capture post-inspect baseline hash:', error);
}
lastSyncedDataRef.current = await getDataHashRef.current();
} else if (!markCurrentDataSynced) {
lastSyncedDataRef.current = '';
}
@@ -925,13 +873,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const establishInitialBaseline = () => {
isInitializedRef.current = true;
void (async () => {
try {
const currentHash = await getDataHash();
if (cancelled) return;
lastSyncedDataRef.current = currentHash;
} catch (error) {
console.warn('[AutoSync] Failed to establish sync baseline hash:', error);
}
const currentHash = await getDataHash();
if (cancelled) return;
lastSyncedDataRef.current = currentHash;
})();
};
@@ -972,13 +916,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
syncTimeoutRef.current = setTimeout(() => {
syncTimeoutRef.current = null;
void (async () => {
let currentHash: string;
try {
currentHash = await getDataHash();
} catch (error) {
console.warn('[AutoSync] Failed to hash local vault before auto-sync:', error);
return;
}
const currentHash = await getDataHash();
if (cancelled) return;
// After a remote apply or merge, skip only that exact applied data.
@@ -1048,7 +986,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.settingsVersion,
enabled,
bookmarksVersion,
pluginSidecarsVersion,
convergentSyncPaused,
syncableSettingsStorageVersion,
]);
@@ -1065,7 +1002,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.settingsVersion,
enabled,
bookmarksVersion,
pluginSidecarsVersion,
syncableSettingsStorageVersion,
]);
+5 -112
View File
@@ -59,7 +59,6 @@ interface CloudSyncRunOptions {
overrideShrink?: boolean;
conflictActionOverride?: CloudSyncConflictAction;
applyConvergentPayload?: ConvergentPayloadApplier;
signal?: AbortSignal;
}
export interface CloudSyncHook {
@@ -108,12 +107,6 @@ export interface CloudSyncHook {
authAttemptId?: number
) => Promise<void>;
connectGoogle: () => Promise<string>;
/** Connect a namespaced plugin sync Provider (encrypted-object storage only). */
connectPluginProvider: (
providerId: string,
configuration?: unknown,
credential?: unknown,
) => Promise<void>;
connectOneDrive: () => Promise<string>;
connectWebDAV: (config: WebDAVConfig) => Promise<void>;
connectS3: (config: S3Config) => Promise<void>;
@@ -131,7 +124,7 @@ export interface CloudSyncHook {
syncToProvider: (
provider: CloudProvider,
payload: SyncPayload,
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload' | 'signal'>,
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload'>,
) => Promise<SyncResult>;
downloadFromProvider: (provider: CloudProvider) => Promise<RemoteSyncPayload | null>;
commitRemoteInspection: (provider: CloudProvider, remoteFile: SyncedFile, payload: SyncPayload, opts?: { recordDownload?: boolean }) => Promise<void>;
@@ -310,39 +303,6 @@ export const useCloudSync = (): CloudSyncHook => {
// A missing/corrupt replica is surfaced by the next explicit sync.
});
}, [state.securityState]);
// Keep plugin sync provider availability aligned with live contributions so
// disabled/uninstalled providers leave the auto-sync ready set after restart.
useEffect(() => {
let cancelled = false;
const refreshAvailable = async () => {
try {
const { pluginExtensionBridge } = await import('./pluginExtensionBridge');
const listed = await pluginExtensionBridge.listProviders('sync');
if (cancelled) return;
const ids = (listed ?? []).map((entry) => {
const nested = (entry as { provider?: { id?: string } }).provider;
return typeof nested?.id === 'string' ? nested.id : '';
}).filter((id) => id.length > 0);
manager.setAvailablePluginSyncProviders(ids);
} catch {
// Transient IPC / host startup failures must not wipe the availability
// catalog; leave the previous set until a successful discovery.
}
};
void refreshAvailable();
let unsubscribe = () => {};
void import('./pluginExtensionBridge').then(({ pluginExtensionBridge }) => {
if (cancelled) return;
unsubscribe = pluginExtensionBridge.onContributionsChanged(() => {
void refreshAvailable();
});
});
return () => {
cancelled = true;
unsubscribe();
};
}, []);
// ========== Computed Values ==========
@@ -716,18 +676,6 @@ export const useCloudSync = (): CloudSyncHook => {
const connectS3 = useCallback(async (config: S3Config): Promise<void> => {
await manager.connectConfigProvider('s3', config);
}, []);
/**
* Connect a namespaced plugin sync Provider (encrypted-object storage only).
* Configuration is opaque plugin-owned JSON; encryption stays host-owned.
*/
const connectPluginProvider = useCallback(async (
providerId: string,
configuration: unknown = {},
credential?: unknown,
): Promise<void> => {
await manager.connectPluginProvider(providerId, configuration, credential);
}, []);
const cancelOAuthConnect = useCallback(() => {
const githubAbort = activeGitHubAuthAbortRef.current;
@@ -786,48 +734,16 @@ export const useCloudSync = (): CloudSyncHook => {
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: CloudSyncRunOptions) => {
await ensureUnlocked();
const controller = new AbortController();
let onAbort: (() => void) | undefined;
if (opts?.signal) {
if (opts.signal.aborted) controller.abort();
else {
onAbort = () => controller.abort();
opts.signal.addEventListener('abort', onAbort, { once: true });
}
}
try {
const results = await manager.syncAllProviders(payload, { ...opts, signal: controller.signal });
const { commitPluginSidecarsAfterSuccessfulSync } = await import('../pluginSyncSidecarBridge');
commitPluginSidecarsAfterSuccessfulSync(payload, results.values());
return results;
} finally {
if (opts?.signal && onAbort) opts.signal.removeEventListener('abort', onAbort);
}
return await manager.syncAllProviders(payload, opts);
}, [ensureUnlocked]);
const syncToProviderWithUnlock = useCallback(async (
provider: CloudProvider,
payload: SyncPayload,
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload' | 'signal'>,
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload'>,
) => {
await ensureUnlocked();
const controller = new AbortController();
let onAbort: (() => void) | undefined;
if (opts?.signal) {
if (opts.signal.aborted) controller.abort();
else {
onAbort = () => controller.abort();
opts.signal.addEventListener('abort', onAbort, { once: true });
}
}
try {
const result = await manager.syncToProvider(provider, payload, { ...opts, signal: controller.signal });
const { commitPluginSidecarsAfterSuccessfulSync } = await import('../pluginSyncSidecarBridge');
commitPluginSidecarsAfterSuccessfulSync(payload, [result]);
return result;
} finally {
if (opts?.signal && onAbort) opts.signal.removeEventListener('abort', onAbort);
}
return await manager.syncToProvider(provider, payload, opts);
}, [ensureUnlocked]);
const downloadFromProviderWithUnlock = useCallback(async (provider: CloudProvider) => {
@@ -869,29 +785,7 @@ export const useCloudSync = (): CloudSyncHook => {
) => Promise<void>,
) => {
await ensureUnlocked();
// Collect live sidecars so conflict apply/upload does not revert plugin
// settings that changed after the last provider baseline. Operational
// collection failures must abort (not proceed as empty). Host gated off /
// non-authoritative collect must omit the field (not last-known cache) so
// apply preserves local DB sidecars.
const {
collectPluginSyncSidecarsFromHost,
isPluginSidecarHostUnavailableError,
} = await import('../pluginSyncSidecarBridge');
let pluginSidecars: SyncPayload['pluginSidecars'] | undefined;
try {
const collected = await collectPluginSyncSidecarsFromHost({ liveOnly: true });
if (collected) pluginSidecars = collected;
} catch (error) {
if (isPluginSidecarHostUnavailableError(error)) {
// Host gated off: omit field so apply preserves local sidecars.
} else {
throw error;
}
}
return manager.resolveConvergentConflict(addressKey, candidateDot, applyPayload, {
pluginSidecars,
});
return manager.resolveConvergentConflict(addressKey, candidateDot, applyPayload);
}, [ensureUnlocked]);
const refreshConvergentSyncConfig = useCallback(() => {
@@ -960,7 +854,6 @@ export const useCloudSync = (): CloudSyncHook => {
connectOneDrive,
connectWebDAV,
connectS3,
connectPluginProvider,
completePKCEAuth,
cancelOAuthConnect,
disconnectProvider,
-238
View File
@@ -43,17 +43,13 @@ const {
applySyncPayload,
buildLocalVaultPayload,
buildCloudSyncPayload,
withPluginSyncSidecars,
buildSyncPayload,
hasCloudSyncEntityData,
hasMeaningfulCloudSyncData,
hasMeaningfulSyncData,
shouldPromptCloudVaultRecovery,
SYNCABLE_SETTING_STORAGE_KEYS,
} = await import("./syncPayload.ts");
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
const { SYNC_STORAGE_KEYS } = await import("../domain/sync.ts");
const { localStorageAdapter } = await import("../infrastructure/persistence/localStorageAdapter.ts");
const knownHost = (id = "kh-1"): KnownHost => ({
id,
@@ -795,165 +791,6 @@ test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
);
});
test("buildLocalVaultPayload includes last-known plugin sidecars for protective backups", () => {
localStorage.setItem(
"netcatty_plugin_sidecars_last_known_v1",
JSON.stringify({
version: 1,
entries: [{
pluginId: "com.example.p",
kind: "settings",
key: "com.example.p.theme\0application\0application",
value: "dark",
updatedAt: 1,
}],
}),
);
try {
const payload = buildLocalVaultPayload(vault([]));
assert.equal(payload.pluginSidecars?.entries?.length, 1);
assert.equal(payload.pluginSidecars?.entries?.[0].value, "dark");
assert.equal(hasMeaningfulSyncData(payload), true);
} finally {
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
}
});
test("buildLocalVaultPayloadAsync keeps last-known when live collect is empty", async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
collectPluginSyncSidecars: async () => ({ version: 1, entries: [] }),
pluginHostReady: () => true,
},
dispatchEvent: () => true,
},
configurable: true,
});
localStorage.setItem(
"netcatty_plugin_sidecars_last_known_v1",
JSON.stringify({
version: 1,
entries: [{
pluginId: "com.example.p",
kind: "settings",
key: "com.example.p.theme\0application\0application",
value: "dark",
updatedAt: 1,
}],
}),
);
try {
const { buildLocalVaultPayloadAsync } = await import("./syncPayload.ts");
const payload = await buildLocalVaultPayloadAsync(vault([]));
assert.equal(payload.pluginSidecars?.entries?.length, 1);
assert.equal(payload.pluginSidecars?.entries?.[0].value, "dark");
} finally {
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
Object.defineProperty(globalThis, "window", {
value: previousWindow,
configurable: true,
});
}
});
test("hasMeaningfulCloudSyncData treats non-empty plugin sidecars as meaningful", () => {
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
pluginSidecars: {
version: 1,
entries: [{
pluginId: "com.example.p",
kind: "settings",
key: "com.example.p.theme\0application\0application",
value: "dark",
updatedAt: 1,
}],
},
}),
true,
);
// Empty bundle alone must not bypass the empty-vault upload guard.
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
pluginSidecars: { version: 1, entries: [] },
}),
false,
);
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
}),
false,
);
});
test("hasMeaningfulCloudSyncData does not treat last-known-only empty sidecars as meaningful", () => {
const previous = localStorageAdapter.read(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
try {
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
version: 1,
entries: [{
pluginId: "com.example.p",
kind: "settings",
key: "k",
value: 1,
updatedAt: 1,
}],
});
// Empty vault + empty sidecar entries must stay blocked even when last-known
// still remembers prior plugin settings (empty-vault upload guard).
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
pluginSidecars: { version: 1, entries: [] },
}),
false,
);
} finally {
if (previous == null) localStorageAdapter.remove(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
else localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, previous);
}
});
test("plugin sidecar storage keys stay aligned between sync domain and storageKeys registry", () => {
assert.equal(
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_LAST_KNOWN,
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
assert.equal(
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_PENDING_REMOTE,
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE,
);
assert.equal(
storageKeys.STORAGE_KEY_AVAILABLE_PLUGIN_SYNC_PROVIDERS,
SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS,
);
});
test("hasCloudSyncEntityData ignores settings-only payloads for empty-vault recovery", () => {
assert.equal(
hasCloudSyncEntityData({
@@ -1501,78 +1338,3 @@ test("applyLocalVaultPayload does not commit convergent writes when local import
assert.equal(committed, false);
});
test("withPluginSyncSidecars attaches non-empty plugin sidecar bundles", () => {
const base = buildSyncPayload({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
});
const withEmpty = withPluginSyncSidecars(base, { version: 1, entries: [] });
assert.deepEqual(withEmpty.pluginSidecars, { version: 1, entries: [] });
const withData = withPluginSyncSidecars(base, {
version: 1,
entries: [{
pluginId: "com.example.sync",
kind: "settings",
key: "com.example.sync.theme\0application\0application",
value: "dark",
updatedAt: 1,
}],
});
assert.equal(withData.pluginSidecars?.entries.length, 1);
assert.equal(withData.pluginSidecars?.entries[0].value, "dark");
});
test("buildCloudSyncPayload includes plugin sidecars from the production collector hook", async () => {
const payload = await buildCloudSyncPayload(vault([]), [], {
collectPluginSidecars: async () => ({
version: 1,
entries: [{
pluginId: "com.example.sync",
kind: "account_baseline",
key: "account",
value: { id: "acct-1" },
updatedAt: 9,
}],
}),
});
assert.equal(payload.pluginSidecars?.entries.length, 1);
assert.equal(payload.pluginSidecars?.entries[0].kind, "account_baseline");
assert.deepEqual(payload.pluginSidecars?.entries[0].value, { id: "acct-1" });
});
test("applySyncPayload applies pluginSidecars through the production applier hook", async () => {
let applied: unknown = null;
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
pluginSidecars: {
version: 1,
entries: [{
pluginId: "com.missing.plugin",
kind: "crdt_baseline",
key: "replica",
value: { clock: 3 },
updatedAt: 2,
}],
},
};
await applySyncPayload(payload, {
importVaultData: () => {},
}, {
applyPluginSidecars: async (sidecars) => {
applied = sidecars;
},
});
assert.equal((applied as SyncPayload["pluginSidecars"])?.entries[0].value.clock, 3);
});
+12 -174
View File
@@ -22,7 +22,6 @@ import type {
import {
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_STORAGE_KEYS,
hasSyncPayloadEntityData,
type SyncPayload,
} from '../domain/sync';
@@ -123,33 +122,15 @@ export interface SyncableVaultData {
}
/**
* Returns true when the payload carries non-empty plugin sidecar entries.
*
* An explicit empty `{entries:[]}` bundle is intentionally NOT treated as
* meaningful by itself — last-known cache alone must not authorize pushing a
* wiped hosts/keys vault to cloud (empty-vault upload guard). Sidecar-only
* wipes still sync when the vault also has entities/settings, or via Force Push.
*/
function hasMeaningfulPluginSidecars(payload: SyncPayload): boolean {
return Array.isArray(payload.pluginSidecars?.entries)
&& payload.pluginSidecars.entries.length > 0;
}
/**
* Returns true when a payload contains entities, settings, or meaningful
* plugin sidecars worth syncing / protecting with empty-vault guards.
* Local-only trust records are intentionally ignored by the cloud variant.
* Returns true when the payload contains any meaningful user data worth
* protecting or syncing.
*/
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
if (
payload.settings && Object.values(payload.settings).some((value) => value !== undefined)
) {
return true;
}
return hasMeaningfulPluginSidecars(payload);
return Boolean(
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
);
}
/**
@@ -159,14 +140,9 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
if (hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS)) return true;
if (
payload.settings && Object.values(payload.settings).some((value) => value !== undefined)
) {
return true;
}
// Plugin-only profiles (settings/baselines, no vault entities) must still sync.
return hasMeaningfulPluginSidecars(payload);
return Boolean(
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
);
}
/**
@@ -901,75 +877,11 @@ export function buildSyncPayload(
};
}
export type PluginSyncSidecarCollector = () =>
| Promise<SyncPayload['pluginSidecars'] | null | undefined>
| SyncPayload['pluginSidecars']
| null
| undefined;
export type PluginSyncSidecarApplier = (
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
) => Promise<void> | void;
/**
* Attach a host-collected plugin sidecar bundle to a cloud payload.
* Secrets must already be excluded by the collector; this only drops empty bundles.
*/
export function withPluginSyncSidecars(
payload: SyncPayload,
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
): SyncPayload {
// null/undefined: collector unavailable — leave payload field unchanged
// (callers should reattach last-known before upload).
if (sidecars == null) {
return payload;
}
// Explicit empty entries is a real reset and must remain on the wire so
// apply paths can distinguish it from a legacy payload missing the field.
return {
...payload,
pluginSidecars: {
version: 1,
entries: Array.isArray(sidecars.entries) ? sidecars.entries : [],
},
};
}
async function defaultCollectPluginSyncSidecars(): Promise<SyncPayload['pluginSidecars'] | null | undefined> {
const { collectPluginSyncSidecarsFromHost } = await import('./pluginSyncSidecarBridge');
// Let operational failures (DB/runtime) propagate so cloud upload does not
// silently strip previously synced sidecars from the remote snapshot.
return collectPluginSyncSidecarsFromHost();
}
async function defaultApplyPluginSyncSidecars(
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
): Promise<void> {
const {
applyPluginSyncSidecarsFromHost,
isPluginSidecarHostUnavailableError,
} = await import('./pluginSyncSidecarBridge');
try {
await applyPluginSyncSidecarsFromHost(sidecars);
} catch (error) {
// Only tolerate the host-gated-off / manager-unavailable path so a later
// plugin-enabled session can still apply from last-known cache. DB/runtime
// failures must fail the surrounding sync so the same remote is retried.
if (isPluginSidecarHostUnavailableError(error)) {
return;
}
throw error;
}
}
export async function buildCloudSyncPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
options?: {
collectPluginSidecars?: PluginSyncSidecarCollector;
},
): Promise<SyncPayload> {
const base: SyncPayload = {
return {
hosts: vault.hosts,
keys: vault.keys,
identities: vault.identities,
@@ -984,9 +896,6 @@ export async function buildCloudSyncPayload(
settings: await collectCloudSyncableSettings(),
syncedAt: Date.now(),
};
const collect = options?.collectPluginSidecars ?? defaultCollectPluginSyncSidecars;
const sidecars = await collect();
return withPluginSyncSidecars(base, sidecars);
}
/** Build a local backup/restore payload, including local-only trust records. */
@@ -994,66 +903,11 @@ export function buildLocalVaultPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): SyncPayload {
const base: SyncPayload = {
return {
...buildSyncPayload(vault, portForwardingRules),
settings: collectLocalBackupSettings(),
knownHosts: vault.knownHosts,
};
// Protective backups must capture plugin sidecars so restore can recover
// plugin settings/baselines. Prefer the last successful host collect cache
// (sync). Callers that can await should use buildLocalVaultPayloadAsync.
try {
const raw = localStorageAdapter.read<{ version?: number; entries?: unknown }>(
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
if (raw && Array.isArray(raw.entries)) {
return withPluginSyncSidecars(base, {
version: 1,
entries: raw.entries as NonNullable<SyncPayload['pluginSidecars']>['entries'],
});
}
} catch {
// ignore cache read failures; backup still carries vault entities
}
return base;
}
/**
* Async local backup payload that prefers a live host collect when available,
* then falls back to last-known cache. Use for protective/version-change backups.
*/
export async function buildLocalVaultPayloadAsync(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): Promise<SyncPayload> {
const base = buildLocalVaultPayload(vault, portForwardingRules);
const {
collectPluginSyncSidecarsFromHost,
isPluginSidecarHostUnavailableError,
} = await import('./pluginSyncSidecarBridge');
try {
const live = await collectPluginSyncSidecarsFromHost();
if (live) {
// Live collect may return authoritative-empty while still deferring the
// last-known cache wipe until a successful sync commit. Protective
// backups must keep the non-empty last-known already attached to `base`.
const liveEmpty = !Array.isArray(live.entries) || live.entries.length === 0;
const baseHasEntries = Array.isArray(base.pluginSidecars?.entries)
&& base.pluginSidecars.entries.length > 0;
if (liveEmpty && baseHasEntries) {
return base;
}
return withPluginSyncSidecars(base, live);
}
} catch (error) {
// Only fall back to last-known when the host is gated off. Operational
// failures must abort protective backups so we never apply over data that
// was not captured in the safety snapshot.
if (!isPluginSidecarHostUnavailableError(error)) {
throw error;
}
}
return base;
}
/**
@@ -1065,10 +919,7 @@ export async function buildLocalVaultPayloadAsync(
function applyPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
options: {
includeLocalOnlyData: boolean;
applyPluginSidecars?: PluginSyncSidecarApplier;
},
options: { includeLocalOnlyData: boolean },
): Promise<void> {
const legacyLineTimestampsEnabled = payload.settings?.terminalSettings?.showLineTimestamps === true;
// Build the vault import object. Cloud sync intentionally ignores
@@ -1113,27 +964,14 @@ function applyPayload(
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
importers.onSettingsApplied?.();
}
// Plugin encrypted sidecars: only apply when the field is present so
// legacy payloads without pluginSidecars do not wipe installed settings.
if (Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) {
const applySidecars = options.applyPluginSidecars ?? defaultApplyPluginSyncSidecars;
await applySidecars(payload.pluginSidecars ?? { version: 1, entries: [] });
}
});
}
export function applySyncPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
options?: {
applyPluginSidecars?: PluginSyncSidecarApplier;
},
): Promise<void> {
return applyPayload(payload, importers, {
includeLocalOnlyData: false,
applyPluginSidecars: options?.applyPluginSidecars,
});
return applyPayload(payload, importers, { includeLocalOnlyData: false });
}
export async function prepareLocalVaultPayloadApply(
+4 -14
View File
@@ -136,14 +136,10 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
}
sync.cancelOAuthConnect();
if (sync.convergentSyncConfig.initialized) return;
const providers = new Set<CloudProvider>(['github', 'google', 'onedrive', 'webdav', 's3']);
for (const id of Object.keys(sync.providers)) {
providers.add(id as CloudProvider);
}
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
for (const provider of providers) {
if (provider === current) continue;
const conn = sync.providers[provider];
if (conn && isProviderReadyForSync(conn)) {
if (isProviderReadyForSync(sync.providers[provider])) {
await sync.disconnectProvider(provider);
}
}
@@ -180,23 +176,18 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
);
const isConnectDisabled = (provider: CloudProvider): boolean => {
const connection = sync.providers[provider];
if (pendingConnectProvider && pendingConnectProvider !== provider) {
return true;
}
if (pendingConnectProvider === provider) {
return true;
}
if (hasConnectingProvider && connection?.status !== 'connecting') {
if (hasConnectingProvider && sync.providers[provider].status !== 'connecting') {
return true;
}
// Never-connected plugin providers still honor the single-provider gate.
if (!connection) {
return !sync.convergentSyncConfig.initialized && sync.hasAnyConnectedProvider;
}
return !sync.convergentSyncConfig.initialized
&& sync.hasAnyConnectedProvider
&& !isProviderReadyForSync(connection);
&& !isProviderReadyForSync(sync.providers[provider]);
};
const beginPendingConnect = (provider: CloudProvider): boolean => {
@@ -919,7 +910,6 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
}}
onResolveConvergentConflict={handleResolveConvergentConflict}
onDowngradeConvergent={handleDowngradeConvergent}
disconnectOtherProviders={disconnectOtherProviders}
/>
<CloudSyncDialogs
+11 -25
View File
@@ -16,7 +16,6 @@ import {
CloudOff,
Github,
Loader2,
Plug,
RefreshCw,
Settings,
X,
@@ -27,7 +26,6 @@ import {
} from 'lucide-react';
import { useCloudSync } from '../application/state/useCloudSync';
import { isProviderReadyForSync, type CloudProvider, formatSyncDateTime } from '../domain/sync';
import { isPluginCloudProviderId } from '../domain/cloudProviderIds';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
@@ -55,7 +53,7 @@ const OneDriveIcon: React.FC<{ className?: string }> = ({ className }) => (
</svg>
);
const providerIcons: Partial<Record<CloudProvider, React.ReactNode>> = {
const providerIcons: Record<CloudProvider, React.ReactNode> = {
github: <Github size={16} />,
google: <GoogleDriveIcon className="w-4 h-4" />,
onedrive: <OneDriveIcon className="w-4 h-4" />,
@@ -63,7 +61,7 @@ const providerIcons: Partial<Record<CloudProvider, React.ReactNode>> = {
s3: <Database size={16} />,
};
const providerNames: Partial<Record<CloudProvider, string>> = {
const providerNames: Record<CloudProvider, string> = {
github: 'GitHub Gist',
google: 'Google Drive',
onedrive: 'OneDrive',
@@ -71,12 +69,6 @@ const providerNames: Partial<Record<CloudProvider, string>> = {
s3: 'S3 Compatible',
};
const resolveProviderIcon = (provider: CloudProvider): React.ReactNode =>
providerIcons[provider] ?? <Plug size={16} />;
const resolveProviderName = (provider: CloudProvider): string =>
providerNames[provider] ?? provider;
// ============================================================================
// Status Dot Component
// ============================================================================
@@ -131,17 +123,13 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
// State is now automatically synced via useSyncExternalStore - no manual refresh needed
// Get connected provider (built-ins first, then any namespaced plugin provider).
// Get connected provider (include syncing status as it's still connected)
const getConnectedProvider = (): CloudProvider | null => {
const preferred: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
for (const id of preferred) {
const conn = sync.providers[id];
if (conn && isProviderReadyForSync(conn)) return id;
}
for (const [id, conn] of Object.entries(sync.providers)) {
if (preferred.includes(id as CloudProvider)) continue;
if (conn && isProviderReadyForSync(conn)) return id as CloudProvider;
}
if (isProviderReadyForSync(sync.providers.github)) return 'github';
if (isProviderReadyForSync(sync.providers.google)) return 'google';
if (isProviderReadyForSync(sync.providers.onedrive)) return 'onedrive';
if (isProviderReadyForSync(sync.providers.webdav)) return 'webdav';
if (isProviderReadyForSync(sync.providers.s3)) return 's3';
return null;
};
@@ -298,17 +286,15 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
{connectedProvider && providerConnection && (
<div className="flex items-center gap-3 p-2 rounded-lg bg-muted/30">
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
{resolveProviderIcon(connectedProvider)}
{providerIcons[connectedProvider]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{resolveProviderName(connectedProvider)}</span>
<span className="text-sm font-medium">{providerNames[connectedProvider]}</span>
<StatusIndicator status={overallStatus} />
</div>
<div className="flex items-center gap-2 mt-0.5">
{providerConnection.account?.avatarUrl
&& connectedProvider
&& !isPluginCloudProviderId(connectedProvider) && (
{providerConnection.account?.avatarUrl && (
<img
src={providerConnection.account.avatarUrl}
alt=""
+19 -74
View File
@@ -108,12 +108,7 @@ import {
resumeScriptRun,
stopScriptRun,
} from "@/application/state/scriptAutomationCoordinator.ts";
import {
hasUnresolvedConnectScriptBindings,
resolveConnectScriptsForHost,
shouldMarkConnectAutomationConsumed,
shouldUseFreshSshConnectionForAutomation,
} from "@/domain/hostConnectScripts.ts";
import { resolveConnectScriptsForHost, hasUnresolvedConnectScriptBindings } from "@/domain/hostConnectScripts.ts";
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
@@ -357,16 +352,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const connectScriptsInFlightRef = useRef(false);
const pendingScriptRunIdRef = useRef<string | null>(null);
const pendingScriptHandledRef = useRef<Snippet | null>(null);
const pendingScriptRef = useRef(pendingScript);
const pendingScriptIdRef = useRef(pendingScriptId);
pendingScriptRef.current = pendingScript;
pendingScriptIdRef.current = pendingScriptId;
const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => {
if (snippet.id) {
return pendingScriptRunIdRef.current === snippet.id;
}
return pendingScriptHandledRef.current === snippet;
}, []);
// Mosh marks status=connected during the SSH handshake so interactive
// prompts remain reachable. Connect/pending scripts must wait until
// mosh-client is ready (#2199). closeSession clears preload ready
@@ -739,9 +724,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [progressValue, setProgressValue] = useState(15);
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
const [connectionReuseAttemptSourceId, setConnectionReuseAttemptSourceId] = useState(
reuseConnectionFromSessionId,
);
const statusRef = useRef<TerminalSession["status"]>(status);
statusRef.current = status;
@@ -1060,14 +1042,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const passwordPickerEmptyText = t("terminal.passwordPicker.empty");
const sudoHintText = t("terminal.sudoHint.pressEnter");
const sessionStartersRef = useRef<ReturnType<typeof createTerminalSessionStarters> | null>(null);
const reuseConnectionSourceRef = useRef(reuseConnectionFromSessionId);
const reuseConnectionSourceAttemptedRef = useRef(false);
const previousReuseConnectionSourcePropRef = useRef(reuseConnectionFromSessionId);
if (previousReuseConnectionSourcePropRef.current !== reuseConnectionFromSessionId) {
previousReuseConnectionSourcePropRef.current = reuseConnectionFromSessionId;
reuseConnectionSourceRef.current = reuseConnectionFromSessionId;
reuseConnectionSourceAttemptedRef.current = false;
}
const auth = useTerminalAuthState({
host,
pendingAuthRef,
@@ -2163,29 +2137,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
knownHosts,
resolvedChainHosts,
sessionId,
reuseConnectionFromSessionIdRef: reuseConnectionSourceRef,
reuseConnectionSourceAttemptedRef,
setConnectionReuseAttemptSourceId,
shouldUseFreshSshConnection: () => {
const currentPendingScript = pendingScriptRef.current;
const currentPendingScriptId = pendingScriptIdRef.current;
const hasUnhandledPendingScript = currentPendingScript && isScriptSnippet(currentPendingScript)
? !isPendingScriptAlreadyHandled(currentPendingScript)
: Boolean(
currentPendingScriptId
&& pendingScriptRunIdRef.current !== currentPendingScriptId,
);
return shouldUseFreshSshConnectionForAutomation({
host: hostRef.current,
snippets: snippetsRef.current,
vaultInitialized: isVaultInitialized(),
hasPendingScript: hasUnhandledPendingScript,
connectAutomationConsumed: connectScriptsConsumedRef.current,
});
},
onConnectAutomationSnapshotCommitted: () => {
connectScriptsConsumedRef.current = true;
},
reuseConnectionFromSessionId,
isNetworkDevice,
startupCommand,
noAutoRun,
@@ -2362,6 +2314,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
pendingScriptHandledRef.current = null;
}, [pendingScript?.id, pendingScriptId]);
const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => {
if (snippet.id) {
return pendingScriptRunIdRef.current === snippet.id;
}
return pendingScriptHandledRef.current === snippet;
}, []);
useEffect(() => {
if (status !== 'connected') return;
if (effectiveTerminalProtocol === 'mosh' && !moshShellReady) return;
@@ -2378,22 +2337,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
}
const resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets);
// An empty hydrated vault is a final decision for this connection. Lock it
// before the output-settling delay so a sync arriving during that window
// cannot inject a new connect script into an already-reused SSH session.
if (
!connectScriptsConsumedRef.current
&& resolvedConnectScripts.length === 0
&& shouldMarkConnectAutomationConsumed({
allConnectScriptsDone: true,
vaultInitialized: isVaultInitialized(),
hasUnresolvedBindings: hasUnresolvedConnectScriptBindings(host, snippets),
})
) {
connectScriptsConsumedRef.current = true;
}
const shouldEvaluateConnect = !connectScriptsConsumedRef.current;
const hasPendingWork = Boolean(pendingOne);
if (!shouldEvaluateConnect && !hasPendingWork) return;
@@ -2405,7 +2348,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const runPending = Boolean(pendingOne);
const connectQueueNow = connectScriptsConsumedRef.current
? []
: resolvedConnectScripts.filter(
: resolveConnectScriptsForHost(host, snippets).filter(
(item) => item.id && !connectScriptsCompletedIdsRef.current.has(item.id),
);
@@ -2417,17 +2360,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
scriptsToRun.push(pendingOne);
}
const resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets);
const allConnectScriptsDone = resolvedConnectScripts.length === 0
|| resolvedConnectScripts.every(
(item) => item.id && connectScriptsCompletedIdsRef.current.has(item.id),
);
if (scriptsToRun.length === 0) {
if (!connectScriptsConsumedRef.current && shouldMarkConnectAutomationConsumed({
allConnectScriptsDone,
vaultInitialized: isVaultInitialized(),
hasUnresolvedBindings: hasUnresolvedConnectScriptBindings(host, snippets),
})) {
if (
!connectScriptsConsumedRef.current
&& allConnectScriptsDone
&& isVaultInitialized()
&& snippets.length > 0
&& !hasUnresolvedConnectScriptBindings(host, snippets)
) {
connectScriptsConsumedRef.current = true;
}
return;
@@ -3235,7 +3181,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
setIsDisconnectedDialogDismissed(false);
setConnectionReuseFellBack(false);
setConnectionReuseAttemptSourceId(undefined);
updateStatus("connecting");
setError(null);
setProgressLogs((prev) => (
@@ -3325,7 +3270,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSerialConnection,
isDisconnectedDialogDismissed,
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
reuseConnectionFromSessionId: connectionReuseAttemptSourceId,
reuseConnectionFromSessionId,
host,
connectionReuseFellBack,
}),
@@ -1,27 +1,18 @@
import React, { useEffect, useState, type Dispatch, type RefObject, type SetStateAction } from 'react';
import { Database, Github, History, Plug, Server, Trash2 } from 'lucide-react';
import React, { type Dispatch, type RefObject, type SetStateAction } from 'react';
import { Database, Github, History, Server, Trash2 } from 'lucide-react';
import type {
CloudProvider,
ConvergentFieldConflict,
ConvergentMigrationPreview,
SyncPayload,
} from '../../domain/sync';
import { isBuiltinCloudProvider } from '../../domain/sync';
import { isPluginCloudProviderId } from '../../domain/cloudProviderIds';
import { planPluginSyncConnect, hasPluginProviderStoredConfig } from '../../domain/pluginSyncConnect';
import { planPluginSyncCredential, syncConfigurationSchemaWithoutSecretRequirements } from '../../domain/pluginSyncCredential';
import { pluginConfigurationMatchesSchema } from '../../domain/pluginConfigurationSchema';
import type { useCloudSync } from '../../application/state/useCloudSync';
import { storePluginSyncSecretsThenConnect } from '../../application/pluginSyncConnectWithSecrets';
import { cleanOneDriveErrorMessage, isProviderReadyForSync } from '../../domain/sync';
import { pluginExtensionBridge } from '../../application/state/pluginExtensionBridge';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { toast } from '../ui/toast';
import { GoogleDriveIcon, OneDriveIcon, ProviderCard, Toggle } from './CloudSyncControls';
import { LocalBackupsPanel } from './CloudSyncLocalBackupsPanel';
import { ConvergentSyncPanel } from './ConvergentSyncPanel';
@@ -57,8 +48,6 @@ interface CloudSyncDashboardTabsProps {
onCancelConvergentMigration: () => void;
onResolveConvergentConflict: (addressKey: string, candidateDot: string) => void | Promise<void>;
onDowngradeConvergent: () => void | Promise<void>;
/** Disconnect other legacy providers before connecting a plugin backend. */
disconnectOtherProviders?: (current: CloudProvider) => Promise<void>;
}
export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
@@ -89,199 +78,7 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
onCancelConvergentMigration,
onResolveConvergentConflict,
onDowngradeConvergent,
disconnectOtherProviders,
}) => {
const [pluginSyncProviders, setPluginSyncProviders] = useState<Array<{
id: string;
title: string;
configurationSchema?: unknown;
}>>([]);
const [pluginConnectBusy, setPluginConnectBusy] = useState<string | null>(null);
const [pluginConfigDialog, setPluginConfigDialog] = useState<{
providerId: string;
title: string;
configurationSchema?: unknown;
} | null>(null);
const [pluginConfigText, setPluginConfigText] = useState('{}');
const [pluginConfigError, setPluginConfigError] = useState<string | null>(null);
const [pluginConfigSaving, setPluginConfigSaving] = useState(false);
useEffect(() => {
let cancelled = false;
const refresh = async () => {
try {
const listed = await pluginExtensionBridge.listProviders('sync');
if (cancelled) return;
setPluginSyncProviders(
(listed ?? []).map((entry) => {
const nested = (entry as {
provider?: {
id?: string;
label?: string;
configurationSchema?: unknown;
};
pluginDisplayName?: string;
}).provider;
const id = String(nested?.id ?? '');
return {
id,
title: String(
nested?.label
?? (entry as { pluginDisplayName?: string }).pluginDisplayName
?? id
?? 'Plugin sync',
),
configurationSchema: nested?.configurationSchema,
};
}).filter((entry) => entry.id.length > 0 && isPluginCloudProviderId(entry.id)),
);
} catch {
if (!cancelled) setPluginSyncProviders([]);
}
};
void refresh();
const unsubscribe = pluginExtensionBridge.onContributionsChanged(() => {
void refresh();
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, []);
const openPluginConfigDialog = (
providerId: string,
title: string,
configurationSchema: unknown | undefined,
) => {
const connection = sync.providers[providerId];
// Include falsy scalars and JSON null so Edit can re-save them.
const seed = hasPluginProviderStoredConfig(connection) ? connection!.config : {};
setPluginConfigText(JSON.stringify(seed, null, 2));
setPluginConfigError(null);
setPluginConfigDialog({ providerId, title, configurationSchema });
};
const runPluginConnect = async (providerId: string, configuration: unknown): Promise<boolean> => {
setPluginConnectBusy(providerId);
try {
if (disconnectOtherProviders) {
await disconnectOtherProviders(providerId as CloudProvider);
}
const credentialPlan = planPluginSyncCredential(configuration, {
configurationSchema: pluginSyncProviders.find((entry) => entry.id === providerId)
?.configurationSchema,
});
const stored = sync.providers[providerId]?.credential;
const existingCredential =
stored
&& typeof stored === 'object'
&& stored.kind === 'secret'
&& typeof stored.id === 'string'
? stored as { kind: 'secret'; id: string; key: string }
: undefined;
if (credentialPlan.secrets.length > 0) {
const { putPluginSyncSecret, deletePluginSyncSecrets } = await import(
'../../infrastructure/services/adapters/pluginSyncIpcHost'
);
// Put secrets before connect; roll back just-created keys if connect fails
// so a rejected password/token is not left readable in plugin_secrets.
await storePluginSyncSecretsThenConnect({
providerId,
secrets: credentialPlan.secrets.map((secret) => ({
secretKey: secret.secretKey,
value: secret.value,
})),
putSecret: putPluginSyncSecret,
deleteSecrets: deletePluginSyncSecrets,
connect: async (credential) => {
await sync.connectPluginProvider(
providerId,
credentialPlan.configuration,
credential,
);
},
});
} else {
await sync.connectPluginProvider(
providerId,
credentialPlan.configuration,
existingCredential,
);
}
toast.success(t('cloudSync.connect.plugin.success'));
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
toast.error(message, t('cloudSync.connect.plugin.failedTitle'));
return false;
} finally {
setPluginConnectBusy(null);
}
};
const handlePluginConnect = async (providerId: string) => {
const listed = pluginSyncProviders.find((entry) => entry.id === providerId);
const connection = sync.providers[providerId];
// Config may be a valid falsy scalar or JSON null — property presence, not truthiness.
const hasStoredConfig = hasPluginProviderStoredConfig(connection);
const plan = planPluginSyncConnect({
configurationSchema: listed?.configurationSchema,
storedConfig: connection?.config,
hasStoredConfig,
});
if (plan.action === 'prompt') {
openPluginConfigDialog(
providerId,
listed?.title ?? providerId,
listed?.configurationSchema,
);
return;
}
await runPluginConnect(providerId, plan.configuration);
};
const handleSavePluginConfig = async () => {
if (!pluginConfigDialog) return;
let configuration: unknown;
try {
configuration = JSON.parse(pluginConfigText) as unknown;
} catch {
setPluginConfigError(t('cloudSync.pluginConfig.invalidJson'));
return;
}
if (pluginConfigDialog.configurationSchema !== undefined) {
const connection = sync.providers[pluginConfigDialog.providerId];
const hasStoredSecret = connection?.credential != null
&& typeof connection.credential === 'object'
&& (connection.credential as { kind?: string }).kind === 'secret';
// Edit seeds stripped config; required secret fields stay satisfied by the
// durable SecretRef until the user re-enters plaintext secrets.
const schema = hasStoredSecret
? syncConfigurationSchemaWithoutSecretRequirements(pluginConfigDialog.configurationSchema)
: pluginConfigDialog.configurationSchema;
if (!pluginConfigurationMatchesSchema(schema, configuration)) {
setPluginConfigError(t('cloudSync.pluginConfig.schemaInvalid'));
return;
}
}
setPluginConfigError(null);
setPluginConfigSaving(true);
try {
const ok = await runPluginConnect(pluginConfigDialog.providerId, configuration);
if (ok) setPluginConfigDialog(null);
// On failure toast already shown; keep dialog open for correction.
} finally {
setPluginConfigSaving(false);
}
};
const pluginProviderIds = new Set<string>([
...pluginSyncProviders.map((entry) => entry.id),
...Object.keys(sync.providers).filter((id) => !isBuiltinCloudProvider(id)),
]);
return (
}) => (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'providers' | 'status')} className="space-y-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="providers">{t('cloudSync.providers.title')}</TabsTrigger>
@@ -390,112 +187,6 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
onDisconnect={() => sync.disconnectProvider('s3')}
onSync={() => handleSync('s3')}
/>
{[...pluginProviderIds].sort().map((providerId) => {
const connection = sync.providers[providerId];
const listed = pluginSyncProviders.find((entry) => entry.id === providerId);
const title = listed?.title ?? providerId;
const connected = connection ? isProviderReadyForSync(connection) : false;
const hasStoredConfig = hasPluginProviderStoredConfig(connection);
const needsConfig = planPluginSyncConnect({
configurationSchema: listed?.configurationSchema,
storedConfig: connection?.config,
hasStoredConfig,
}).action === 'prompt';
return (
<ProviderCard
key={providerId}
provider={providerId as CloudProvider}
name={title}
icon={<Plug size={24} />}
isConnected={connected}
isSyncing={connection?.status === 'syncing'}
isConnecting={
connection?.status === 'connecting'
|| pluginConnectBusy === providerId
}
account={connection?.account
? {
// Never load plugin-supplied avatar URLs in the main
// renderer (offline plugin network boundary).
id: connection.account.id,
name: connection.account.name,
email: connection.account.email,
}
: undefined}
lastSync={connection?.lastSync}
error={connection?.error}
disabled={isConnectDisabled(providerId as CloudProvider)}
onEdit={
connected || needsConfig || hasStoredConfig
? () => openPluginConfigDialog(
providerId,
title,
listed?.configurationSchema,
)
: undefined
}
onConnect={() => {
void handlePluginConnect(providerId);
}}
onDisconnect={() => sync.disconnectProvider(providerId as CloudProvider)}
onSync={() => handleSync(providerId as CloudProvider)}
/>
);
})}
<Dialog
open={pluginConfigDialog != null}
onOpenChange={(open) => {
if (!open && !pluginConfigSaving) setPluginConfigDialog(null);
}}
>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>
{t('cloudSync.pluginConfig.title', {
name: pluginConfigDialog?.title ?? '',
})}
</DialogTitle>
<DialogDescription>
{t('cloudSync.pluginConfig.desc')}
</DialogDescription>
</DialogHeader>
<textarea
value={pluginConfigText}
onChange={(event) => {
setPluginConfigText(event.target.value);
if (pluginConfigError) setPluginConfigError(null);
}}
spellCheck={false}
disabled={pluginConfigSaving}
className="min-h-40 w-full resize-y rounded-md border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t('cloudSync.pluginConfig.label')}
/>
{pluginConfigError ? (
<p role="alert" className="text-xs text-destructive">{pluginConfigError}</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={pluginConfigSaving}
onClick={() => setPluginConfigDialog(null)}
>
{t('common.cancel')}
</Button>
<Button
type="button"
disabled={pluginConfigSaving}
onClick={() => { void handleSavePluginConfig(); }}
>
{pluginConfigSaving
? t('cloudSync.provider.connecting')
: t('cloudSync.provider.connect')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
<TabsContent value="status" className="space-y-4">
@@ -680,5 +371,4 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
</div>
</TabsContent>
</Tabs>
);
};
);
+4 -3
View File
@@ -3,6 +3,7 @@ import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import {
buildCloudSyncPayload,
buildLocalVaultPayload,
applySyncPayload,
getEffectivePortForwardingRulesForSync,
prepareLocalVaultPayloadApply,
@@ -40,10 +41,10 @@ export default function SettingsSyncTab(props: {
return buildCloudSyncPayload(vault, getEffectivePortForwardingRules());
}, [vault, getEffectivePortForwardingRules]);
const onBuildLocalPayload = useCallback(async (): Promise<SyncPayload> => {
const onBuildLocalPayload = useCallback((): SyncPayload => {
const effectiveKnownHosts = getEffectiveKnownHosts(vault.knownHosts);
const { buildLocalVaultPayloadAsync } = await import('../../../application/syncPayload');
return buildLocalVaultPayloadAsync(
return buildLocalVaultPayload(
{ ...vault, knownHosts: effectiveKnownHosts ?? [] },
getEffectivePortForwardingRules(),
);
@@ -426,7 +426,7 @@ test("startSSH tells the bridge to skip shell discovery for network devices", as
};
const ctx = createStarterContext({
isNetworkDevice: true,
reuseConnectionFromSessionIdRef: { current: "source-session" },
reuseConnectionFromSessionId: "source-session",
terminalBackend,
});
@@ -436,206 +436,6 @@ test("startSSH tells the bridge to skip shell discovery for network devices", as
assert.equal(capturedOptions?.skipShellPidDiscovery, true);
});
test("startSSH requests a fresh transport for ordinary opens with connection automation", async () => {
const captured: Record<string, unknown>[] = [];
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
captured.push(options);
return `ssh-session-${captured.length}`;
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const reuseConnectionFromSessionIdRef = { current: "source-session" as string | undefined };
const reuseAttempts: Array<string | undefined> = [];
let requiresFreshConnection = true;
let committedAutomationSnapshots = 0;
const automatedStarters = createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => requiresFreshConnection,
onConnectAutomationSnapshotCommitted: () => {
committedAutomationSnapshots += 1;
},
reuseConnectionFromSessionIdRef,
setConnectionReuseAttemptSourceId: (sourceSessionId: string | undefined) => {
reuseAttempts.push(sourceSessionId);
},
terminalBackend,
}) as never);
await automatedStarters.startSSH(createTermStub() as never);
await automatedStarters.startSSH(createTermStub() as never);
assert.equal(committedAutomationSnapshots, 0);
requiresFreshConnection = false;
await automatedStarters.startSSH(createTermStub() as never);
assert.equal(committedAutomationSnapshots, 1);
await createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
terminalBackend,
}) as never).startSSH(createTermStub() as never);
assert.equal(captured[0].reuseTransport, undefined);
assert.equal(captured[0].sourceSessionId, "source-session");
assert.equal(captured[1].reuseTransport, false);
assert.equal(captured[1].sourceSessionId, undefined);
assert.equal(captured[2].reuseTransport, undefined);
assert.equal(captured[2].sourceSessionId, undefined);
assert.equal(captured[3].reuseTransport, undefined);
assert.deepEqual(reuseAttempts, ["source-session", undefined, undefined]);
});
test("startSSH commits an empty automation snapshot only after the backend session succeeds", async () => {
let resolveStart: ((sessionId: string) => void) | undefined;
let commitCount = 0;
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: () => new Promise<string>((resolve) => {
resolveStart = resolve;
}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const startPromise = createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
onConnectAutomationSnapshotCommitted: () => {
commitCount += 1;
},
terminalBackend,
}) as never).startSSH(createTermStub() as never);
await Promise.resolve();
assert.equal(commitCount, 0);
assert.ok(resolveStart);
resolveStart("ssh-session");
await startPromise;
assert.equal(commitCount, 1);
});
test("startSSH rechecks the live automation policy before password fallback", async () => {
const captured: Record<string, unknown>[] = [];
let requiresFreshConnection = false;
let commitCount = 0;
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
captured.push(options);
if (captured.length === 1) {
requiresFreshConnection = true;
throw new Error("Authentication failed");
}
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
authMethod: "key",
identityFileId: "key-1",
password: "login-secret",
},
keys: [{
id: "key-1",
name: "Key",
privateKey: "plain-private-key",
publicKey: "",
source: "embedded",
}],
shouldUseFreshSshConnection: () => requiresFreshConnection,
onConnectAutomationSnapshotCommitted: () => {
commitCount += 1;
},
terminalBackend,
});
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(captured.length, 2);
assert.equal(captured[0].reuseTransport, undefined);
assert.equal(captured[1].reuseTransport, false);
assert.equal(commitCount, 0);
});
test("startSSH keeps interactive source auth retries off unrelated pooled transports", async () => {
const captured: Record<string, unknown>[] = [];
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
captured.push(options);
if (captured.length === 1) throw new Error("Authentication failed");
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const reuseConnectionFromSessionIdRef = { current: "source-session" as string | undefined };
const reuseConnectionSourceAttemptedRef = { current: false };
const firstCtx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
authMethod: "key",
identityFileId: "key-1",
},
keys: [{
id: "key-1",
name: "Key",
privateKey: "plain-private-key",
publicKey: "",
source: "embedded",
}],
reuseConnectionFromSessionIdRef,
reuseConnectionSourceAttemptedRef,
shouldUseFreshSshConnection: () => false,
terminalBackend,
});
await createTerminalSessionStarters(firstCtx as never).startSSH(createTermStub() as never);
assert.equal(reuseConnectionSourceAttemptedRef.current, true);
const retryCtx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
authMethod: "password",
password: "corrected-secret",
},
reuseConnectionFromSessionIdRef,
reuseConnectionSourceAttemptedRef,
shouldUseFreshSshConnection: () => false,
terminalBackend,
});
await createTerminalSessionStarters(retryCtx as never).startSSH(createTermStub() as never);
assert.equal(captured.length, 2);
assert.equal(captured[0].sourceSessionId, "source-session");
assert.equal(captured[0].reuseTransport, undefined);
assert.equal(captured[1].sourceSessionId, undefined);
assert.equal(captured[1].reuseTransport, false);
assert.equal(reuseConnectionSourceAttemptedRef.current, false);
});
test("startSSH uses the system agent when a synced vault key cannot be decrypted", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
@@ -529,27 +529,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
? ctx.host.identityFilePaths
: undefined;
let sourceReuseAttemptedWithinStart = false;
const startAttempt = async (attempt: {
password?: string;
key?: SSHKey;
useIdentityFiles?: boolean;
useSshAgent?: boolean;
}): Promise<string> => {
const sourceSessionId = ctx.reuseConnectionFromSessionIdRef?.current;
const sourceReuseAttempted = ctx.reuseConnectionSourceAttemptedRef?.current
?? sourceReuseAttemptedWithinStart;
const isFallbackAfterSourceReuse = sourceReuseAttempted && !sourceSessionId;
if (ctx.reuseConnectionFromSessionIdRef) {
ctx.reuseConnectionFromSessionIdRef.current = undefined;
}
if (sourceSessionId) {
sourceReuseAttemptedWithinStart = true;
if (ctx.reuseConnectionSourceAttemptedRef) {
ctx.reuseConnectionSourceAttemptedRef.current = true;
}
}
ctx.setConnectionReuseAttemptSourceId?.(sourceSessionId);
ctx.setIsConnectionAwaitingUserInput?.(false);
ctx.setIsConnectionPastTcpDial?.(false);
// Resolve keepalive per-host: a host can opt into its own values
@@ -561,8 +546,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
globalTerminalSettings,
);
const connectionTimeouts = resolveHostSshConnectionTimeouts(ctx.host);
const requiresFreshSshConnection = ctx.shouldUseFreshSshConnection?.() === true;
const startedSessionId = await ctx.terminalBackend.startSSHSession({
return ctx.terminalBackend.startSSHSession({
sessionId: ctx.sessionId,
hostLabel: ctx.host.label,
hostname: ctx.host.hostname,
@@ -615,23 +599,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// (issue #1204). Only honored on the very first connect attempt; the
// bridge silently falls back to a fresh connection if the source is
// gone, so reconnect/retry after the source closed still works.
sourceSessionId,
// Connect-time automation must see the complete login sequence. An
// explicit Copy/Split keeps its source-session reuse contract, while
// an ordinary open bypasses endpoint/idle transport reuse.
reuseTransport: !sourceSessionId && (requiresFreshSshConnection || isFallbackAfterSourceReuse)
? false
: undefined,
sourceSessionId: ctx.reuseConnectionFromSessionId,
skipShellPidDiscovery: ctx.isNetworkDevice === true,
});
if (!requiresFreshSshConnection) {
ctx.onConnectAutomationSnapshotCommitted?.();
}
sourceReuseAttemptedWithinStart = false;
if (ctx.reuseConnectionSourceAttemptedRef) {
ctx.reuseConnectionSourceAttemptedRef.current = false;
}
return startedSessionId;
};
let id: string;
@@ -1,6 +1,6 @@
import type { FitAddon } from "@xterm/addon-fit";
import type { SerializeAddon } from "@xterm/addon-serialize";
import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react";
import type { Dispatch, RefObject, SetStateAction } from "react";
import type { Host, Identity, KnownHost, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
import type { PromptLineBreakState } from "./promptLineBreak";
import type {
@@ -147,21 +147,9 @@ export type TerminalSessionStartersContext = {
knownHosts?: KnownHost[];
resolvedChainHosts: Host[];
sessionId: string;
// One-shot source session intent for Copy/Split. Consumed by the first SSH
// attempt so later reconnects do not skip the initial login sequence.
reuseConnectionFromSessionIdRef?: MutableRefObject<string | undefined>;
// Persists across renderer auth retries after the one-shot source intent is
// consumed. Cleared only after a backend session starts successfully.
reuseConnectionSourceAttemptedRef?: MutableRefObject<boolean>;
// Mirrors the source actually consumed by the current SSH attempt so the UI
// only hides its connecting dialog while Copy/Split reuse is being tried.
setConnectionReuseAttemptSourceId?: (sourceSessionId: string | undefined) => void;
// Connect automation and still-unhandled pending scripts need the initial
// login output. Evaluate per attempt because pending work is one-shot.
shouldUseFreshSshConnection?: () => boolean;
// Commit the no-automation snapshot only after the corresponding backend
// session actually starts, so failed auth attempts do not consume scripts.
onConnectAutomationSnapshotCommitted?: () => void;
// Source session id to reuse an authenticated SSH connection from when this
// terminal was created from an existing SSH session.
reuseConnectionFromSessionId?: string;
isNetworkDevice?: boolean;
startupCommand?: string;
noAutoRun?: boolean;
-8
View File
@@ -421,14 +421,6 @@ test("connection dialog keeps existing local and disconnected behavior", () => {
});
test("connection reuse hides connecting dialog only while reuse is still possible", () => {
assert.equal(
shouldHideConnectingDialogForConnectionReuse({
reuseConnectionFromSessionId: undefined,
host: host(),
connectionReuseFellBack: false,
}),
false,
);
assert.equal(
shouldHideConnectingDialogForConnectionReuse({
reuseConnectionFromSessionId: "source-session",
+1 -13
View File
@@ -413,18 +413,6 @@ connection draft may reference an identity or key draft from the same import by
its source ID; Netcatty maps that reference to the new host-owned credential ID
before encrypted persistence and rejects unresolved or ambiguous references.
PR 8 adds sync Providers as another operation-keyed map under permission
`provider.sync`. Plugins implement only encrypted object storage: `connect`,
`disconnect`, `getAccount`, `getCapabilities`, `readObject`, `writeObject`, and
`deleteObject`. Capability reporting covers revisions, conditional writes,
atomic replacement, and size limits (`SyncLimits`). Large objects leave the
JSON control plane on the existing stream seam; plugins never receive the cloud
master key or plaintext vault. Netcatty continues to own encryption, CRDT merge,
migrations, protection snapshots, conflict handling, and read-merge-write-verify.
WebDAV is adapted through the shared encrypted-object storage interface so the
same path can exercise configuration, secret handles, upload/download,
verification, and recovery.
`PluginSecretStore.get()` never returns plaintext. It returns a host-issued
`SecretRef`. Its random ID stays opaque; its non-secret `key` binds later lease
authorization to the same manifest resource used by `get()`/`set()`. `set()`
@@ -587,7 +575,7 @@ The cross-phase contract was checked against every planned consumer:
| PR 5 terminal providers | namespaced provider IDs, provider request/result envelopes and bounded streams |
| PR 6 data pipeline | direct MessagePort transfer envelopes, sequence, per-chunk credit, and bounded receive-window fields |
| PR 7 connection/auth/import | provider kinds and configuration schemas, platform-specific companion variants, framing, stable failures and progress |
| PR 8 sync | implemented: namespaced `sync` Providers, `SyncLimits`, operation-keyed connect/read/write/delete/capabilities, inline or streamed encrypted objects |
| PR 8 sync | namespaced sync providers and JSON/binary bounded transport |
| PR 9 rollout | schema/API selection, compatibility reporting and the final API 1.0 freeze |
The contract tests construct representative manifests for the contribution UI,
@@ -225,7 +225,7 @@ new protocol route. Plugin packages still cannot add mappings themselves.
| PR 5 terminal Providers | validated host requests, cancellation, lifecycle events | Provider ranking, deadlines, snapshots, built-in highlighter/autocomplete adapters |
| PR 6 terminal pipeline | runtime identity and placement policy | direct MessagePort fast path, sensitive-input bypass, circuit breaker |
| PR 7 connection/auth/import | activation-owned Provider requests, exact result validators, bounded streams, diagnostics, secret leases, credential refs | implemented: connection sessions, authentication challenges, importer preview/commit |
| PR 8 sync | streams, lifecycle identity, namespaced storage boundary | implemented: `kind: "sync"` Providers with `provider.sync`, encrypted-object connect/read/write/delete/capabilities, WebDAV through shared storage surface, non-cascade sidecars for `sync: true` settings and account/CRDT baselines |
| PR 8 sync | streams, lifecycle identity, namespaced storage boundary | dynamic providers, encrypted sidecar, CRDT state and account baselines |
| PR 9 distribution | retained immutable versions, compare-and-set restore, placement resolver, module resources | signatures, trust, health checks, audited update and user rollback policy, API 1.0, and the reproducible terminal benchmark harness/environment/release gate for the 1% throughput and 4 ms p95 / 8 ms p99 input-latency targets |
## Data-model decisions that must remain explicit
@@ -249,11 +249,10 @@ writing the active-version pointer directly.
`plugin_kv` is runtime-owned local data and is removed by explicit uninstall.
Phase-3 encrypted secrets, persistent grants and security audit, plus phase-4
settings and view state, use separate non-cascade tables. PR 8 stores encrypted
sync sidecars (`plugin_sync_sidecars`) in the same non-cascade class for
`sync: true` settings plus plugin sync account/CRDT baselines. Missing or
uninstalled plugin code must not cascade-delete those rows or coerce related
sync configuration away.
settings and view state, use separate non-cascade tables. Future connection
profiles and CRDT sidecar baselines are the same user-owned class and must not
be added to the `plugin_kv` cascade when missing plugin code must preserve user
or synchronized data.
Activation events are declared by the public manifest. Phase 4 now starts only
`onStartupFinished` plugins during contribution initialization; commands, views,
@@ -219,10 +219,8 @@ after durable user data can exist.
a post-permission lookup. Importers use bounded streams, not directory walks,
and their draft schemas reject executable startup commands and hidden
plaintext built-in credential fields before Vault persistence.
- PR 8 stores encrypted sync sidecars in `plugin_sync_sidecars` (no package
cascade) for non-secret `sync: true` settings plus account/CRDT baselines, and
transports larger encrypted objects over the existing stream seam. Secret
settings never enter cloud sidecars.
- PR 8 stores encrypted sync sidecars separately from package cascade storage
and transports larger encrypted objects over the existing stream seam.
- PR 9 supplies signed publisher principals and trust policy through placement;
signed identity changes force a fresh grant instead of widening an unsigned
grant silently.
-87
View File
@@ -1,87 +0,0 @@
# Sync providers
Netcatty cloud sync providers are dynamic and namespaced. Built-in providers
(`github`, `google`, `onedrive`, `webdav`, `s3`) stay compatible; plugins
register additional IDs under their plugin namespace with `kind: "sync"` and
permission `provider.sync`.
## Boundary
Plugins implement **encrypted object storage only**:
- `connect` / `disconnect` / `getAccount`
- `getCapabilities` (`revisions`, `conditionalWrites`, `atomicReplacement`, size limits)
- `readObject` / `writeObject` / `deleteObject`
Netcatty owns encryption, the master key, CRDT merge, migrations, protection
snapshots, conflict handling, and read-merge-write-verify. Plugin providers
never receive the vault master key or plaintext sync payloads.
## Secrets
Only non-secret configuration marked for sync enters cloud payloads. Plugin
connect secrets (`password`, `token`, `secret`, `apiKey`, `accessToken`) are
stripped from configuration, stored in the OS-backed plugin secret store, and
passed to `SyncConnectPayload.credential` as opaque `{ kind: "secret", id, key }`
references. Additional extracted secrets are stored under `sync-credential:<field>`
keys so plugins can `secrets.get` / `credentials.createLease` them.
Durable reconnects persist an opaque SecretRef (`{ kind, id, key }`), not
plaintext. The host injects Authorization only after consuming an
operation-bound lease whose `operationId` matches `network:<origin>`.
**Using a SecretRef from a sandbox plugin:** create an operation-bound lease via
`credentials.createLease` with `operationId` set to `network:<origin>` (same
origin the request will call), then call `network.request` with:
```json
{
"url": "https://example.com/…",
"credentialLease": { "kind": "secret-lease", "id": "…", "operationId": "network:https://example.com", "expiresAt": 0 },
"authorization": { "scheme": "Bearer" }
}
```
The host consumes the lease (bound to the request origin, not a plugin-echoed
id) and injects `Authorization` (Bearer or Basic). Plaintext never returns to
the plugin. Companion `credentialLeases` remains available for node-only
companions.
WebDAV continues to exercise the shared EncryptedObjectStorage path for
configuration, proxy behavior, upload/download, and recovery. Write verification
on WebDAV is performed by the adapter's pad+verify upload (not a second host
byte re-read). Credentials remain field-encrypted at rest via the secure field
adapter.
## Streams and SyncLimits
Public `SyncLimits` (see plugin contract) define:
- `maxObjectBytes` — hard ciphertext cap
- `inlineObjectBytes` — maximum size that may travel inline on the control plane
- key / revision length bounds
Above `inlineObjectBytes`, main↔plugin uses credit-window streams
(`STREAM_WINDOW_BYTES` = 256 KiB). Renderer↔main uses structured-clone
`Uint8Array` for inline payloads and pull/chunked IPC (`sync-write-begin` /
`sync-write-chunk` / `sync-write-commit`, `sync-read-chunk`) for larger objects.
Transfers are per-sender, TTL-bounded, capped, and cancelled via
`cancelPluginExtensionRequest(requestId)` / `AbortSignal`.
## Sidecars (non-cascade)
Missing or disabled plugins must not delete synced settings or connection
baselines. Host-owned `plugin_sync_sidecars` carry `sync:true` non-secret
settings and account/CRDT baselines through collect/apply with last-known and
prefer-cloud merge semantics. Device-local baselines survive remote settings
wipes; empty-vault upload guards ignore last-known-only evidence.
## WebDAV
The production WebDAV adapter is wrapped as EncryptedObjectStorage so it shares
the same encrypt→write / read→decrypt surface as plugin providers. WebDAV's
native pad+verify upload already satisfies write verification and may leave
trailing padding on the remote object; the shared bridge therefore skips a
full byte re-read on that path (`assumeVerifiedWrites`) — without that flag the
host compare would false-fail on padded bodies. Plugin providers keep
host-owned byte compare after write.
+1 -2
View File
@@ -283,6 +283,5 @@ runtime identity, cancellation, progress, permission names, and stream
protocol. PR 6 added its direct interceptor transport without changing the
ordinary registry. PR 7 reused that registration and runtime lifecycle for
connection, authentication, and importer Providers, with operation-specific
result validators and bounded stream consumers. PR 8 sync Providers (implemented)
and PR 9
result validators and bounded stream consumers. PR 8 sync Providers and PR 9
rollout can reuse the same boundaries.
+2 -2
View File
@@ -64,8 +64,8 @@ platform is unreleased, so both tables are part of the complete schema at
Secret settings never enter the settings table or a renderer snapshot. The
host stores plaintext only through the phase-3 safeStorage-backed secret store,
shows a configured indicator, and exposes an opaque `SecretRef` to the owning
runtime. Only non-secret fields explicitly declaring `sync: true` enter the
encrypted sidecar sync path (PR 8).
runtime. Only non-secret fields explicitly declaring `sync: true` may be used by
the future encrypted sidecar sync phase.
## Commands, menus, and Context Keys
-32
View File
@@ -1,32 +0,0 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
BUILTIN_CLOUD_PROVIDERS,
assertCloudProviderId,
isBuiltinCloudProvider,
isPluginCloudProviderId,
providerConnectionStorageKey,
} from './cloudProviderIds';
describe('cloudProviderIds', () => {
it('recognizes built-in providers', () => {
for (const id of BUILTIN_CLOUD_PROVIDERS) {
assert.equal(isBuiltinCloudProvider(id), true);
assert.equal(isPluginCloudProviderId(id), false);
assert.equal(providerConnectionStorageKey(id), `netcatty_provider_${id}_v1`);
}
});
it('accepts namespaced plugin provider IDs without coercing them to built-ins', () => {
const id = 'com.example.backup.sync';
assert.equal(isPluginCloudProviderId(id), true);
assert.equal(isBuiltinCloudProvider(id), false);
assert.equal(providerConnectionStorageKey(id), `netcatty_provider_plugin_v1:${id}`);
assert.equal(assertCloudProviderId(id), id);
});
it('rejects empty or NUL-containing provider IDs', () => {
assert.throws(() => assertCloudProviderId(''), /invalid/i);
assert.throws(() => assertCloudProviderId('bad\0id'), /invalid/i);
});
});
-59
View File
@@ -1,59 +0,0 @@
/**
* Cloud provider identity helpers.
*
* Built-in providers keep their stable short IDs. Plugin sync Providers use
* namespaced contribution IDs (e.g. com.example.backup.sync). The manager
* boundary accepts both without coercing missing plugins away.
*/
export const BUILTIN_CLOUD_PROVIDERS = [
'github',
'google',
'onedrive',
'webdav',
's3',
] as const;
export type BuiltinCloudProvider = (typeof BUILTIN_CLOUD_PROVIDERS)[number];
/** Built-in short IDs or namespaced plugin contribution IDs. */
export type CloudProviderId = BuiltinCloudProvider | (string & {});
const BUILTIN_SET = new Set<string>(BUILTIN_CLOUD_PROVIDERS);
export function isBuiltinCloudProvider(provider: string): provider is BuiltinCloudProvider {
return BUILTIN_SET.has(provider);
}
/**
* True when the provider ID looks like a namespaced plugin contribution.
* Contribution IDs are reverse-DNS style and always contain a dot.
*/
export function isPluginCloudProviderId(provider: string): boolean {
if (isBuiltinCloudProvider(provider)) return false;
if (typeof provider !== 'string' || provider.length < 3 || provider.length > 256) return false;
if (provider.includes('\0') || provider.includes('/') || provider.includes('\\')) return false;
return provider.includes('.');
}
export function assertCloudProviderId(provider: string): CloudProviderId {
if (typeof provider !== 'string' || provider.length < 1 || provider.length > 256) {
throw new TypeError('Cloud provider ID is invalid');
}
if (provider.includes('\0')) {
throw new TypeError('Cloud provider ID is invalid');
}
return provider;
}
/** localStorage / encrypted local key for a provider connection record. */
export function providerConnectionStorageKey(provider: CloudProviderId): string {
if (isBuiltinCloudProvider(provider)) {
return `netcatty_provider_${provider}_v1`;
}
// Plugin IDs may contain characters unsafe for ad-hoc key templates.
return `netcatty_provider_plugin_v1:${provider}`;
}
/** Registry of dynamic plugin provider IDs that have been connected on this device. */
export const PLUGIN_CLOUD_PROVIDER_REGISTRY_KEY = 'netcatty_plugin_cloud_providers_v1';
-3
View File
@@ -125,9 +125,6 @@ export function cloudSyncPayloadsEqual(left: SyncPayload, right: SyncPayload): b
.map((key) => [key, (payload as unknown as Record<string, unknown>)[key] ?? []]),
),
settings: payload.settings ?? {},
// Plugin sidecars are host-owned opaque data on the encrypted blob and
// must participate in migration freshness / equality checks.
pluginSidecars: payload.pluginSidecars ?? { version: 1, entries: [] },
});
return fingerprint(project(left)) === fingerprint(project(right));
}
+3 -96
View File
@@ -25,58 +25,6 @@ import {
materializeSyncPayloadFromConvergentState,
withConvergentSyncEnvelope,
} from './payload';
import {
mergePluginSyncSidecars,
mergePluginSyncSidecarsThreeWay,
} from '../pluginSyncSidecar';
/** LWW-union plugin sidecars from every migration input that carries them. */
function mergeMigrationSidecars(
...bundles: Array<SyncPayload['pluginSidecars'] | null | undefined>
): SyncPayload['pluginSidecars'] | undefined {
let entries: NonNullable<SyncPayload['pluginSidecars']>['entries'] = [];
let sawAny = false;
for (const bundle of bundles) {
if (!bundle || !Array.isArray(bundle.entries)) continue;
sawAny = true;
entries = mergePluginSyncSidecars({ local: entries, remote: bundle });
}
return sawAny ? { version: 1, entries } : undefined;
}
/**
* Three-way merge local sidecars against each remote source using that
* source's trusted baseline (falls back to local baseline). Preserves
* explicit local deletions instead of resurrecting them via LWW union.
*/
function mergeMigrationSidecarsWithBaselines(options: {
local: SyncPayload['pluginSidecars'] | null | undefined;
localBaseline: SyncPayload['pluginSidecars'] | null | undefined;
sources: Array<{
remote: SyncPayload['pluginSidecars'] | null | undefined;
baseline: SyncPayload['pluginSidecars'] | null | undefined;
}>;
}): SyncPayload['pluginSidecars'] | undefined {
let entries = Array.isArray(options.local?.entries) ? [...options.local.entries] : [];
const localBase = Array.isArray(options.localBaseline?.entries)
? options.localBaseline.entries
: [];
let sawAny = Array.isArray(options.local?.entries);
for (const source of options.sources) {
if (!source.remote || !Array.isArray(source.remote.entries)) continue;
sawAny = true;
const base = Array.isArray(source.baseline?.entries)
? source.baseline.entries
: localBase;
entries = mergePluginSyncSidecarsThreeWay({
base,
local: entries,
remote: source.remote.entries,
});
}
if (!sawAny) return undefined;
return { version: 1, entries };
}
/*
* A local snapshot with no cloud entities and no trusted base is a fresh
@@ -255,43 +203,14 @@ export function planConvergentSyncMigration(options: {
}
if (blockedReasons.length === 0) {
state = createConvergentSyncStateFromPayload(merged, options.deviceId, options.now);
// Prefer the three-way merge result already on `merged` (preserves
// explicit sidecar deletions). Do not re-LWW raw provider bundles —
// that would resurrect entries three-way merge correctly removed.
const migrationSidecars = Object.prototype.hasOwnProperty.call(merged, 'pluginSidecars')
? merged.pluginSidecars
: mergeMigrationSidecars(
options.localPayload.pluginSidecars,
...v1Inputs.map((input) => input.payload.pluginSidecars),
);
materialized = materializeSyncPayloadFromConvergentState(state, {
syncedAt: options.now,
syncMeta: merged.syncMeta,
...(migrationSidecars ? { pluginSidecars: migrationSidecars } : {}),
});
}
} else if (blockedReasons.length === 0) {
state = v2Inputs.map((input) => input.state).reduce(mergeConvergentSyncStates);
// Three-way per provider so local resets are not resurrected from a
// still-stale remote entry during convergent enablement.
const joinedSidecars = mergeMigrationSidecarsWithBaselines({
local: options.localPayload.pluginSidecars,
localBaseline: options.localTrustedBaseline?.pluginSidecars,
sources: [
...v2Inputs.map((input) => ({
remote: input.payload.pluginSidecars,
baseline: input.trustedBaseline?.pluginSidecars,
})),
...v1Inputs.map((input) => ({
remote: input.payload.pluginSidecars,
baseline: input.trustedBaseline?.pluginSidecars,
})),
],
});
const joinedPayload = materializeSyncPayloadFromConvergentState(state, {
syncedAt: options.now,
...(joinedSidecars ? { pluginSidecars: joinedSidecars } : {}),
});
const joinedPayload = materializeSyncPayloadFromConvergentState(state, { syncedAt: options.now });
const legacySources: Array<{
id: string;
payload: SyncPayload;
@@ -338,13 +257,7 @@ export function planConvergentSyncMigration(options: {
}
if (blockedReasons.length === 0) {
state = branches.reduce(mergeConvergentSyncStates, state);
// joinedSidecars already unions local + all provider inputs. Re-LWW-ing
// raw sources again cannot add unique entries and can confuse future
// three-way paths that expect the joined set to be final.
materialized = materializeSyncPayloadFromConvergentState(state, {
syncedAt: options.now,
...(joinedSidecars ? { pluginSidecars: joinedSidecars } : {}),
});
materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: options.now });
}
}
@@ -352,13 +265,7 @@ export function planConvergentSyncMigration(options: {
if (conflicts.length > 0) blockedReasons.push('The convergent state contains unresolved field conflicts');
const canInitialize = blockedReasons.length === 0 && state !== null && materialized !== null;
const payload = canInitialize && state
? withConvergentSyncEnvelope(state, {
syncedAt: options.now,
syncMeta: materialized?.syncMeta,
...(materialized?.pluginSidecars
? { pluginSidecars: materialized.pluginSidecars }
: {}),
})
? withConvergentSyncEnvelope(state, { syncedAt: options.now, syncMeta: materialized?.syncMeta })
: null;
const previewPayload = materialized ?? options.localPayload;
const entityCounts = Object.fromEntries(
+1 -18
View File
@@ -202,8 +202,6 @@ export function materializeSyncPayloadFromConvergentState(
options: {
syncedAt: number;
syncMeta?: SyncReliabilityMeta;
/** Opaque plugin sidecars travel with the encrypted blob outside CRDT fields. */
pluginSidecars?: SyncPayload['pluginSidecars'];
},
): SyncPayload {
requireKnownCollections(state);
@@ -230,17 +228,6 @@ export function materializeSyncPayloadFromConvergentState(
settings,
syncedAt: options.syncedAt,
...(options.syncMeta ? { syncMeta: options.syncMeta } : {}),
// Preserve explicit empty bundles so lifecycle materializations (conflict
// resolve / downgrade) can clear or re-upload sidecars rather than omit
// the field and look like a legacy payload.
...(options.pluginSidecars && Array.isArray(options.pluginSidecars.entries)
? {
pluginSidecars: {
version: 1 as const,
entries: options.pluginSidecars.entries,
},
}
: {}),
};
}
@@ -526,11 +513,7 @@ export function hydrateConvergentSyncEnvelope(
export function withConvergentSyncEnvelope(
state: ConvergentSyncStateV2,
options: {
syncedAt: number;
syncMeta?: SyncReliabilityMeta;
pluginSidecars?: SyncPayload['pluginSidecars'];
},
options: { syncedAt: number; syncMeta?: SyncReliabilityMeta },
): SyncPayload {
const payload = materializeSyncPayloadFromConvergentState(state, options);
return {
-71
View File
@@ -1,71 +0,0 @@
/**
* Shared encrypted-object storage surface for built-in cloud adapters and
* plugin sync Providers. Netcatty always encrypts before write and decrypts
* after read; implementations only handle already-encrypted bytes.
*/
export interface EncryptedObjectAccount {
id: string;
email?: string;
name?: string;
avatarUrl?: string;
}
export interface EncryptedObjectStorageCapabilities {
revisions: boolean;
conditionalWrites: boolean;
atomicReplacement: boolean;
maxObjectBytes?: number;
maxObjects?: number;
}
export interface EncryptedObjectReadResult {
found: boolean;
key: string;
bytes: Uint8Array | null;
revision?: string;
contentType?: string;
}
export interface EncryptedObjectWriteResult {
created: boolean;
revision?: string;
}
export interface EncryptedObjectDeleteResult {
deleted: boolean;
}
export interface EncryptedObjectWriteOptions {
/** When set, the write is conditional on the current remote revision. `null` means the object must not exist. */
expectedRevision?: string | null;
signal?: AbortSignal;
}
export interface EncryptedObjectDeleteOptions {
expectedRevision?: string;
signal?: AbortSignal;
}
/**
* Provider-agnostic encrypted object store. Plugins and WebDAV both implement
* this shape so CloudSyncManager can encrypt→write / read→decrypt without
* special-casing storage backends.
*/
export interface EncryptedObjectStorage {
readonly providerId: string;
connect(configuration?: unknown, options?: { signal?: AbortSignal }): Promise<{ account: EncryptedObjectAccount }>;
disconnect(options?: { signal?: AbortSignal }): Promise<void>;
getAccount(options?: { signal?: AbortSignal }): Promise<EncryptedObjectAccount | null>;
getCapabilities(options?: { signal?: AbortSignal }): Promise<EncryptedObjectStorageCapabilities>;
readObject(key: string, options?: { signal?: AbortSignal; preferStream?: boolean }): Promise<EncryptedObjectReadResult>;
writeObject(
key: string,
bytes: Uint8Array,
options?: EncryptedObjectWriteOptions & { preferStream?: boolean },
): Promise<EncryptedObjectWriteResult>;
deleteObject(key: string, options?: EncryptedObjectDeleteOptions): Promise<EncryptedObjectDeleteResult>;
}
/** Default object key used when adapting the legacy single-file CloudAdapter path. */
export const DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY = 'netcatty-vault.json';
-67
View File
@@ -5,13 +5,10 @@ import {
appendHostConnectScript,
getGlobalConnectScripts,
getHostConnectScriptIds,
hasHostConnectAutomation,
migrateHostConnectScriptIds,
reorderHostConnectScript,
removeHostConnectScript,
resolveConnectScriptsForHost,
shouldMarkConnectAutomationConsumed,
shouldUseFreshSshConnectionForAutomation,
syncHostsForSnippetTargetChange,
} from './hostConnectScripts.ts';
@@ -57,70 +54,6 @@ test('resolveConnectScriptsForHost runs globals before host queue and dedupes',
assert.deepEqual(resolved.map((item) => item.id), ['global', 'both', 'host-only']);
});
test('hasHostConnectAutomation covers host, global, and unresolved connect scripts', () => {
assert.equal(
hasHostConnectAutomation(
{ ...host, connectScriptIds: ['host-script'] },
[script({ id: 'host-script', targets: ['host-a'] })],
),
true,
);
assert.equal(
hasHostConnectAutomation(host, [script({ id: 'global', targetsAllHosts: true })]),
true,
);
assert.equal(
hasHostConnectAutomation({ ...host, loginScriptId: 'not-loaded-yet' }, []),
true,
);
assert.equal(hasHostConnectAutomation(host, []), false);
});
test('fresh SSH automation policy is conservative before vault hydration and for pending scripts', () => {
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [],
vaultInitialized: false,
}), true);
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [],
vaultInitialized: true,
hasPendingScript: true,
}), true);
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [],
vaultInitialized: true,
}), false);
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [script({ id: 'global', targetsAllHosts: true })],
vaultInitialized: true,
connectAutomationConsumed: true,
}), false);
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [script({ id: 'global', targetsAllHosts: true })],
vaultInitialized: true,
hasPendingScript: true,
connectAutomationConsumed: true,
}), true);
});
test('empty hydrated vault finalizes the current connection automation decision', () => {
assert.equal(shouldMarkConnectAutomationConsumed({
allConnectScriptsDone: true,
vaultInitialized: true,
hasUnresolvedBindings: false,
}), true);
assert.equal(shouldMarkConnectAutomationConsumed({
allConnectScriptsDone: true,
vaultInitialized: false,
hasUnresolvedBindings: false,
}), false);
});
test('append updates host connectScriptIds order', () => {
const snippets = [
script({ id: 'a', targets: ['host-a'] }),
-43
View File
@@ -118,49 +118,6 @@ export function resolveConnectScriptsForHost(host: Host, snippets: Snippet[]): S
return [...globals, ...hostScripts];
}
/**
* Whether connecting this host can run automation that depends on the initial
* login output. Missing referenced scripts still count: vault hydration or a
* later sync may restore them after the terminal has already started.
*/
export function hasHostConnectAutomation(host: Host, snippets: Snippet[]): boolean {
return hasUnresolvedConnectScriptBindings(host, snippets)
|| resolveConnectScriptsForHost(host, snippets).length > 0;
}
export function shouldUseFreshSshConnectionForAutomation(options: {
host: Host;
snippets: Snippet[];
vaultInitialized: boolean;
hasPendingScript?: boolean;
connectAutomationConsumed?: boolean;
}): boolean {
return options.hasPendingScript === true
|| (
options.connectAutomationConsumed !== true
&& (
!options.vaultInitialized
|| hasHostConnectAutomation(options.host, options.snippets)
)
);
}
/**
* Mark the current connection's automation decision as final once the vault
* has hydrated, even when it hydrated to an empty script list. This prevents a
* later sync from starting a newly arrived global script against a connection
* whose initial login output may already have been skipped.
*/
export function shouldMarkConnectAutomationConsumed(options: {
allConnectScriptsDone: boolean;
vaultInitialized: boolean;
hasUnresolvedBindings: boolean;
}): boolean {
return options.allConnectScriptsDone
&& options.vaultInitialized
&& !options.hasUnresolvedBindings;
}
export function appendHostConnectScript(host: Host, scriptId: string, snippets: Snippet[]): Host {
const snippet = scriptById(snippets, scriptId);
if (!snippet) return host;
-1
View File
@@ -18,7 +18,6 @@ const ALLOWED_KEYWORDS = new Set([
'properties',
'required',
'type',
'writeOnly',
]);
type Schema = Record<string, unknown>;
-65
View File
@@ -1,65 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { planPluginSyncConnect, hasPluginProviderStoredConfig } from './pluginSyncConnect';
const requiredSchema = {
type: 'object',
additionalProperties: false,
properties: {
endpoint: { type: 'string', minLength: 1 },
enabled: { type: 'boolean' },
},
required: ['endpoint'],
};
const optionalSchema = {
type: 'object',
additionalProperties: false,
properties: {
region: { type: 'string' },
},
};
test('planPluginSyncConnect reuses stored config including falsy scalars', () => {
assert.deepEqual(
planPluginSyncConnect({ hasStoredConfig: true, storedConfig: false }),
{ action: 'connect', configuration: false },
);
assert.deepEqual(
planPluginSyncConnect({ hasStoredConfig: true, storedConfig: 0, configurationSchema: requiredSchema }),
{ action: 'connect', configuration: 0 },
);
assert.deepEqual(
planPluginSyncConnect({ hasStoredConfig: true, storedConfig: '', configurationSchema: requiredSchema }),
{ action: 'connect', configuration: '' },
);
assert.deepEqual(
planPluginSyncConnect({ hasStoredConfig: true, storedConfig: null, configurationSchema: { type: 'null' } }),
{ action: 'connect', configuration: null },
);
});
test('hasPluginProviderStoredConfig uses property presence including null', () => {
assert.equal(hasPluginProviderStoredConfig({ config: null }), true);
assert.equal(hasPluginProviderStoredConfig({ config: false }), true);
assert.equal(hasPluginProviderStoredConfig({}), false);
assert.equal(hasPluginProviderStoredConfig(undefined), false);
});
test('planPluginSyncConnect prompts when schema rejects empty config', () => {
assert.deepEqual(
planPluginSyncConnect({ hasStoredConfig: false, storedConfig: undefined, configurationSchema: requiredSchema }),
{ action: 'prompt' },
);
});
test('planPluginSyncConnect uses empty object when schema allows it or is absent', () => {
assert.deepEqual(
planPluginSyncConnect({ hasStoredConfig: false, storedConfig: undefined }),
{ action: 'connect', configuration: {} },
);
assert.deepEqual(
planPluginSyncConnect({ hasStoredConfig: false, storedConfig: undefined, configurationSchema: optionalSchema }),
{ action: 'connect', configuration: {} },
);
});
-40
View File
@@ -1,40 +0,0 @@
import { pluginConfigurationMatchesSchema } from './pluginConfigurationSchema';
export type PluginSyncConnectPlan =
| { action: 'connect'; configuration: unknown }
| { action: 'prompt' };
/**
* Decide how to connect a plugin sync provider.
* - Reuse retained config when present (including falsy scalars and JSON null).
* - Connect with `{}` when no schema or when empty config is schema-valid.
* - Otherwise prompt for configuration before connect.
*
* Callers must set `hasStoredConfig` from property presence (`'config' in`
* / `hasOwnProperty`), not truthiness — `config: null` is a stored value.
*/
export function planPluginSyncConnect(options: {
configurationSchema?: unknown;
storedConfig: unknown;
hasStoredConfig: boolean;
}): PluginSyncConnectPlan {
if (options.hasStoredConfig) {
return { action: 'connect', configuration: options.storedConfig };
}
const schema = options.configurationSchema;
if (schema === undefined) {
return { action: 'connect', configuration: {} };
}
if (pluginConfigurationMatchesSchema(schema, {})) {
return { action: 'connect', configuration: {} };
}
return { action: 'prompt' };
}
/** True when a provider connection retains a config property (including null). */
export function hasPluginProviderStoredConfig(
connection: { config?: unknown } | null | undefined,
): boolean {
return connection != null
&& Object.prototype.hasOwnProperty.call(connection, 'config');
}
-105
View File
@@ -1,105 +0,0 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
planPluginSyncCredential,
pluginSyncSecretStoreKeys,
syncConfigurationSchemaWithoutSecretRequirements,
} from './pluginSyncCredential.ts';
describe('planPluginSyncCredential', () => {
it('extracts all secret keys and strips them from configuration', () => {
const plan = planPluginSyncCredential({
endpoint: 'https://dav.example',
username: 'alice',
password: 's3cret',
token: 'also-secret',
});
assert.equal(plan.plaintextSecret, 's3cret');
assert.equal(plan.extractedFrom, 'password');
assert.equal(plan.secrets.length, 2);
assert.deepEqual(
plan.secrets.map((entry) => ({ key: entry.key, value: entry.value })),
[
{ key: 'password', value: 's3cret' },
{ key: 'token', value: 'also-secret' },
],
);
assert.deepEqual(plan.configuration, {
endpoint: 'https://dav.example',
username: 'alice',
});
});
it('extracts writeOnly schema fields that are not well-known names', () => {
const plan = planPluginSyncCredential(
{
endpoint: 'https://dav.example',
appPassword: 'hidden',
},
{
configurationSchema: {
type: 'object',
additionalProperties: false,
properties: {
endpoint: { type: 'string' },
appPassword: { type: 'string', writeOnly: true },
},
required: ['endpoint', 'appPassword'],
},
},
);
assert.equal(plan.secrets.length, 1);
assert.equal(plan.secrets[0]?.key, 'appPassword');
assert.equal(plan.secrets[0]?.secretKey, 'sync-credential:appPassword');
assert.deepEqual(plan.configuration, { endpoint: 'https://dav.example' });
});
it('passes through non-object configs unchanged', () => {
assert.deepEqual(planPluginSyncCredential(null), {
configuration: null,
secrets: [],
secretKey: 'sync-credential',
});
assert.deepEqual(planPluginSyncCredential('x'), {
configuration: 'x',
secrets: [],
secretKey: 'sync-credential',
});
});
it('leaves configs without secrets alone', () => {
const configuration = { endpoint: 'https://dav.example', username: 'alice' };
assert.deepEqual(planPluginSyncCredential(configuration), {
configuration,
secrets: [],
secretKey: 'sync-credential',
});
});
});
describe('syncConfigurationSchemaWithoutSecretRequirements', () => {
it('drops known secret keys and writeOnly fields from required', () => {
const schema = {
type: 'object',
additionalProperties: false,
properties: {
endpoint: { type: 'string' },
password: { type: 'string' },
appPassword: { type: 'string', writeOnly: true },
},
required: ['endpoint', 'password', 'appPassword'],
};
assert.deepEqual(syncConfigurationSchemaWithoutSecretRequirements(schema), {
...schema,
required: ['endpoint'],
});
});
});
describe('pluginSyncSecretStoreKeys', () => {
it('includes primary and secondary credential keys', () => {
assert.ok(pluginSyncSecretStoreKeys().includes('sync-credential'));
assert.ok(pluginSyncSecretStoreKeys().includes('sync-credential:token'));
assert.ok(pluginSyncSecretStoreKeys(['appPassword']).includes('sync-credential:appPassword'));
});
});
-155
View File
@@ -1,155 +0,0 @@
/**
* Split opaque sync secrets out of plugin configuration so only non-secret
* JSON reaches cloud persistence / SyncConnectPayload.configuration.
*/
export const PLUGIN_SYNC_SECRET_CONFIG_KEYS = [
'password',
'token',
'secret',
'apiKey',
'accessToken',
] as const;
export type PluginSyncSecretConfigKey = (typeof PLUGIN_SYNC_SECRET_CONFIG_KEYS)[number];
export interface PluginSyncExtractedSecret {
/** Configuration property name that held the secret. */
key: string;
value: string;
/** SecretStore key used for the opaque ref (stable per config field). */
secretKey: string;
}
export interface PluginSyncCredentialPlan {
/** Configuration with secret fields removed (safe to persist as plugin config). */
configuration: unknown;
/** All extracted top-level secrets (may be empty). */
secrets: PluginSyncExtractedSecret[];
/**
* Primary secret for SyncConnectPayload.credential (first extracted).
* Additional secrets remain in OS storage under their secretKey for plugins
* that call secrets.get / createLease by key.
*/
plaintextSecret?: string;
secretKey: string;
extractedFrom?: string;
}
function isWellKnownSecretConfigKey(key: string): boolean {
return (PLUGIN_SYNC_SECRET_CONFIG_KEYS as readonly string[]).includes(key);
}
/**
* Top-level property names that configurationSchema marks as writeOnly string
* fields (JSON Schema writeOnly) — treated as secrets even when not well-known names.
*/
export function listWriteOnlySecretPropertyNames(schema: unknown): string[] {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return [];
const properties = (schema as { properties?: unknown }).properties;
if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return [];
const names: string[] = [];
for (const [name, child] of Object.entries(properties as Record<string, unknown>)) {
if (!child || typeof child !== 'object' || Array.isArray(child)) continue;
const node = child as { type?: unknown; writeOnly?: unknown };
if (node.writeOnly === true && (node.type === undefined || node.type === 'string')) {
names.push(name);
}
}
return names;
}
function secretStoreKeyForConfigField(field: string): string {
return field === 'password' ? 'sync-credential' : `sync-credential:${field}`;
}
/**
* Extract top-level secret strings from plugin configuration JSON.
* Every matching key is removed from configuration and returned in `secrets`.
* Non-object configs pass through unchanged.
*
* Secret keys are the well-known name whitelist plus any top-level properties
* marked `writeOnly: true` in the provider configurationSchema.
*/
export function planPluginSyncCredential(
configuration: unknown,
options?: { configurationSchema?: unknown },
): PluginSyncCredentialPlan {
if (!configuration || typeof configuration !== 'object' || Array.isArray(configuration)) {
return { configuration, secrets: [], secretKey: 'sync-credential' };
}
const source = configuration as Record<string, unknown>;
const schemaSecretNames = new Set(listWriteOnlySecretPropertyNames(options?.configurationSchema));
const candidateKeys = new Set<string>([
...PLUGIN_SYNC_SECRET_CONFIG_KEYS,
...schemaSecretNames,
]);
const secrets: PluginSyncExtractedSecret[] = [];
// Stable order: well-known list first, then schema writeOnly names alphabetically.
const orderedKeys = [
...PLUGIN_SYNC_SECRET_CONFIG_KEYS.filter((key) => candidateKeys.has(key)),
...[...schemaSecretNames].filter((key) => !isWellKnownSecretConfigKey(key)).sort(),
];
for (const key of orderedKeys) {
const value = source[key];
if (typeof value === 'string' && value.length > 0) {
secrets.push({
key,
value,
secretKey: secretStoreKeyForConfigField(key),
});
}
}
if (secrets.length === 0) {
return { configuration, secrets: [], secretKey: 'sync-credential' };
}
const stripped: Record<string, unknown> = {};
for (const [key, value] of Object.entries(source)) {
if (candidateKeys.has(key)) continue;
stripped[key] = value;
}
const primary = secrets[0]!;
return {
configuration: stripped,
secrets,
plaintextSecret: primary.value,
secretKey: primary.secretKey,
extractedFrom: primary.key,
};
}
/**
* Sync connect strips secret fields from configuration before invoke. Host
* schema validation must therefore treat those keys as optional even when the
* contribution marks them required (secrets arrive via SyncConnectPayload.credential).
*/
export function syncConfigurationSchemaWithoutSecretRequirements(schema: unknown): unknown {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema;
const source = schema as Record<string, unknown>;
if (!Array.isArray(source.required)) return schema;
const secretNames = new Set([
...PLUGIN_SYNC_SECRET_CONFIG_KEYS,
...listWriteOnlySecretPropertyNames(schema),
]);
const required = source.required.filter(
(name) => typeof name === 'string' && !secretNames.has(name),
);
if (required.length === source.required.length) return schema;
return { ...source, required };
}
/** Stable SecretStore keys used for plugin sync credentials (for delete-on-disconnect). */
export function pluginSyncSecretStoreKeys(extraFieldNames: readonly string[] = []): readonly string[] {
const keys = new Set<string>([
'sync-credential',
...PLUGIN_SYNC_SECRET_CONFIG_KEYS
.filter((key) => key !== 'password')
.map((key) => `sync-credential:${key}`),
]);
for (const name of extraFieldNames) {
if (typeof name === 'string' && name.length > 0) {
keys.add(secretStoreKeyForConfigField(name));
}
}
return [...keys];
}
-31
View File
@@ -1,31 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeDurablePluginSyncCredentialRef } from './sync.ts';
test('normalizeDurablePluginSyncCredentialRef accepts durable refs only', () => {
assert.deepEqual(
normalizeDurablePluginSyncCredentialRef({ kind: 'secret', id: 's1' }),
{ kind: 'secret', id: 's1' },
);
assert.deepEqual(
normalizeDurablePluginSyncCredentialRef({ kind: 'credential', id: 'c1', key: 'k' }),
{ kind: 'credential', id: 'c1', key: 'k' },
);
assert.equal(
normalizeDurablePluginSyncCredentialRef({ kind: 'secret-lease', id: 'l1' }),
undefined,
);
assert.equal(
normalizeDurablePluginSyncCredentialRef([{ kind: 'secret', id: 's1' }]),
undefined,
);
assert.equal(normalizeDurablePluginSyncCredentialRef({ kind: 'secret', id: '' }), undefined);
assert.equal(
normalizeDurablePluginSyncCredentialRef({ kind: 'secret', id: 'x', key: 1 }),
undefined,
);
assert.equal(
normalizeDurablePluginSyncCredentialRef({ kind: 'secret', id: 'x'.repeat(513) }),
undefined,
);
});
-258
View File
@@ -1,258 +0,0 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
collectPluginSyncSidecars,
excludeSecretPluginSettingsFromSidecars,
isCloudSyncablePluginSetting,
mergePluginSyncSidecars,
mergePluginSyncSidecarsThreeWay,
parseSettingsSidecarKey,
type PluginSettingFieldDescriptor,
type PluginSyncSidecarEntry,
} from './pluginSyncSidecar';
const pluginId = 'com.example.sidecar';
const declared = new Map<string, PluginSettingFieldDescriptor[]>([
[pluginId, [
{ id: `${pluginId}.theme`, sync: true, secret: false },
{ id: `${pluginId}.token`, sync: true, secret: true },
{ id: `${pluginId}.localOnly`, sync: false, secret: false },
]],
]);
describe('pluginSyncSidecar', () => {
it('rejects secret fields from cloud sync even when sync is true', () => {
assert.equal(isCloudSyncablePluginSetting({ sync: true, secret: true }), false);
assert.equal(isCloudSyncablePluginSetting({ sync: true, secret: false }), true);
assert.equal(isCloudSyncablePluginSetting({ sync: false, secret: false }), false);
});
it('collects only non-secret sync:true settings into the sidecar bundle', () => {
const bundle = collectPluginSyncSidecars({
declaredSettingsByPlugin: declared,
storedSettings: [
{
pluginId,
settingId: `${pluginId}.theme`,
scope: 'application',
scopeId: 'application',
value: 'dark',
updatedAt: 10,
},
{
pluginId,
settingId: `${pluginId}.token`,
scope: 'application',
scopeId: 'application',
value: 'should-not-sync',
updatedAt: 10,
},
{
pluginId,
settingId: `${pluginId}.localOnly`,
scope: 'application',
scopeId: 'application',
value: true,
updatedAt: 10,
},
],
now: 20,
});
assert.equal(bundle.version, 1);
assert.equal(bundle.entries.length, 1);
assert.equal(bundle.entries[0].kind, 'settings');
assert.equal(bundle.entries[0].value, 'dark');
assert.deepEqual(parseSettingsSidecarKey(bundle.entries[0].key), {
settingId: `${pluginId}.theme`,
scope: 'application',
scopeId: 'application',
});
});
it('preserves account and CRDT baselines when the plugin is missing', () => {
const existing: PluginSyncSidecarEntry[] = [
{
pluginId: 'com.missing.plugin',
kind: 'account_baseline',
key: 'account',
value: { accountId: 'acct-1' },
updatedAt: 5,
},
{
pluginId: 'com.missing.plugin',
kind: 'crdt_baseline',
key: 'replica',
value: { clock: 3 },
updatedAt: 5,
},
{
pluginId: 'com.missing.plugin',
kind: 'settings',
key: 'com.missing.plugin.theme\0application\0application',
value: 'light',
updatedAt: 5,
},
];
const bundle = collectPluginSyncSidecars({
declaredSettingsByPlugin: new Map(), // plugin not installed
storedSettings: [],
existingSidecars: existing,
now: 100,
});
assert.equal(bundle.entries.length, 3);
assert.ok(bundle.entries.some((entry) => entry.kind === 'account_baseline'));
assert.ok(bundle.entries.some((entry) => entry.kind === 'crdt_baseline'));
assert.ok(bundle.entries.some((entry) => entry.kind === 'settings' && entry.value === 'light'));
});
it('merges remote sidecars without dropping local baselines for missing remote plugins', () => {
const local: PluginSyncSidecarEntry[] = [
{
pluginId: 'com.local.only',
kind: 'account_baseline',
key: 'account',
value: { id: 'local' },
updatedAt: 1,
},
];
const remote = {
version: 1 as const,
entries: [
{
pluginId,
kind: 'settings' as const,
key: `${pluginId}.theme\0application\0application`,
value: 'dark',
updatedAt: 2,
},
],
};
const merged = mergePluginSyncSidecars({ local, remote, declaredSettingsByPlugin: declared });
assert.equal(merged.length, 2);
assert.ok(merged.some((entry) => entry.pluginId === 'com.local.only'));
assert.ok(merged.some((entry) => entry.value === 'dark'));
});
it('excludes secret settings when stripping sidecars with known declarations', () => {
const entries: PluginSyncSidecarEntry[] = [
{
pluginId,
kind: 'settings',
key: `${pluginId}.token\0application\0application`,
value: 'secret',
updatedAt: 1,
},
{
pluginId,
kind: 'settings',
key: `${pluginId}.theme\0application\0application`,
value: 'dark',
updatedAt: 1,
},
];
const filtered = excludeSecretPluginSettingsFromSidecars(entries, declared);
assert.equal(filtered.length, 1);
assert.equal(filtered[0].value, 'dark');
});
it('collect prefers newer retained sidecars over older stored settings', () => {
const bundle = collectPluginSyncSidecars({
declaredSettingsByPlugin: declared,
storedSettings: [{
pluginId,
settingId: `${pluginId}.theme`,
scope: 'application',
scopeId: 'application',
value: 'local-old',
updatedAt: 100,
}],
existingSidecars: [{
pluginId,
kind: 'settings',
key: `${pluginId}.theme\0application\0application`,
value: 'remote-new',
updatedAt: 200,
}],
now: 300,
});
assert.equal(bundle.entries.length, 1);
assert.equal(bundle.entries[0].value, 'remote-new');
assert.equal(bundle.entries[0].updatedAt, 200);
});
it('three-way merge propagates local settings deletions against an unchanged remote', () => {
const entry: PluginSyncSidecarEntry = {
pluginId,
kind: 'settings',
key: `${pluginId}.theme\0application\0application`,
value: 'dark',
updatedAt: 10,
};
const merged = mergePluginSyncSidecarsThreeWay({
base: [entry],
local: [],
remote: [entry],
});
assert.equal(merged.some((item) => item.key === entry.key), false);
});
it('three-way merge keeps a newer remote edit over a local deletion', () => {
const base: PluginSyncSidecarEntry = {
pluginId,
kind: 'settings',
key: `${pluginId}.theme\0application\0application`,
value: 'dark',
updatedAt: 10,
};
const remote: PluginSyncSidecarEntry = {
...base,
value: 'light',
updatedAt: 20,
};
const merged = mergePluginSyncSidecarsThreeWay({
base: [base],
local: [],
remote: [remote],
});
assert.equal(merged.length, 1);
assert.equal(merged[0].value, 'light');
});
it('preferCloud keeps local-only settings and baseline orphans', () => {
const localOnly: PluginSyncSidecarEntry = {
pluginId,
kind: 'settings',
key: `${pluginId}.theme\0application\0application`,
value: 'local-new',
updatedAt: 50,
};
const baseline: PluginSyncSidecarEntry = {
pluginId,
kind: 'account_baseline',
key: 'account',
value: { id: 'local-acct' },
updatedAt: 40,
};
const remoteBaseline: PluginSyncSidecarEntry = {
pluginId,
kind: 'crdt_baseline',
key: 'crdt',
value: { clock: 1 },
updatedAt: 30,
};
const merged = mergePluginSyncSidecarsThreeWay({
base: [],
local: [localOnly, baseline],
remote: [remoteBaseline],
strategy: 'preferCloud',
});
assert.equal(merged.some((item) => item.key === localOnly.key), true);
assert.equal(merged.some((item) => item.kind === 'account_baseline'), true);
assert.equal(merged.some((item) => item.kind === 'crdt_baseline'), true);
});
});
-344
View File
@@ -1,344 +0,0 @@
/**
* Pure helpers for plugin cloud-sync sidecars.
*
* Sidecars hold user-owned encrypted-sync data that must survive missing or
* uninstalled plugins: non-secret settings marked `sync: true`, plus optional
* plugin sync account / CRDT baselines. Secret settings never enter sidecars.
*/
export type PluginSyncSidecarKind = 'settings' | 'account_baseline' | 'crdt_baseline';
export interface PluginSettingFieldDescriptor {
id: string;
secret?: boolean;
sync?: boolean;
scope?: string;
}
export interface PluginSettingValueRecord {
pluginId: string;
settingId: string;
scope: string;
scopeId: string;
value: unknown;
updatedAt?: number;
}
export interface PluginSyncSidecarEntry {
pluginId: string;
kind: PluginSyncSidecarKind;
key: string;
value: unknown;
updatedAt: number;
}
export interface PluginSyncSidecarBundle {
/** Schema version for the sidecar envelope inside the encrypted payload. */
version: 1;
entries: PluginSyncSidecarEntry[];
}
export interface CollectPluginSyncSidecarsInput {
/** Declared setting fields keyed by plugin ID (from installed manifests when available). */
declaredSettingsByPlugin: ReadonlyMap<string, readonly PluginSettingFieldDescriptor[]>;
/** Stored non-secret setting values (from non-cascade plugin_settings rows). */
storedSettings: readonly PluginSettingValueRecord[];
/** Existing sidecar rows (account/CRDT baselines and previously synced settings). */
existingSidecars?: readonly PluginSyncSidecarEntry[];
now?: number;
}
const SIDECAR_KINDS = new Set<PluginSyncSidecarKind>([
'settings',
'account_baseline',
'crdt_baseline',
]);
export function isPluginSyncSidecarKind(value: unknown): value is PluginSyncSidecarKind {
return typeof value === 'string' && SIDECAR_KINDS.has(value as PluginSyncSidecarKind);
}
/**
* Returns true when a setting field may enter cloud sync sidecars.
* Secrets always fail closed even if a descriptor incorrectly sets sync.
*/
export function isCloudSyncablePluginSetting(
field: Pick<PluginSettingFieldDescriptor, 'secret' | 'sync'>,
): boolean {
if (field.secret === true) return false;
return field.sync === true;
}
function settingsSidecarKey(settingId: string, scope: string, scopeId: string): string {
return `${settingId}\0${scope}\0${scopeId}`;
}
/**
* Build the sidecar bundle for the encrypted cloud payload.
* Missing plugins keep existing sidecar rows; only non-secret sync:true
* settings from the current store are (re)published under kind "settings".
*/
export function collectPluginSyncSidecars(
input: CollectPluginSyncSidecarsInput,
): PluginSyncSidecarBundle {
const now = input.now ?? Date.now();
const entries: PluginSyncSidecarEntry[] = [];
const settingsIndex = new Map<string, PluginSyncSidecarEntry>();
// Preserve non-settings sidecars and previous settings for unknown plugins.
for (const existing of input.existingSidecars ?? []) {
if (!existing || typeof existing.pluginId !== 'string' || !isPluginSyncSidecarKind(existing.kind)) {
continue;
}
if (existing.kind !== 'settings') {
entries.push({
pluginId: existing.pluginId,
kind: existing.kind,
key: String(existing.key),
value: existing.value,
updatedAt: Number(existing.updatedAt) || now,
});
continue;
}
const copy: PluginSyncSidecarEntry = {
pluginId: existing.pluginId,
kind: 'settings',
key: String(existing.key),
value: existing.value,
updatedAt: Number(existing.updatedAt) || now,
};
settingsIndex.set(`${copy.pluginId}\0${copy.key}`, copy);
}
for (const stored of input.storedSettings) {
if (!stored || typeof stored.pluginId !== 'string' || typeof stored.settingId !== 'string') continue;
const declared = input.declaredSettingsByPlugin.get(stored.pluginId);
// Missing plugin: keep any prior settings sidecar rows, do not invent new ones
// from undeclared storage (could include secret-adjacent values).
if (!declared) continue;
const field = declared.find((entry) => entry.id === stored.settingId);
if (!field || !isCloudSyncablePluginSetting(field)) continue;
const key = settingsSidecarKey(stored.settingId, stored.scope, stored.scopeId);
const entry: PluginSyncSidecarEntry = {
pluginId: stored.pluginId,
kind: 'settings',
key,
value: stored.value,
updatedAt: Number(stored.updatedAt) || now,
};
const mapKey = `${entry.pluginId}\0${entry.key}`;
const previous = settingsIndex.get(mapKey);
// Keep a retained sidecar when it is strictly newer than the stored row
// (e.g. schema-rejected remote apply left sidecar only).
if (!previous || entry.updatedAt >= previous.updatedAt) {
settingsIndex.set(mapKey, entry);
}
}
entries.push(...settingsIndex.values());
entries.sort((a, b) => {
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
});
return { version: 1, entries };
}
/**
* Merge a remote sidecar bundle into local non-cascade storage without deleting
* local rows for plugins that are not present on the remote (partial sync safety).
* Secret settings are never applied even if a malicious remote marks them as settings.
*/
/**
* Three-way merge for settings sidecars so local resets (absent from local,
* present in base) propagate instead of being resurrected from base/remote.
* Non-settings kinds (baselines) still use last-writer-wins union.
*/
export function mergePluginSyncSidecarsThreeWay(params: {
base: readonly PluginSyncSidecarEntry[];
local: readonly PluginSyncSidecarEntry[];
remote: readonly PluginSyncSidecarEntry[];
/**
* When set, honor cloud/local conflict policy for settings instead of pure
* timestamp LWW (preferCloud / preferLocal). Default is smart three-way.
*/
strategy?: 'smart' | 'preferCloud' | 'preferLocal';
}): PluginSyncSidecarEntry[] {
const strategy = params.strategy ?? 'smart';
const keyOf = (entry: PluginSyncSidecarEntry) => `${entry.pluginId}\0${entry.kind}\0${entry.key}`;
const toMap = (entries: readonly PluginSyncSidecarEntry[]) => {
const map = new Map<string, PluginSyncSidecarEntry>();
for (const entry of entries) {
if (!entry || typeof entry.pluginId !== 'string' || !isPluginSyncSidecarKind(entry.kind)) continue;
map.set(keyOf(entry), {
pluginId: entry.pluginId,
kind: entry.kind,
key: String(entry.key),
value: entry.value,
updatedAt: Number(entry.updatedAt) || 0,
});
}
return map;
};
const baseMap = toMap(params.base);
const localMap = toMap(params.local);
const remoteMap = toMap(params.remote);
const allKeys = new Set([...baseMap.keys(), ...localMap.keys(), ...remoteMap.keys()]);
const out: PluginSyncSidecarEntry[] = [];
for (const mapKey of allKeys) {
const b = baseMap.get(mapKey);
const l = localMap.get(mapKey);
const r = remoteMap.get(mapKey);
const kind = (l?.kind ?? r?.kind ?? b?.kind) as PluginSyncSidecarKind | undefined;
if (!kind) continue;
if (kind !== 'settings') {
// Baselines: LWW between local and remote; always preserve local orphans
// (device-local CRDT/account merge bases must survive preferCloud).
if (l && r) {
if (strategy === 'preferCloud') out.push(r);
else if (strategy === 'preferLocal') out.push(l);
else out.push(l.updatedAt >= r.updatedAt ? l : r);
} else if (l) out.push(l);
else if (r) out.push(r);
continue;
}
// Settings three-way with optional conflict policy:
// base+remote, not local → local deleted
if (b && !l && r) {
if (strategy === 'preferLocal') continue; // honor local deletion
if (strategy === 'preferCloud' || r.updatedAt > b.updatedAt) out.push(r);
continue;
}
// base+local, not remote → remote deleted
if (b && l && !r) {
if (strategy === 'preferCloud') continue; // honor remote deletion
if (strategy === 'preferLocal' || l.updatedAt > b.updatedAt) out.push(l);
continue;
}
// both deleted
if (b && !l && !r) continue;
// only local / only remote additions — keep local-only so preferCloud does
// not delete unsynced local settings on the subsequent local apply path.
if (!b && l && !r) {
out.push(l);
continue;
}
if (!b && !l && r) {
out.push(r);
continue;
}
// both sides present (with or without base)
if (l && r) {
if (strategy === 'preferCloud') {
out.push(r);
} else if (strategy === 'preferLocal') {
out.push(l);
} else if (l.updatedAt !== r.updatedAt) {
out.push(l.updatedAt > r.updatedAt ? l : r);
} else {
// Equal timestamps: stable secondary key so devices converge.
const lTie = JSON.stringify(l.value);
const rTie = JSON.stringify(r.value);
out.push(lTie >= rTie ? l : r);
}
continue;
}
if (l) out.push(l);
else if (r) out.push(r);
}
return out.sort((a, b) => {
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
});
}
export function mergePluginSyncSidecars(params: {
local: readonly PluginSyncSidecarEntry[];
remote: PluginSyncSidecarBundle | null | undefined;
declaredSettingsByPlugin?: ReadonlyMap<string, readonly PluginSettingFieldDescriptor[]>;
}): PluginSyncSidecarEntry[] {
const remoteEntries = Array.isArray(params.remote?.entries) ? params.remote.entries : [];
const merged = new Map<string, PluginSyncSidecarEntry>();
for (const entry of params.local) {
if (!entry || typeof entry.pluginId !== 'string' || !isPluginSyncSidecarKind(entry.kind)) continue;
merged.set(`${entry.pluginId}\0${entry.kind}\0${entry.key}`, {
pluginId: entry.pluginId,
kind: entry.kind,
key: String(entry.key),
value: entry.value,
updatedAt: Number(entry.updatedAt) || 0,
});
}
for (const raw of remoteEntries) {
if (!raw || typeof raw.pluginId !== 'string' || !isPluginSyncSidecarKind(raw.kind)) continue;
if (raw.kind === 'settings') {
const declared = params.declaredSettingsByPlugin?.get(raw.pluginId);
if (declared) {
// key format: settingId\0scope\0scopeId
const settingId = String(raw.key).split('\0')[0] ?? '';
const field = declared.find((entry) => entry.id === settingId);
if (field && !isCloudSyncablePluginSetting(field)) continue;
}
}
const next: PluginSyncSidecarEntry = {
pluginId: raw.pluginId,
kind: raw.kind,
key: String(raw.key),
value: raw.value,
updatedAt: Number(raw.updatedAt) || 0,
};
const mapKey = `${next.pluginId}\0${next.kind}\0${next.key}`;
const previous = merged.get(mapKey);
if (!previous || next.updatedAt >= previous.updatedAt) {
merged.set(mapKey, next);
}
}
return [...merged.values()].sort((a, b) => {
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
});
}
/**
* Parse a settings sidecar key into setting coordinates.
*/
export function parseSettingsSidecarKey(key: string): {
settingId: string;
scope: string;
scopeId: string;
} | null {
const parts = String(key).split('\0');
if (parts.length !== 3) return null;
const [settingId, scope, scopeId] = parts;
if (!settingId || !scope || !scopeId) return null;
return { settingId, scope, scopeId };
}
/**
* Strip secret settings from an arbitrary sidecar entry list (defense in depth).
*/
export function excludeSecretPluginSettingsFromSidecars(
entries: readonly PluginSyncSidecarEntry[],
declaredSettingsByPlugin: ReadonlyMap<string, readonly PluginSettingFieldDescriptor[]>,
): PluginSyncSidecarEntry[] {
return entries.filter((entry) => {
if (entry.kind !== 'settings') return true;
const declared = declaredSettingsByPlugin.get(entry.pluginId);
if (!declared) return true; // preserve orphaned rows for missing plugins
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) return false;
const field = declared.find((item) => item.id === parsed.settingId);
if (!field) return true;
return isCloudSyncablePluginSetting(field);
});
}
-35
View File
@@ -1,35 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { hasProviderConnectionData, isProviderReadyForSync } from './sync';
test('hasProviderConnectionData treats falsy scalar configs as present', () => {
assert.equal(hasProviderConnectionData({ config: false }), true);
assert.equal(hasProviderConnectionData({ config: 0 }), true);
assert.equal(hasProviderConnectionData({ config: '' }), true);
assert.equal(hasProviderConnectionData({ config: null as never }), true);
assert.equal(hasProviderConnectionData({}), false);
assert.equal(hasProviderConnectionData({ tokens: undefined }), false);
});
test('isProviderReadyForSync keeps error status ready when only scalar config remains', () => {
assert.equal(
isProviderReadyForSync({ status: 'error', config: false }),
true,
);
assert.equal(
isProviderReadyForSync({ status: 'error', config: 0 }),
true,
);
assert.equal(
isProviderReadyForSync({ status: 'error', config: '' }),
true,
);
assert.equal(
isProviderReadyForSync({ status: 'error' }),
false,
);
assert.equal(
isProviderReadyForSync({ status: 'disconnected', config: false }),
false,
);
});
+5 -101
View File
@@ -55,23 +55,9 @@ export type ConflictResolution =
// ============================================================================
/**
* Supported cloud storage providers.
* Built-in short IDs remain stable; plugin sync Providers use namespaced
* contribution IDs (e.g. com.example.backup.sync).
* Supported cloud storage providers
*/
export type BuiltinCloudProvider = 'github' | 'google' | 'onedrive' | 'webdav' | 's3';
export type CloudProvider = BuiltinCloudProvider | (string & {});
export const BUILTIN_CLOUD_PROVIDERS: readonly BuiltinCloudProvider[] = [
'github',
'google',
'onedrive',
'webdav',
's3',
] as const;
export const isBuiltinCloudProvider = (provider: string): provider is BuiltinCloudProvider =>
(BUILTIN_CLOUD_PROVIDERS as readonly string[]).includes(provider);
export type CloudProvider = 'github' | 'google' | 'onedrive' | 'webdav' | 's3';
export type WebDAVAuthType = 'basic' | 'digest' | 'token';
@@ -162,74 +148,21 @@ export interface ProviderAccount {
/**
* Cloud provider connection state
*/
/**
* Opaque host-owned sync credential reference (SecretRef / CredentialRef shape).
* Never stores plaintext secrets — only the reference the plugin connect path needs.
* Secret leases are one-shot and must not be persisted for reconnect.
*/
export interface PluginSyncCredentialRef {
kind: 'secret' | 'credential';
id: string;
key?: string;
}
/** Reasonable upper bounds for durable opaque ref strings persisted at rest. */
const MAX_PLUGIN_SYNC_CREDENTIAL_ID_CHARS = 512;
const MAX_PLUGIN_SYNC_CREDENTIAL_KEY_CHARS = 256;
/**
* Normalize a value into a durable PluginSyncCredentialRef for reconnect
* persistence. Rejects arrays, leases, and oversized / malformed shapes.
*/
export function normalizeDurablePluginSyncCredentialRef(
value: unknown,
): PluginSyncCredentialRef | undefined {
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const kind = record.kind;
const id = record.id;
if (kind !== 'secret' && kind !== 'credential') return undefined;
if (typeof id !== 'string' || id.length < 1 || id.length > MAX_PLUGIN_SYNC_CREDENTIAL_ID_CHARS) {
return undefined;
}
if (!Object.prototype.hasOwnProperty.call(record, 'key') || record.key === undefined) {
return { kind, id };
}
const key = record.key;
if (typeof key !== 'string' || key.length < 1 || key.length > MAX_PLUGIN_SYNC_CREDENTIAL_KEY_CHARS) {
return undefined;
}
return { kind, id, key };
}
export interface ProviderConnection {
provider: CloudProvider;
status: ProviderConnectionStatus;
account?: ProviderAccount;
tokens?: OAuthTokens;
config?: WebDAVConfig | S3Config;
/** Plugin sync providers: persisted SyncConnectPayload.credential for reconnect. */
credential?: PluginSyncCredentialRef;
lastSync?: number; // Unix timestamp
lastSyncVersion?: number;
resourceId?: string; // gistId / fileId / itemId
error?: string;
}
/**
* Whether a connection still has usable credentials/config to retry.
* Plugin configs may be valid falsy JSON scalars (`false`, `0`, `""`) or even
* JSON `null` when a schema uses `type: "null"`. Presence is property
* existence for `config`; do not use truthiness (`||` / `Boolean`).
*/
export const hasProviderConnectionData = (
connection: Pick<ProviderConnection, 'tokens' | 'config' | 'credential'>,
): boolean =>
connection.tokens != null
|| Object.prototype.hasOwnProperty.call(connection, 'config')
|| connection.credential != null;
const hasProviderConnectionData = (
connection: Pick<ProviderConnection, 'tokens' | 'config'>,
): boolean => Boolean(connection.tokens || connection.config);
export const isProviderReadyForSync = (
connection: Pick<ProviderConnection, 'status' | 'tokens' | 'config'>,
@@ -366,12 +299,6 @@ export interface SyncPayload {
};
};
/**
* Encrypted-sidecar envelope for plugin user data that must survive missing
* plugins (sync:true settings, account/CRDT baselines). Secrets never appear here.
*/
pluginSidecars?: import('./pluginSyncSidecar').PluginSyncSidecarBundle;
// Sync metadata
syncedAt: number; // When this payload was created
@@ -697,35 +624,12 @@ export const SYNC_STORAGE_KEYS = {
PROVIDER_WEBDAV: 'netcatty_provider_webdav_v1',
PROVIDER_S3: 'netcatty_provider_s3_v1',
PROVIDER_SMB: 'netcatty_provider_smb_v1',
/** Registry of connected namespaced plugin sync provider IDs. */
PLUGIN_CLOUD_PROVIDERS: 'netcatty_plugin_cloud_providers_v1',
/** Contribution-available plugin sync provider IDs (live catalog membership). */
AVAILABLE_PLUGIN_SYNC_PROVIDERS: 'netcatty_available_plugin_sync_providers_v1',
/** Last successful sidecar collect (upload fallback when host is offline). */
PLUGIN_SIDECARS_LAST_KNOWN: 'netcatty_plugin_sidecars_last_known_v1',
/** Remote sidecar apply queued while the plugin host was unavailable. */
PLUGIN_SIDECARS_PENDING_REMOTE: 'netcatty_plugin_sidecars_pending_remote_v1',
LOCAL_SYNC_META: 'netcatty_local_sync_meta_v1',
SYNC_BASE_PAYLOAD: 'netcatty_sync_base_payload_v1',
CONVERGENT_REPLICA: 'netcatty_convergent_sync_replica_v2',
CONVERGENT_PROVIDER_BASELINE: 'netcatty_convergent_sync_provider_baseline_v2',
} as const;
/** Resolve the localStorage key used for a provider connection (built-in or plugin). */
export function providerConnectionStorageKey(provider: CloudProvider): string {
if (isBuiltinCloudProvider(provider)) {
const map: Record<BuiltinCloudProvider, string> = {
github: SYNC_STORAGE_KEYS.PROVIDER_GITHUB,
google: SYNC_STORAGE_KEYS.PROVIDER_GOOGLE,
onedrive: SYNC_STORAGE_KEYS.PROVIDER_ONEDRIVE,
webdav: SYNC_STORAGE_KEYS.PROVIDER_WEBDAV,
s3: SYNC_STORAGE_KEYS.PROVIDER_S3,
};
return map[provider];
}
return `netcatty_provider_plugin_v1:${provider}`;
}
// ============================================================================
// Constants
// ============================================================================
-108
View File
@@ -418,114 +418,6 @@ test("mergeSyncPayloads keeps local settings conflict policy when a base exists"
});
});
test("mergeSyncPayloads carries plugin sidecars through without dropping remote baselines", () => {
const local: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
pluginSidecars: {
version: 1,
entries: [{
pluginId: "com.local.plugin",
kind: "settings",
key: "com.local.plugin.theme\0application\0application",
value: "dark",
updatedAt: 5,
}],
},
};
const remote: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 2,
pluginSidecars: {
version: 1,
entries: [{
pluginId: "com.remote.plugin",
kind: "account_baseline",
key: "account",
value: { id: "acct-r" },
updatedAt: 9,
}],
},
};
const result = mergeSyncPayloads(null, local, remote);
assert.ok(result.payload.pluginSidecars);
assert.equal(result.payload.pluginSidecars!.entries.length, 2);
assert.ok(result.payload.pluginSidecars!.entries.some((e) => e.pluginId === "com.local.plugin"));
assert.ok(result.payload.pluginSidecars!.entries.some((e) => e.kind === "account_baseline"));
});
test("mergeSyncPayloads propagates local sidecar setting resets via three-way merge", () => {
const entry = {
pluginId: "com.example.p",
kind: "settings" as const,
key: "com.example.p.theme\0application\0application",
value: "dark",
updatedAt: 1,
};
const base: SyncPayload = {
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
syncedAt: 1,
pluginSidecars: { version: 1, entries: [entry] },
};
const local: SyncPayload = {
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
syncedAt: 2,
// user reset: explicit empty sidecar bundle (omitted field = legacy/unsupported)
pluginSidecars: { version: 1, entries: [] },
};
const remote: SyncPayload = {
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
syncedAt: 2,
pluginSidecars: { version: 1, entries: [entry] },
};
const result = mergeSyncPayloads(base, local, remote);
assert.equal(
result.payload.pluginSidecars?.entries?.some((e) => e.key === entry.key) ?? false,
false,
"local reset must not be resurrected from base/remote",
);
assert.ok(
Object.prototype.hasOwnProperty.call(result.payload, "pluginSidecars"),
"explicit empty sidecar field must remain so apply clears remote/local DB",
);
assert.deepEqual(result.payload.pluginSidecars, { version: 1, entries: [] });
});
test("mergeSyncPayloads preserves sidecars when remote legacy payload omits the field", () => {
const entry = {
pluginId: "com.example.p",
kind: "settings" as const,
key: "com.example.p.theme\0application\0application",
value: "dark",
updatedAt: 1,
};
const base: SyncPayload = {
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
syncedAt: 1,
pluginSidecars: { version: 1, entries: [entry] },
};
const local: SyncPayload = {
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
syncedAt: 2,
pluginSidecars: { version: 1, entries: [entry] },
};
const remote: SyncPayload = {
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
syncedAt: 2,
// legacy remote omits pluginSidecars entirely
};
const result = mergeSyncPayloads(base, local, remote);
assert.ok(result.payload.pluginSidecars?.entries?.some((e) => e.key === entry.key));
});
test("mergeSyncPayloads treats missing optional arrays as legacy payloads, not deletions", () => {
const identity = {
id: "identity-1",
-26
View File
@@ -21,7 +21,6 @@
import { carryForwardSyncDeletions, getDeletedEntityIds } from './syncReliability';
import type { CloudSyncPayloadEntityKey, SyncPayload } from './sync';
import { mergePluginSyncSidecarsThreeWay } from './pluginSyncSidecar';
// ---------------------------------------------------------------------------
// Public types
@@ -553,28 +552,6 @@ export function mergeSyncPayloads(
const groupConfigs = unwrapGC(groupConfigsResult.merged);
// Plugin sidecars: three-way merge so local resets propagate and baselines
// from missing plugins are preserved. An *omitted* pluginSidecars field means
// legacy/unsupported (treat as base for that side). Explicit { entries: [] }
// is an authoritative wipe.
const baseHasSidecars = Object.prototype.hasOwnProperty.call(b, 'pluginSidecars');
const localHasSidecars = Object.prototype.hasOwnProperty.call(local, 'pluginSidecars');
const remoteHasSidecars = Object.prototype.hasOwnProperty.call(remote, 'pluginSidecars');
const baseSidecars = Array.isArray(b.pluginSidecars?.entries) ? b.pluginSidecars.entries : [];
const localSidecars = localHasSidecars
? (Array.isArray(local.pluginSidecars?.entries) ? local.pluginSidecars.entries : [])
: baseSidecars;
const remoteSidecars = remoteHasSidecars
? (Array.isArray(remote.pluginSidecars?.entries) ? remote.pluginSidecars.entries : [])
: baseSidecars;
const mergedSidecarEntries = mergePluginSyncSidecarsThreeWay({
base: baseSidecars,
local: localSidecars,
remote: remoteSidecars,
});
// Emit the field when any side explicitly carried it (including empty reset).
const anySideHadPluginSidecars = baseHasSidecars || localHasSidecars || remoteHasSidecars;
const payload: SyncPayload = carryForwardSyncDeletions({
hosts: hosts.merged,
keys: keys.merged,
@@ -588,9 +565,6 @@ export function mergeSyncPayloads(
portForwardingRules: portForwardingRules.merged,
groupConfigs,
settings,
...(mergedSidecarEntries.length > 0 || anySideHadPluginSidecars
? { pluginSidecars: { version: 1 as const, entries: mergedSidecarEntries } }
: {}),
syncedAt: Date.now(),
}, [local, remote]);
@@ -287,8 +287,26 @@ function translateOpenCodeEvent(event, emitter, state = {}) {
emitter.toolResult(callId, part.state.output || "", toolName);
}
} else if (part.state?.status === "error") {
emitter.emitError(part.state.error || "OpenCode tool failed");
return { idle: false, error: true, content: false };
// Tool-level failures must not abort the whole OpenCode turn. Other
// drivers (Cursor / Codex / Grok) surface tool errors as tool results
// so the model can adapt and continue multi-step work (issue #2718).
state.toolCalls = state.toolCalls || new Set();
if (!state.toolCalls.has(callId)) {
state.toolCalls.add(callId);
emitter.toolCall(toolName, input, callId);
}
state.toolResults = state.toolResults || new Set();
if (!state.toolResults.has(callId)) {
state.toolResults.add(callId);
// Prefer non-empty error, then output, then a stable default (blank
// string error must not hide a useful output payload).
const rawError = part.state.error || part.state.output || "OpenCode tool failed";
const errorText = typeof rawError === "string"
? rawError
: (extractOpenCodeErrorMessage(rawError) || "OpenCode tool failed");
emitter.toolResult(callId, errorText, toolName);
}
return { idle: false, error: false, content: true };
}
}
return { idle: false, error: false, content: part.type === "tool" };
@@ -403,6 +403,101 @@ test("translateOpenCodeEvent emits a tool call before a result when only complet
]);
});
test("translateOpenCodeEvent maps tool status error to toolResult without failing the turn (#2718)", () => {
const { events, emitter } = collector();
const state = {};
const result = translateOpenCodeEvent(
{
type: "message.part.updated",
properties: {
part: {
type: "tool",
callID: "tool-err",
tool: "netcatty-remote-hosts_terminal_execute",
state: {
status: "error",
input: { command: "du /missing" },
error: "Error: Operation failed",
},
},
},
},
emitter,
state,
);
assert.equal(result.error, false);
assert.equal(result.content, true);
assert.deepEqual(events, [
{
k: "toolCall",
name: "netcatty-remote-hosts_terminal_execute",
args: { command: "du /missing" },
id: "tool-err",
},
{
k: "toolResult",
id: "tool-err",
out: "Error: Operation failed",
name: "netcatty-remote-hosts_terminal_execute",
},
]);
assert.equal(events.some((event) => event.k === "error"), false);
});
test("runOpenCodeTurn continues after a tool error part and reaches session.idle (#2718)", async () => {
const { events, emitter } = collector();
const abortController = new AbortController();
const stream = {
async *[Symbol.asyncIterator]() {
yield {
type: "message.part.updated",
properties: {
part: {
type: "tool",
sessionID: "sess-1",
callID: "t1",
tool: "terminal_execute",
state: { status: "error", input: { command: "false" }, error: "Error: Operation failed" },
},
},
};
yield {
type: "message.part.updated",
properties: {
part: {
type: "text",
sessionID: "sess-1",
id: "p1",
text: "retrying",
},
delta: "retrying",
},
};
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
},
};
const client = {
global: { event: async () => ({ stream }) },
session: {
create: async () => ({ data: { id: "sess-1" } }),
promptAsync: async () => ({ data: true }),
},
};
await runOpenCodeTurn({
prompt: "run false then continue",
emitter,
abortController,
openCodeFactory: async () => ({ client, server: { close() {} } }),
});
assert.ok(events.some((event) => event.k === "toolResult" && event.out === "Error: Operation failed"));
assert.ok(events.some((event) => event.k === "text" && event.t === "retrying"));
assert.ok(events.some((event) => event.k === "done"));
assert.equal(events.some((event) => event.k === "error"), false);
});
test("mapOpenCodeModels flattens providers", () => {
assert.deepEqual(mapOpenCodeModels({
providers: [
+2 -24
View File
@@ -1108,7 +1108,7 @@ function isStrictAgentAuthFailure(options, err) {
}
function canReuseExistingSession(options) {
if (options.reuseTransport === false || !options.sourceSessionId || options.x11Forwarding) return false;
if (!options.sourceSessionId || options.x11Forwarding) return false;
return Boolean(findReusableSession(sessions, options.sourceSessionId, {
hostname: options.hostname,
port: options.port || 22,
@@ -1300,38 +1300,16 @@ async function startSSHSessionWithRetries(event, options, pendingDialState) {
async function startSSHSessionWrapper(event, options) {
const pendingDialState = { coordination: null };
let sourcePinHolder = null;
let sourceReuseState = options.sourceSessionId && options.reuseTransport !== false
? { attempted: false, session: null }
: null;
if (options.sourceSessionId && options.reuseTransport !== false) {
const sourceAtRequest = findReusableSession(sessions, options.sourceSessionId);
if (sourceAtRequest?.connRef) {
sourcePinHolder = {};
acquireConnectionRef(sourcePinHolder, sourceAtRequest.connRef);
sourceReuseState.session = {
conn: sourceAtRequest.conn,
connRef: sourceAtRequest.connRef,
stream: sourceAtRequest.stream,
_reuseEndpoint: sourceAtRequest._reuseEndpoint,
};
}
}
try {
// Main-process LAN probe so macOS Local Network TCC attributes to Netcatty
// before the (possibly worker-hosted) SSH dial. See #2663 / TN3179.
await ensureMacLocalNetworkAccess(options);
return await startSSHSessionWithRetries(event, {
...options,
...(sourceReuseState ? { _sourceReuseState: sourceReuseState } : {}),
}, pendingDialState);
return await startSSHSessionWithRetries(event, options, pendingDialState);
} catch (err) {
if (pendingDialState.coordination) {
failTransportDial(pendingDialState.coordination, err);
}
throw err;
} finally {
if (sourcePinHolder) releaseConnectionRef(sourcePinHolder);
}
}
@@ -104,91 +104,6 @@ test("simultaneous normal opens of the same host make one physical SSH dial", as
assert.equal(sessions.get("normal-1").connRef.count, 2);
});
test("reuseTransport false makes simultaneous same-host opens dial independently", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "10.0.0.51",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
reuseTransport: false,
};
await Promise.all([
start({ sender: makeSender() }, { ...options, sessionId: "fresh-1" }),
start({ sender: makeSender() }, { ...options, sessionId: "fresh-2" }),
]);
assert.equal(getClientConstructCount(), 2);
assert.notEqual(sessions.get("fresh-1").conn, sessions.get("fresh-2").conn);
});
test("a sequential ordinary open with reuse disabled bypasses the live same-host transport", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "10.0.0.52",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
await start({ sender: makeSender() }, {
...options,
sessionId: "second",
reuseTransport: false,
});
assert.equal(getClientConstructCount(), 2);
assert.notEqual(sessions.get("first").conn, sessions.get("second").conn);
});
test("an ordinary open with reuse disabled bypasses an idle same-host transport", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "10.0.0.53",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
const first = sessions.get("first");
const firstTransport = first.connRef;
first.stream.emit("close");
assert.equal(firstTransport.state, "idle");
await start({ sender: makeSender() }, {
...options,
sessionId: "second",
reuseTransport: false,
});
assert.equal(getClientConstructCount(), 2);
assert.notEqual(firstTransport.conn, sessions.get("second").conn);
});
function makeSender() {
return {
id: 1,
@@ -397,34 +312,6 @@ test("Copy Tab reuses the source connection instead of dialing fresh", async (t)
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0, "successful reuse should not emit fallback");
});
test("reuseTransport false bypasses a matching live source and connects fresh", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const sourceConn = makeReusableConn();
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "fresh",
hostname: "10.0.0.1",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
sourceSessionId: "source",
reuseTransport: false,
},
);
assert.equal(getClientConstructCount(), 1);
assert.equal(sourceConn.openedShells.length, 0);
assert.notEqual(sessions.get("fresh").conn, sourceConn);
});
test("Copy Tab records a distinct remote shell for each shared terminal", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
@@ -556,13 +443,8 @@ test("concurrent Copy Tab requests serialize shell discovery per connection", as
]);
assert.equal(sessions.get("source").shellPid, "111");
// The macOS network preflight runs before either request joins the shared
// shell-open queue, so under load either copy may enqueue first. The contract
// is that both remote shells are identified once and never collide.
assert.deepEqual(
new Set([sessions.get("copy-1").shellPid, sessions.get("copy-2").shellPid]),
new Set(["222", "333"]),
);
assert.equal(sessions.get("copy-1").shellPid, "222");
assert.equal(sessions.get("copy-2").shellPid, "333");
});
test("concurrent copies keep distinct shell IDs when the source closes immediately", async (t) => {
@@ -595,10 +477,8 @@ test("concurrent copies keep distinct shell IDs when the source closes immediate
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
await copies;
assert.deepEqual(
new Set([sessions.get("copy-1").shellPid, sessions.get("copy-2").shellPid]),
new Set(["222", "333"]),
);
assert.equal(sessions.get("copy-1").shellPid, "222");
assert.equal(sessions.get("copy-2").shellPid, "333");
});
test("Copy Tab preserves the server locale unless the host explicitly overrides it", async (t) => {
@@ -711,20 +591,19 @@ test("source closed while reused shell is pending keeps the connection alive", a
terminalBridge.init({ sessions, electronModule: {} });
const start = registerStartHandler(bridge, sessions);
// Begin the copy; conn.shell() is deferred. The request pin protects the
// explicit source across preflight, and the shell-open pin protects the
// pending channel (count -> 3) before the real session is attached.
// Begin the copy; conn.shell() is deferred, so the reuse path has acquired the
// ref (count -> 2) but not yet attached the session.
const startPromise = start(
{ sender: makeSender() },
{ sessionId: "copy", hostname: "10.0.0.1", username: "alice", sourceSessionId: "source" },
);
await new Promise((r) => setImmediate(r));
assert.equal(connRef.count, 3, "request and shell-open pins protect the source connection");
assert.equal(connRef.count, 2, "reuse pins the connection before opening the shell");
// Close the source tab while the copy's shell is still opening.
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
assert.equal(conn.ended, 0, "connection must survive for the pending copy");
assert.equal(connRef.count, 2);
assert.equal(connRef.count, 1);
// Now let the copy's shell open.
conn.flushShell();
@@ -799,8 +678,6 @@ test("synchronous shell failure releases the ref and falls back to fresh", async
test("falls back to a fresh connection when the source is gone", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const otherConn = makeReusableConn();
sessions.set("other", makeSourceSession(otherConn, { hostname: "10.0.0.1", username: "alice" }));
const start = registerStartHandler(bridge, sessions);
// sourceSessionId points at a session that doesn't exist -> fresh connect.
@@ -818,7 +695,6 @@ test("falls back to a fresh connection when the source is gone", async (t) => {
},
),
);
assert.equal(otherConn.openedShells.length, 0, "must not substitute another matching transport");
assert.equal(getClientConstructCount(), 1, "should attempt one fresh connection");
assert.deepEqual(
getConnectionReuseFallbackEvents(sender).map((m) => m.payload),
+4 -29
View File
@@ -871,29 +871,9 @@ printf '%s\n' '${scanCompleteMarker}'`;
// X11. For X11 hosts we deliberately skip reuse and make a fresh
// connection so the duplicate keeps working X11 forwarding.
const reuseEndpoint = buildConnectionReuseEndpoint(options);
const allowTransportReuse = options.reuseTransport !== false;
// Copy/Split reuse is source-specific. If that source disappears or its
// channel cannot open, fall through to a fresh login instead of silently
// borrowing another live, idle, or in-flight transport for the endpoint.
const allowGeneralTransportReuse = allowTransportReuse && !options.sourceSessionId;
const sourceReuseState = options._sourceReuseState;
const canAttemptSourceReuse = Boolean(
allowTransportReuse
&& options.sourceSessionId
&& !options.x11Forwarding
&& (!sourceReuseState || sourceReuseState.attempted !== true),
);
if (canAttemptSourceReuse) {
if (sourceReuseState) sourceReuseState.attempted = true;
const sourceSession = findReusableSession(sessions, options.sourceSessionId, reuseEndpoint)
|| (sourceReuseState?.session
? findReusableSession(
new Map([[options.sourceSessionId, sourceReuseState.session]]),
options.sourceSessionId,
reuseEndpoint,
)
: null);
if (options.sourceSessionId && !options.x11Forwarding) {
const sourceSession = findReusableSession(sessions, options.sourceSessionId, reuseEndpoint);
if (sourceSession) {
try {
return await reuseShellSession(event, options, sourceSession, sessionId, log);
@@ -917,7 +897,7 @@ printf '%s\n' '${scanCompleteMarker}'`;
// Idle-park / endpoint reuse: after the last tab returns its lease the
// transport may still be warm. Open a new shell channel without re-auth.
if (allowGeneralTransportReuse && !options.x11Forwarding && typeof findTransportByEndpoint === "function") {
if (!options.x11Forwarding && typeof findTransportByEndpoint === "function") {
// Shell park reuse requires exact agentForwarding match so disabling
// ForwardAgent cannot reattach to a warm conn that still exposes the agent.
const parked = findTransportByEndpoint(reuseEndpoint, { kind: "shell" });
@@ -958,12 +938,7 @@ printf '%s\n' '${scanCompleteMarker}'`;
// for the same compatible endpoint can wait for this leader and then
// open its own channel on the authenticated transport.
let pendingDialCoordination = options._pendingDialState?.coordination || null;
if (
allowGeneralTransportReuse
&& !pendingDialCoordination
&& !options.x11Forwarding
&& typeof beginTransportDial === "function"
) {
if (!pendingDialCoordination && !options.x11Forwarding && typeof beginTransportDial === "function") {
const coordination = beginTransportDial(reuseEndpoint, { kind: "shell" });
if (coordination.role === "reuse" || coordination.role === "join") {
try {
@@ -46,19 +46,71 @@ function buildZodSchema(inputShape) {
}
function formatRpcError(result) {
return `Error: ${result?.error || "Operation failed"}`;
if (result?.error) return `Error: ${result.error}`;
if (result?.code) return `Error: Operation failed (${result.code})`;
return "Error: Operation failed";
}
/**
* True when the RPC result carries command-run evidence (output and/or exit
* code). Non-zero exits from PTY exec set ok:false without an error string —
* that is still a completed command, not an operational failure.
*/
function hasTerminalExecuteEvidence(result) {
if (!result || typeof result !== "object") return false;
if (typeof result.stdout === "string" && result.stdout.length > 0) return true;
if (typeof result.stderr === "string" && result.stderr.length > 0) return true;
return result.exitCode != null;
}
function formatTerminalExecuteResult(result) {
const parts = [];
if (result.stdout) parts.push(result.stdout);
if (result.stderr) parts.push(`[stderr] ${result.stderr}`);
if (result.exitCode != null) {
if (result?.stdout) parts.push(result.stdout);
if (result?.stderr) parts.push(`[stderr] ${result.stderr}`);
if (result?.exitCode != null) {
parts.push(`[exit code: ${result.exitCode}]`);
}
if (result?.error) parts.push(`[error] ${result.error}`);
return parts.join("\n");
}
// Align with Catty executor wording when a command ran but produced no text.
const TERMINAL_EXECUTE_NO_OUTPUT_TEXT = "Command completed (no output)";
/**
* Format terminal_execute for MCP clients.
* Match Catty semantics: when the command actually ran, always surface
* stdout/stderr/exitCode so the model can judge the failure. Only pure
* operational failures (session missing, blocked, etc.) become a bare
* "Error: …" payload. See issue #2718.
*
* Successful empty results (serial/network raw PTY often returns ok:true with
* empty stdout/stderr and exitCode:null) must NOT fall back to
* "Error: Operation failed" — Codex P2 on PR #2724.
*/
function formatTerminalExecuteMcpResponse(result) {
if (!result || typeof result !== "object") {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
}
// Operational failure with no command evidence (session not found, busy, blocked…).
if (result.ok === false && !hasTerminalExecuteEvidence(result)) {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
}
// Empty successful payloads (serial/raw PTY): neutral text.
// Never fall back to formatRpcError here — that mislabels silent successes (#2724 P2).
// When result.error is set, formatTerminalExecuteResult already includes `[error] …`.
const text = formatTerminalExecuteResult(result) || TERMINAL_EXECUTE_NO_OUTPUT_TEXT;
// Mark isError only for operational/runtime failures that include an error
// string (timeout, cancel, stream lost). Non-zero exit alone is success of
// the tool call with a failed command — same as Catty ok:true + exitCode.
if (result.ok === false && result.error) {
return { content: [{ type: "text", text }], isError: true };
}
return { content: [{ type: "text", text }] };
}
function createToolHandler(toolDef, deps) {
const {
rpcCall,
@@ -76,10 +128,10 @@ function createToolHandler(toolDef, deps) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall(rpcMethod, { ...scopeParams, sessionId, command });
if (!result.ok) {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
}
if (TERMINAL_START_TOOLS.has(mcpTool)) {
if (!result?.ok) {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
}
return {
content: [{
type: "text",
@@ -94,7 +146,7 @@ function createToolHandler(toolDef, deps) {
}],
};
}
return { content: [{ type: "text", text: formatTerminalExecuteResult(result) }] };
return formatTerminalExecuteMcpResponse(result);
}
if (TERMINAL_POLL_TOOLS.has(mcpTool) || TERMINAL_STOP_TOOLS.has(mcpTool)) {
@@ -188,6 +240,10 @@ function registerMcpTools(server, deps) {
module.exports = {
buildZodSchema,
buildZodShapeObject,
formatRpcError,
formatTerminalExecuteResult,
formatTerminalExecuteMcpResponse,
hasTerminalExecuteEvidence,
registerMcpTools,
SFTP_WRITE_TOOLS,
};
@@ -235,3 +235,129 @@ test("session_close remains available as a cleanup action in observer mode", asy
assert.equal(result.isError, undefined);
assert.equal(guardCalls, 0);
});
test("terminal_execute MCP response preserves stdout/exitCode on non-zero exit (#2718)", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, _shape, candidate) {
if (name === "terminal_execute") handler = candidate;
},
};
registerMcpTools(fakeServer, {
rpcCall: async () => ({
ok: false,
stdout: "du: cannot access '/missing': No such file or directory",
stderr: "",
exitCode: 1,
}),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
assert.ok(handler, "terminal_execute handler registered");
const result = await handler({ sessionId: "sess-1", command: "du /missing" });
assert.equal(result.isError, undefined);
const text = result.content?.[0]?.text || "";
assert.match(text, /cannot access '\/missing'/);
assert.match(text, /\[exit code: 1\]/);
assert.doesNotMatch(text, /Operation failed/);
});
test("terminal_execute MCP response keeps operational failures as isError", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, _shape, candidate) {
if (name === "terminal_execute") handler = candidate;
},
};
registerMcpTools(fakeServer, {
rpcCall: async () => ({ ok: false, error: "Session not found" }),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
const result = await handler({ sessionId: "gone", command: "uptime" });
assert.equal(result.isError, true);
assert.equal(result.content?.[0]?.text, "Error: Session not found");
});
test("terminal_execute MCP response includes partial output on timeout", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, _shape, candidate) {
if (name === "terminal_execute") handler = candidate;
},
};
registerMcpTools(fakeServer, {
rpcCall: async () => ({
ok: false,
stdout: "partial lines",
stderr: "",
exitCode: -1,
error: "Command timed out (60s)",
}),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
const result = await handler({ sessionId: "sess-1", command: "sleep 999" });
assert.equal(result.isError, true);
const text = result.content?.[0]?.text || "";
assert.match(text, /partial lines/);
assert.match(text, /\[exit code: -1\]/);
assert.match(text, /\[error\] Command timed out \(60s\)/);
});
test("terminal_execute MCP response uses neutral text for successful empty output (#2724)", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, _shape, candidate) {
if (name === "terminal_execute") handler = candidate;
},
};
// Serial/network-device raw PTY success: ok true, empty streams, exitCode null.
registerMcpTools(fakeServer, {
rpcCall: async () => ({
ok: true,
stdout: "",
stderr: "",
exitCode: null,
}),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
const result = await handler({ sessionId: "sess-1", command: "configure terminal" });
assert.equal(result.isError, undefined);
assert.equal(result.content?.[0]?.text, "Command completed (no output)");
assert.doesNotMatch(result.content?.[0]?.text || "", /Operation failed/);
});
test("terminal_execute MCP response keeps exit-only non-zero without isError", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, _shape, candidate) {
if (name === "terminal_execute") handler = candidate;
},
};
registerMcpTools(fakeServer, {
rpcCall: async () => ({
ok: false,
stdout: "",
stderr: "",
exitCode: 1,
}),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
const result = await handler({ sessionId: "sess-1", command: "false" });
assert.equal(result.isError, undefined);
assert.equal(result.content?.[0]?.text, "[exit code: 1]");
assert.doesNotMatch(result.content?.[0]?.text || "", /Operation failed/);
});
-2
View File
@@ -188,9 +188,7 @@ function createBridgeRegistrar(context) {
terminalProviderService: pluginHostService?.terminalProviderService,
terminalDataPipelineService: pluginHostService?.terminalDataPipelineService,
extensionProviderService: pluginHostService?.extensionProviderService,
syncSidecarService: pluginHostService?.syncSidecarService,
credentialResolver: pluginHostService?.credentialResolver,
secretStore: pluginHostService?.secretStore,
getTerminalWorkerManager: () => terminalWorkerManager,
selectImporterFile: async (event) => {
const owner = electronModule.BrowserWindow.fromWebContents(event.sender);
-8
View File
@@ -452,14 +452,6 @@ class PluginContributionService {
const normalizedScopeId = normalizeScopeId(setting.scope, scopeId);
if (setting.secret) this.secretStore.delete(pluginId, secretSettingKey(setting.id, setting.scope, normalizedScopeId));
else this.database.deleteSetting(pluginId, setting.id, setting.scope, normalizedScopeId);
// Clear the matching cloud-sync sidecar so collect does not rehydrate the
// retained remote value after an intentional local reset.
if (typeof this.database.deleteSyncSidecar === "function" && !setting.secret) {
const sidecarKey = `${setting.id}\0${setting.scope}\0${normalizedScopeId}`;
try {
this.database.deleteSyncSidecar(pluginId, "settings", sidecarKey);
} catch {}
}
this.#emitChange("setting-reset", pluginId);
try {
await this.runtimeSupervisor.notify(pluginId, "plugin.settings.changed", {
+4 -150
View File
@@ -5,7 +5,7 @@ const fs = require("node:fs");
const path = require("node:path");
const { DatabaseSync } = require("node:sqlite");
const SCHEMA_VERSION = 2;
const SCHEMA_VERSION = 1;
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
@@ -19,8 +19,6 @@ const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
plugin_permission_grants: ["plugin_id", "permission", "resource", "resource_kind", "declaration_hash", "granted_at"],
plugin_secrets: ["plugin_id", "key", "secret_ref", "ciphertext", "created_at", "updated_at"],
plugin_security_audit: ["id", "plugin_id", "event", "details_json", "created_at"],
// User-owned encrypted-sync sidecars: no FK cascade on package uninstall.
plugin_sync_sidecars: ["plugin_id", "kind", "key", "value_json", "updated_at"],
});
function parseJson(text, label) {
@@ -154,35 +152,7 @@ class PluginDatabase {
);
CREATE INDEX plugin_security_audit_lookup
ON plugin_security_audit(plugin_id, created_at DESC);
CREATE TABLE plugin_sync_sidecars (
plugin_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('settings', 'account_baseline', 'crdt_baseline')),
key TEXT NOT NULL,
value_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, kind, key)
);
CREATE INDEX plugin_sync_sidecars_lookup
ON plugin_sync_sidecars(plugin_id, kind, key);
PRAGMA user_version = 2;
`);
});
} else if (version === 1) {
// Pre-sidecar schema-1 databases only created the original tables.
// Migrate in place to schema 2 with the non-cascade sidecar table.
this.transaction(() => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS plugin_sync_sidecars (
plugin_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('settings', 'account_baseline', 'crdt_baseline')),
key TEXT NOT NULL,
value_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, kind, key)
);
CREATE INDEX IF NOT EXISTS plugin_sync_sidecars_lookup
ON plugin_sync_sidecars(plugin_id, kind, key);
PRAGMA user_version = 2;
PRAGMA user_version = 1;
`);
});
}
@@ -485,17 +455,16 @@ class PluginDatabase {
return row ? parseJson(row.value_json, "setting value") : undefined;
}
setSetting(pluginId, settingId, scope, scopeId, value, updatedAt = this.clock()) {
setSetting(pluginId, settingId, scope, scopeId, value) {
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new TypeError("Plugin setting value must be JSON serializable");
const stamp = Number.isFinite(Number(updatedAt)) ? Number(updatedAt) : this.clock();
this.db.prepare(`
INSERT INTO plugin_settings(plugin_id, setting_id, scope, scope_id, value_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(plugin_id, setting_id, scope, scope_id) DO UPDATE SET
value_json = excluded.value_json,
updated_at = excluded.updated_at
`).run(pluginId, settingId, scope, scopeId, serialized, stamp);
`).run(pluginId, settingId, scope, scopeId, serialized, this.clock());
}
deleteSetting(pluginId, settingId, scope, scopeId) {
@@ -519,107 +488,6 @@ class PluginDatabase {
}));
}
listAllSettings() {
return this.db.prepare(`
SELECT plugin_id, setting_id, scope, scope_id, value_json, updated_at
FROM plugin_settings
ORDER BY plugin_id COLLATE BINARY, setting_id COLLATE BINARY, scope COLLATE BINARY, scope_id COLLATE BINARY
`).all().map((row) => ({
pluginId: row.plugin_id,
settingId: row.setting_id,
scope: row.scope,
scopeId: row.scope_id,
value: parseJson(row.value_json, "setting value"),
updatedAt: Number(row.updated_at),
}));
}
getSyncSidecar(pluginId, kind, key) {
const row = this.db.prepare(`
SELECT value_json, updated_at FROM plugin_sync_sidecars
WHERE plugin_id = ? AND kind = ? AND key = ?
`).get(pluginId, kind, key);
if (!row) return undefined;
return {
pluginId,
kind,
key,
value: parseJson(row.value_json, "sync sidecar value"),
updatedAt: Number(row.updated_at),
};
}
setSyncSidecar(pluginId, kind, key, value, updatedAt = this.clock()) {
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new TypeError("Plugin sync sidecar value must be JSON serializable");
this.db.prepare(`
INSERT INTO plugin_sync_sidecars(plugin_id, kind, key, value_json, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(plugin_id, kind, key) DO UPDATE SET
value_json = excluded.value_json,
updated_at = excluded.updated_at
`).run(pluginId, kind, key, serialized, updatedAt);
}
deleteSyncSidecar(pluginId, kind, key) {
this.db.prepare(`
DELETE FROM plugin_sync_sidecars
WHERE plugin_id = ? AND kind = ? AND key = ?
`).run(pluginId, kind, key);
}
listSyncSidecars(pluginId) {
return this.db.prepare(`
SELECT plugin_id, kind, key, value_json, updated_at
FROM plugin_sync_sidecars
WHERE plugin_id = ?
ORDER BY kind COLLATE BINARY, key COLLATE BINARY
`).all(pluginId).map((row) => ({
pluginId: row.plugin_id,
kind: row.kind,
key: row.key,
value: parseJson(row.value_json, "sync sidecar value"),
updatedAt: Number(row.updated_at),
}));
}
listAllSyncSidecars() {
return this.db.prepare(`
SELECT plugin_id, kind, key, value_json, updated_at
FROM plugin_sync_sidecars
ORDER BY plugin_id COLLATE BINARY, kind COLLATE BINARY, key COLLATE BINARY
`).all().map((row) => ({
pluginId: row.plugin_id,
kind: row.kind,
key: row.key,
value: parseJson(row.value_json, "sync sidecar value"),
updatedAt: Number(row.updated_at),
}));
}
replaceAllSyncSidecars(entries) {
this.transaction(() => {
this.db.prepare("DELETE FROM plugin_sync_sidecars").run();
const insert = this.db.prepare(`
INSERT INTO plugin_sync_sidecars(plugin_id, kind, key, value_json, updated_at)
VALUES (?, ?, ?, ?, ?)
`);
for (const entry of entries) {
const serialized = JSON.stringify(entry.value);
if (serialized === undefined) {
throw new TypeError("Plugin sync sidecar value must be JSON serializable");
}
insert.run(
entry.pluginId,
entry.kind,
entry.key,
serialized,
Number(entry.updatedAt) || this.clock(),
);
}
});
}
getViewState(pluginId, viewId, scopeId) {
const row = this.db.prepare(`
SELECT state_json FROM plugin_view_state
@@ -745,20 +613,6 @@ class PluginDatabase {
.run(pluginId, key);
}
/** Delete all secrets whose key equals prefix or starts with `${prefix}:`. */
deleteSecretsByKeyPrefix(pluginId, prefix) {
const result = this.db.prepare(`
DELETE FROM plugin_secrets
WHERE plugin_id = ?
AND (key = ? OR key LIKE ? ESCAPE '\\')
`).run(
pluginId,
prefix,
`${String(prefix).replace(/[%_\\]/g, (ch) => `\\${ch}`)}:%`,
);
return Number(result?.changes) || 0;
}
recordSecurityAudit(pluginId, event, details) {
let detailsJson = JSON.stringify(details ?? {});
const originalBytes = Buffer.byteLength(detailsJson);
-120
View File
@@ -59,121 +59,6 @@ test("obsolete unpublished v1 layouts fail with an explicit reset instruction",
);
});
test("complete schema-1 databases migrate in place to schema 2 with sidecar table", (context) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-v1-migrate-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
const file = path.join(root, "plugins.sqlite");
const v1 = new DatabaseSync(file);
// Full pre-sidecar schema-1 layout (all tables except plugin_sync_sidecars).
v1.exec(`
PRAGMA foreign_keys = ON;
CREATE TABLE plugins (
id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
active_version TEXT,
installed_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE plugin_versions (
plugin_id TEXT NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
version TEXT NOT NULL,
manifest_json TEXT NOT NULL,
archive_sha256 TEXT NOT NULL,
package_relative_path TEXT NOT NULL,
installed_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, version)
);
CREATE TABLE plugin_runtime_state (
plugin_id TEXT NOT NULL,
plugin_version TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'stopped',
runtime_kind TEXT,
last_error TEXT,
quarantined_at INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, plugin_version),
FOREIGN KEY (plugin_id, plugin_version)
REFERENCES plugin_versions(plugin_id, version) ON DELETE CASCADE
);
CREATE TABLE plugin_crashes (
plugin_id TEXT NOT NULL,
plugin_version TEXT NOT NULL,
crashed_at INTEGER NOT NULL,
FOREIGN KEY (plugin_id, plugin_version)
REFERENCES plugin_versions(plugin_id, version) ON DELETE CASCADE
);
CREATE TABLE plugin_kv (
plugin_id TEXT NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, key)
);
CREATE TABLE plugin_settings (
plugin_id TEXT NOT NULL,
setting_id TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('application', 'workspace', 'host', 'session', 'device')),
scope_id TEXT NOT NULL,
value_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, setting_id, scope, scope_id)
);
CREATE TABLE plugin_view_state (
plugin_id TEXT NOT NULL,
view_id TEXT NOT NULL,
scope_id TEXT NOT NULL,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, view_id, scope_id)
);
CREATE TABLE plugin_permission_grants (
plugin_id TEXT NOT NULL,
permission TEXT NOT NULL,
resource TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('exact', 'directory')),
declaration_hash TEXT NOT NULL,
granted_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, permission, resource)
);
CREATE TABLE plugin_secrets (
plugin_id TEXT NOT NULL,
key TEXT NOT NULL,
secret_ref TEXT NOT NULL UNIQUE,
ciphertext BLOB NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, key)
);
CREATE TABLE plugin_security_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
event TEXT NOT NULL,
details_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);
PRAGMA user_version = 1;
`);
v1.prepare(
"INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at) VALUES (?, 1, ?, 1, 1)",
).run("com.example.v1", "1.0.0");
v1.close();
const database = new PluginDatabase(file);
assert.equal(database.db.prepare("PRAGMA user_version").get().user_version, SCHEMA_VERSION);
assert.deepEqual(
database.db.prepare("PRAGMA table_info(plugin_sync_sidecars)").all().map(({ name }) => name),
["plugin_id", "kind", "key", "value_json", "updated_at"],
);
// Existing rows survive the in-place migration.
assert.equal(database.db.prepare("SELECT id FROM plugins").get().id, "com.example.v1");
database.setSyncSidecar("com.example.v1", "settings", "theme\0application\0application", "dark", 2);
assert.equal(
database.getSyncSidecar("com.example.v1", "settings", "theme\0application\0application")?.value,
"dark",
);
database.close();
});
test("initial schema scopes runtime and crash state to immutable plugin versions", (context) => {
const database = createDatabase(context);
assert.deepEqual(
@@ -208,13 +93,8 @@ test("initial schema scopes runtime and crash state to immutable plugin versions
database.db.prepare("PRAGMA table_info(plugin_view_state)").all().map(({ name }) => name),
["plugin_id", "view_id", "scope_id", "state_json", "updated_at"],
);
assert.deepEqual(
database.db.prepare("PRAGMA table_info(plugin_sync_sidecars)").all().map(({ name }) => name),
["plugin_id", "kind", "key", "value_json", "updated_at"],
);
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_settings)").all(), []);
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_view_state)").all(), []);
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_sync_sidecars)").all(), []);
database.close();
});
+2 -443
View File
@@ -14,18 +14,16 @@ const { PluginRpcError, RPC_ERRORS, raceWithAbort } = require("./rpcRouter.cjs")
const { compileRestrictedJsonSchema } = require("./restrictedJsonSchema.cjs");
const pluginContractSchema = require("./generated/plugin-contract.schema.json");
const EXTENSION_PROVIDER_KINDS = Object.freeze(["connection", "authentication", "importer", "sync"]);
const EXTENSION_PROVIDER_KINDS = Object.freeze(["connection", "authentication", "importer"]);
const PROVIDER_PERMISSIONS = Object.freeze({
connection: "provider.connection",
authentication: "provider.authentication",
importer: "provider.importer",
sync: "provider.sync",
});
const OPERATIONS = Object.freeze({
connection: new Set(["validateConfiguration", "probe", "open", "resize", "signal", "reconnect", "close", "getStatus"]),
authentication: new Set(["begin", "respond", "cancel"]),
importer: new Set(["detect", "parse"]),
sync: new Set(["connect", "disconnect", "getAccount", "getCapabilities", "readObject", "writeObject", "deleteObject"]),
});
const MAX_PROVIDER_JSON_BYTES = 128 * 1024;
const DEFAULT_DEADLINE_MS = 30_000;
@@ -38,36 +36,6 @@ const MAX_IMPORT_BYTES = importerLimits.maxInputBytes;
const MAX_IMPORT_OUTPUT_BYTES = importerLimits.maxOutputBytes;
const MAX_IMPORT_RECORDS = importerLimits.maxRecords;
const MAX_IMPORT_RECORD_BYTES = importerLimits.maxRecordBytes;
const SYNC_SECRET_CONFIG_KEYS = new Set(["password", "token", "secret", "apiKey", "accessToken"]);
/** Drop known secret / writeOnly field names from required[] so stripped sync configs still validate. */
function schemaWithoutSyncSecretRequirements(schema) {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
if (!Array.isArray(schema.required)) return schema;
const writeOnlyNames = new Set();
if (schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties)) {
for (const [name, child] of Object.entries(schema.properties)) {
if (child && typeof child === "object" && !Array.isArray(child) && child.writeOnly === true) {
writeOnlyNames.add(name);
}
}
}
const required = schema.required.filter(
(name) => !SYNC_SECRET_CONFIG_KEYS.has(name) && !writeOnlyNames.has(name),
);
if (required.length === schema.required.length) return schema;
return { ...schema, required };
}
const syncLimits = pluginContractSchema.$defs.SyncLimits.const;
const MAX_SYNC_OBJECT_BYTES = syncLimits.maxObjectBytes;
const MAX_SYNC_OBJECT_KEY_LENGTH = syncLimits.maxObjectKeyLength;
const MAX_SYNC_REVISION_LENGTH = syncLimits.maxRevisionLength;
const INLINE_SYNC_OBJECT_BYTES = syncLimits.inlineObjectBytes;
// inlineObjectBytes is sized to fit under the 128 KiB provider JSON budget after
// base64 expansion (~4/3) with envelope headroom. Keep the runtime cutoff equal
// to the public SyncLimits constant so plugins that honor the schema are not
// rejected with dataLoss in the 90–96 KiB window.
const INLINE_SYNC_OBJECT_SAFE_BYTES = INLINE_SYNC_OBJECT_BYTES;
const definitionValidators = Object.freeze({
AuthenticationResult: createDefinitionValidator("AuthenticationResult"),
ConnectionOpenResult: createDefinitionValidator("ConnectionOpenResult"),
@@ -78,13 +46,6 @@ const definitionValidators = Object.freeze({
ImporterDetectResult: createDefinitionValidator("ImporterDetectResult"),
ImporterParseResult: createDefinitionValidator("ImporterParseResult"),
ImporterRecord: createDefinitionValidator("ImporterRecord"),
SyncConnectResult: createDefinitionValidator("SyncConnectResult"),
SyncDisconnectResult: createDefinitionValidator("SyncDisconnectResult"),
SyncGetAccountResult: createDefinitionValidator("SyncGetAccountResult"),
SyncCapabilitiesResult: createDefinitionValidator("SyncCapabilitiesResult"),
SyncReadObjectResult: createDefinitionValidator("SyncReadObjectResult"),
SyncWriteObjectResult: createDefinitionValidator("SyncWriteObjectResult"),
SyncDeleteObjectResult: createDefinitionValidator("SyncDeleteObjectResult"),
});
function invalidArgument(message) {
@@ -351,96 +312,6 @@ function normalizeImportRecord(value) {
return freezeJson(value);
}
function assertSyncObjectKey(value) {
return assertString(value, "Sync object key", MAX_SYNC_OBJECT_KEY_LENGTH);
}
function assertSyncRevision(value, label = "Sync object revision") {
if (value == null) return value;
return assertString(value, label, MAX_SYNC_REVISION_LENGTH);
}
function assertSyncResult(operation, value) {
if (operation === "disconnect") {
assertDefinition("SyncDisconnectResult", value, "Sync disconnect result");
if (value !== null) throw new TypeError("Sync disconnect result must be null");
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`Sync ${operation} result must be an object`);
}
if (operation === "connect") {
assertDefinition("SyncConnectResult", value, "Sync connect result");
assertString(value.account?.id, "Sync account ID", 512);
} else if (operation === "getAccount") {
assertDefinition("SyncGetAccountResult", value, "Sync getAccount result");
if (value.account != null) assertString(value.account.id, "Sync account ID", 512);
} else if (operation === "getCapabilities") {
assertDefinition("SyncCapabilitiesResult", value, "Sync capabilities result");
if (typeof value.revisions !== "boolean"
|| typeof value.conditionalWrites !== "boolean"
|| typeof value.atomicReplacement !== "boolean") {
throw new TypeError("Sync capabilities result is invalid");
}
if (value.maxObjectBytes != null
&& (!Number.isSafeInteger(value.maxObjectBytes)
|| value.maxObjectBytes < 1
|| value.maxObjectBytes > MAX_SYNC_OBJECT_BYTES)) {
throw new TypeError("Sync maxObjectBytes is invalid");
}
} else if (operation === "readObject") {
assertDefinition("SyncReadObjectResult", value, "Sync readObject result");
if (value.found === true) {
if (!Number.isSafeInteger(value.byteLength) || value.byteLength < 0 || value.byteLength > MAX_SYNC_OBJECT_BYTES) {
throw new TypeError("Sync readObject byteLength is invalid");
}
if (value.streamed === true) {
// Host streams the ciphertext separately.
} else if (value.encoding === "base64") {
if (typeof value.data !== "string") throw new TypeError("Sync readObject data is invalid");
const decoded = Buffer.byteLength(value.data, "base64");
// Use the safe inline cutoff (base64 + envelope must fit control-plane JSON).
if (value.byteLength > INLINE_SYNC_OBJECT_SAFE_BYTES) {
throw new TypeError("Sync readObject inline payload exceeds the inline limit");
}
if (decoded < value.byteLength) {
throw new TypeError("Sync readObject data is shorter than byteLength");
}
} else {
throw new TypeError("Sync readObject result encoding is invalid");
}
if (value.revision !== undefined) assertSyncRevision(value.revision);
} else if (value.found !== false) {
throw new TypeError("Sync readObject found flag is invalid");
}
} else if (operation === "writeObject") {
assertDefinition("SyncWriteObjectResult", value, "Sync writeObject result");
if (typeof value.created !== "boolean") throw new TypeError("Sync writeObject created flag is invalid");
if (value.revision !== undefined) assertSyncRevision(value.revision);
} else if (operation === "deleteObject") {
assertDefinition("SyncDeleteObjectResult", value, "Sync deleteObject result");
if (typeof value.deleted !== "boolean") throw new TypeError("Sync deleteObject deleted flag is invalid");
} else {
throw new TypeError(`Unsupported sync operation result: ${operation}`);
}
assertBoundedJson(value, `Sync ${operation} result`);
return freezeJson(value);
}
function decodeBase64Bytes(data, expectedLength, label) {
if (typeof data !== "string") throw invalidArgument(`${label} data is invalid`);
let bytes;
try {
bytes = Buffer.from(data, "base64");
} catch {
throw invalidArgument(`${label} data is not valid base64`);
}
if (bytes.byteLength !== expectedLength) {
throw invalidArgument(`${label} byteLength does not match decoded data`);
}
return bytes;
}
class PluginExtensionProviderService {
constructor(options) {
if (!options?.contributionService || !options?.permissionEngine || !options?.runtimeSupervisor
@@ -511,10 +382,7 @@ class PluginExtensionProviderService {
const configuration = request.payload?.configuration;
if (activation.provider.configurationSchema !== undefined && configuration !== undefined) {
try {
const schema = kind === "sync"
? schemaWithoutSyncSecretRequirements(activation.provider.configurationSchema)
: activation.provider.configurationSchema;
compileRestrictedJsonSchema(schema)(configuration);
compileRestrictedJsonSchema(activation.provider.configurationSchema)(configuration);
} catch (error) {
throw invalidArgument(`Provider configuration failed host schema validation: ${error?.message ?? error}`);
}
@@ -532,7 +400,6 @@ class PluginExtensionProviderService {
if (kind === "connection") return assertConnectionResult(operation, result);
if (kind === "authentication") return assertAuthenticationResult(result);
if (kind === "importer" && operation === "detect") return assertImporterDetectResult(result);
if (kind === "sync") return assertSyncResult(operation, result);
return freezeJson(assertBoundedJson(result, "Extension Provider result"));
} catch (error) {
throw new PluginRpcError(RPC_ERRORS.dataLoss, `Extension Provider result failed validation: ${error?.message ?? error}`);
@@ -1037,310 +904,6 @@ class PluginExtensionProviderService {
}
}
async connectSync(params, options = {}) {
const providerId = assertString(params?.providerId, "Sync Provider ID");
const operationId = `sync:connect:${randomUUID()}`;
const activation = options.activation ?? await this.activate(providerId, "sync", options.signal);
try {
return await this.invoke({
providerId,
kind: "sync",
operation: "connect",
payload: {
operationId,
configuration: freezeJson(assertBoundedJson(
params?.configuration === undefined ? {} : params.configuration,
"Sync configuration",
)),
...(params.credential === undefined ? {} : { credential: params.credential }),
},
deadlineMs: params?.deadlineMs,
}, { ...options, activation });
} finally {
this.leaseStore.revokeOperation(activation.identity.pluginId, operationId);
}
}
async disconnectSync(params, options = {}) {
return this.invoke({
providerId: assertString(params?.providerId, "Sync Provider ID"),
kind: "sync",
operation: "disconnect",
payload: {
operationId: `sync:disconnect:${randomUUID()}`,
},
deadlineMs: params?.deadlineMs,
}, options);
}
async getSyncAccount(params, options = {}) {
return this.invoke({
providerId: assertString(params?.providerId, "Sync Provider ID"),
kind: "sync",
operation: "getAccount",
payload: {
operationId: `sync:getAccount:${randomUUID()}`,
},
deadlineMs: params?.deadlineMs,
}, options);
}
async getSyncCapabilities(params, options = {}) {
return this.invoke({
providerId: assertString(params?.providerId, "Sync Provider ID"),
kind: "sync",
operation: "getCapabilities",
payload: {
operationId: `sync:getCapabilities:${randomUUID()}`,
},
deadlineMs: params?.deadlineMs,
}, options);
}
async readSyncObject(params, options = {}) {
const providerId = assertString(params?.providerId, "Sync Provider ID");
const key = assertSyncObjectKey(params?.key);
const deadlineMs = normalizeDeadlineMs(params?.deadlineMs);
const deadlineAt = performance.now() + deadlineMs;
const activation = options.activation ?? await this.activate(providerId, "sync", options.signal);
const operationId = `sync:read:${randomUUID()}`;
// Always offer an output stream so large encrypted objects can leave the
// JSON control plane; small objects may still return inline base64.
const outputStreamId = `${operationId}:output`;
const chunks = [];
let totalBytes = 0;
let resolveOutputDone;
let rejectOutputDone;
let outputSettled = false;
const outputDone = new Promise((resolve, reject) => {
resolveOutputDone = (value) => {
if (outputSettled) return;
outputSettled = true;
resolve(value);
};
rejectOutputDone = (error) => {
if (outputSettled) return;
outputSettled = true;
reject(error);
};
});
void outputDone.catch(() => {});
let acceptedStream = null;
const expected = this.expectIncoming(activation.identity, outputStreamId, async (stream) => {
acceptedStream = stream;
stream.bind({
onChunk: (chunk, release) => {
try {
if (chunk.encoding !== "binary") throw new Error("Sync object stream must be binary");
totalBytes += chunk.bytes.byteLength;
if (totalBytes > MAX_SYNC_OBJECT_BYTES) {
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Sync object exceeds its size limit");
}
chunks.push(Buffer.from(chunk.bytes));
release();
} catch (error) {
rejectOutputDone(error);
throw error;
}
},
onClose: (reason) => {
try {
if (reason !== "end") {
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Sync object stream did not end normally");
}
resolveOutputDone(Buffer.concat(chunks));
} catch (error) {
rejectOutputDone(error);
}
},
});
return stream;
}, options.signal, deadlineMs);
const request = this.invoke({
providerId,
kind: "sync",
operation: "readObject",
payload: {
key,
operationId,
outputStreamId,
windowBytes: STREAM_WINDOW_BYTES,
},
deadlineMs,
}, { ...options, activation });
const cancelAcceptedStream = (error) => {
try {
if (acceptedStream && typeof acceptedStream.cancel === "function") {
acceptedStream.cancel(error);
}
} catch {
// Best-effort containment after timeout/failure.
}
};
try {
const result = await waitUntilDeadline(
request,
deadlineAt,
options.signal,
"Sync readObject exceeded its deadline",
);
if (result.found !== true) {
// Swallow the cancelled expectation; inline/not-found paths intentionally
// do not consume the pre-opened output stream.
void expected.promise.catch(() => {});
expected.cancel(new PluginRpcError(RPC_ERRORS.cancelled, "Sync object not found"));
cancelAcceptedStream(new PluginRpcError(RPC_ERRORS.cancelled, "Sync object not found"));
return Object.freeze({ found: false, key, bytes: null, revision: undefined });
}
if (result.streamed === true) {
await waitUntilDeadline(
waitForStreamOrRequestFailure(expected.promise, request),
deadlineAt,
options.signal,
"Sync readObject stream exceeded its deadline",
);
const bytes = await waitUntilDeadline(
outputDone,
deadlineAt,
options.signal,
"Sync readObject stream exceeded its deadline",
);
if (bytes.byteLength !== result.byteLength) {
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Sync readObject stream size does not match byteLength");
}
// Bytes are mutable buffers; freeze only the metadata envelope.
return Object.freeze({
found: true,
key,
bytes: new Uint8Array(bytes),
revision: result.revision,
contentType: result.contentType,
});
}
// Inline base64 path — cancel unused stream expectation.
void expected.promise.catch(() => {});
expected.cancel(new PluginRpcError(RPC_ERRORS.cancelled, "Sync object returned inline"));
cancelAcceptedStream(new PluginRpcError(RPC_ERRORS.cancelled, "Sync object returned inline"));
const bytes = decodeBase64Bytes(result.data, result.byteLength, "Sync readObject");
return Object.freeze({
found: true,
key,
bytes: new Uint8Array(bytes),
revision: result.revision,
contentType: result.contentType,
});
} catch (error) {
void expected?.promise?.catch?.(() => {});
expected?.cancel?.(error);
cancelAcceptedStream(error);
throw error;
}
}
async writeSyncObject(params, options = {}) {
const providerId = assertString(params?.providerId, "Sync Provider ID");
const key = assertSyncObjectKey(params?.key);
const rawBytes = params?.bytes instanceof Uint8Array
? params.bytes
: params?.bytes == null
? null
: new Uint8Array(params.bytes);
if (!rawBytes) throw invalidArgument("Sync writeObject bytes are required");
if (rawBytes.byteLength > MAX_SYNC_OBJECT_BYTES) {
throw invalidArgument("Sync writeObject payload exceeds the size limit");
}
const deadlineMs = normalizeDeadlineMs(params?.deadlineMs);
const deadlineAt = performance.now() + deadlineMs;
const activation = options.activation ?? await this.activate(providerId, "sync", options.signal);
const operationId = `sync:write:${randomUUID()}`;
const useStream = rawBytes.byteLength > INLINE_SYNC_OBJECT_SAFE_BYTES || params?.preferStream === true;
const inputStreamId = useStream ? `${operationId}:input` : undefined;
const expectedRevision = params?.expectedRevision === undefined
? undefined
: params.expectedRevision === null
? null
: assertSyncRevision(params.expectedRevision, "Sync expectedRevision");
const request = this.invoke({
providerId,
kind: "sync",
operation: "writeObject",
payload: {
key,
operationId,
byteLength: rawBytes.byteLength,
...(expectedRevision === undefined ? {} : { expectedRevision }),
...(useStream
? { inputStreamId, windowBytes: STREAM_WINDOW_BYTES }
: {
encoding: "base64",
data: Buffer.from(rawBytes).toString("base64"),
}),
},
deadlineMs,
}, { ...options, activation });
let input;
try {
if (useStream) {
input = await waitUntilDeadline(
waitForStreamOrRequestFailure(
this.runtimeSupervisor.openStream(activation.activation.plugin.id, inputStreamId, STREAM_WINDOW_BYTES, {
expectedIdentity: activation.identity,
}),
request,
),
deadlineAt,
options.signal,
"Sync writeObject input stream exceeded its deadline",
);
for (let offset = 0; offset < rawBytes.byteLength; offset += STREAM_WINDOW_BYTES) {
await waitUntilDeadline(
input.write(rawBytes.subarray(offset, Math.min(rawBytes.byteLength, offset + STREAM_WINDOW_BYTES))),
deadlineAt,
options.signal,
"Sync writeObject input stream exceeded its deadline",
);
}
await waitUntilDeadline(
input.end(),
deadlineAt,
options.signal,
"Sync writeObject input stream exceeded its deadline",
);
}
return await waitUntilDeadline(
request,
deadlineAt,
options.signal,
"Sync writeObject exceeded its deadline",
);
} catch (error) {
try { input?.cancel?.(); } catch {}
throw error;
}
}
async deleteSyncObject(params, options = {}) {
return this.invoke({
providerId: assertString(params?.providerId, "Sync Provider ID"),
kind: "sync",
operation: "deleteObject",
payload: {
key: assertSyncObjectKey(params?.key),
operationId: `sync:delete:${randomUUID()}`,
...(params?.expectedRevision === undefined
? {}
: { expectedRevision: assertSyncRevision(params.expectedRevision, "Sync expectedRevision") }),
},
deadlineMs: params?.deadlineMs,
}, options);
}
shutdown() {
this.streamRegistration?.dispose();
this.runtimeRegistration?.dispose();
@@ -1354,13 +917,9 @@ class PluginExtensionProviderService {
module.exports = {
DEFAULT_DEADLINE_MS,
EXTENSION_PROVIDER_KINDS,
INLINE_SYNC_OBJECT_BYTES,
INLINE_SYNC_OBJECT_SAFE_BYTES,
MAX_IMPORT_BYTES,
MAX_IMPORT_RECORDS,
MAX_SYNC_OBJECT_BYTES,
PluginExtensionProviderService,
STREAM_WINDOW_BYTES,
assertSyncResult,
normalizeImportRecord,
};
@@ -26,13 +26,6 @@ function fixture(options = {}) {
},
{ id: "com.example.transport.auth", kind: "authentication" },
{ id: "com.example.transport.importer", kind: "importer" },
{
id: "com.example.transport.sync",
kind: "sync",
...(options.syncConfigurationSchema === undefined
? {}
: { configurationSchema: options.syncConfigurationSchema }),
},
];
const contributionService = {
listProviders({ kind }) {
@@ -60,13 +53,7 @@ function fixture(options = {}) {
};
const permissions = [];
const permissionEngine = {
async authorize(context, descriptor) {
if (typeof options.authorize === "function") {
return options.authorize(context, descriptor, permissions);
}
permissions.push({ context, descriptor });
return { scope: "application" };
},
async authorize(context, descriptor) { permissions.push({ context, descriptor }); return { scope: "application" }; },
};
const streamHandlers = [];
const rpcRegistry = {
@@ -933,152 +920,3 @@ test("import parsing enforces its deadline while the selected input source stall
assert.equal(sourceReturned, true);
assert.equal(h.revokedOperations.length, 1);
});
test("sync providers require provider.sync and only exchange encrypted object bytes", async () => {
const cipher = Buffer.from([0x9b, 0x01, 0x02, 0x03]);
let writePayload;
const h = fixture({
async request({ params }) {
assert.equal(params.kind, "sync");
if (params.operation === "connect") {
return {
requestId: params.requestId,
status: "ok",
result: { account: { id: "acct-1", name: "Plugin Sync" } },
};
}
if (params.operation === "getCapabilities") {
return {
requestId: params.requestId,
status: "ok",
result: {
revisions: true,
conditionalWrites: true,
atomicReplacement: true,
maxObjectBytes: 1024,
},
};
}
if (params.operation === "readObject") {
assert.equal(params.payload.key, "netcatty-vault.json");
assert.equal(typeof params.payload.outputStreamId, "string");
return {
requestId: params.requestId,
status: "ok",
result: {
found: true,
byteLength: cipher.byteLength,
encoding: "base64",
data: cipher.toString("base64"),
revision: "rev-1",
},
};
}
if (params.operation === "writeObject") {
writePayload = params.payload;
assert.equal(params.payload.key, "netcatty-vault.json");
assert.equal(params.payload.encoding, "base64");
assert.equal(Buffer.from(params.payload.data, "base64").equals(cipher), true);
return {
requestId: params.requestId,
status: "ok",
result: { created: true, revision: "rev-2" },
};
}
if (params.operation === "deleteObject") {
return {
requestId: params.requestId,
status: "ok",
result: { deleted: true },
};
}
if (params.operation === "disconnect") {
return { requestId: params.requestId, status: "ok", result: null };
}
throw new Error(`unexpected operation ${params.operation}`);
},
});
const connected = await h.service.connectSync({
providerId: "com.example.transport.sync",
configuration: { endpoint: "https://example.test" },
});
assert.equal(connected.account.id, "acct-1");
assert.equal(h.permissions[0].descriptor.permission, "provider.sync");
const caps = await h.service.getSyncCapabilities({
providerId: "com.example.transport.sync",
});
assert.equal(caps.conditionalWrites, true);
const read = await h.service.readSyncObject({
providerId: "com.example.transport.sync",
key: "netcatty-vault.json",
});
assert.equal(read.found, true);
assert.equal(Buffer.from(read.bytes).equals(cipher), true);
assert.equal(read.revision, "rev-1");
const written = await h.service.writeSyncObject({
providerId: "com.example.transport.sync",
key: "netcatty-vault.json",
bytes: cipher,
});
assert.equal(written.created, true);
assert.equal(written.revision, "rev-2");
assert.equal(writePayload.byteLength, cipher.byteLength);
const deleted = await h.service.deleteSyncObject({
providerId: "com.example.transport.sync",
key: "netcatty-vault.json",
});
assert.equal(deleted.deleted, true);
const disconnected = await h.service.disconnectSync({
providerId: "com.example.transport.sync",
});
assert.equal(disconnected, null);
});
test("sync providers fail closed without provider.sync permission", async () => {
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const h = fixture({
async request() {
throw new Error("plugin should not be invoked");
},
async authorize() {
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "missing provider.sync");
},
});
await assert.rejects(h.service.connectSync({
providerId: "com.example.transport.sync",
configuration: {},
}), /provider\.sync|permission/i);
});
test("sync writeObject streams large encrypted objects outside the JSON budget", async () => {
const { INLINE_SYNC_OBJECT_BYTES } = require("./extensionProviderService.cjs");
const large = Buffer.alloc(INLINE_SYNC_OBJECT_BYTES + 16, 0xab);
let sawStream = false;
const h = fixture({
async request({ params }) {
assert.equal(params.operation, "writeObject");
assert.equal(typeof params.payload.inputStreamId, "string");
assert.equal(params.payload.data, undefined);
sawStream = true;
return {
requestId: params.requestId,
status: "ok",
result: { created: false, revision: "stream-rev" },
};
},
});
const result = await h.service.writeSyncObject({
providerId: "com.example.transport.sync",
key: "netcatty-vault.json",
bytes: large,
});
assert.equal(sawStream, true);
assert.equal(result.revision, "stream-rev");
assert.ok(h.writes.some((entry) => entry[1] === "end"));
});
@@ -96,15 +96,6 @@
"maxRecords": 10000
}
},
"SyncLimits": {
"description": "Host-enforced byte and identifier limits for one encrypted sync-object operation. inlineObjectBytes is the maximum ciphertext size that may travel inline on the control plane (below the 128 KiB provider JSON budget after base64 expansion).",
"const": {
"maxObjectBytes": 67108864,
"maxObjectKeyLength": 1024,
"maxRevisionLength": 256,
"inlineObjectBytes": 92160
}
},
"StreamId": {
"type": "string",
"minLength": 1,
@@ -2389,376 +2380,6 @@
}
]
},
"SyncObjectKey": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"SyncObjectRevision": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"SyncAccount": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"email": {
"type": "string",
"maxLength": 512
},
"name": {
"type": "string",
"maxLength": 512
},
"avatarUrl": {
"type": "string",
"maxLength": 2048
}
},
"additionalProperties": false
},
"SyncConnectPayload": {
"type": "object",
"required": [
"configuration",
"operationId"
],
"properties": {
"configuration": {
"$ref": "#/$defs/JsonValue"
},
"operationId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"credential": {
"oneOf": [
{
"$ref": "#/$defs/SecretRef"
},
{
"$ref": "#/$defs/CredentialRef"
},
{
"$ref": "#/$defs/SecretLeaseRef"
}
]
}
},
"additionalProperties": false
},
"SyncConnectResult": {
"type": "object",
"required": [
"account"
],
"properties": {
"account": {
"$ref": "#/$defs/SyncAccount"
}
},
"additionalProperties": false
},
"SyncDisconnectPayload": {
"type": "object",
"properties": {
"operationId": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"additionalProperties": false
},
"SyncDisconnectResult": {
"type": "null"
},
"SyncGetAccountPayload": {
"type": "object",
"properties": {
"operationId": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"additionalProperties": false
},
"SyncGetAccountResult": {
"type": "object",
"required": [
"account"
],
"properties": {
"account": {
"oneOf": [
{
"$ref": "#/$defs/SyncAccount"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
"SyncGetCapabilitiesPayload": {
"type": "object",
"properties": {
"operationId": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"additionalProperties": false
},
"SyncCapabilitiesResult": {
"type": "object",
"required": [
"revisions",
"conditionalWrites",
"atomicReplacement"
],
"properties": {
"revisions": {
"type": "boolean"
},
"conditionalWrites": {
"type": "boolean"
},
"atomicReplacement": {
"type": "boolean"
},
"maxObjectBytes": {
"type": "integer",
"minimum": 1,
"maximum": 67108864
},
"maxObjects": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
}
},
"additionalProperties": false
},
"SyncReadObjectPayload": {
"type": "object",
"required": [
"key",
"operationId"
],
"properties": {
"key": {
"$ref": "#/$defs/SyncObjectKey"
},
"operationId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"outputStreamId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"windowBytes": {
"type": "integer",
"minimum": 1024,
"maximum": 16777216
}
},
"additionalProperties": false
},
"SyncReadObjectResult": {
"oneOf": [
{
"type": "object",
"required": [
"found"
],
"properties": {
"found": {
"const": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"found",
"byteLength",
"encoding",
"data"
],
"properties": {
"found": {
"const": true
},
"byteLength": {
"type": "integer",
"minimum": 0,
"maximum": 67108864
},
"encoding": {
"const": "base64"
},
"data": {
"type": "string",
"maxLength": 9007199254740991
},
"revision": {
"$ref": "#/$defs/SyncObjectRevision"
},
"contentType": {
"type": "string",
"maxLength": 256
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"found",
"byteLength",
"streamed"
],
"properties": {
"found": {
"const": true
},
"byteLength": {
"type": "integer",
"minimum": 0,
"maximum": 67108864
},
"streamed": {
"const": true
},
"revision": {
"$ref": "#/$defs/SyncObjectRevision"
},
"contentType": {
"type": "string",
"maxLength": 256
}
},
"additionalProperties": false
}
]
},
"SyncWriteObjectPayload": {
"type": "object",
"required": [
"key",
"operationId",
"byteLength"
],
"properties": {
"key": {
"$ref": "#/$defs/SyncObjectKey"
},
"operationId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"byteLength": {
"type": "integer",
"minimum": 0,
"maximum": 67108864
},
"expectedRevision": {
"oneOf": [
{
"$ref": "#/$defs/SyncObjectRevision"
},
{
"type": "null"
}
]
},
"encoding": {
"const": "base64"
},
"data": {
"type": "string",
"maxLength": 9007199254740991
},
"inputStreamId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"windowBytes": {
"type": "integer",
"minimum": 1024,
"maximum": 16777216
}
},
"additionalProperties": false
},
"SyncWriteObjectResult": {
"type": "object",
"required": [
"created"
],
"properties": {
"created": {
"type": "boolean"
},
"revision": {
"$ref": "#/$defs/SyncObjectRevision"
}
},
"additionalProperties": false
},
"SyncDeleteObjectPayload": {
"type": "object",
"required": [
"key",
"operationId"
],
"properties": {
"key": {
"$ref": "#/$defs/SyncObjectKey"
},
"operationId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"expectedRevision": {
"$ref": "#/$defs/SyncObjectRevision"
}
},
"additionalProperties": false
},
"SyncDeleteObjectResult": {
"type": "object",
"required": [
"deleted"
],
"properties": {
"deleted": {
"type": "boolean"
}
},
"additionalProperties": false
},
"CompanionPlatform": {
"type": "string",
"enum": [
-31
View File
@@ -35,7 +35,6 @@ const { SecretLeaseStore } = require("./secretLease.cjs");
const { PluginTerminalProviderService } = require("./terminalProviderService.cjs");
const { PluginTerminalDataPipelineService } = require("./terminalDataPipelineService.cjs");
const { PluginExtensionProviderService } = require("./extensionProviderService.cjs");
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
function getElectronProcessMetrics(app, pid) {
const metric = app.getAppMetrics?.().find((candidate) => candidate.pid === pid);
@@ -214,35 +213,6 @@ function createPluginHostService(options) {
rpcRegistry,
runtimeSupervisor,
});
const syncSidecarService = new PluginSyncSidecarService({
database,
contributionService,
});
// Replay retained cloud sidecars into plugin_settings before the plugin
// runtime starts so onStartupFinished sees hydrated values.
const originalOnPluginEnabled = contributionService.onPluginEnabled.bind(contributionService);
contributionService.onPluginEnabled = async (pluginId) => {
try {
await syncSidecarService.hydrateInstalledPluginSettings(pluginId);
} catch {
// Hydration is best-effort; enable must still succeed.
}
return originalOnPluginEnabled(pluginId);
};
// Startup activation goes through contributionService.initialize() → #startPlugin
// without onPluginEnabled. Hydrate every enabled plugin before that path runs.
const originalInitialize = contributionService.initialize.bind(contributionService);
contributionService.initialize = async () => {
for (const plugin of database.listPlugins()) {
if (!plugin?.enabled || typeof plugin.id !== "string") continue;
try {
await syncSidecarService.hydrateInstalledPluginSettings(plugin.id);
} catch {
// Best-effort; startup must continue.
}
}
return originalInitialize();
};
const terminalDataPipelineService = options.electron.MessageChannelMain
? new PluginTerminalDataPipelineService({
contributionService,
@@ -290,7 +260,6 @@ function createPluginHostService(options) {
database,
filesystemBroker,
extensionProviderService,
syncSidecarService,
leaseStore,
manager,
moduleResources,
+1 -38
View File
@@ -166,7 +166,7 @@ class PluginNetworkBroker {
validate(params) {
const request = assertNetworkRequest(params);
const validated = {
return {
url: request.url.href,
method: request.method,
headers: request.headers,
@@ -175,43 +175,6 @@ class PluginNetworkBroker {
}),
timeoutMs: request.timeoutMs,
};
// Preserve host-consumed credential fields. assertNetworkRequest ignores
// unknown keys; without this, registry validateParams would strip them and
// network.request lease injection would become dead code.
if (params?.credentialLease !== undefined) {
const lease = params.credentialLease;
if (
!lease
|| typeof lease !== "object"
|| Array.isArray(lease)
|| lease.kind !== "secret-lease"
|| typeof lease.id !== "string"
|| typeof lease.operationId !== "string"
|| !Number.isFinite(lease.expiresAt)
) {
throw invalidArgument("Plugin network credential lease is invalid");
}
validated.credentialLease = Object.freeze({
kind: "secret-lease",
id: lease.id,
operationId: lease.operationId,
expiresAt: lease.expiresAt,
});
}
if (params?.authorization !== undefined) {
const authorization = params.authorization;
if (!authorization || typeof authorization !== "object" || Array.isArray(authorization)) {
throw invalidArgument("Plugin network authorization is invalid");
}
const scheme = authorization.scheme === "Basic" ? "Basic" : "Bearer";
validated.authorization = Object.freeze({
scheme,
...(typeof authorization.username === "string"
? { username: authorization.username }
: {}),
});
}
return validated;
}
describeAuthorization(params) {
-464
View File
@@ -5,12 +5,6 @@ const { randomUUID } = require("node:crypto");
const { isPluginDevelopmentEnabled } = require("./constants.cjs");
const { raceWithAbort } = require("./rpcRouter.cjs");
const {
INLINE_SYNC_OBJECT_SAFE_BYTES,
MAX_SYNC_OBJECT_BYTES,
STREAM_WINDOW_BYTES,
} = require("./extensionProviderService.cjs");
const { assertSecretKey } = require("./secretStore.cjs");
const CHANNELS = Object.freeze({
status: "netcatty:plugins:status",
@@ -33,22 +27,6 @@ const CHANNELS = Object.freeze({
extensionProviders: "netcatty:plugins:extension-providers",
extensionInvoke: "netcatty:plugins:extension-invoke",
extensionCancel: "netcatty:plugins:extension-cancel",
syncConnect: "netcatty:plugins:sync-connect",
syncDisconnect: "netcatty:plugins:sync-disconnect",
syncGetAccount: "netcatty:plugins:sync-get-account",
syncGetCapabilities: "netcatty:plugins:sync-get-capabilities",
syncReadObject: "netcatty:plugins:sync-read-object",
syncReadChunk: "netcatty:plugins:sync-read-chunk",
syncWriteObject: "netcatty:plugins:sync-write-object",
syncWriteBegin: "netcatty:plugins:sync-write-begin",
syncWriteChunk: "netcatty:plugins:sync-write-chunk",
syncWriteCommit: "netcatty:plugins:sync-write-commit",
syncDeleteObject: "netcatty:plugins:sync-delete-object",
syncPutSecret: "netcatty:plugins:sync-put-secret",
syncDeleteSecrets: "netcatty:plugins:sync-delete-secrets",
syncSidecarsCollect: "netcatty:plugins:sync-sidecars-collect",
syncSidecarsApply: "netcatty:plugins:sync-sidecars-apply",
hostAvailableSync: "netcatty:plugins:host-available-sync",
connectionStart: "netcatty:plugins:connection-start",
connectionWrite: "netcatty:plugins:connection-write",
connectionControl: "netcatty:plugins:connection-control",
@@ -76,8 +54,6 @@ const CHANNELS = Object.freeze({
const SCOPE_KINDS = Object.freeze(["workspace", "host", "session", "device"]);
const MAX_ACTIVE_TERMINAL_REQUESTS_PER_SENDER = 64;
const MAX_SYNC_TRANSFERS_PER_SENDER = 8;
const SYNC_TRANSFER_TTL_MS = 5 * 60_000;
const MAX_IMPORT_FILE_BYTES = 64 * 1024 * 1024;
const MAX_IMPORT_SELECTIONS_PER_SENDER = 8;
const IMPORT_SELECTION_TTL_MS = 5 * 60_000;
@@ -197,9 +173,7 @@ function registerPluginBridge(ipcMain, options) {
const terminalProviderService = options.terminalProviderService;
const terminalDataPipelineService = options.terminalDataPipelineService;
const extensionProviderService = options.extensionProviderService;
const syncSidecarService = options.syncSidecarService;
const credentialResolver = options.credentialResolver;
const secretStore = options.secretStore;
const connectionStatusPollMs = Number.isSafeInteger(options.connectionStatusPollMs)
&& options.connectionStatusPollMs >= 0
&& options.connectionStatusPollMs <= 60_000
@@ -225,8 +199,6 @@ function registerPluginBridge(ipcMain, options) {
const observedAuthenticationSenders = new WeakSet();
const importerSelectionsBySender = new WeakMap();
const observedImporterSelectionSenders = new WeakSet();
const syncTransfersBySender = new WeakMap();
const observedSyncTransferSenders = new WeakSet();
const scopeCatalogSenderKey = (event) => {
const id = event?.sender?.id;
return Number.isSafeInteger(id) && id > 0 ? id : "default";
@@ -559,80 +531,6 @@ function registerPluginBridge(ipcMain, options) {
}
return selections;
};
const syncTransferMap = (sender) => {
if (!sender || typeof sender !== "object") throw new Error("Plugin sync transfer sender is unavailable");
let transfers = syncTransfersBySender.get(sender);
if (!transfers) {
transfers = new Map();
syncTransfersBySender.set(sender, transfers);
}
if (!observedSyncTransferSenders.has(sender)) {
observedSyncTransferSenders.add(sender);
sender.once?.("destroyed", () => {
for (const [transferId, transfer] of [...transfers.entries()]) {
try {
transfer.controller?.abort();
if (transfer.requestId) {
extensionRequestMap(sender).delete(transfer.requestId);
}
} catch { /* ignore */ }
transfers.delete(transferId);
}
});
}
const now = Date.now();
for (const [transferId, transfer] of [...transfers.entries()]) {
if (typeof transfer.expiresAt === "number" && transfer.expiresAt <= now) {
try {
transfer.controller?.abort();
if (transfer.requestId) extensionRequestMap(sender).delete(transfer.requestId);
} catch { /* ignore */ }
transfers.delete(transferId);
}
}
return transfers;
};
const releaseSyncTransfer = (sender, transferId, transfer) => {
const transfers = syncTransfersBySender.get(sender);
if (transfers && transferId && transfers.get(transferId) === transfer) {
transfers.delete(transferId);
}
if (transfer?.requestId) {
const requests = extensionRequestMap(sender);
if (requests.get(transfer.requestId) === transfer.controller || transfer.controller == null) {
requests.delete(transfer.requestId);
}
}
};
const assertSyncTransferBudget = (transfers) => {
if (transfers.size >= MAX_SYNC_TRANSFERS_PER_SENDER) {
throw new Error("Too many active Plugin sync transfers");
}
};
const coerceIpcBytes = (value, label) => {
if (value instanceof Uint8Array) return value;
if (Buffer.isBuffer(value)) return value;
if (ArrayBuffer.isView(value)) {
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
}
if (value && typeof value === "object" && value.type === "Buffer" && Array.isArray(value.data)) {
return Buffer.from(value.data);
}
throw new TypeError(`${label} must be binary bytes`);
};
const registerStreamingWriteRequest = (event, requestId) => {
if (typeof requestId !== "string" || requestId.length < 1 || requestId.length > 128 || requestId.includes("\0")) {
throw new TypeError("Plugin extension request ID is invalid");
}
const requests = extensionRequestMap(event.sender);
if (requests.has(requestId)) throw new Error("Plugin extension request ID is already active");
if (requests.size >= MAX_ACTIVE_TERMINAL_REQUESTS_PER_SENDER) {
throw new Error("Too many active Plugin extension requests");
}
const controller = new AbortController();
requests.set(requestId, controller);
return controller;
};
ipcMain.handle(CHANNELS.status, async (event) => {
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
let available = false;
@@ -743,368 +641,6 @@ function registerPluginBridge(ipcMain, options) {
if (!extensionProviderService) throw new Error("Plugin extension Providers are unavailable");
return extensionProviderService.listProviders(payload ?? {});
});
// Synchronous readiness probe for renderer restore paths (no await).
ipcMain.on(CHANNELS.hostAvailableSync, (event) => {
event.returnValue = Boolean(syncSidecarService);
});
// Fallback null = host gated off / manager unavailable. Renderer must treat
// null as "unavailable" (last-known) rather than an authoritative empty set.
handlePassive(CHANNELS.syncSidecarsCollect, null, async () => {
if (!syncSidecarService) return null;
return syncSidecarService.collectForSync();
});
handlePassive(CHANNELS.syncSidecarsApply, null, async (_activeManager, payload) => {
if (!syncSidecarService) return { applied: false };
const entries = await syncSidecarService.applyFromSync(payload ?? { version: 1, entries: [] });
// Include merged entries so the renderer can refresh last-known without a
// follow-up collect (preserves missing-plugin rows remote omitted).
return { applied: true, count: entries.length, entries };
});
const runSyncProvider = (channel, methodName) => {
ipcMain.handle(channel, async (event, payload) => runExtensionRequest(event, payload, async (signal) => {
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
const method = extensionProviderService[methodName];
if (typeof method !== "function") throw new Error(`Plugin sync method missing: ${methodName}`);
return method.call(extensionProviderService, payload ?? {}, { signal });
}));
};
runSyncProvider(CHANNELS.syncConnect, "connectSync");
runSyncProvider(CHANNELS.syncDisconnect, "disconnectSync");
runSyncProvider(CHANNELS.syncGetAccount, "getSyncAccount");
runSyncProvider(CHANNELS.syncGetCapabilities, "getSyncCapabilities");
runSyncProvider(CHANNELS.syncDeleteObject, "deleteSyncObject");
ipcMain.handle(CHANNELS.syncReadObject, async (event, payload) => {
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
const requestId = payload?.requestId;
const controller = registerStreamingWriteRequest(event, requestId);
try {
await raceWithAbort(resolveManager(), controller.signal);
const result = await extensionProviderService.readSyncObject(payload ?? {}, {
signal: controller.signal,
});
if (!result?.found || !result.bytes) {
extensionRequestMap(event.sender).delete(requestId);
return { found: false, key: payload?.key, data: null };
}
const bytes = result.bytes instanceof Uint8Array
? result.bytes
: new Uint8Array(result.bytes);
const preferStream = payload?.preferStream === true;
if (!preferStream && bytes.byteLength <= INLINE_SYNC_OBJECT_SAFE_BYTES) {
extensionRequestMap(event.sender).delete(requestId);
return {
found: true,
key: result.key,
data: bytes,
revision: result.revision,
contentType: result.contentType,
};
}
const transfers = syncTransferMap(event.sender);
assertSyncTransferBudget(transfers);
const transferId = randomUUID();
const transfer = {
kind: "read",
sender: event.sender,
requestId,
controller,
bytes,
offset: 0,
expiresAt: Date.now() + SYNC_TRANSFER_TTL_MS,
};
transfers.set(transferId, transfer);
const onAbort = () => releaseSyncTransfer(event.sender, transferId, transfer);
controller.signal.addEventListener("abort", onAbort, { once: true });
transfer.onAbort = onAbort;
return {
found: true,
key: result.key,
streamed: true,
transferId,
byteLength: bytes.byteLength,
revision: result.revision,
contentType: result.contentType,
};
} catch (error) {
extensionRequestMap(event.sender).delete(requestId);
throw error;
}
});
ipcMain.handle(CHANNELS.syncReadChunk, async (event, payload) => {
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
const transferId = payload?.transferId;
if (typeof transferId !== "string" || transferId.length < 1) {
throw new TypeError("Sync read transfer ID is invalid");
}
const transfers = syncTransferMap(event.sender);
const transfer = transfers.get(transferId);
if (!transfer || transfer.kind !== "read") {
throw new Error("Sync read transfer is not owned by this window");
}
if (payload?.requestId !== transfer.requestId) {
throw new Error("Sync read transfer request ID mismatch");
}
if (transfer.controller?.signal?.aborted) {
releaseSyncTransfer(event.sender, transferId, transfer);
throw new DOMException("Aborted", "AbortError");
}
transfer.expiresAt = Date.now() + SYNC_TRANSFER_TTL_MS;
const maxBytes = Number.isSafeInteger(payload?.maxBytes) && payload.maxBytes > 0
? Math.min(payload.maxBytes, STREAM_WINDOW_BYTES)
: STREAM_WINDOW_BYTES;
const start = transfer.offset;
const end = Math.min(transfer.bytes.byteLength, start + maxBytes);
const chunk = transfer.bytes.subarray(start, end);
transfer.offset = end;
const done = end >= transfer.bytes.byteLength;
if (done) releaseSyncTransfer(event.sender, transferId, transfer);
return { chunk, done };
});
ipcMain.handle(CHANNELS.syncWriteObject, async (event, payload) => runExtensionRequest(event, payload, async (signal) => {
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
const data = coerceIpcBytes(payload?.data ?? payload?.bytes, "Sync writeObject data");
if (data.byteLength > INLINE_SYNC_OBJECT_SAFE_BYTES) {
throw new Error("Sync writeObject payload requires the streamed write path");
}
if (data.byteLength > MAX_SYNC_OBJECT_BYTES) {
throw new Error("Sync writeObject payload exceeds the size limit");
}
return extensionProviderService.writeSyncObject({
...payload,
bytes: data instanceof Uint8Array ? data : new Uint8Array(data),
preferStream: false,
}, { signal });
}));
ipcMain.handle(CHANNELS.syncWriteBegin, async (event, payload) => {
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
const requestId = payload?.requestId;
const controller = registerStreamingWriteRequest(event, requestId);
const failBegin = (error) => {
controller.abort();
extensionRequestMap(event.sender).delete(requestId);
throw error;
};
const byteLength = payload?.byteLength;
if (!Number.isSafeInteger(byteLength) || byteLength < 1 || byteLength > MAX_SYNC_OBJECT_BYTES) {
failBegin(new TypeError("Sync writeObject byteLength is invalid"));
}
const providerId = payload?.providerId;
const key = payload?.key;
if (typeof providerId !== "string" || providerId.length < 1) {
failBegin(new TypeError("Sync writeObject provider ID is invalid"));
}
if (typeof key !== "string" || key.length < 1) {
failBegin(new TypeError("Sync writeObject key is invalid"));
}
const transfers = syncTransferMap(event.sender);
try {
assertSyncTransferBudget(transfers);
} catch (error) {
failBegin(error);
}
const transferId = randomUUID();
const transfer = {
kind: "write",
sender: event.sender,
requestId,
controller,
providerId,
key,
expectedRevision: payload?.expectedRevision,
deadlineMs: payload?.deadlineMs,
byteLength,
received: 0,
sequence: -1,
chunks: [],
signal: controller.signal,
expiresAt: Date.now() + SYNC_TRANSFER_TTL_MS,
};
transfers.set(transferId, transfer);
const onAbort = () => releaseSyncTransfer(event.sender, transferId, transfer);
controller.signal.addEventListener("abort", onAbort, { once: true });
transfer.onAbort = onAbort;
return { transferId, windowBytes: STREAM_WINDOW_BYTES };
});
ipcMain.handle(CHANNELS.syncWriteChunk, async (event, payload) => {
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
const transferId = payload?.transferId;
if (typeof transferId !== "string" || transferId.length < 1) {
throw new TypeError("Sync write transfer ID is invalid");
}
const transfers = syncTransferMap(event.sender);
const transfer = transfers.get(transferId);
if (!transfer || transfer.kind !== "write") {
throw new Error("Sync write transfer is not owned by this window");
}
const failChunk = (error) => {
try { transfer.controller?.abort(); } catch { /* ignore */ }
releaseSyncTransfer(event.sender, transferId, transfer);
throw error;
};
if (payload?.requestId !== transfer.requestId) {
failChunk(new Error("Sync write transfer request ID mismatch"));
}
if (transfer.signal.aborted) {
failChunk(new DOMException("Aborted", "AbortError"));
}
const sequence = payload?.sequence;
if (!Number.isSafeInteger(sequence) || sequence !== transfer.sequence + 1) {
failChunk(new TypeError("Sync write chunk sequence is invalid"));
}
let chunk;
try {
chunk = coerceIpcBytes(payload?.chunk, "Sync write chunk");
} catch (error) {
failChunk(error);
}
if (chunk.byteLength < 1 || chunk.byteLength > STREAM_WINDOW_BYTES) {
failChunk(new TypeError("Sync write chunk size is invalid"));
}
if (transfer.received + chunk.byteLength > transfer.byteLength) {
failChunk(new Error("Sync write exceeded declared byteLength"));
}
transfer.chunks.push(Buffer.from(chunk));
transfer.received += chunk.byteLength;
transfer.sequence = sequence;
transfer.expiresAt = Date.now() + SYNC_TRANSFER_TTL_MS;
return { accepted: chunk.byteLength };
});
ipcMain.handle(CHANNELS.syncWriteCommit, async (event, payload) => {
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
const transferId = payload?.transferId;
if (typeof transferId !== "string" || transferId.length < 1) {
throw new TypeError("Sync write transfer ID is invalid");
}
const transfers = syncTransferMap(event.sender);
const transfer = transfers.get(transferId);
if (!transfer || transfer.kind !== "write") {
throw new Error("Sync write transfer is not owned by this window");
}
const failCommit = (error) => {
try { transfer.controller?.abort(); } catch { /* ignore */ }
releaseSyncTransfer(event.sender, transferId, transfer);
throw error;
};
if (payload?.requestId !== transfer.requestId) {
failCommit(new Error("Sync write transfer request ID mismatch"));
}
if (transfer.received !== transfer.byteLength) {
failCommit(new Error("Sync write commit size does not match byteLength"));
}
const bytes = Buffer.concat(transfer.chunks);
transfers.delete(transferId);
try {
if (transfer.signal.aborted) {
throw new DOMException("Aborted", "AbortError");
}
await raceWithAbort(resolveManager(), transfer.signal);
return await extensionProviderService.writeSyncObject({
providerId: transfer.providerId,
key: transfer.key,
bytes: new Uint8Array(bytes),
expectedRevision: transfer.expectedRevision,
preferStream: true,
deadlineMs: transfer.deadlineMs,
}, { signal: transfer.signal });
} catch (error) {
throw error;
} finally {
if (extensionRequestMap(event.sender).get(transfer.requestId) === transfer.controller
|| extensionRequestMap(event.sender).has(transfer.requestId)) {
extensionRequestMap(event.sender).delete(transfer.requestId);
}
}
});
ipcMain.handle(CHANNELS.syncPutSecret, async (event, payload) => {
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
if (!secretStore || typeof secretStore.set !== "function") {
throw new Error("Plugin sync secret storage is unavailable");
}
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
const providerId = payload?.providerId;
if (typeof providerId !== "string" || providerId.length < 1) {
throw new TypeError("Sync provider ID is invalid");
}
const key = assertSecretKey(typeof payload?.key === "string" && payload.key
? payload.key
: "sync-credential");
const value = payload?.value;
if (typeof value !== "string" || value.length < 1) {
throw new TypeError("Sync secret value is invalid");
}
const providers = extensionProviderService.listProviders({ kind: "sync" });
const match = Array.isArray(providers)
? providers.find((entry) => entry?.provider?.id === providerId || entry?.id === providerId)
: null;
if (!match || typeof match.pluginId !== "string") {
throw new Error(`Plugin sync provider is unavailable: ${providerId}`);
}
return secretStore.set(match.pluginId, key, value);
});
ipcMain.handle(CHANNELS.syncDeleteSecrets, async (event, payload) => {
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
if (!secretStore || typeof secretStore.delete !== "function") {
throw new Error("Plugin sync secret storage is unavailable");
}
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
const providerId = payload?.providerId;
if (typeof providerId !== "string" || providerId.length < 1) {
throw new TypeError("Sync provider ID is invalid");
}
const providers = extensionProviderService.listProviders({ kind: "sync" });
const match = Array.isArray(providers)
? providers.find((entry) => entry?.provider?.id === providerId || entry?.id === providerId)
: null;
if (!match || typeof match.pluginId !== "string") {
// Provider may already be uninstalled; treat as no-op cleanup.
return { deleted: 0 };
}
const keys = Array.isArray(payload?.keys) && payload.keys.length > 0
? payload.keys
: null;
if (!keys) {
// Wipe every sync-credential* key (well-known + schema writeOnly fields).
if (typeof secretStore.deleteByKeyPrefix === "function") {
const deleted = secretStore.deleteByKeyPrefix(match.pluginId, "sync-credential");
return { deleted: Number(deleted) || 0 };
}
}
let deleted = 0;
for (const rawKey of keys ?? [
"sync-credential",
"sync-credential:token",
"sync-credential:secret",
"sync-credential:apiKey",
"sync-credential:accessToken",
]) {
if (typeof rawKey !== "string" || rawKey.length < 1) continue;
const key = assertSecretKey(rawKey);
try {
secretStore.delete(match.pluginId, key);
deleted += 1;
} catch {
/* ignore missing / unavailable keys */
}
}
return { deleted };
});
handlePassive(CHANNELS.credentialCatalogUpdate, 0, async (_activeManager, payload) => {
if (!credentialResolver || typeof credentialResolver.update !== "function") {
throw new Error("Plugin Vault credential catalog is unavailable");
-94
View File
@@ -15,16 +15,9 @@ const {
function createIpcMain() {
const handlers = new Map();
const listeners = new Map();
return {
handlers,
listeners,
handle(channel, handler) { handlers.set(channel, handler); },
on(channel, listener) {
const list = listeners.get(channel) ?? [];
list.push(listener);
listeners.set(channel, list);
},
};
}
@@ -1463,90 +1456,3 @@ test("closing the terminal while a plugin connection opens cancels the provider
await assert.rejects(pending, (error) => error?.name === "AbortError");
assert.deepEqual(closed, ["session-close-during-start"]);
});
test("sync read/write shuttle Uint8Array inline and stream large writes", async () => {
const written = [];
const ipcMain = createIpcMain();
registerPluginBridge(ipcMain, {
manager: { async initialize() {} },
extensionProviderService: {
listProviders() {
return [{
pluginId: "com.example",
pluginVersion: "1.0.0",
provider: { id: "com.example.sync", kind: "sync", label: "Example Sync" },
}];
},
async readSyncObject(params) {
return {
found: true,
key: params.key,
bytes: Buffer.from("cipher-inline"),
revision: "r1",
};
},
async writeSyncObject(params) {
written.push({
key: params.key,
byteLength: params.bytes.byteLength,
preferStream: params.preferStream === true,
});
return { created: true, revision: "r2" };
},
},
secretStore: {
set(pluginId, key, value) {
return Object.freeze({ kind: "secret", id: "sec-1", key, pluginId, value });
},
},
env: { NETCATTY_PLUGIN_DEV: "1" },
isTrustedSender: () => true,
});
const event = { sender: { id: 99, once() {}, isDestroyed: () => false } };
const inlineRead = await ipcMain.handlers.get(CHANNELS.syncReadObject)(event, {
requestId: "sync-read-1",
providerId: "com.example.sync",
key: "vault",
});
assert.equal(inlineRead.found, true);
assert.equal(inlineRead.streamed, undefined);
assert.ok(inlineRead.data instanceof Uint8Array || Buffer.isBuffer(inlineRead.data));
assert.equal(Buffer.from(inlineRead.data).toString("utf8"), "cipher-inline");
const inlineWrite = await ipcMain.handlers.get(CHANNELS.syncWriteObject)(event, {
requestId: "sync-write-1",
providerId: "com.example.sync",
key: "vault",
data: Buffer.from("small"),
});
assert.deepEqual(inlineWrite, { created: true, revision: "r2" });
const begin = await ipcMain.handlers.get(CHANNELS.syncWriteBegin)(event, {
requestId: "sync-write-stream",
providerId: "com.example.sync",
key: "vault",
byteLength: 5,
});
assert.equal(typeof begin.transferId, "string");
await ipcMain.handlers.get(CHANNELS.syncWriteChunk)(event, {
requestId: "sync-write-stream",
transferId: begin.transferId,
sequence: 0,
chunk: Buffer.from("large"),
});
const committed = await ipcMain.handlers.get(CHANNELS.syncWriteCommit)(event, {
requestId: "sync-write-stream",
transferId: begin.transferId,
});
assert.deepEqual(committed, { created: true, revision: "r2" });
assert.equal(written.at(-1)?.preferStream, true);
assert.equal(written.at(-1)?.byteLength, 5);
const secret = await ipcMain.handlers.get(CHANNELS.syncPutSecret)(event, {
providerId: "com.example.sync",
key: "sync-credential",
value: "pw",
});
assert.deepEqual(secret, { kind: "secret", id: "sec-1", key: "sync-credential", pluginId: "com.example", value: "pw" });
});
@@ -1,186 +0,0 @@
"use strict";
/**
* Pure sidecar collect/merge helpers for the plugin host.
* Mirrors domain/pluginSyncSidecar.ts so main-process CJS can load without TS.
*/
const SIDECAR_KINDS = new Set(["settings", "account_baseline", "crdt_baseline"]);
function isPluginSyncSidecarKind(value) {
return typeof value === "string" && SIDECAR_KINDS.has(value);
}
function isCloudSyncablePluginSetting(field) {
if (!field || field.secret === true) return false;
return field.sync === true;
}
function settingsSidecarKey(settingId, scope, scopeId) {
return `${settingId}\0${scope}\0${scopeId}`;
}
function parseSettingsSidecarKey(key) {
const parts = String(key).split("\0");
if (parts.length !== 3) return null;
const [settingId, scope, scopeId] = parts;
if (!settingId || !scope || !scopeId) return null;
return { settingId, scope, scopeId };
}
/**
* Map a settings sidecar key onto the currently declared scope coordinates.
* Returns null when the declared scope cannot be remapped safely (e.g. host
* scope without a fresh host id).
*/
function resolveSettingsSidecarTarget(field, parsed) {
const targetScope = typeof field?.scope === "string" && field.scope.length > 0
? field.scope
: parsed.scope;
let targetScopeId = parsed.scopeId;
if (targetScope === "application") {
targetScopeId = "application";
} else if (targetScope === "device") {
// parseSettingsSidecarKey guarantees a non-empty scopeId when parsed is set.
targetScopeId = parsed.scope === "device" ? parsed.scopeId : "device";
} else if (targetScope !== parsed.scope) {
return null;
}
return {
targetScope,
targetScopeId,
nextKey: settingsSidecarKey(parsed.settingId, targetScope, targetScopeId),
};
}
function collectPluginSyncSidecars(input) {
const now = input.now ?? Date.now();
const entries = [];
const settingsIndex = new Map();
for (const existing of input.existingSidecars ?? []) {
if (!existing || typeof existing.pluginId !== "string" || !isPluginSyncSidecarKind(existing.kind)) {
continue;
}
if (existing.kind !== "settings") {
entries.push({
pluginId: existing.pluginId,
kind: existing.kind,
key: String(existing.key),
value: existing.value,
updatedAt: Number(existing.updatedAt) || now,
});
continue;
}
const copy = {
pluginId: existing.pluginId,
kind: "settings",
key: String(existing.key),
value: existing.value,
updatedAt: Number(existing.updatedAt) || now,
};
settingsIndex.set(`${copy.pluginId}\0${copy.key}`, copy);
}
for (const stored of input.storedSettings ?? []) {
if (!stored || typeof stored.pluginId !== "string" || typeof stored.settingId !== "string") continue;
const declared = input.declaredSettingsByPlugin.get(stored.pluginId);
if (!declared) continue;
const field = declared.find((entry) => entry.id === stored.settingId);
if (!field || !isCloudSyncablePluginSetting(field)) continue;
const key = settingsSidecarKey(stored.settingId, stored.scope, stored.scopeId);
const entry = {
pluginId: stored.pluginId,
kind: "settings",
key,
value: stored.value,
updatedAt: Number(stored.updatedAt) || now,
};
const mapKey = `${entry.pluginId}\0${entry.key}`;
const previous = settingsIndex.get(mapKey);
// Keep a retained sidecar when it is strictly newer than the stored row
// (e.g. schema-rejected remote apply left sidecar only).
if (!previous || entry.updatedAt >= previous.updatedAt) {
settingsIndex.set(mapKey, entry);
}
}
entries.push(...settingsIndex.values());
entries.sort((a, b) => {
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
});
return { version: 1, entries };
}
function mergePluginSyncSidecars(params) {
const remoteEntries = Array.isArray(params.remote?.entries) ? params.remote.entries : [];
const merged = new Map();
for (const entry of params.local ?? []) {
if (!entry || typeof entry.pluginId !== "string" || !isPluginSyncSidecarKind(entry.kind)) continue;
merged.set(`${entry.pluginId}\0${entry.kind}\0${entry.key}`, {
pluginId: entry.pluginId,
kind: entry.kind,
key: String(entry.key),
value: entry.value,
updatedAt: Number(entry.updatedAt) || 0,
});
}
for (const raw of remoteEntries) {
if (!raw || typeof raw.pluginId !== "string" || !isPluginSyncSidecarKind(raw.kind)) continue;
if (raw.kind === "settings") {
const declared = params.declaredSettingsByPlugin?.get(raw.pluginId);
if (declared) {
const settingId = String(raw.key).split("\0")[0] ?? "";
const field = declared.find((entry) => entry.id === settingId);
if (field && !isCloudSyncablePluginSetting(field)) continue;
}
}
const next = {
pluginId: raw.pluginId,
kind: raw.kind,
key: String(raw.key),
value: raw.value,
updatedAt: Number(raw.updatedAt) || 0,
};
const mapKey = `${next.pluginId}\0${next.kind}\0${next.key}`;
const previous = merged.get(mapKey);
if (!previous || next.updatedAt >= previous.updatedAt) {
merged.set(mapKey, next);
}
}
return [...merged.values()].sort((a, b) => {
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
});
}
function excludeSecretPluginSettingsFromSidecars(entries, declaredSettingsByPlugin) {
return entries.filter((entry) => {
if (entry.kind !== "settings") return true;
const declared = declaredSettingsByPlugin.get(entry.pluginId);
if (!declared) return true;
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) return false;
const field = declared.find((item) => item.id === parsed.settingId);
if (!field) return true;
return isCloudSyncablePluginSetting(field);
});
}
module.exports = {
collectPluginSyncSidecars,
excludeSecretPluginSettingsFromSidecars,
isCloudSyncablePluginSetting,
isPluginSyncSidecarKind,
mergePluginSyncSidecars,
parseSettingsSidecarKey,
resolveSettingsSidecarTarget,
settingsSidecarKey,
};
@@ -1,410 +0,0 @@
"use strict";
/**
* Host-side collection and apply path for plugin encrypted sync sidecars.
* Uses non-cascade tables so uninstall/missing plugins do not destroy data.
*/
const {
collectPluginSyncSidecars,
excludeSecretPluginSettingsFromSidecars,
mergePluginSyncSidecars,
parseSettingsSidecarKey,
resolveSettingsSidecarTarget,
settingsSidecarKey,
isCloudSyncablePluginSetting,
} = require("./pluginSyncSidecarHelpers.cjs");
class PluginSyncSidecarService {
constructor(options) {
if (!options?.database) {
throw new TypeError("Plugin sync sidecar service requires a database");
}
this.database = options.database;
this.contributionService = options.contributionService ?? null;
}
#declaredSettingsByPlugin() {
const map = new Map();
const snapshot = this.contributionService?.snapshot?.();
const plugins = snapshot?.plugins ?? [];
for (const plugin of plugins) {
const fields = (plugin.settings ?? []).map((setting) => ({
id: setting.id,
secret: setting.secret === true,
sync: setting.sync === true,
scope: setting.scope,
}));
map.set(plugin.id, fields);
}
return map;
}
/**
* Build the sidecar bundle for inclusion in the encrypted cloud payload.
*/
collectForSync() {
const declared = this.#declaredSettingsByPlugin();
const storedSettings = typeof this.database.listAllSettings === "function"
? this.database.listAllSettings()
: [];
const existingSidecars = typeof this.database.listAllSyncSidecars === "function"
? this.database.listAllSyncSidecars()
: [];
// Keep retained settings sidecars for installed plugins even when
// plugin_settings has no row yet (downloaded while plugin was missing).
// Do not write them into plugin_settings here — that would bypass the
// contribution service's schema validation. applyFromSync / enable paths
// rehydrate through updateSetting. User resets clear the sidecar row via
// contributionService.resetSetting so deliberate deletions stay deleted.
const filteredExisting = existingSidecars.filter((entry) => {
if (entry.kind !== "settings") return true;
if (!declared.has(entry.pluginId)) return true; // missing plugin — preserve
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) return false;
const fields = declared.get(entry.pluginId) ?? [];
const field = fields.find((item) => item.id === parsed.settingId);
// Drop secret / non-sync declarations; unknown fields stay for schema lag.
if (field && !isCloudSyncablePluginSetting(field)) return false;
return true;
});
const bundle = collectPluginSyncSidecars({
declaredSettingsByPlugin: declared,
storedSettings,
existingSidecars: filteredExisting,
});
bundle.entries = excludeSecretPluginSettingsFromSidecars(bundle.entries, declared);
// Persist collected settings into the non-cascade table so a later uninstall
// still has rows to re-emit (plugin_settings alone is not enough once the
// declaration disappears and collection skips undeclared stored values).
if (typeof this.database.replaceAllSyncSidecars === "function") {
this.database.replaceAllSyncSidecars(bundle.entries);
}
return bundle;
}
/**
* Merge a remote sidecar bundle into local non-cascade storage.
* Does not delete local rows for plugins absent from the remote bundle.
* Installed-plugin settings go through the contribution service when available
* so values are validated and runtime change events fire.
*/
async applyFromSync(remoteBundle) {
const declared = this.#declaredSettingsByPlugin();
const local = typeof this.database.listAllSyncSidecars === "function"
? this.database.listAllSyncSidecars()
: [];
const remoteEntries = Array.isArray(remoteBundle?.entries) ? remoteBundle.entries : [];
// Explicit empty remote bundle is an authoritative wipe (including rows for
// missing plugins). Legacy payloads without pluginSidecars never reach here
// (apply path only runs when the field is present).
const remoteIsAuthoritativeEmpty = Array.isArray(remoteBundle?.entries)
&& remoteBundle.entries.length === 0;
// Remote is authoritative for installed plugins. For missing plugins, if the
// remote bundle mentions the plugin at all, remote is authoritative for that
// plugin's entire sidecar set (do not resurrect local-only keys another
// device deleted). Only plugins completely absent from remote keep local
// retention so first-time missing-plugin baselines are not wiped.
const remotePluginIds = new Set(
remoteEntries.map((entry) => entry.pluginId).filter((id) => typeof id === "string"),
);
const remoteKeys = new Set(
remoteEntries.map((entry) => `${entry.pluginId}\0${entry.kind}\0${entry.key}`),
);
// Settings: remote is authoritative for installed plugins and for missing
// plugins mentioned in the remote bundle. Baselines (account/crdt) always
// enter merge so domain LWW can compare local vs remote; orphans survive.
const preservedLocal = local.filter((entry) => {
if (entry.kind !== "settings") {
return true;
}
if (remoteIsAuthoritativeEmpty) return false;
if (declared.has(entry.pluginId)) return false; // installed: remote wins
if (remotePluginIds.has(entry.pluginId)) return false;
return true;
});
const merged = mergePluginSyncSidecars({
local: preservedLocal,
remote: { version: 1, entries: remoteEntries },
declaredSettingsByPlugin: declared,
});
const safe = excludeSecretPluginSettingsFromSidecars(merged, declared);
if (typeof this.database.replaceAllSyncSidecars === "function") {
this.database.replaceAllSyncSidecars(safe);
}
// Drop installed-plugin settings that remote no longer carries.
// Prefer contributionService.resetSetting so plugins and the renderer
// receive change events; fall back to direct DB delete when unavailable.
// Include live plugin_settings rows that were never mirrored into
// plugin_sync_sidecars (e.g. pre-sidecar schema migrations) so remote
// deletions still clear them instead of being republished on next collect.
const resetCandidates = [];
const seenResetKeys = new Set();
const pushResetCandidate = (entry) => {
const mapKey = `${entry.pluginId}\0${entry.kind}\0${entry.key}`;
if (seenResetKeys.has(mapKey)) return;
seenResetKeys.add(mapKey);
resetCandidates.push(entry);
};
for (const entry of local) {
if (entry.kind !== "settings") continue;
if (!declared.has(entry.pluginId)) continue;
if (remoteKeys.has(`${entry.pluginId}\0${entry.kind}\0${entry.key}`)) continue;
pushResetCandidate(entry);
}
if (typeof this.database.listAllSettings === "function") {
for (const row of this.database.listAllSettings()) {
if (!declared.has(row.pluginId)) continue;
const fields = declared.get(row.pluginId) ?? [];
const field = fields.find((item) => item.id === row.settingId);
if (!field || !isCloudSyncablePluginSetting(field)) continue;
const key = settingsSidecarKey(row.settingId, row.scope, row.scopeId);
if (remoteKeys.has(`${row.pluginId}\0settings\0${key}`)) continue;
pushResetCandidate({
pluginId: row.pluginId,
kind: "settings",
key,
value: row.value,
updatedAt: row.updatedAt,
});
}
}
for (const entry of resetCandidates) {
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) continue;
if (typeof this.contributionService?.resetSetting === "function") {
try {
await this.contributionService.resetSetting(
entry.pluginId,
parsed.settingId,
parsed.scopeId,
);
} catch {
// Validation rejected the reset (e.g. required setting) — still try
// the stored-coordinate delete below for stale-scope rows.
}
// resetSetting normalizes against the *current* declared scope. After a
// scope migration (application → device), that may miss the stale row
// still stored under the old coordinates; always delete those too.
try {
this.database.deleteSetting(
entry.pluginId,
parsed.settingId,
parsed.scope,
parsed.scopeId,
);
} catch {
// Best-effort cleanup of the stored-coordinate row.
}
continue;
}
this.database.deleteSetting(entry.pluginId, parsed.settingId, parsed.scope, parsed.scopeId);
}
for (const entry of safe) {
if (entry.kind !== "settings") continue;
const fields = declared.get(entry.pluginId);
if (!fields) continue;
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) continue;
const field = fields.find((item) => item.id === parsed.settingId);
if (!field || !isCloudSyncablePluginSetting(field)) continue;
// Always write under the currently declared scope/key coordinates.
// Older sidecars may encode an obsolete scope after a plugin update;
// recreating that scope would leave duplicate rows that collection
// republishes. Application scope always uses the fixed "application" id.
const target = resolveSettingsSidecarTarget(field, parsed);
if (!target) continue;
const { targetScope, targetScopeId, nextKey } = target;
if (typeof this.contributionService?.updateSetting === "function") {
try {
await this.contributionService.updateSetting(
entry.pluginId,
parsed.settingId,
entry.value,
targetScopeId,
{ source: "host" },
);
// Preserve remote LWW timestamp after validated write (updateSetting uses clock).
this.database.setSetting(
entry.pluginId,
parsed.settingId,
targetScope,
targetScopeId,
entry.value,
entry.updatedAt,
);
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
try {
this.database.deleteSetting(
entry.pluginId,
parsed.settingId,
parsed.scope,
parsed.scopeId,
);
} catch {
// Best-effort cleanup of the obsolete scope row.
}
if (typeof this.database.setSyncSidecar === "function") {
try {
this.database.setSyncSidecar(
entry.pluginId,
"settings",
nextKey,
entry.value,
entry.updatedAt,
);
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
this.database.deleteSyncSidecar(entry.pluginId, "settings", entry.key);
}
} catch {
// Sidecar re-key is best-effort after settings write.
}
}
}
} catch {
// Invalid against current schema — keep sidecar row only.
}
continue;
}
this.database.setSetting(
entry.pluginId,
parsed.settingId,
targetScope,
targetScopeId,
entry.value,
entry.updatedAt,
);
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
try {
this.database.deleteSetting(
entry.pluginId,
parsed.settingId,
parsed.scope,
parsed.scopeId,
);
} catch {
// Best-effort cleanup of the obsolete scope row.
}
if (typeof this.database.setSyncSidecar === "function") {
try {
this.database.setSyncSidecar(
entry.pluginId,
"settings",
nextKey,
entry.value,
entry.updatedAt,
);
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
this.database.deleteSyncSidecar(entry.pluginId, "settings", entry.key);
}
} catch {
// Sidecar re-key is best-effort after settings write.
}
}
}
}
return safe;
}
/**
* After a plugin is installed/enabled, validate and apply retained settings
* sidecars into plugin_settings so the running plugin sees cloud values.
*/
async hydrateInstalledPluginSettings(pluginId) {
if (typeof pluginId !== "string" || pluginId.length < 1) return;
const declared = this.#declaredSettingsByPlugin().get(pluginId);
if (!declared) return;
const rows = typeof this.database.listSyncSidecars === "function"
? this.database.listSyncSidecars(pluginId)
: [];
for (const entry of rows) {
if (entry.kind !== "settings") continue;
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) continue;
const field = declared.find((item) => item.id === parsed.settingId);
if (!field || !isCloudSyncablePluginSetting(field)) continue;
const target = resolveSettingsSidecarTarget(field, parsed);
if (!target) continue;
const { targetScope, targetScopeId, nextKey } = target;
// Do not overwrite a newer local edit that has not been collected yet.
if (typeof this.database.listSettings === "function") {
const localRows = this.database.listSettings(pluginId);
const local = localRows.find((row) => (
row.settingId === parsed.settingId
&& row.scope === targetScope
&& row.scopeId === targetScopeId
));
if (local && Number(local.updatedAt) > Number(entry.updatedAt)) {
continue;
}
}
if (typeof this.contributionService?.updateSetting === "function") {
try {
await this.contributionService.updateSetting(
pluginId,
parsed.settingId,
entry.value,
targetScopeId,
{ source: "host" },
);
this.database.setSetting(
pluginId,
parsed.settingId,
targetScope,
targetScopeId,
entry.value,
entry.updatedAt,
);
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
try {
this.database.deleteSetting(
pluginId,
parsed.settingId,
parsed.scope,
parsed.scopeId,
);
} catch {
// Best-effort cleanup of the obsolete scope row.
}
if (typeof this.database.setSyncSidecar === "function") {
try {
this.database.setSyncSidecar(
pluginId,
"settings",
nextKey,
entry.value,
entry.updatedAt,
);
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
this.database.deleteSyncSidecar(pluginId, "settings", entry.key);
}
} catch {
// Sidecar re-key is best-effort after settings write.
}
}
}
} catch {
// Invalid against current schema — keep sidecar only.
}
}
}
}
/**
* Persist an account or CRDT baseline without cascading on uninstall.
*/
setBaseline(pluginId, kind, key, value) {
if (kind !== "account_baseline" && kind !== "crdt_baseline") {
throw new TypeError("Baseline kind must be account_baseline or crdt_baseline");
}
this.database.setSyncSidecar(pluginId, kind, key, value);
}
getBaseline(pluginId, kind, key) {
return this.database.getSyncSidecar(pluginId, kind, key);
}
}
module.exports = {
PluginSyncSidecarService,
};
@@ -1,512 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { PluginDatabase } = require("./database.cjs");
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
function tempDb(context) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-sidecar-"));
context.after(() => {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
});
return new PluginDatabase(path.join(dir, "plugins.sqlite"));
}
test("sidecar table has no package cascade and survives removePlugin", (context) => {
const database = tempDb(context);
database.installVersion({
pluginId: "com.example.sync",
version: "1.0.0",
manifest: { id: "com.example.sync", version: "1.0.0" },
archiveSha256: "a".repeat(64),
packageRelativePath: "packages/com.example.sync/1.0.0",
}, { enable: true });
database.setSyncSidecar("com.example.sync", "account_baseline", "account", { id: "acct" }, 10);
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_sync_sidecars)").all(), []);
database.removePlugin("com.example.sync");
assert.deepEqual(database.getSyncSidecar("com.example.sync", "account_baseline", "account"), {
pluginId: "com.example.sync",
kind: "account_baseline",
key: "account",
value: { id: "acct" },
updatedAt: 10,
});
assert.equal(
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
"dark",
);
});
test("collectForSync excludes secrets and preserves missing-plugin baselines", (context) => {
const database = tempDb(context);
database.setSyncSidecar("com.missing.plugin", "crdt_baseline", "replica", { clock: 2 }, 5);
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
database.setSetting("com.example.sync", "com.example.sync.token", "application", "application", "secret");
const contributionService = {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
{ id: "com.example.sync.token", secret: true, sync: true, scope: "application" },
],
}],
};
},
};
const service = new PluginSyncSidecarService({ database, contributionService });
const bundle = service.collectForSync();
assert.equal(bundle.version, 1);
assert.ok(bundle.entries.some((entry) => entry.kind === "crdt_baseline" && entry.pluginId === "com.missing.plugin"));
assert.ok(bundle.entries.some((entry) => entry.kind === "settings" && entry.value === "dark"));
assert.equal(bundle.entries.some((entry) => entry.value === "secret"), false);
// Collected settings must land in the non-cascade table for post-uninstall collect.
const persisted = database.listAllSyncSidecars();
assert.ok(persisted.some((entry) => entry.kind === "settings" && entry.value === "dark"));
assert.ok(persisted.some((entry) => entry.kind === "crdt_baseline"));
});
test("collectForSync re-emits retained sidecars after plugin reinstall without writing settings", (context) => {
const database = tempDb(context);
// Sidecar applied while plugin was missing (no plugin_settings row).
database.setSyncSidecar(
"com.example.sync",
"settings",
"com.example.sync.theme\0application\0application",
"from-cloud",
9,
);
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
],
}],
};
},
},
});
const bundle = service.collectForSync();
assert.ok(bundle.entries.some((e) => e.value === "from-cloud"));
// Collect must not bypass schema validation by writing plugin_settings.
assert.equal(
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
undefined,
);
});
test("collectForSync re-emits settings after plugin uninstall via non-cascade table", (context) => {
const database = tempDb(context);
database.installVersion({
pluginId: "com.example.sync",
version: "1.0.0",
manifest: { id: "com.example.sync", version: "1.0.0" },
archiveSha256: "b".repeat(64),
packageRelativePath: "packages/com.example.sync/1.0.0",
}, { enable: true });
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
const contributionService = {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
],
}],
};
},
};
const service = new PluginSyncSidecarService({ database, contributionService });
service.collectForSync();
database.removePlugin("com.example.sync");
const missingService = new PluginSyncSidecarService({
database,
contributionService: { snapshot: () => ({ plugins: [] }) },
});
const afterUninstall = missingService.collectForSync();
assert.ok(
afterUninstall.entries.some((entry) => entry.kind === "settings" && entry.value === "dark"),
"settings sidecar must survive uninstall via non-cascade persistence",
);
});
test("collectForSync omits deleted settings for installed plugins but keeps missing-plugin rows", (context) => {
const database = tempDb(context);
// User reset clears both plugin_settings and the matching sidecar row.
database.setSyncSidecar(
"com.example.sync",
"settings",
"com.example.sync.theme\0application\0application",
"stale",
5,
);
database.deleteSyncSidecar(
"com.example.sync",
"settings",
"com.example.sync.theme\0application\0application",
);
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "keep", 5);
// Only store a different setting for the installed plugin.
database.setSetting("com.example.sync", "com.example.sync.other", "application", "application", "ok");
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
{ id: "com.example.sync.other", secret: false, sync: true, scope: "application" },
],
}],
};
},
},
});
const bundle = service.collectForSync();
assert.equal(bundle.entries.some((e) => e.value === "stale"), false);
assert.ok(bundle.entries.some((e) => e.value === "ok"));
assert.ok(bundle.entries.some((e) => e.pluginId === "com.missing.plugin" && e.value === "keep"));
});
test("applyFromSync preserves remote updatedAt when writing settings", async (context) => {
const database = tempDb(context);
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
],
}],
};
},
},
});
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.example.sync",
kind: "settings",
key: "com.example.sync.theme\0application\0application",
value: "dark",
updatedAt: 42,
}],
});
const rows = database.listAllSettings();
const theme = rows.find((r) => r.settingId === "com.example.sync.theme");
assert.equal(theme?.value, "dark");
assert.equal(theme?.updatedAt, 42);
});
test("applyFromSync does not drop local baselines for plugins absent remotely", async (context) => {
const database = tempDb(context);
database.setSyncSidecar("com.local.only", "account_baseline", "account", { id: "local" }, 1);
const service = new PluginSyncSidecarService({
database,
contributionService: { snapshot: () => ({ plugins: [] }) },
});
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.remote.plugin",
kind: "settings",
key: "com.remote.plugin.theme\0application\0application",
value: "light",
updatedAt: 2,
}],
});
const all = database.listAllSyncSidecars();
assert.ok(all.some((entry) => entry.pluginId === "com.local.only"));
assert.ok(all.some((entry) => entry.pluginId === "com.remote.plugin" && entry.value === "light"));
});
test("hydrateInstalledPluginSettings applies retained sidecars through contribution service", async (context) => {
const database = tempDb(context);
database.setSyncSidecar(
"com.example.sync",
"settings",
"com.example.sync.theme\0application\0application",
"from-cloud",
9,
);
const updates = [];
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
],
}],
};
},
async updateSetting(pluginId, settingId, value, scopeId) {
updates.push({ pluginId, settingId, value, scopeId });
},
},
});
await service.hydrateInstalledPluginSettings("com.example.sync");
assert.equal(updates.length, 1);
assert.equal(updates[0].value, "from-cloud");
assert.equal(
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
"from-cloud",
);
});
test("hydrateInstalledPluginSettings skips sidecars older than local settings", async (context) => {
const database = tempDb(context);
database.setSetting(
"com.example.sync",
"com.example.sync.theme",
"application",
"application",
"local-newer",
20,
);
database.setSyncSidecar(
"com.example.sync",
"settings",
"com.example.sync.theme\0application\0application",
"from-cloud-stale",
9,
);
const updates = [];
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
],
}],
};
},
async updateSetting(pluginId, settingId, value, scopeId) {
updates.push({ pluginId, settingId, value, scopeId });
},
},
});
await service.hydrateInstalledPluginSettings("com.example.sync");
assert.equal(updates.length, 0);
assert.equal(
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
"local-newer",
);
});
test("applyFromSync empty remote clears installed settings without sidecar rows", async (context) => {
const database = tempDb(context);
// Pre-sidecar migration residue: setting exists, sidecar table empty.
database.setSetting(
"com.example.sync",
"com.example.sync.theme",
"application",
"application",
"stale-local",
3,
);
const resets = [];
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
],
}],
};
},
async resetSetting(pluginId, settingId, scopeId) {
resets.push({ pluginId, settingId, scopeId });
database.deleteSetting(pluginId, settingId, "application", scopeId);
},
},
});
await service.applyFromSync({ version: 1, entries: [] });
assert.equal(resets.length, 1);
assert.equal(
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
undefined,
);
});
test("applyFromSync deletes stale-scope settings under stored coordinates", async (context) => {
const database = tempDb(context);
// Sidecar/key still encodes application scope, but declaration moved to device.
database.setSetting(
"com.example.sync",
"com.example.sync.theme",
"application",
"application",
"stale-scope",
3,
);
database.setSyncSidecar(
"com.example.sync",
"settings",
"com.example.sync.theme\0application\0application",
"stale-scope",
3,
);
const resets = [];
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "device" },
],
}],
};
},
async resetSetting(pluginId, settingId, scopeId) {
// Simulates normalizeScopeId against the *current* device scope.
resets.push({ pluginId, settingId, scopeId });
database.deleteSetting(pluginId, settingId, "device", scopeId === "application" ? "device" : scopeId);
},
},
});
await service.applyFromSync({ version: 1, entries: [] });
assert.equal(
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
undefined,
"stale application-scope row must be deleted by stored-coordinate fallback",
);
});
test("applyFromSync empty remote bundle wipes missing-plugin retained rows", async (context) => {
const database = tempDb(context);
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "old", 1);
const service = new PluginSyncSidecarService({
database,
contributionService: { snapshot: () => ({ plugins: [] }) },
});
await service.applyFromSync({ version: 1, entries: [] });
assert.equal(database.listAllSyncSidecars().length, 0);
});
test("applyFromSync does not resurrect missing-plugin keys deleted on another device", async (context) => {
const database = tempDb(context);
// Local still has a setting that remote deleted, plus we keep another remote key.
database.setSyncSidecar(
"com.missing.plugin",
"settings",
"com.missing.plugin.deleted\0application\0application",
"stale",
1,
);
database.setSyncSidecar(
"com.missing.plugin",
"settings",
"com.missing.plugin.kept\0application\0application",
"local-old",
1,
);
const service = new PluginSyncSidecarService({
database,
contributionService: { snapshot: () => ({ plugins: [] }) },
});
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.missing.plugin",
kind: "settings",
key: "com.missing.plugin.kept\0application\0application",
value: "remote-new",
updatedAt: 5,
}],
});
const all = database.listAllSyncSidecars();
assert.equal(
all.some((entry) => String(entry.key).includes("deleted")),
false,
"must not resurrect remote-deleted missing-plugin setting",
);
assert.ok(all.some((entry) => entry.value === "remote-new"));
});
test("applyFromSync empty remote bundle preserves local baselines", async (context) => {
const database = tempDb(context);
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "old", 1);
database.setSyncSidecar("com.missing.plugin", "account_baseline", "account", { id: "a1" }, 2);
database.setSyncSidecar("com.missing.plugin", "crdt_baseline", "crdt", { clock: 9 }, 3);
const service = new PluginSyncSidecarService({
database,
contributionService: { snapshot: () => ({ plugins: [] }) },
});
await service.applyFromSync({ version: 1, entries: [] });
const all = database.listAllSyncSidecars();
assert.equal(all.some((entry) => entry.kind === "settings"), false);
assert.equal(all.some((entry) => entry.kind === "account_baseline"), true);
assert.equal(all.some((entry) => entry.kind === "crdt_baseline"), true);
});
test("applyFromSync keeps newer local baseline over older remote (LWW)", async (context) => {
const database = tempDb(context);
database.setSyncSidecar("com.example.plugin", "crdt_baseline", "crdt", { clock: 100 }, 100);
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot: () => ({
plugins: [{
id: "com.example.plugin",
settings: [],
}],
}),
},
});
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.example.plugin",
kind: "crdt_baseline",
key: "crdt",
value: { clock: 50 },
updatedAt: 50,
}],
});
const row = database.listAllSyncSidecars().find((entry) => entry.kind === "crdt_baseline");
assert.ok(row);
assert.equal(row.updatedAt, 100);
assert.deepEqual(row.value, { clock: 100 });
});
@@ -22,7 +22,6 @@ const ALLOWED_KEYWORDS = new Set([
"properties",
"required",
"type",
"writeOnly",
]);
const ajv = new Ajv2020({
+23 -68
View File
@@ -57,16 +57,6 @@ const CONNECTION_PROVIDER_OPERATIONS = Object.freeze([
"getStatus",
]);
const IMPORTER_PROVIDER_OPERATIONS = Object.freeze(["detect", "parse"]);
const SYNC_PROVIDER_OPERATIONS = Object.freeze([
"connect",
"disconnect",
"getAccount",
"getCapabilities",
"readObject",
"writeObject",
"deleteObject",
]);
const OPERATION_MAP_PROVIDER_KINDS = Object.freeze(new Set(["connection", "importer", "sync"]));
function pluginErrorNameFromRpcError(error) {
if (typeof error?.data?.pluginCode === "string") return error.data.pluginCode;
@@ -253,33 +243,21 @@ function assertProviderKind(kind) {
return kind;
}
function operationMapLabel(kind) {
if (kind === "connection") return "Connection";
if (kind === "importer") return "Importer";
if (kind === "sync") return "Sync";
return kind;
}
function operationsForProviderKind(kind) {
if (kind === "connection") return CONNECTION_PROVIDER_OPERATIONS;
if (kind === "importer") return IMPORTER_PROVIDER_OPERATIONS;
if (kind === "sync") return SYNC_PROVIDER_OPERATIONS;
return null;
}
function normalizeProviderHandler(kind, handler) {
if (!OPERATION_MAP_PROVIDER_KINDS.has(kind)) {
if (kind !== "connection" && kind !== "importer") {
if (typeof handler !== "function") {
throw new PluginError("invalid_argument", "Plugin Provider handler must be a function");
}
return handler;
}
const label = operationMapLabel(kind);
const label = kind === "connection" ? "Connection" : "Importer";
if (!handler || typeof handler !== "object" || Array.isArray(handler)) {
throw new PluginError("invalid_argument", `${label} Provider handler must be an operation map`);
}
const normalized = {};
const operations = operationsForProviderKind(kind);
const operations = kind === "connection"
? CONNECTION_PROVIDER_OPERATIONS
: IMPORTER_PROVIDER_OPERATIONS;
for (const operation of operations) {
if (typeof handler[operation] !== "function") {
throw new PluginError("invalid_argument", `${label} Provider handler is missing operation: ${operation}`);
@@ -661,11 +639,8 @@ export async function startPluginRuntime({
const payload = message.params?.payload === undefined
? undefined
: freezeRuntimeJson(message.params.payload);
const connectionOpen = kind === "connection" && operation === "open";
const importerParse = kind === "importer" && operation === "parse";
const syncReadStream = kind === "sync" && operation === "readObject" && typeof payload?.outputStreamId === "string";
const syncWriteStream = kind === "sync" && operation === "writeObject" && typeof payload?.inputStreamId === "string";
const streamed = connectionOpen || importerParse || syncReadStream || syncWriteStream;
const streamed = (kind === "connection" && operation === "open")
|| (kind === "importer" && operation === "parse");
let input;
let output;
let cancelStreams;
@@ -673,38 +648,24 @@ export async function startPluginRuntime({
const inputStreamId = payload?.inputStreamId;
const outputStreamId = payload?.outputStreamId;
const windowBytes = payload?.windowBytes;
if (connectionOpen || importerParse) {
if (typeof inputStreamId !== "string" || typeof outputStreamId !== "string") {
throw new PluginError("invalid_argument", "Streamed Provider invocation requires input and output stream IDs");
}
} else if (syncReadStream && typeof outputStreamId !== "string") {
throw new PluginError("invalid_argument", "Sync readObject stream requires an output stream ID");
} else if (syncWriteStream && typeof inputStreamId !== "string") {
throw new PluginError("invalid_argument", "Sync writeObject stream requires an input stream ID");
if (typeof inputStreamId !== "string" || typeof outputStreamId !== "string") {
throw new PluginError("invalid_argument", "Streamed Provider invocation requires input and output stream IDs");
}
if (typeof inputStreamId === "string") {
input = runtimeApi.streams.acceptReadable(inputStreamId);
// Prevent a cancelled request from creating an unhandled rejected
// promise when the Provider never awaited its input stream.
void input.catch(() => {});
}
if (typeof outputStreamId === "string") {
try {
output = await runtimeApi.streams.openWritable(outputStreamId, windowBytes);
} catch (error) {
if (typeof inputStreamId === "string") {
runtimeApi.streams.rejectReadable(inputStreamId, error);
}
throw error;
}
input = runtimeApi.streams.acceptReadable(inputStreamId);
// Prevent a cancelled request from creating an unhandled rejected
// promise when the Provider never awaited its input stream.
void input.catch(() => {});
try {
output = await runtimeApi.streams.openWritable(outputStreamId, windowBytes);
} catch (error) {
runtimeApi.streams.rejectReadable(inputStreamId, error);
throw error;
}
cancelStreams = () => {
const error = new PluginError("cancelled", "Streamed Provider invocation was cancelled");
if (typeof inputStreamId === "string") {
runtimeApi.streams.rejectReadable(inputStreamId, error);
void input?.then((stream) => stream.cancel(), () => {});
}
output?.cancel?.();
runtimeApi.streams.rejectReadable(inputStreamId, error);
output.cancel();
void input.then((stream) => stream.cancel(), () => {});
};
}
const cancellationDisposable = cancelStreams
@@ -718,11 +679,10 @@ export async function startPluginRuntime({
payload,
deadlineMs,
cancellationToken,
...(input !== undefined ? { input } : {}),
...(output !== undefined ? { output } : {}),
...(streamed ? { input, output } : {}),
});
try {
const handler = OPERATION_MAP_PROVIDER_KINDS.has(registration.kind)
const handler = registration.kind === "connection" || registration.kind === "importer"
? registration.handler[operation]
: registration.handler;
if (typeof handler !== "function") {
@@ -732,11 +692,6 @@ export async function startPluginRuntime({
if (cancellationToken.isCancellationRequested) {
return { requestId, status: "cancelled" };
}
// Sync readObject may return inline base64 or found:false while the host
// still opened an output stream for large-object fallback — release it.
if (syncReadStream && output && !(result && result.streamed === true)) {
try { output.cancel?.(); } catch { /* best-effort */ }
}
return { requestId, status: "ok", result: result === undefined ? null : result };
} catch (error) {
cancelStreams?.();
-9
View File
@@ -87,15 +87,6 @@ class PluginSecretStore {
this.database.deleteSecret(pluginId, key);
}
deleteByKeyPrefix(pluginId, prefix) {
assertSecretKey(prefix);
if (typeof this.database.deleteSecretsByKeyPrefix !== "function") {
this.delete(pluginId, prefix);
return 1;
}
return this.database.deleteSecretsByKeyPrefix(pluginId, prefix);
}
resolve(pluginId, secret) {
this.#assertAvailable();
const record = this.getRecordByReference(pluginId, secret);
+3 -39
View File
@@ -65,45 +65,9 @@ function registerSecurePluginCapabilities(registry, options) {
authorization: (params) => options.credentialBroker.describeAuthorization(params),
});
registry.registerRequest("network.request", async (params, context) => {
// params are already registry-validated (preserve credentialLease/authorization).
const validated = params;
const lease = validated?.credentialLease;
if (lease === undefined) {
return options.networkBroker.request(validated, context);
}
if (!options.credentialBroker || typeof options.credentialBroker.consumeLease !== "function") {
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin credential leases are unavailable");
}
// Bind consumption to the host-owned network operation id (network:<origin>),
// not the plugin-echoed lease.operationId, so leases cannot be replayed across ops.
const hostOperationId = options.networkBroker.describeAuthorization(validated).operationId;
const plaintext = await options.credentialBroker.consumeLease(
context,
lease,
hostOperationId,
);
if (typeof plaintext !== "string" || plaintext.length < 1) {
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Plugin credential lease resolved to an empty secret");
}
const authorization = validated?.authorization && typeof validated.authorization === "object"
&& !Array.isArray(validated.authorization)
? validated.authorization
: { scheme: "Bearer" };
const scheme = authorization.scheme === "Basic" ? "Basic" : "Bearer";
const headers = { ...(validated.headers ?? {}) };
if (scheme === "Basic") {
const username = typeof authorization.username === "string" ? authorization.username : "";
headers.authorization = `Basic ${Buffer.from(`${username}:${plaintext}`, "utf8").toString("base64")}`;
} else {
headers.authorization = `Bearer ${plaintext}`;
}
const { credentialLease: _lease, authorization: _auth, ...requestParams } = validated;
return options.networkBroker.request({
...requestParams,
headers,
}, context);
}, {
registry.registerRequest("network.request", (params, context) => (
options.networkBroker.request(params, context)
), {
metadata: { capability: "network", mutating: true, permission: "network" },
validateParams: (params) => options.networkBroker.validate(params),
authorization: (params) => options.networkBroker.describeAuthorization(params),
@@ -241,96 +241,3 @@ test("companion requests consume operation-bound credential leases exactly once"
});
assert.equal(forwarded[0].context, context);
});
test("network.request injects Authorization from a consumed credential lease", async () => {
const registrations = new Map();
const registry = {
use() {},
registerRequest(method, handler, options) { registrations.set(method, { handler, options }); },
};
const middlewareOwner = { createMiddleware: () => async (_context, next) => next() };
const forwarded = [];
const consumed = [];
const credentialBroker = {
async consumeLease(context, lease, operationId) {
consumed.push({ lease, operationId, pluginId: context.pluginId });
return "lease-token-value";
},
};
const networkBroker = {
// Mirror production validate: preserve credentialLease / authorization.
validate: (params) => ({
url: params.url,
method: params.method ?? "GET",
headers: params.headers ?? {},
timeoutMs: params.timeoutMs ?? 30_000,
...(params.credentialLease === undefined ? {} : { credentialLease: params.credentialLease }),
...(params.authorization === undefined ? {} : { authorization: params.authorization }),
}),
describeAuthorization: () => ({
permission: "network",
resources: ["https://example.com"],
reason: "test",
operationId: "network:https://example.com",
}),
async request(params, context) {
forwarded.push({ params, context });
return { status: 204, headers: {}, body: { encoding: "base64", data: "" } };
},
};
const broker = new Proxy({}, { get: () => () => ({}) });
registerSecurePluginCapabilities(registry, {
quotaManager: middlewareOwner,
permissionEngine: middlewareOwner,
secretStore: {},
credentialBroker,
networkBroker,
filesystemBroker: broker,
companionSupervisor: broker,
assertLeaseParams: (params) => params,
});
const context = {
pluginId: "com.example.secure",
async assertActive() {},
};
const registration = registrations.get("network.request");
// Simulate hostRpcRegistry: validateParams first, then handler(validatedParams).
const validated = registration.options.validateParams({
url: "https://example.com/vault",
credentialLease: {
kind: "secret-lease",
id: "lease-1",
operationId: "network:https://example.com",
expiresAt: Date.now() + 60_000,
},
authorization: { scheme: "Bearer" },
});
await registration.handler(validated, context);
assert.equal(consumed.length, 1);
assert.equal(consumed[0].operationId, "network:https://example.com");
assert.equal(forwarded[0].params.headers.authorization, "Bearer lease-token-value");
assert.equal(forwarded[0].params.credentialLease, undefined);
});
test("networkBroker.validate preserves credentialLease for registry dispatch", () => {
const { PluginNetworkBroker } = require("./networkBroker.cjs");
const broker = new PluginNetworkBroker({
fetch: async () => ({ status: 204, headers: new Headers(), body: null, url: "https://example.com/" }),
permissionEngine: { check: () => {} },
});
const validated = broker.validate({
url: "https://example.com/vault",
credentialLease: {
kind: "secret-lease",
id: "lease-1",
operationId: "network:https://example.com",
expiresAt: Date.now() + 60_000,
},
authorization: { scheme: "Basic", username: "alice" },
});
assert.equal(validated.credentialLease.kind, "secret-lease");
assert.equal(validated.authorization.scheme, "Basic");
assert.equal(validated.authorization.username, "alice");
});
-63
View File
@@ -130,69 +130,6 @@ function createPreloadApi(ctx) {
...request,
}),
cancelPluginExtensionRequest: (requestId) => ipcRenderer.invoke("netcatty:plugins:extension-cancel", { requestId }),
pluginSyncConnect: (request) => {
const requestId = typeof request?.requestId === "string" && request.requestId
? request.requestId
: randomUUID();
return ipcRenderer.invoke("netcatty:plugins:sync-connect", { ...request, requestId });
},
pluginSyncDisconnect: (request) => {
const requestId = typeof request?.requestId === "string" && request.requestId
? request.requestId
: randomUUID();
return ipcRenderer.invoke("netcatty:plugins:sync-disconnect", { ...request, requestId });
},
pluginSyncGetAccount: (request) => {
const requestId = typeof request?.requestId === "string" && request.requestId
? request.requestId
: randomUUID();
return ipcRenderer.invoke("netcatty:plugins:sync-get-account", { ...request, requestId });
},
pluginSyncGetCapabilities: (request) => {
const requestId = typeof request?.requestId === "string" && request.requestId
? request.requestId
: randomUUID();
return ipcRenderer.invoke("netcatty:plugins:sync-get-capabilities", { ...request, requestId });
},
pluginSyncReadObject: (request) => {
const requestId = typeof request?.requestId === "string" && request.requestId
? request.requestId
: randomUUID();
return ipcRenderer.invoke("netcatty:plugins:sync-read-object", { ...request, requestId });
},
pluginSyncReadChunk: (request) => ipcRenderer.invoke("netcatty:plugins:sync-read-chunk", request),
pluginSyncWriteObject: (request) => {
const requestId = typeof request?.requestId === "string" && request.requestId
? request.requestId
: randomUUID();
return ipcRenderer.invoke("netcatty:plugins:sync-write-object", { ...request, requestId });
},
pluginSyncWriteBegin: (request) => {
const requestId = typeof request?.requestId === "string" && request.requestId
? request.requestId
: randomUUID();
return ipcRenderer.invoke("netcatty:plugins:sync-write-begin", { ...request, requestId });
},
pluginSyncWriteChunk: (request) => ipcRenderer.invoke("netcatty:plugins:sync-write-chunk", request),
pluginSyncWriteCommit: (request) => ipcRenderer.invoke("netcatty:plugins:sync-write-commit", request),
pluginSyncDeleteObject: (request) => {
const requestId = typeof request?.requestId === "string" && request.requestId
? request.requestId
: randomUUID();
return ipcRenderer.invoke("netcatty:plugins:sync-delete-object", { ...request, requestId });
},
pluginSyncPutSecret: (request) => ipcRenderer.invoke("netcatty:plugins:sync-put-secret", request ?? {}),
pluginSyncDeleteSecrets: (request) => ipcRenderer.invoke("netcatty:plugins:sync-delete-secrets", request ?? {}),
collectPluginSyncSidecars: () => ipcRenderer.invoke("netcatty:plugins:sync-sidecars-collect", {}),
applyPluginSyncSidecars: (bundle) => ipcRenderer.invoke("netcatty:plugins:sync-sidecars-apply", bundle ?? { version: 1, entries: [] }),
/** True only when the main-process plugin host wired a sidecar service. */
pluginHostReady: () => {
try {
return ipcRenderer.sendSync("netcatty:plugins:host-available-sync") === true;
} catch {
return false;
}
},
startPluginConnection: async (request) => {
markRequestedTerminalDataSessionOpen(request);
const result = await ipcRenderer.invoke("netcatty:plugins:connection-start", {
Vendored
+6 -90
View File
@@ -165,10 +165,11 @@ declare global {
// Skip POSIX process discovery when copying a network-device session.
skipShellPidDiscovery?: boolean;
/**
* When false, terminal/SFTP opens must dial a fresh SSH connection and
* must not borrow a live, parked, or in-flight registry transport. Used by
* connect-time terminal automation and dedicated bulk transfers.
* Default true (normal terminal, browse, and MFA-skip reuse).
* When false, openSftp must dial a fresh SSH connection and must not
* borrow a parked/shared registry transport. Used for dedicated bulk
* transfer / restart-resume so transfers do not attach to a terminal
* conn that may die or leave checkpoint resume half-bound.
* Default true (browse / MFA-skip reuse of parked transports).
*/
reuseTransport?: boolean;
}
@@ -353,95 +354,10 @@ declare global {
providePluginTerminal?(request: NetcattyTerminalProviderRequest): Promise<ReadonlyArray<NetcattyTerminalProviderResult>>;
cancelPluginTerminalRequest?(requestId: string): Promise<boolean>;
publishPluginTerminalSessionEvent?(event: NetcattyTerminalSessionEvent): Promise<ReadonlyArray<{ pluginId: string; delivered: boolean }>>;
listPluginExtensionProviders?(options: { kind: 'connection' | 'authentication' | 'importer' | 'sync'; locale?: string }): Promise<ReadonlyArray<NetcattyExtensionProviderContribution>>;
listPluginExtensionProviders?(options: { kind: 'connection' | 'authentication' | 'importer'; locale?: string }): Promise<ReadonlyArray<NetcattyExtensionProviderContribution>>;
updatePluginCredentialCatalog?(entries: ReadonlyArray<{ id: string; ciphertext: string }>): Promise<number>;
invokePluginExtensionProvider?(request: NetcattyExtensionProviderRequest): Promise<import("@netcatty/plugin-contract").JsonValue>;
cancelPluginExtensionRequest?(requestId: string): Promise<boolean>;
pluginSyncConnect?(request: {
requestId?: string;
providerId: string;
configuration?: unknown;
credential?: unknown;
deadlineMs?: number;
}): Promise<{ account: { id: string; email?: string; name?: string; avatarUrl?: string } }>;
pluginSyncDisconnect?(request: { requestId?: string; providerId: string; deadlineMs?: number }): Promise<null>;
pluginSyncGetAccount?(request: { requestId?: string; providerId: string; deadlineMs?: number }): Promise<{
account: { id: string; email?: string; name?: string; avatarUrl?: string } | null;
}>;
pluginSyncGetCapabilities?(request: { requestId?: string; providerId: string; deadlineMs?: number }): Promise<{
revisions: boolean;
conditionalWrites: boolean;
atomicReplacement: boolean;
maxObjectBytes?: number;
maxObjects?: number;
}>;
pluginSyncReadObject?(request: {
requestId?: string;
providerId: string;
key: string;
preferStream?: boolean;
deadlineMs?: number;
}): Promise<{
found: boolean;
key: string;
data?: Uint8Array | null;
streamed?: boolean;
transferId?: string;
byteLength?: number;
revision?: string;
contentType?: string;
}>;
pluginSyncReadChunk?(request: {
requestId: string;
transferId: string;
maxBytes?: number;
}): Promise<{ chunk: Uint8Array; done: boolean }>;
pluginSyncWriteObject?(request: {
requestId?: string;
providerId: string;
key: string;
data: Uint8Array;
expectedRevision?: string | null;
preferStream?: boolean;
deadlineMs?: number;
}): Promise<{ created: boolean; revision?: string }>;
pluginSyncWriteBegin?(request: {
requestId?: string;
providerId: string;
key: string;
byteLength: number;
expectedRevision?: string | null;
deadlineMs?: number;
}): Promise<{ transferId: string; windowBytes: number }>;
pluginSyncWriteChunk?(request: {
requestId: string;
transferId: string;
sequence: number;
chunk: Uint8Array;
}): Promise<{ accepted: number }>;
pluginSyncWriteCommit?(request: {
requestId: string;
transferId: string;
}): Promise<{ created: boolean; revision?: string }>;
pluginSyncDeleteObject?(request: {
requestId?: string;
providerId: string;
key: string;
expectedRevision?: string;
deadlineMs?: number;
}): Promise<{ deleted: boolean }>;
pluginSyncPutSecret?(request: {
providerId: string;
key: string;
value: string;
}): Promise<{ kind: 'secret'; id: string; key: string }>;
pluginSyncDeleteSecrets?(request: {
providerId: string;
keys?: string[];
}): Promise<{ deleted: number }>;
collectPluginSyncSidecars?(): Promise<unknown>;
applyPluginSyncSidecars?(bundle: unknown): Promise<{ applied: boolean; count?: number; entries?: unknown }>;
pluginHostReady?(): boolean;
startPluginConnection?(request: NetcattyPluginConnectionStartRequest): Promise<{ sessionId: string; providerId: string; status: 'connecting' | 'connected'; diagnostics: ReadonlyArray<import("@netcatty/plugin-contract").ProviderValidationIssue> }>;
writePluginConnection?(sessionId: string, data: Uint8Array): Promise<void>;
controlPluginConnection?(sessionId: string, operation: 'resize' | 'signal' | 'reconnect' | 'close' | 'getStatus', payload?: Record<string, unknown>): Promise<unknown>;
-6
View File
@@ -257,12 +257,6 @@ export const STORAGE_KEY_PF_RECONNECT_CANCEL = '__netcatty_pf_cancel_reconnect';
// Default SSH Key Passphrases (for ~/.ssh keys not managed in the vault)
export const STORAGE_KEY_DEFAULT_KEY_PASSPHRASES = 'netcatty_default_key_passphrases_v1';
// Plugin sync sidecars / availability. Literals MUST match domain/sync
// SYNC_STORAGE_KEYS (PLUGIN_SIDECARS_* / AVAILABLE_PLUGIN_SYNC_PROVIDERS).
export const STORAGE_KEY_PLUGIN_SIDECARS_LAST_KNOWN = 'netcatty_plugin_sidecars_last_known_v1';
export const STORAGE_KEY_PLUGIN_SIDECARS_PENDING_REMOTE = 'netcatty_plugin_sidecars_pending_remote_v1';
export const STORAGE_KEY_AVAILABLE_PLUGIN_SYNC_PROVIDERS = 'netcatty_available_plugin_sync_providers_v1';
// Debug Flags (no _v1 suffix — developer-only, not persisted data)
export const STORAGE_KEY_DEBUG_HOTKEYS = 'debug.hotkeys';
export const STORAGE_KEY_DEBUG_UPDATE_DEMO = 'debug.updateDemo';
+12 -180
View File
@@ -151,52 +151,6 @@ export function decryptProxyProfiles(profiles: ProxyProfile[]): Promise<ProxyPro
// Provider Connection (Cloud Sync)
// ---------------------------------------------------------------------------
/**
* Host-owned sealed-config envelope. Must be unambiguous against plugin-owned
* JSON: exactly one reserved key, no extra properties. Never treat a plugin
* object that merely contains a similar key as already sealed.
*/
const PLUGIN_CONFIG_ENVELOPE_KEY = "__netcatty_plugin_config_v1" as const;
const LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY = "__encryptedPluginConfig" as const;
/** At-rest envelope for ProviderConnection.credential (opaque refs only). */
const PLUGIN_CREDENTIAL_ENVELOPE_KEY = "__netcatty_plugin_credential_v1" as const;
type PluginConfigEnvelope = {
[PLUGIN_CONFIG_ENVELOPE_KEY]: string;
};
type PluginCredentialEnvelope = {
[PLUGIN_CREDENTIAL_ENVELOPE_KEY]: string;
};
function isPluginConfigEnvelope(value: unknown): value is PluginConfigEnvelope {
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
const keys = Object.keys(record);
return keys.length === 1
&& keys[0] === PLUGIN_CONFIG_ENVELOPE_KEY
&& typeof record[PLUGIN_CONFIG_ENVELOPE_KEY] === "string";
}
/** Legacy envelope shape (still accepted on decrypt for one migration hop). */
function isLegacyPluginConfigEnvelope(value: unknown): value is { __encryptedPluginConfig: string } {
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
const keys = Object.keys(record);
return keys.length === 1
&& keys[0] === LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY
&& typeof record[LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY] === "string";
}
function isPluginCredentialEnvelope(value: unknown): value is PluginCredentialEnvelope {
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
const keys = Object.keys(record);
return keys.length === 1
&& keys[0] === PLUGIN_CREDENTIAL_ENVELOPE_KEY
&& typeof record[PLUGIN_CREDENTIAL_ENVELOPE_KEY] === "string";
}
export async function encryptProviderSecrets(conn: ProviderConnection): Promise<ProviderConnection> {
const out = { ...conn };
@@ -207,97 +161,22 @@ export async function encryptProviderSecrets(conn: ProviderConnection): Promise<
out.tokens = t;
}
// Config may be a valid falsy scalar (false, 0, "") — only null/undefined means absent.
if (out.config != null) {
const providerId = String(out.provider ?? "");
const isBuiltin = providerId === "webdav"
|| providerId === "s3"
|| providerId === "github"
|| providerId === "google"
|| providerId === "onedrive";
// Built-in providers use field-level encryption; plugin IDs always seal
// the whole opaque config so field-name collisions cannot leak secrets.
if (isBuiltin && typeof out.config === "object" && "authType" in out.config) {
if (out.config) {
// WebDAV — use authType (required field unique to WebDAVConfig) as discriminator
// so that token-auth configs (which may lack a password key after JSON round-trip)
// still get their token field encrypted.
if ("authType" in out.config) {
const c = { ...out.config } as WebDAVConfig;
c.password = await encryptField(c.password);
c.token = await encryptField(c.token);
out.config = c;
} else if (isBuiltin && typeof out.config === "object" && "secretAccessKey" in out.config) {
}
// S3
if ("secretAccessKey" in out.config) {
const c = { ...out.config } as S3Config;
c.secretAccessKey = (await encryptField(c.secretAccessKey)) ?? "";
c.sessionToken = await encryptField(c.sessionToken);
out.config = c;
} else if (!isBuiltin) {
// Always (re)seal opaque plugin config. An exact marker-shaped object may
// be either a trusted host envelope or plugin-owned JSON that collides
// with our key — try unwrap; on failure seal the whole value as opaque.
let toSeal: unknown = out.config;
if (isPluginConfigEnvelope(out.config) || isLegacyPluginConfigEnvelope(out.config)) {
const sealedValue = isPluginConfigEnvelope(out.config)
? out.config[PLUGIN_CONFIG_ENVELOPE_KEY]
: out.config[LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY];
const plain = await decryptField(sealedValue);
if (plain != null && plain !== "") {
try {
toSeal = JSON.parse(plain);
} catch {
toSeal = out.config;
}
} else {
toSeal = out.config;
}
}
const sealed = await encryptField(JSON.stringify(toSeal));
if (sealed) {
out.config = {
[PLUGIN_CONFIG_ENVELOPE_KEY]: sealed,
} as ProviderConnection["config"];
}
}
}
// Seal durable plugin credential refs as one opaque blob (same threat model
// as plugin config: do not leave kind/id/key plaintext in localStorage).
if (out.credential != null && typeof out.credential === "object") {
let toSeal: unknown = out.credential;
if (isPluginCredentialEnvelope(out.credential)) {
const plain = await decryptField(out.credential[PLUGIN_CREDENTIAL_ENVELOPE_KEY]);
if (plain != null && plain !== "") {
try {
toSeal = JSON.parse(plain);
} catch {
toSeal = out.credential;
}
} else {
toSeal = out.credential;
}
}
const kind = (toSeal as { kind?: unknown }).kind;
const id = (toSeal as { id?: unknown }).id;
const key = (toSeal as { key?: unknown }).key;
if ((kind === "secret" || kind === "credential") && typeof id === "string" && id.length > 0) {
const normalized = {
kind,
id,
...(typeof key === "string" ? { key } : {}),
};
const sealed = await encryptField(JSON.stringify(normalized));
if (sealed) {
out.credential = {
[PLUGIN_CREDENTIAL_ENVELOPE_KEY]: sealed,
} as unknown as ProviderConnection["credential"];
}
} else if (isPluginCredentialEnvelope(toSeal)) {
// Marker-collision object that is not a durable ref — seal as opaque JSON.
const sealed = await encryptField(JSON.stringify(toSeal));
if (sealed) {
out.credential = {
[PLUGIN_CREDENTIAL_ENVELOPE_KEY]: sealed,
} as unknown as ProviderConnection["credential"];
}
} else {
// Drop leases / malformed shapes — never persist them at rest.
delete out.credential;
}
}
@@ -314,65 +193,18 @@ export async function decryptProviderSecrets(conn: ProviderConnection): Promise<
out.tokens = t;
}
// Config may be a valid falsy scalar — only null/undefined means absent.
if (out.config != null) {
const providerId = String(out.provider ?? "");
const isBuiltin = providerId === "webdav"
|| providerId === "s3"
|| providerId === "github"
|| providerId === "google"
|| providerId === "onedrive";
if (isBuiltin && typeof out.config === "object" && "authType" in out.config) {
if (out.config) {
if ("authType" in out.config) {
const c = { ...out.config } as WebDAVConfig;
c.password = await decryptField(c.password);
c.token = await decryptField(c.token);
out.config = c;
} else if (isBuiltin && typeof out.config === "object" && "secretAccessKey" in out.config) {
}
if ("secretAccessKey" in out.config) {
const c = { ...out.config } as S3Config;
c.secretAccessKey = (await decryptField(c.secretAccessKey)) ?? "";
c.sessionToken = await decryptField(c.sessionToken);
out.config = c;
} else if (isPluginConfigEnvelope(out.config) || isLegacyPluginConfigEnvelope(out.config)) {
const sealed = isPluginConfigEnvelope(out.config)
? out.config[PLUGIN_CONFIG_ENVELOPE_KEY]
: out.config[LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY];
const plain = await decryptField(sealed);
// plain may be JSON "false"/"0"/'""' — treat empty decrypt as failure only.
if (plain != null && plain !== "") {
try {
out.config = JSON.parse(plain) as ProviderConnection["config"];
} catch {
// leave sealed if corrupt
}
}
}
}
if (isPluginCredentialEnvelope(out.credential)) {
const plain = await decryptField(out.credential[PLUGIN_CREDENTIAL_ENVELOPE_KEY]);
if (plain != null && plain !== "") {
try {
const parsed = JSON.parse(plain) as {
kind?: unknown;
id?: unknown;
key?: unknown;
};
if (
(parsed.kind === "secret" || parsed.kind === "credential")
&& typeof parsed.id === "string"
&& parsed.id.length > 0
) {
out.credential = {
kind: parsed.kind,
id: parsed.id,
...(typeof parsed.key === "string" ? { key: parsed.key } : {}),
};
} else {
delete out.credential;
}
} catch {
// leave sealed if corrupt
}
}
}
+9 -49
View File
@@ -50,7 +50,6 @@ import {
completeGitHubAuthImpl,
completePKCEAuthImpl,
connectConfigProviderImpl,
connectPluginProviderImpl,
resetProviderStatusImpl,
setProviderErrorImpl,
clearConnectingStatusImpl,
@@ -119,7 +118,6 @@ import {
changeMasterKeyImpl,
verifyPasswordImpl,
handleProviderReauthRequiredImpl,
setAvailablePluginSyncProviderIdsImpl,
} from './cloudSync/stateAndSecurityMethods';
import {
clearConvergentSyncStorageImpl,
@@ -234,8 +232,6 @@ export class CloudSyncManager {
private providerWriteSeq: Record<CloudProvider, number> = {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
};
/** Optional abort signal for the in-flight syncNow / convergent upload path. */
activeSyncAbortSignal: AbortSignal | undefined;
constructor() {
this.state = this.loadInitialState();
@@ -347,13 +343,15 @@ export class CloudSyncManager {
*/
private notifyStateChange(): void {
// Deep clone the state to ensure all nested objects are new references
const providers: Record<CloudProvider, ProviderConnection> = {};
for (const [providerId, connection] of Object.entries(this.state.providers)) {
providers[providerId] = { ...connection };
}
this.stateSnapshot = {
...this.state,
providers,
providers: {
github: { ...this.state.providers.github },
google: { ...this.state.providers.google },
onedrive: { ...this.state.providers.onedrive },
webdav: { ...this.state.providers.webdav },
s3: { ...this.state.providers.s3 },
},
syncHistory: [...this.state.syncHistory],
convergentConflicts: [...this.state.convergentConflicts],
currentConflict: this.state.currentConflict ? { ...this.state.currentConflict } : null,
@@ -510,26 +508,6 @@ export class CloudSyncManager {
return connectConfigProviderImpl.call(this, provider, config);
}
/**
* Connect a namespaced plugin sync Provider (encrypted-object storage only).
*/
async connectPluginProvider(
providerId: string,
configuration: unknown = {},
credential?: unknown,
): Promise<void> {
return connectPluginProviderImpl.call(this, providerId, configuration, credential);
}
/**
* Replace the live set of contribution-available plugin sync provider IDs.
* Call when plugin contributions change so disabled/uninstalled providers
* leave the auto-sync ready set.
*/
setAvailablePluginSyncProviders(providerIds: readonly string[]): void {
setAvailablePluginSyncProviderIdsImpl.call(this, providerIds);
}
/**
* Reset provider status to disconnected without tearing down existing connections.
* Used when an auth attempt is cancelled/fails — avoids destroying a previously
@@ -717,16 +695,9 @@ export class CloudSyncManager {
payload: SyncPayload,
commitReplica: () => Promise<void>,
) => Promise<void>;
signal?: AbortSignal;
} = {},
): Promise<SyncResult> {
const previous = this.activeSyncAbortSignal;
this.activeSyncAbortSignal = opts.signal;
try {
return await syncToProviderImpl.call(this, provider, payload, opts);
} finally {
this.activeSyncAbortSignal = previous;
}
return syncToProviderImpl.call(this, provider, payload, opts);
}
/**
@@ -822,16 +793,9 @@ export class CloudSyncManager {
payload: SyncPayload,
commitReplica: () => Promise<void>,
) => Promise<void>;
signal?: AbortSignal;
} = {},
): Promise<Map<CloudProvider, SyncResult>> {
const previous = this.activeSyncAbortSignal;
this.activeSyncAbortSignal = opts.signal;
try {
return await syncAllProvidersImpl.call(this, inputPayload, opts);
} finally {
this.activeSyncAbortSignal = previous;
}
return syncAllProvidersImpl.call(this, inputPayload, opts);
}
// ==========================================================================
@@ -947,16 +911,12 @@ export class CloudSyncManager {
payload: SyncPayload,
commitReplica: () => Promise<void>,
) => Promise<void>,
options?: {
pluginSidecars?: SyncPayload['pluginSidecars'];
},
): Promise<{ payload: SyncPayload; results: Map<CloudProvider, SyncResult> }> {
return resolveConvergentConflictAndSyncImpl.call(
this,
addressKey,
candidateDot,
applyPayload,
options,
);
}
@@ -1,417 +0,0 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { SyncedFile } from '../../../domain/sync';
import type { CloudAdapter } from './index';
import {
cloudAdapterAsEncryptedObjectStorage,
encryptedObjectStorageAsCloudAdapter,
webdavEncryptedObjectCapabilities,
} from './encryptedObjectStorageBridge';
import { DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY } from '../../../domain/encryptedObjectStorage';
import { createPluginSyncObjectStorage } from './pluginSyncObjectStorage';
function makeSyncedFile(version: number, payload = 'cipher'): SyncedFile {
return {
meta: {
version,
updatedAt: 1,
deviceId: 'device',
appVersion: '0.0.0',
iv: 'iv',
salt: 'salt',
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
},
payload,
};
}
function memoryCloudAdapter(initial: SyncedFile | null = null): CloudAdapter & { store: SyncedFile | null } {
const adapter = {
store: initial,
isAuthenticated: true,
accountInfo: { id: 'webdav-host', name: 'user@host' },
resourceId: null as string | null,
signOut() {
adapter.isAuthenticated = false;
adapter.accountInfo = null;
adapter.store = null;
},
async initializeSync() {
adapter.resourceId = DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
return adapter.resourceId;
},
async upload(file: SyncedFile) {
adapter.store = file;
adapter.resourceId = DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
return adapter.resourceId;
},
async download() {
return adapter.store;
},
async deleteSync() {
adapter.store = null;
},
getTokens() {
return null;
},
};
return adapter;
}
describe('encryptedObjectStorageBridge', () => {
it('adapts WebDAV-style CloudAdapter through the encrypted-object interface', async () => {
const adapter = memoryCloudAdapter(makeSyncedFile(3, 'remote-cipher'));
const storage = cloudAdapterAsEncryptedObjectStorage(adapter, 'webdav', {
capabilities: webdavEncryptedObjectCapabilities(),
});
const caps = await storage.getCapabilities();
assert.equal(caps.atomicReplacement, true);
assert.equal(caps.revisions, false);
const connected = await storage.connect();
assert.equal(connected.account.id, 'webdav-host');
const read = await storage.readObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY);
assert.equal(read.found, true);
assert.ok(read.bytes);
assert.equal(read.revision, '3');
const next = makeSyncedFile(4, 'next-cipher');
const encoded = new TextEncoder().encode(JSON.stringify(next));
const written = await storage.writeObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY, encoded);
assert.equal(written.created, false);
assert.equal(written.revision, '4');
const reloaded = await adapter.download();
assert.equal(reloaded?.payload, 'next-cipher');
const deleted = await storage.deleteObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY);
assert.equal(deleted.deleted, true);
assert.equal(await adapter.download(), null);
});
it('adapts EncryptedObjectStorage back to CloudAdapter for manager upload/download', async () => {
const memory = new Map<string, Uint8Array>();
let backingResourceId: string | null = '/persisted/path.json';
const storage = {
providerId: 'com.example.sync',
async connect() {
backingResourceId = '/after-connect/path.json';
return { account: { id: 'plugin-acct' } };
},
async disconnect() {},
async getAccount() {
return { id: 'plugin-acct' };
},
async getCapabilities() {
return {
revisions: true,
conditionalWrites: true,
atomicReplacement: true,
maxObjectBytes: 1024,
};
},
async readObject(key: string) {
const bytes = memory.get(key) ?? null;
return bytes
? { found: true as const, key, bytes, revision: 'r1' }
: { found: false as const, key, bytes: null };
},
async writeObject(key: string, bytes: Uint8Array) {
const created = !memory.has(key);
memory.set(key, bytes);
return { created, revision: 'r2' };
},
async deleteObject(key: string) {
const deleted = memory.delete(key);
return { deleted };
},
};
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
initiallyAuthenticated: true,
resourceId: '/persisted/path.json',
resolveResourceId: () => backingResourceId,
});
assert.equal(adapter.isAuthenticated, true);
assert.equal(adapter.resourceId, '/persisted/path.json');
await adapter.initializeSync();
assert.equal(adapter.accountInfo?.id, 'plugin-acct');
assert.equal(adapter.resourceId, '/after-connect/path.json');
const file = makeSyncedFile(9, 'plugin-cipher');
await adapter.upload(file);
const downloaded = await adapter.download();
assert.equal(downloaded?.payload, 'plugin-cipher');
assert.equal(downloaded?.meta.version, 9);
});
// note: upload() re-reads for verification; memory map above is sufficient
it('rebindSession re-issues connect after a prior session for plugin runtime replacement', async () => {
const ops: string[] = [];
const storage = {
providerId: 'com.example.sync',
async connect() {
ops.push('connect');
return { account: { id: 'a1' } };
},
async disconnect() {},
async getAccount() {
return { id: 'a1' };
},
async getCapabilities() {
return { revisions: false, conditionalWrites: false, atomicReplacement: true };
},
async readObject(key: string) {
return { found: false as const, key, bytes: null };
},
async writeObject() {
return { created: true as const };
},
async deleteObject() {
return { deleted: false as const };
},
};
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
initiallyAuthenticated: true,
rebindSession: true,
});
await adapter.initializeSync();
await adapter.download();
await adapter.download();
assert.ok(ops.filter((op) => op === 'connect').length >= 3,
'rebindSession must call connect on each ensureConnected path');
});
it('re-issues connect after an I/O failure so replaced runtimes can recover', async () => {
const ops: string[] = [];
let failNextRead = true;
const storage = {
providerId: 'com.example.sync',
async connect() {
ops.push('connect');
return { account: { id: 'a1' } };
},
async disconnect() {},
async getAccount() {
return { id: 'a1' };
},
async getCapabilities() {
return { revisions: false, conditionalWrites: false, atomicReplacement: true };
},
async readObject(key: string) {
if (failNextRead) {
failNextRead = false;
throw new Error('runtime gone');
}
return { found: false as const, key, bytes: null };
},
async writeObject() {
return { created: true as const };
},
async deleteObject() {
return { deleted: false as const };
},
};
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
initiallyAuthenticated: true,
});
await adapter.initializeSync();
assert.equal(ops.filter((op) => op === 'connect').length, 1);
await assert.rejects(() => adapter.download(), /runtime gone/);
await adapter.download();
assert.equal(
ops.filter((op) => op === 'connect').length,
2,
'failed I/O must stale the session so the next ensureConnected rebinds',
);
});
it('lazy-connects before first I/O and passes revisions for conditional writes', async () => {
const ops: string[] = [];
let revision: string | undefined = 'rev-1';
let stored: Uint8Array | null = new TextEncoder().encode(JSON.stringify(makeSyncedFile(1, 'c')));
const storage = {
providerId: 'com.example.sync',
async connect() {
ops.push('connect');
return { account: { id: 'a1' } };
},
async disconnect() {
ops.push('disconnect');
},
async getAccount() {
return { id: 'a1' };
},
async getCapabilities() {
return { revisions: true, conditionalWrites: true, atomicReplacement: true };
},
async readObject(key: string) {
ops.push(`read:${key}:${revision ?? ''}`);
if (!stored) return { found: false as const, key, bytes: null };
return {
found: true as const,
key,
bytes: stored,
revision,
};
},
async writeObject(key: string, bytes: Uint8Array, options?: { expectedRevision?: string | null }) {
ops.push(`write:${key}:${options?.expectedRevision === null ? 'null' : (options?.expectedRevision ?? '')}`);
stored = bytes;
revision = revision ? 'rev-2' : 'rev-new';
return { created: !revision || revision === 'rev-new', revision };
},
async deleteObject() {
return { deleted: true };
},
};
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
initiallyAuthenticated: true,
});
// Restored session: authenticated for cache reuse, but not yet connected.
assert.equal(adapter.isAuthenticated, true);
const downloaded = await adapter.download();
assert.equal(downloaded?.payload, 'c');
assert.deepEqual(ops[0], 'connect');
assert.ok(ops.some((op) => op.startsWith('read:')));
await adapter.upload(makeSyncedFile(2, 'next'));
assert.ok(
ops.some((op) => op === 'write:netcatty-vault.json:rev-1'),
`expected conditional write with rev-1, got ${JSON.stringify(ops)}`,
);
// Confirmed absence → must-not-exist (expectedRevision null)
stored = null;
revision = undefined;
const adapter2 = encryptedObjectStorageAsCloudAdapter(storage, {
initiallyAuthenticated: true,
});
assert.equal(await adapter2.download(), null);
await adapter2.upload(makeSyncedFile(3, 'fresh'));
assert.ok(
ops.some((op) => op === 'write:netcatty-vault.json:null'),
`expected must-not-exist write, got ${JSON.stringify(ops)}`,
);
});
it('plugin sync object storage only forwards encrypted bytes to the host', async () => {
const calls: string[] = [];
const host = {
async connectSync(params: { providerId: string; configuration?: unknown }) {
calls.push(`connect:${params.providerId}`);
assert.deepEqual(params.configuration, { endpoint: 'https://example.test' });
return { account: { id: 'a1', name: 'Plugin' } };
},
async disconnectSync() {
calls.push('disconnect');
return null;
},
async getSyncAccount() {
return { account: { id: 'a1' } };
},
async getSyncCapabilities() {
return {
revisions: true,
conditionalWrites: true,
atomicReplacement: false,
maxObjectBytes: 64,
};
},
async readSyncObject(params: { key: string }) {
calls.push(`read:${params.key}`);
return {
found: true,
key: params.key,
bytes: new Uint8Array([1, 2, 3]),
revision: 'rev-1',
};
},
async writeSyncObject(params: { key: string; bytes: Uint8Array; expectedRevision?: string | null }) {
calls.push(`write:${params.key}:${params.bytes.byteLength}:${params.expectedRevision ?? ''}`);
// Ensure bytes look like opaque ciphertext, not a vault JSON root.
assert.equal(params.bytes[0], 0x9b);
return { created: true, revision: 'rev-2' };
},
async deleteSyncObject(params: { key: string }) {
calls.push(`delete:${params.key}`);
return { deleted: true };
},
};
const storage = createPluginSyncObjectStorage({
providerId: 'com.example.sync',
host,
configuration: { endpoint: 'https://example.test' },
});
await storage.connect();
const caps = await storage.getCapabilities();
assert.equal(caps.conditionalWrites, true);
const read = await storage.readObject('vault');
assert.deepEqual([...read.bytes!], [1, 2, 3]);
await storage.writeObject('vault', new Uint8Array([0x9b, 0x01]), { expectedRevision: null });
await storage.deleteObject('vault');
await storage.disconnect();
assert.deepEqual(calls, [
'connect:com.example.sync',
'read:vault',
'write:vault:2:',
'delete:vault',
'disconnect',
]);
});
it('skips host re-read when assumeVerifiedWrites is set (WebDAV path)', async () => {
const ops: string[] = [];
let stored: Uint8Array | null = null;
const storage = {
providerId: 'webdav',
async connect() {
ops.push('connect');
return { account: { id: 'w1' } };
},
async disconnect() {},
async getAccount() {
return { id: 'w1' };
},
async getCapabilities() {
return webdavEncryptedObjectCapabilities();
},
async readObject(key: string) {
ops.push(`read:${key}`);
if (!stored) return { found: false as const, key, bytes: null };
return { found: true as const, key, bytes: stored, revision: '1' };
},
async writeObject(key: string, bytes: Uint8Array) {
ops.push(`write:${key}`);
stored = bytes;
return { created: true as const, revision: '1' };
},
async deleteObject() {
return { deleted: true as const };
},
};
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
initiallyAuthenticated: true,
assumeVerifiedWrites: true,
});
await adapter.upload(makeSyncedFile(1, 'body'));
assert.deepEqual(
ops.filter((op) => op.startsWith('read:')),
[],
'assumeVerifiedWrites must not re-read after write',
);
assert.ok(ops.includes('write:netcatty-vault.json'));
});
});
@@ -1,387 +0,0 @@
/**
* Bridges between the legacy single-file CloudAdapter interface and the
* shared EncryptedObjectStorage surface used by plugin sync Providers.
*/
import type {
EncryptedObjectAccount,
EncryptedObjectDeleteResult,
EncryptedObjectReadResult,
EncryptedObjectStorage,
EncryptedObjectStorageCapabilities,
EncryptedObjectWriteResult,
} from '../../../domain/encryptedObjectStorage';
// EncryptedObjectStorageCapabilities used for session capability gating.
import {
DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY,
} from '../../../domain/encryptedObjectStorage';
import type {
CloudProvider,
OAuthTokens,
ProviderAccount,
SyncedFile,
} from '../../../domain/sync';
import type { CloudAdapter } from './index';
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
function syncedFileToBytes(syncedFile: SyncedFile): Uint8Array {
return textEncoder.encode(JSON.stringify(syncedFile));
}
function bytesToSyncedFile(bytes: Uint8Array): SyncedFile {
const raw = textDecoder.decode(bytes);
// Require a complete JSON object. Do not accept a valid prefix with trailing
// garbage — that hides provider corruption from read-and-verify recovery.
return JSON.parse(raw) as SyncedFile;
}
/** Thrown when a conditional write is rejected (revision / precondition). */
export class ConditionalWriteConflictError extends Error {
readonly code = 'conditional_write_conflict';
constructor(message = 'Encrypted object conditional write was rejected', options?: { cause?: unknown }) {
super(message, options);
this.name = 'ConditionalWriteConflictError';
}
}
export function isConditionalWriteConflictError(error: unknown): boolean {
if (error instanceof ConditionalWriteConflictError) return true;
if (!error || typeof error !== 'object') return false;
const maybe = error as { name?: unknown; code?: unknown; message?: unknown; data?: { pluginCode?: unknown } };
if (maybe.name === 'ConditionalWriteConflictError') return true;
if (maybe.code === 'conditional_write_conflict' || maybe.code === -32009 || maybe.code === 'failed_precondition') {
return true;
}
if (maybe.data?.pluginCode === 'failed_precondition') return true;
return typeof maybe.message === 'string'
&& /failed[_ ]precondition|expectedRevision|revision mismatch|conditional write/i.test(maybe.message);
}
/**
* Adapt a legacy CloudAdapter into EncryptedObjectStorage.
* Uses a single default object key matching the historical vault file name.
*/
export function cloudAdapterAsEncryptedObjectStorage(
adapter: CloudAdapter,
providerId: string,
options: {
objectKey?: string;
capabilities?: EncryptedObjectStorageCapabilities;
} = {},
): EncryptedObjectStorage {
const objectKey = options.objectKey ?? DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
const capabilities: EncryptedObjectStorageCapabilities = options.capabilities ?? {
revisions: false,
conditionalWrites: false,
atomicReplacement: true,
};
return {
providerId,
async connect(): Promise<{ account: EncryptedObjectAccount }> {
if (!adapter.isAuthenticated) {
throw new Error(`Cloud provider ${providerId} is not authenticated`);
}
await adapter.initializeSync();
const account = adapter.accountInfo;
if (!account) {
return { account: { id: providerId } };
}
return { account: { ...account } };
},
async disconnect(): Promise<void> {
adapter.signOut();
},
async getAccount(): Promise<EncryptedObjectAccount | null> {
return adapter.accountInfo ? { ...adapter.accountInfo } : null;
},
async getCapabilities(): Promise<EncryptedObjectStorageCapabilities> {
return { ...capabilities };
},
async readObject(key: string): Promise<EncryptedObjectReadResult> {
if (key !== objectKey) {
return { found: false, key, bytes: null };
}
const file = await adapter.download();
if (!file) return { found: false, key, bytes: null };
const bytes = syncedFileToBytes(file);
return {
found: true,
key,
bytes,
revision: file.meta?.version != null ? String(file.meta.version) : undefined,
contentType: 'application/json',
};
},
async writeObject(key: string, bytes: Uint8Array): Promise<EncryptedObjectWriteResult> {
if (key !== objectKey) {
throw new Error(`Cloud adapter ${providerId} only supports object key ${objectKey}`);
}
const syncedFile = bytesToSyncedFile(bytes);
// Skip a pre-upload GET for `created`. The single-object vault path does not
// consume that flag, and WebDAV already pays for pad+verify inside upload.
await adapter.upload(syncedFile);
return {
created: false,
revision: syncedFile.meta?.version != null ? String(syncedFile.meta.version) : undefined,
};
},
async deleteObject(key: string): Promise<EncryptedObjectDeleteResult> {
if (key !== objectKey) {
return { deleted: false };
}
const existing = await adapter.download();
if (!existing) return { deleted: false };
await adapter.deleteSync();
return { deleted: true };
},
};
}
/**
* Adapt EncryptedObjectStorage into the legacy CloudAdapter surface so the
* existing encrypt→upload / download→decrypt manager path can drive WebDAV and
* plugin providers through one code path.
*
* Authentication and resourceId must match pre-bridge CloudAdapter semantics:
* config-backed providers (WebDAV) report authenticated as soon as credentials
* exist so getConnectedAdapter can reuse the cached instance; resourceId must
* preserve the persisted path (or the backing adapter's authoritative id) rather
* than always forcing the default object key.
*/
export function encryptedObjectStorageAsCloudAdapter(
storage: EncryptedObjectStorage,
options: {
objectKey?: string;
account?: ProviderAccount | null;
/** When true, getConnectedAdapter reuses this instance without recreating. */
initiallyAuthenticated?: boolean;
/** Seeded resource id (e.g. path restored from provider connection storage). */
resourceId?: string | null;
/**
* Prefer the backing adapter's resource id after connect/upload (WebDAV sets
* `/netcatty-vault.json` via initializeSync; plugins may use object keys).
*/
resolveResourceId?: () => string | null | undefined;
/**
* When true, re-issue connect() even if a prior session was established.
* Required for plugin providers whose in-process clients die on runtime
* restart/replace while the CloudAdapter cache stays alive.
*/
rebindSession?: boolean;
/**
* When true, skip the host full-byte re-read after writeObject. Use only when
* the backing storage already performs Netcatty-grade write verification
* (WebDAV pad+verify). Plugin providers keep host-owned verify.
*/
assumeVerifiedWrites?: boolean;
} = {},
): CloudAdapter {
const objectKey = options.objectKey ?? DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
let account: ProviderAccount | null = options.account ?? null;
let resourceId: string | null = options.resourceId ?? null;
let authenticated = options.initiallyAuthenticated === true;
/** Distinct from credential presence: plugin providers need an explicit connect. */
let sessionConnected = false;
/**
* Last observed remote revision for conditional writes.
* - string: known revision
* - null: confirmed absent (must-not-exist write)
* - undefined: unknown / unconditional
*/
let lastRevision: string | null | undefined;
const refreshResourceId = (fallback?: string | null): string | null => {
const resolved = options.resolveResourceId?.();
if (typeof resolved === 'string' && resolved.length > 0) {
resourceId = resolved;
return resourceId;
}
if (typeof fallback === 'string' && fallback.length > 0) {
resourceId = fallback;
return resourceId;
}
return resourceId;
};
let capabilities: EncryptedObjectStorageCapabilities | null = null;
const ensureConnected = async (): Promise<void> => {
if (sessionConnected && !options.rebindSession) return;
// rebindSession: always re-run connect so a replaced plugin runtime gets a
// fresh client. Connect is expected to be idempotent when still healthy.
const result = await storage.connect();
account = result.account;
capabilities = await storage.getCapabilities();
authenticated = true;
sessionConnected = true;
refreshResourceId(objectKey);
};
/** After I/O failure, force the next ensureConnected to re-issue connect(). */
const markSessionStale = (): void => {
sessionConnected = false;
};
return {
get isAuthenticated() {
return authenticated;
},
get accountInfo() {
return account;
},
get resourceId() {
return resourceId;
},
signOut() {
authenticated = false;
sessionConnected = false;
lastRevision = undefined;
capabilities = null;
account = null;
resourceId = null;
// Fire-and-forget is intentional for CloudAdapter.signOut sync API;
// disconnectProvider should call initializeSync/connect after a full await
// path when a future async signOut is added to CloudAdapter.
void Promise.resolve(storage.disconnect()).catch(() => {
// Plugin runtime may already be gone; local sign-out still succeeds.
});
},
async initializeSync(): Promise<string | null> {
try {
await ensureConnected();
return refreshResourceId(objectKey);
} catch (error) {
markSessionStale();
throw error;
}
},
async upload(syncedFile: SyncedFile, uploadOptions?: { signal?: AbortSignal }): Promise<string> {
try {
await ensureConnected();
const bytes = syncedFileToBytes(syncedFile);
if (
capabilities?.maxObjectBytes != null
&& bytes.byteLength > capabilities.maxObjectBytes
) {
throw new Error(
`Encrypted object exceeds provider maxObjectBytes (${capabilities.maxObjectBytes})`,
);
}
if (
capabilities?.maxObjects != null
&& capabilities.maxObjects < 1
) {
throw new Error(
`Encrypted object provider reports maxObjects < 1 (${capabilities.maxObjects})`,
);
}
const conditional = capabilities?.conditionalWrites === true;
let writeResult: EncryptedObjectWriteResult;
try {
writeResult = await storage.writeObject(objectKey, bytes, {
...(conditional && lastRevision !== undefined
? { expectedRevision: lastRevision }
: {}),
signal: uploadOptions?.signal,
});
} catch (writeError) {
if (conditional && isConditionalWriteConflictError(writeError)) {
throw new ConditionalWriteConflictError(
writeError instanceof Error ? writeError.message : String(writeError),
{ cause: writeError },
);
}
throw writeError;
}
if (options.assumeVerifiedWrites === true) {
// Backing adapter already verified (e.g. WebDAV pad+verify). Still honor
// maxObjectBytes above and refresh revision from the write result.
if (typeof writeResult.revision === 'string' && writeResult.revision.length > 0) {
lastRevision = writeResult.revision;
}
} else {
// Host-owned write verification: re-read and compare ciphertext bytes.
const verified = await storage.readObject(objectKey, { signal: uploadOptions?.signal });
if (!verified.found || !verified.bytes) {
throw new Error('Encrypted object write verification failed: object missing after write');
}
if (verified.bytes.byteLength !== bytes.byteLength) {
throw new Error('Encrypted object write verification failed: size mismatch');
}
for (let i = 0; i < bytes.byteLength; i += 1) {
if (verified.bytes[i] !== bytes[i]) {
throw new Error('Encrypted object write verification failed: content mismatch');
}
}
if (typeof writeResult.revision === 'string' && writeResult.revision.length > 0) {
lastRevision = writeResult.revision;
} else if (typeof verified.revision === 'string' && verified.revision.length > 0) {
lastRevision = verified.revision;
} else {
lastRevision = undefined;
}
}
authenticated = true;
return refreshResourceId(objectKey) ?? objectKey;
} catch (error) {
markSessionStale();
throw error;
}
},
async download(downloadOptions?: { signal?: AbortSignal }): Promise<SyncedFile | null> {
try {
await ensureConnected();
const result = await storage.readObject(objectKey, { signal: downloadOptions?.signal });
if (!result.found || !result.bytes) {
// Confirmed absence: next conditional write must use expectedRevision null.
lastRevision = null;
return null;
}
if (typeof result.revision === 'string' && result.revision.length > 0) {
lastRevision = result.revision;
} else {
lastRevision = undefined;
}
return bytesToSyncedFile(result.bytes);
} catch (error) {
markSessionStale();
throw error;
}
},
async deleteSync(deleteOptions?: { signal?: AbortSignal }): Promise<void> {
try {
await ensureConnected();
await storage.deleteObject(objectKey, {
...(typeof lastRevision === 'string' ? { expectedRevision: lastRevision } : {}),
signal: deleteOptions?.signal,
});
lastRevision = null;
} catch (error) {
markSessionStale();
throw error;
}
},
getTokens(): OAuthTokens | null {
return null;
},
};
}
/**
* WebDAV-specific capabilities: atomic replacement via temp+MOVE, no native revisions.
*/
export function webdavEncryptedObjectCapabilities(): EncryptedObjectStorageCapabilities {
return {
revisions: false,
conditionalWrites: false,
atomicReplacement: true,
};
}
export function isWebdavProvider(provider: CloudProvider): boolean {
return provider === 'webdav';
}
+8 -77
View File
@@ -10,13 +10,6 @@ import type {
WebDAVConfig,
S3Config,
} from '../../../domain/sync';
import { isBuiltinCloudProvider } from '../../../domain/sync';
import type { EncryptedObjectStorage } from '../../../domain/encryptedObjectStorage';
import {
cloudAdapterAsEncryptedObjectStorage,
encryptedObjectStorageAsCloudAdapter,
webdavEncryptedObjectCapabilities,
} from './encryptedObjectStorageBridge';
/**
* Unified adapter interface
@@ -28,28 +21,20 @@ export interface CloudAdapter {
signOut(): void;
initializeSync(): Promise<string | null>;
upload(syncedFile: SyncedFile, options?: { signal?: AbortSignal }): Promise<string>;
download(options?: { signal?: AbortSignal }): Promise<SyncedFile | null>;
deleteSync(options?: { signal?: AbortSignal }): Promise<void>;
upload(syncedFile: SyncedFile): Promise<string>;
download(): Promise<SyncedFile | null>;
deleteSync(): Promise<void>;
getTokens(): OAuthTokens | null;
}
export type { EncryptedObjectStorage };
/**
* Create adapter for a specific provider.
* Built-in providers keep their dedicated adapters. Namespaced plugin provider
* IDs require an EncryptedObjectStorage factory (plugin host bridge).
* Create adapter for a specific provider
*/
export const createAdapter = async (
provider: CloudProvider,
tokens?: OAuthTokens,
resourceId?: string,
config?: WebDAVConfig | S3Config,
options?: {
/** Plugin sync storage factory for namespaced provider IDs. */
createPluginStorage?: (providerId: string) => EncryptedObjectStorage | Promise<EncryptedObjectStorage>;
},
config?: WebDAVConfig | S3Config
): Promise<CloudAdapter> => {
switch (provider) {
case 'github': {
@@ -66,67 +51,13 @@ export const createAdapter = async (
}
case 'webdav': {
const { WebDAVAdapter } = await import('./WebDAVAdapter');
// Production WebDAV path runs through EncryptedObjectStorage so plugin
// providers and WebDAV share one encrypt→write / read→decrypt surface.
const raw = new WebDAVAdapter(config as WebDAVConfig | undefined, resourceId);
const storage = cloudAdapterAsEncryptedObjectStorage(raw, 'webdav', {
capabilities: webdavEncryptedObjectCapabilities(),
});
return encryptedObjectStorageAsCloudAdapter(storage, {
account: raw.accountInfo,
// Match raw WebDAVAdapter: config present ⇒ authenticated for cache reuse.
initiallyAuthenticated: raw.isAuthenticated,
// Preserve constructor/persisted resourceId; refresh from raw after connect.
resourceId: raw.resourceId ?? resourceId ?? null,
resolveResourceId: () => raw.resourceId,
// WebDAVAdapter.upload already performs pad + strict body verify; skip the
// shared bridge's second full re-read to avoid write amplification.
assumeVerifiedWrites: true,
});
return new WebDAVAdapter(config as WebDAVConfig | undefined, resourceId);
}
case 's3': {
const { S3Adapter } = await import('./S3Adapter');
return new S3Adapter(config as S3Config | undefined, resourceId);
}
default: {
if (isBuiltinCloudProvider(provider)) {
throw new Error(`Unknown provider: ${provider}`);
}
if (!options?.createPluginStorage) {
throw new Error(
`Plugin sync provider ${provider} is unavailable (missing or not registered)`,
);
}
const storage = await options.createPluginStorage(provider);
// Credentials/config already validated by getConnectedAdapter; report
// authenticated so the manager reuses this instance across sync calls.
// rebindSession: plugin runtimes can restart while availability membership
// stays unchanged, so ensureConnected must re-issue connect() with the
// saved configuration/credential instead of skipping after the first call.
return encryptedObjectStorageAsCloudAdapter(storage, {
initiallyAuthenticated: true,
resourceId: resourceId ?? null,
rebindSession: true,
});
}
default:
throw new Error(`Unknown provider: ${provider}`);
}
};
/**
* View a CloudAdapter as EncryptedObjectStorage. WebDAV is the first built-in
* adapted through this shared surface for configuration, secrets, upload,
* download, verification, and recovery exercises.
*/
export const asEncryptedObjectStorage = (
provider: CloudProvider,
adapter: CloudAdapter,
): EncryptedObjectStorage =>
cloudAdapterAsEncryptedObjectStorage(adapter, provider, {
capabilities: provider === 'webdav'
? webdavEncryptedObjectCapabilities()
: {
revisions: provider === 'github',
conditionalWrites: false,
atomicReplacement: provider === 'webdav' || provider === 's3',
},
});
@@ -1,415 +0,0 @@
/**
* Renderer-side host that drives plugin sync Providers over the preload IPC
* surface. Main process extensionProviderService owns activation, permission
* checks, and stream handling; this client shuttles already-encrypted object
* bytes with inline Uint8Array for small objects and pull/chunked transfers
* above the SyncLimits inline cutoff.
*/
import {
PLUGIN_SYNC_INLINE_OBJECT_BYTES,
} from '@netcatty/plugin-contract';
import type {
EncryptedObjectAccount,
EncryptedObjectStorageCapabilities,
} from '../../../domain/encryptedObjectStorage';
import type { PluginSyncProviderHost } from './pluginSyncObjectStorage';
/** Match host STREAM_WINDOW_BYTES so each IPC chunk maps 1:1 to a plugin stream write. */
const STREAM_WINDOW_BYTES = 256 * 1024;
/** Same as main INLINE_SYNC_OBJECT_SAFE_BYTES (aligned SyncLimits.inlineObjectBytes). */
const INLINE_SYNC_OBJECT_SAFE_BYTES = PLUGIN_SYNC_INLINE_OBJECT_BYTES;
type ElectronPluginSyncApi = {
cancelPluginExtensionRequest?: (requestId: string) => Promise<boolean>;
pluginSyncConnect?: (params: {
requestId: string;
providerId: string;
configuration?: unknown;
credential?: unknown;
deadlineMs?: number;
}) => Promise<{ account: EncryptedObjectAccount }>;
pluginSyncDisconnect?: (params: {
requestId: string;
providerId: string;
deadlineMs?: number;
}) => Promise<null>;
pluginSyncGetAccount?: (params: {
requestId: string;
providerId: string;
deadlineMs?: number;
}) => Promise<{ account: EncryptedObjectAccount | null }>;
pluginSyncGetCapabilities?: (params: {
requestId: string;
providerId: string;
deadlineMs?: number;
}) => Promise<EncryptedObjectStorageCapabilities>;
pluginSyncReadObject?: (params: {
requestId: string;
providerId: string;
key: string;
preferStream?: boolean;
deadlineMs?: number;
}) => Promise<{
found: boolean;
key: string;
data?: Uint8Array | null;
streamed?: boolean;
transferId?: string;
byteLength?: number;
revision?: string;
contentType?: string;
}>;
pluginSyncReadChunk?: (params: {
requestId: string;
transferId: string;
maxBytes?: number;
}) => Promise<{ chunk: Uint8Array; done: boolean }>;
pluginSyncWriteObject?: (params: {
requestId: string;
providerId: string;
key: string;
data: Uint8Array;
expectedRevision?: string | null;
preferStream?: boolean;
deadlineMs?: number;
}) => Promise<{ created: boolean; revision?: string }>;
pluginSyncWriteBegin?: (params: {
requestId: string;
providerId: string;
key: string;
byteLength: number;
expectedRevision?: string | null;
deadlineMs?: number;
}) => Promise<{ transferId: string; windowBytes: number }>;
pluginSyncWriteChunk?: (params: {
requestId: string;
transferId: string;
sequence: number;
chunk: Uint8Array;
}) => Promise<{ accepted: number }>;
pluginSyncWriteCommit?: (params: {
requestId: string;
transferId: string;
}) => Promise<{ created: boolean; revision?: string }>;
pluginSyncDeleteObject?: (params: {
requestId: string;
providerId: string;
key: string;
expectedRevision?: string;
deadlineMs?: number;
}) => Promise<{ deleted: boolean }>;
pluginSyncPutSecret?: (params: {
providerId: string;
key: string;
value: string;
}) => Promise<{ kind: 'secret'; id: string; key: string }>;
pluginSyncDeleteSecrets?: (params: {
providerId: string;
keys?: string[];
}) => Promise<{ deleted: number }>;
};
function getPluginSyncApi(): ElectronPluginSyncApi | null {
if (typeof window === 'undefined') return null;
// Preload exposes the production bridge as window.netcatty only.
const bridge = (window as Window & {
netcatty?: ElectronPluginSyncApi;
electron?: ElectronPluginSyncApi;
}).netcatty
?? (window as Window & { electron?: ElectronPluginSyncApi }).electron;
return bridge ?? null;
}
function mintRequestId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `sync-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function coerceBytes(value: unknown): Uint8Array {
if (value instanceof Uint8Array) return value;
if (ArrayBuffer.isView(value)) {
const view = value as ArrayBufferView;
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}
if (value && typeof value === 'object' && (value as { type?: string }).type === 'Buffer'
&& Array.isArray((value as { data?: unknown }).data)) {
return Uint8Array.from((value as { data: number[] }).data);
}
throw new Error('Plugin sync IPC expected binary bytes');
}
async function withAbortSignal<T>(
api: ElectronPluginSyncApi,
signal: AbortSignal | undefined,
run: (requestId: string) => Promise<T>,
): Promise<T> {
const requestId = mintRequestId();
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
let onAbort: (() => void) | undefined;
if (signal) {
onAbort = () => {
void api.cancelPluginExtensionRequest?.(requestId).catch(() => false);
};
signal.addEventListener('abort', onAbort, { once: true });
}
try {
return await run(requestId);
} catch (error) {
// Release main-process transfer / abort controller on stream or RPC failure
// even when the caller did not abort (TTL alone is not enough).
try {
await api.cancelPluginExtensionRequest?.(requestId);
} catch {
/* ignore */
}
throw error;
} finally {
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
}
}
export function isPluginSyncIpcAvailable(): boolean {
const api = getPluginSyncApi();
if (
typeof api?.pluginSyncConnect !== 'function'
|| typeof api?.pluginSyncReadObject !== 'function'
|| typeof api?.pluginSyncWriteObject !== 'function'
) {
return false;
}
// Preload always exposes the IPC methods; the host is only ready when the
// main process has a live plugin host (sync sidecar service wired).
const ready = (api as { pluginHostReady?: () => boolean }).pluginHostReady;
if (typeof ready === 'function') {
try {
return ready() === true;
} catch {
return false;
}
}
return true;
}
/**
* Create a PluginSyncProviderHost bound to the renderer preload API.
* Throws when the plugin development gate / host is unavailable.
*/
export function createPluginSyncIpcHost(): PluginSyncProviderHost {
return {
async connectSync(params, options) {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncConnect !== 'function') {
throw new Error('Plugin sync host is unavailable');
}
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncConnect!({
...params,
requestId,
}));
},
async disconnectSync(params, options) {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncDisconnect !== 'function') {
throw new Error('Plugin sync host is unavailable');
}
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncDisconnect!({
...params,
requestId,
}));
},
async getSyncAccount(params, options) {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncGetAccount !== 'function') {
throw new Error('Plugin sync host is unavailable');
}
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncGetAccount!({
...params,
requestId,
}));
},
async getSyncCapabilities(params, options) {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncGetCapabilities !== 'function') {
throw new Error('Plugin sync host is unavailable');
}
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncGetCapabilities!({
...params,
requestId,
}));
},
async readSyncObject(params, options) {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncReadObject !== 'function') {
throw new Error('Plugin sync host is unavailable');
}
return withAbortSignal(api, options?.signal, async (requestId) => {
const preferStream = params.preferStream === true;
const result = await api.pluginSyncReadObject!({
...params,
preferStream,
requestId,
});
if (!result.found) {
return { found: false, key: params.key, bytes: null };
}
if (result.streamed === true && typeof result.transferId === 'string') {
if (typeof api.pluginSyncReadChunk !== 'function') {
throw new Error('Plugin sync streamed read is unavailable');
}
const total = result.byteLength;
if (!Number.isSafeInteger(total) || total < 1) {
throw new Error('Plugin sync streamed read missing byteLength');
}
const parts: Uint8Array[] = [];
let received = 0;
for (;;) {
if (options?.signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const { chunk, done } = await api.pluginSyncReadChunk({
requestId,
transferId: result.transferId,
maxBytes: STREAM_WINDOW_BYTES,
});
const bytes = coerceBytes(chunk);
parts.push(bytes);
received += bytes.byteLength;
if (received > total) {
throw new Error('Plugin sync read exceeded declared byteLength');
}
if (done) break;
}
if (received !== total) {
throw new Error('Plugin sync read size does not match byteLength');
}
const merged = new Uint8Array(received);
let offset = 0;
for (const part of parts) {
merged.set(part, offset);
offset += part.byteLength;
}
return {
found: true,
key: result.key,
bytes: merged,
revision: result.revision,
contentType: result.contentType,
};
}
if (result.data == null) {
return { found: false, key: params.key, bytes: null };
}
return {
found: true,
key: result.key,
bytes: coerceBytes(result.data),
revision: result.revision,
contentType: result.contentType,
};
});
},
async writeSyncObject(params, options) {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncWriteObject !== 'function') {
throw new Error('Plugin sync host is unavailable');
}
return withAbortSignal(api, options?.signal, async (requestId) => {
const bytes = params.bytes instanceof Uint8Array
? params.bytes
: new Uint8Array(params.bytes);
const useStream = bytes.byteLength > INLINE_SYNC_OBJECT_SAFE_BYTES
|| params.preferStream === true;
if (!useStream) {
return api.pluginSyncWriteObject!({
requestId,
providerId: params.providerId,
key: params.key,
data: bytes,
expectedRevision: params.expectedRevision,
preferStream: false,
deadlineMs: params.deadlineMs,
});
}
if (
typeof api.pluginSyncWriteBegin !== 'function'
|| typeof api.pluginSyncWriteChunk !== 'function'
|| typeof api.pluginSyncWriteCommit !== 'function'
) {
throw new Error('Plugin sync streamed write is unavailable');
}
const { transferId, windowBytes } = await api.pluginSyncWriteBegin({
requestId,
providerId: params.providerId,
key: params.key,
byteLength: bytes.byteLength,
expectedRevision: params.expectedRevision,
deadlineMs: params.deadlineMs,
});
const chunkSize = Number.isSafeInteger(windowBytes) && windowBytes > 0
? Math.min(windowBytes, STREAM_WINDOW_BYTES)
: STREAM_WINDOW_BYTES;
let sequence = 0;
for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) {
if (options?.signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const end = Math.min(bytes.byteLength, offset + chunkSize);
await api.pluginSyncWriteChunk({
requestId,
transferId,
sequence,
chunk: bytes.subarray(offset, end),
});
sequence += 1;
}
return api.pluginSyncWriteCommit({ requestId, transferId });
});
},
async deleteSyncObject(params, options) {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncDeleteObject !== 'function') {
throw new Error('Plugin sync host is unavailable');
}
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncDeleteObject!({
...params,
requestId,
}));
},
};
}
/** Store a sync credential in the OS-backed plugin secret store; returns an opaque SecretRef. */
export async function putPluginSyncSecret(params: {
providerId: string;
key?: string;
value: string;
}): Promise<{ kind: 'secret'; id: string; key: string }> {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncPutSecret !== 'function') {
throw new Error('Plugin sync secret storage is unavailable');
}
return api.pluginSyncPutSecret({
providerId: params.providerId,
key: params.key ?? 'sync-credential',
value: params.value,
});
}
/** Best-effort delete of OS-backed sync secrets for a plugin provider (disconnect cleanup). */
export async function deletePluginSyncSecrets(params: {
providerId: string;
keys?: string[];
}): Promise<{ deleted: number }> {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncDeleteSecrets !== 'function') {
return { deleted: 0 };
}
return api.pluginSyncDeleteSecrets({
providerId: params.providerId,
...(params.keys ? { keys: [...params.keys] } : {}),
});
}
@@ -1,138 +0,0 @@
/**
* EncryptedObjectStorage implementation that drives a plugin sync Provider
* through the host extension Provider service. Plugins only ever see already
* encrypted object bytes — never the master key or plaintext vault.
*/
import type {
EncryptedObjectAccount,
EncryptedObjectDeleteResult,
EncryptedObjectReadResult,
EncryptedObjectStorage,
EncryptedObjectStorageCapabilities,
EncryptedObjectWriteResult,
} from '../../../domain/encryptedObjectStorage';
import type { PluginSyncCredentialRef as DurablePluginSyncCredentialRef } from '../../../domain/sync';
/**
* SyncConnectPayload.credential — includes one-shot leases for live connect.
* Durable reconnect persistence only keeps secret/credential refs (see domain).
*/
export type PluginSyncCredentialRef =
| DurablePluginSyncCredentialRef
| { kind: 'secret-lease'; id: string; key?: string; operationId?: string; expiresAt?: number };
export type { DurablePluginSyncCredentialRef };
export interface PluginSyncProviderHost {
connectSync(
params: {
providerId: string;
configuration?: unknown;
credential?: PluginSyncCredentialRef;
deadlineMs?: number;
},
options?: { signal?: AbortSignal },
): Promise<{ account: EncryptedObjectAccount }>;
disconnectSync(
params: { providerId: string; deadlineMs?: number },
options?: { signal?: AbortSignal },
): Promise<null>;
getSyncAccount(
params: { providerId: string; deadlineMs?: number },
options?: { signal?: AbortSignal },
): Promise<{ account: EncryptedObjectAccount | null }>;
getSyncCapabilities(
params: { providerId: string; deadlineMs?: number },
options?: { signal?: AbortSignal },
): Promise<EncryptedObjectStorageCapabilities>;
readSyncObject(
params: { providerId: string; key: string; preferStream?: boolean; deadlineMs?: number },
options?: { signal?: AbortSignal },
): Promise<{
found: boolean;
key: string;
bytes: Uint8Array | null;
revision?: string;
contentType?: string;
}>;
writeSyncObject(
params: {
providerId: string;
key: string;
bytes: Uint8Array;
expectedRevision?: string | null;
preferStream?: boolean;
deadlineMs?: number;
},
options?: { signal?: AbortSignal },
): Promise<{ created: boolean; revision?: string }>;
deleteSyncObject(
params: { providerId: string; key: string; expectedRevision?: string; deadlineMs?: number },
options?: { signal?: AbortSignal },
): Promise<{ deleted: boolean }>;
}
export function createPluginSyncObjectStorage(options: {
providerId: string;
host: PluginSyncProviderHost;
configuration?: unknown;
/** Canonical SyncConnectPayload.credential for host-owned secret refs. */
credential?: PluginSyncCredentialRef;
deadlineMs?: number;
}): EncryptedObjectStorage {
const { providerId, host, configuration, credential, deadlineMs } = options;
return {
providerId,
async connect(connectConfiguration, connectOptions): Promise<{ account: EncryptedObjectAccount }> {
// Only treat undefined as "use stored / default". Explicit null is a
// valid JSON configuration for schemas with type: "null".
const resolvedConfiguration = connectConfiguration !== undefined
? connectConfiguration
: (configuration !== undefined ? configuration : {});
return host.connectSync({
providerId,
configuration: resolvedConfiguration,
...(credential ? { credential } : {}),
deadlineMs,
}, { signal: connectOptions?.signal });
},
async disconnect(disconnectOptions): Promise<void> {
await host.disconnectSync({ providerId, deadlineMs }, { signal: disconnectOptions?.signal });
},
async getAccount(accountOptions): Promise<EncryptedObjectAccount | null> {
const result = await host.getSyncAccount({ providerId, deadlineMs }, { signal: accountOptions?.signal });
return result.account ?? null;
},
async getCapabilities(capOptions): Promise<EncryptedObjectStorageCapabilities> {
return host.getSyncCapabilities({ providerId, deadlineMs }, { signal: capOptions?.signal });
},
async readObject(key, readOptions): Promise<EncryptedObjectReadResult> {
return host.readSyncObject({
providerId,
key,
preferStream: readOptions?.preferStream,
deadlineMs,
}, { signal: readOptions?.signal });
},
async writeObject(key, bytes, writeOptions): Promise<EncryptedObjectWriteResult> {
return host.writeSyncObject({
providerId,
key,
bytes,
expectedRevision: writeOptions?.expectedRevision,
preferStream: writeOptions?.preferStream,
deadlineMs,
}, { signal: writeOptions?.signal });
},
async deleteObject(key, deleteOptions): Promise<EncryptedObjectDeleteResult> {
return host.deleteSyncObject({
providerId,
key,
expectedRevision: deleteOptions?.expectedRevision,
deadlineMs,
}, { signal: deleteOptions?.signal });
},
};
}
@@ -1,151 +0,0 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { SyncedFile } from '../../../domain/sync';
import { createAdapter } from './index';
import {
cloudAdapterAsEncryptedObjectStorage,
webdavEncryptedObjectCapabilities,
} from './encryptedObjectStorageBridge';
import { DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY } from '../../../domain/encryptedObjectStorage';
import type { CloudAdapter } from './index';
/**
* Prove the production WebDAV factory returns a CloudAdapter that is wired
* through EncryptedObjectStorage (same surface plugins use), by exercising
* the shared bridge with a real in-memory CloudAdapter that mirrors WebDAV's
* single-file semantics and comparing with createAdapter's webdav wrap shape.
*/
function memoryWebdavAdapter(initial: SyncedFile | null = null): CloudAdapter & { store: SyncedFile | null } {
const adapter = {
store: initial,
isAuthenticated: true,
accountInfo: { id: 'webdav.example', name: 'user@webdav.example' },
resourceId: null as string | null,
signOut() {
adapter.isAuthenticated = false;
adapter.accountInfo = null;
adapter.store = null;
},
async initializeSync() {
adapter.resourceId = DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
return adapter.resourceId;
},
async upload(file: SyncedFile) {
adapter.store = file;
adapter.resourceId = DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
return adapter.resourceId;
},
async download() {
return adapter.store;
},
async deleteSync() {
adapter.store = null;
},
getTokens() {
return null;
},
};
return adapter;
}
describe('WebDAV EncryptedObjectStorage production path', () => {
it('createAdapter(webdav) wraps through EncryptedObjectStorage (not a raw WebDAVAdapter)', async () => {
const { default: WebDAVAdapter } = await import('./WebDAVAdapter');
const adapter = await createAdapter('webdav', undefined, undefined, {
endpoint: 'https://webdav.example.test',
authType: 'basic',
username: 'user',
password: 'secret',
});
assert.equal(adapter instanceof WebDAVAdapter, false, 'must not return raw WebDAVAdapter');
assert.equal(typeof adapter.upload, 'function');
assert.equal(typeof adapter.download, 'function');
assert.equal(typeof adapter.initializeSync, 'function');
assert.equal(typeof adapter.deleteSync, 'function');
assert.equal(adapter.getTokens(), null);
});
it('createAdapter(webdav) is authenticated when config exists so getConnectedAdapter can reuse', async () => {
const adapter = await createAdapter('webdav', undefined, undefined, {
endpoint: 'https://webdav.example.test',
authType: 'basic',
username: 'user',
password: 'secret',
});
// Pre-wrap WebDAVAdapter reported isAuthenticated whenever config existed.
// The bridge must match so manager cache reuse (existing?.isAuthenticated) works.
assert.equal(adapter.isAuthenticated, true);
});
it('createAdapter(webdav) preserves constructor resourceId and refreshes from backing adapter after initializeSync', async () => {
const persistedPath = '/netcatty-vault.json';
const adapter = await createAdapter(
'webdav',
undefined,
persistedPath,
{
endpoint: 'https://webdav.example.test',
authType: 'basic',
username: 'user',
password: 'secret',
},
);
assert.equal(
adapter.resourceId,
persistedPath,
'must not discard resourceId passed into createAdapter',
);
// Without network, initializeSync fails; still prove resourceId is not
// overwritten to the bare DEFAULT key before connect runs.
assert.notEqual(adapter.resourceId, 'netcatty-vault.json');
});
it('WebDAV-style adapters round-trip encrypted SyncedFile bytes through EncryptedObjectStorage', async () => {
const raw = memoryWebdavAdapter({
meta: {
version: 2,
updatedAt: 1,
deviceId: 'd',
appVersion: '0.0.0',
iv: 'iv',
salt: 'salt',
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
},
payload: 'remote-cipher',
});
const storage = cloudAdapterAsEncryptedObjectStorage(raw, 'webdav', {
capabilities: webdavEncryptedObjectCapabilities(),
});
const caps = await storage.getCapabilities();
assert.equal(caps.atomicReplacement, true);
assert.equal(caps.revisions, false);
await storage.connect();
const read = await storage.readObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY);
assert.equal(read.found, true);
assert.ok(read.bytes);
const text = new TextDecoder().decode(read.bytes!);
assert.match(text, /remote-cipher/);
const next: SyncedFile = {
meta: {
version: 3,
updatedAt: 2,
deviceId: 'd',
appVersion: '0.0.0',
iv: 'iv',
salt: 'salt',
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
},
payload: 'next-cipher',
};
await storage.writeObject(
DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY,
new TextEncoder().encode(JSON.stringify(next)),
);
assert.equal(raw.store?.payload, 'next-cipher');
});
});
@@ -18,16 +18,10 @@ import type {
SyncPayload,
WebDAVConfig,
} from '../../../domain/sync';
import { normalizeDurablePluginSyncCredentialRef } from '../../../domain/sync';
import { isPluginCloudProviderId } from '../../../domain/cloudProviderIds';
import type {
ProviderSyncAnchor,
StartProviderAuthResult,
} from '../CloudSyncManager';
import {
registerPluginProviderIdImpl,
unregisterPluginProviderIdImpl,
} from './stateAndSecurityMethods';
const SYNC_REMOTE_ANCHOR_STORAGE_KEY = 'netcatty_sync_remote_anchor_v1';
@@ -315,141 +309,6 @@ export async function connectConfigProviderImpl(this: any,
}
}
/**
* Connect a namespaced plugin sync Provider. Configuration is opaque plugin-
* owned JSON; Netcatty still owns encryption and only forwards encrypted objects.
*/
export async function connectPluginProviderImpl(
this: any,
providerId: string,
configuration: unknown = {},
credential?: unknown,
): Promise<void> {
if (!isPluginCloudProviderId(providerId)) {
throw new Error(`Invalid plugin sync provider ID: ${providerId}`);
}
// Initialize sequence counters before the first status write so cross-window
// handlers never see NaN and discard every later update.
if (this.providerWriteSeq[providerId] == null || Number.isNaN(this.providerWriteSeq[providerId])) {
this.providerWriteSeq[providerId] = 0;
}
if (this.providerDecryptSeq[providerId] == null || Number.isNaN(this.providerDecryptSeq[providerId])) {
this.providerDecryptSeq[providerId] = 0;
}
if (this.providerDecrypted[providerId] == null) this.providerDecrypted[providerId] = true;
if (this.providerAuthAttemptSeq[providerId] == null || Number.isNaN(this.providerAuthAttemptSeq[providerId])) {
this.providerAuthAttemptSeq[providerId] = 0;
}
this.updateProviderStatus(providerId, 'connecting');
try {
const createPluginStorage = async (id: string) => {
if (typeof this.createPluginStorage === 'function') {
return this.createPluginStorage(id, {
provider: id,
status: 'connecting',
config: configuration as ProviderConnection['config'],
});
}
const { createPluginSyncIpcHost, isPluginSyncIpcAvailable } = await import(
'../adapters/pluginSyncIpcHost'
);
const { createPluginSyncObjectStorage } = await import('../adapters/pluginSyncObjectStorage');
if (!isPluginSyncIpcAvailable()) {
throw new Error(`Plugin sync provider ${id} is unavailable (plugin host not enabled)`);
}
return createPluginSyncObjectStorage({
providerId: id,
host: createPluginSyncIpcHost(),
configuration,
credential: credential as import('../adapters/pluginSyncObjectStorage').PluginSyncCredentialRef | undefined,
});
};
const adapter = await createAdapter(
providerId,
undefined,
undefined,
undefined,
{ createPluginStorage },
);
// Only cache after initializeSync succeeds so a rejected reconnect cannot
// leave a half-authenticated adapter pinned for later auto-sync.
let resourceId: string | null;
try {
resourceId = await adapter.initializeSync();
} catch (error) {
try { adapter.signOut(); } catch { /* ignore */ }
this.adapters.delete(providerId);
throw error;
}
this.adapters.set(providerId, adapter);
const account = adapter.accountInfo ?? { id: providerId };
if (this.providerDecryptSeq[providerId] == null) this.providerDecryptSeq[providerId] = 0;
if (this.providerWriteSeq[providerId] == null) this.providerWriteSeq[providerId] = 0;
if (this.providerDecrypted[providerId] == null) this.providerDecrypted[providerId] = true;
++this.providerDecryptSeq[providerId];
// Capture pre-connect identity before overwriting state.
const previous = this.state.providers[providerId];
const previousAccountId = previous?.account?.id ?? null;
const previousResource = previous?.resourceId ?? null;
const configFingerprint = (value: unknown): string => {
const normalize = (input: unknown): unknown => {
if (Array.isArray(input)) return input.map(normalize);
if (input && typeof input === 'object') {
return Object.fromEntries(
Object.keys(input as Record<string, unknown>)
.sort()
.map((key) => [key, normalize((input as Record<string, unknown>)[key])]),
);
}
return input;
};
try {
return JSON.stringify(normalize(value ?? null));
} catch {
return String(value);
}
};
const previousConfigFp = configFingerprint(previous?.config ?? null);
const nextAccountId = account?.id ?? null;
const nextResource = resourceId || null;
const nextConfigFp = configFingerprint(configuration ?? null);
this.state.providers[providerId] = {
provider: providerId,
status: 'connected',
config: configuration as ProviderConnection['config'],
...((() => {
const ref = normalizeDurablePluginSyncCredentialRef(credential);
return ref ? { credential: ref } : {};
})()),
account,
resourceId: resourceId || undefined,
};
registerPluginProviderIdImpl.call(this, providerId);
await this.saveProviderConnection(providerId, this.state.providers[providerId]);
// Preserve merge base / anchors when reconnecting the same account+resource
// and configuration; clear when backend identity or config changes.
const previousCredentialFp = `${previous?.credential?.kind ?? ''}\0${previous?.credential?.id ?? ''}\0${previous?.credential?.key ?? ''}`;
const nextCredentialFp = `${this.state.providers[providerId].credential?.kind ?? ''}\0${this.state.providers[providerId].credential?.id ?? ''}\0${this.state.providers[providerId].credential?.key ?? ''}`;
if (
previousAccountId !== nextAccountId
|| previousResource !== nextResource
|| previousConfigFp !== nextConfigFp
|| previousCredentialFp !== nextCredentialFp
) {
clearProviderMergeStateImpl.call(this, providerId);
}
this.emit({
type: 'AUTH_COMPLETED',
provider: providerId,
account,
});
this.notifyStateChange();
} catch (error) {
this.updateProviderStatus(providerId, 'error', String(error));
throw error;
}
}
export function resetProviderStatusImpl(this: any,provider: CloudProvider, authAttemptId?: number): void {
const restoreState = this.providerAuthRestoreState[provider];
if (
@@ -531,22 +390,10 @@ export async function disconnectProviderImpl(this: any,provider: CloudProvider):
};
await this.saveProviderConnection(provider, this.state.providers[provider]);
// Explicit disconnect removes the dynamic provider from the restart registry.
// Missing plugins with preserved config remain registered and are not coerced away.
unregisterPluginProviderIdImpl.call(this, provider);
// Clear all trusted merge state so a later account/resource cannot reuse
// an unrelated snapshot or convergent baseline.
clearProviderMergeStateImpl.call(this, provider);
this.removeFromStorage(this.providerAccountIdKey(provider));
// Drop OS-backed sync secrets so disconnect does not leave reusable passwords.
if (isPluginCloudProviderId(provider)) {
try {
const { deletePluginSyncSecrets } = await import('../adapters/pluginSyncIpcHost');
await deletePluginSyncSecrets({ providerId: provider });
} catch {
/* best-effort; disconnect still succeeds */
}
}
// Reset BLOCKED state if it was present — disconnect implicitly resolves
// any pending shrink-block warning since there's no provider to push to.
this.exitBlockedState();
@@ -619,18 +466,7 @@ export function clearSyncAnchorImpl(this: any,provider?: CloudProvider): void {
this.removeFromStorage(this.syncAnchorKey(provider));
return;
}
const providers = new Set<CloudProvider>([
'github', 'google', 'onedrive', 'webdav', 's3',
]);
for (const id of Object.keys(this.state?.providers ?? {})) {
providers.add(id as CloudProvider);
}
if (typeof this.listRegisteredPluginProviderIds === 'function') {
for (const id of this.listRegisteredPluginProviderIds()) {
providers.add(id as CloudProvider);
}
}
for (const p of providers) {
for (const p of ['github', 'google', 'onedrive', 'webdav', 's3'] as const) {
this.removeFromStorage(this.syncAnchorKey(p));
}
}
@@ -39,12 +39,8 @@ interface ProviderRuntime {
provider: CloudProvider;
adapter: CloudAdapter;
latestRemote: SyncedFile | null;
/** Decrypted payload from the most recent successful download for this cycle. */
latestRemotePayload?: SyncPayload;
verifiedState?: ConvergentSyncStateV2;
verifiedFile?: SyncedFile;
/** Decrypted payload retained when the provider is marked verified. */
verifiedPayload?: SyncPayload;
resourceId?: string;
error?: string;
}
@@ -140,31 +136,8 @@ function mergeStates(
return merged;
}
function materializedPayload(
state: ConvergentSyncStateV2,
now: number,
pluginSidecars?: SyncPayload['pluginSidecars'],
): SyncPayload {
return materializeSyncPayloadFromConvergentState(state, {
syncedAt: now,
...(pluginSidecars && Array.isArray(pluginSidecars.entries)
? { pluginSidecars }
: {}),
});
}
function sidecarBundleFromPayload(
payload: SyncPayload | null | undefined,
): SyncPayload['pluginSidecars'] | undefined {
if (!payload || !Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) {
return undefined;
}
return {
version: 1,
entries: Array.isArray(payload.pluginSidecars?.entries)
? payload.pluginSidecars.entries
: [],
};
function materializedPayload(state: ConvergentSyncStateV2, now: number): SyncPayload {
return materializeSyncPayloadFromConvergentState(state, { syncedAt: now });
}
function currentConflicts(state: ConvergentSyncStateV2): ConvergentFieldConflict[] {
@@ -193,18 +166,12 @@ async function downloadProviderState(
runtime: ProviderRuntime,
syncSecurityGeneration?: number,
): Promise<DecodedRemote | null> {
const file = await runtime.adapter.download({
signal: manager.activeSyncAbortSignal,
});
const file = await runtime.adapter.download();
assertSyncSecurityGeneration(manager, syncSecurityGeneration);
runtime.latestRemote = file;
if (!file) {
runtime.latestRemotePayload = undefined;
return null;
}
const decoded = await decodeRemote(manager, runtime.provider, file, syncSecurityGeneration);
runtime.latestRemotePayload = decoded.payload;
return decoded;
return file
? decodeRemote(manager, runtime.provider, file, syncSecurityGeneration)
: null;
}
async function saveVerifiedBaseline(
@@ -230,8 +197,6 @@ async function markProviderVerified(
): Promise<void> {
runtime.verifiedState = decoded.state;
runtime.verifiedFile = decoded.file;
runtime.verifiedPayload = decoded.payload;
runtime.latestRemotePayload = decoded.payload;
runtime.error = undefined;
await saveVerifiedBaseline(manager, runtime, decoded);
@@ -271,10 +236,7 @@ async function runInitialDownloads(
const runtime: ProviderRuntime = { provider, adapter, latestRemote: null };
try {
const decoded = await downloadProviderState(manager, runtime, syncSecurityGeneration);
if (decoded) {
runtime.verifiedState = decoded.state;
runtime.verifiedPayload = decoded.payload;
}
if (decoded) runtime.verifiedState = decoded.state;
} catch (error) {
runtime.error = errorMessage(error);
}
@@ -435,25 +397,6 @@ export async function syncConvergentProvidersUnlockedImpl(
await persistReplica(this, durableBeforeVerification, now());
updateConflictState(this, durableBeforeVerification);
// Capture pre-cycle sidecar baselines per provider (never union across
// providers). Cross-provider union invents a shared base that can treat one
// provider's empty remote as deleting another provider's retained settings.
const cycleBaseByProvider = new Map<CloudProvider, NonNullable<SyncPayload['pluginSidecars']>['entries']>();
if (typeof this.loadConvergentProviderBaseline === 'function') {
for (const runtime of usable) {
try {
const baseline = await this.loadConvergentProviderBaseline(runtime.provider) as {
materializedPayload?: SyncPayload;
} | null;
const baseBundle = baseline?.materializedPayload?.pluginSidecars;
if (!baseBundle || !Array.isArray(baseBundle.entries)) continue;
cycleBaseByProvider.set(runtime.provider, baseBundle.entries);
} catch {
// First cycle or missing baseline is fine.
}
}
}
for (let round = 0; round < maxRounds && usable.length > 0; round += 1) {
if (round > 0) await jitter(round);
@@ -489,90 +432,14 @@ export async function syncConvergentProvidersUnlockedImpl(
runtime.error = errorMessage(error);
}
}));
// Merge plugin sidecars per successful provider with that provider's own
// pre-cycle baseline. Failed providers are skipped so their baselines
// cannot invent deletions against another provider's empty remote.
const {
mergePluginSyncSidecarsThreeWay,
} = await import('../../../domain/pluginSyncSidecar');
const localSidecarEntries = Array.isArray(inputPayload.pluginSidecars?.entries)
? inputPayload.pluginSidecars.entries
: [];
let mergedSidecars = [...localSidecarEntries];
for (const runtime of usable) {
if (runtime.error) continue;
const fromPreflight = preflightVerified.get(runtime.provider)?.payload?.pluginSidecars;
const fromLatest = runtime.latestRemotePayload?.pluginSidecars;
const remoteBundle = fromPreflight ?? fromLatest;
if (!remoteBundle) continue;
const baseForProvider = cycleBaseByProvider.get(runtime.provider) ?? [];
const sidecarStrategy =
strategy === 'preferCloud' || strategy === 'preferLocal' ? strategy : 'smart';
mergedSidecars = mergePluginSyncSidecarsThreeWay({
base: baseForProvider,
local: mergedSidecars,
remote: Array.isArray(remoteBundle.entries) ? remoteBundle.entries : [],
strategy: sidecarStrategy,
});
}
const pluginSidecars = mergedSidecars.length > 0
|| Object.prototype.hasOwnProperty.call(inputPayload, 'pluginSidecars')
? { version: 1 as const, entries: mergedSidecars }
: inputPayload.pluginSidecars;
const sidecarFingerprint = (
bundle: typeof pluginSidecars,
payload?: SyncPayload | null,
) => {
const present = payload
? Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')
: bundle != null && Array.isArray(bundle.entries);
const entries = (bundle?.entries ?? [])
.map((e) => [e.pluginId, e.kind, e.key, e.updatedAt, e.value] as const)
.sort((a, b) => {
if (a[0] !== b[0]) return String(a[0]).localeCompare(String(b[0]));
if (a[1] !== b[1]) return String(a[1]).localeCompare(String(b[1]));
return String(a[2]).localeCompare(String(b[2]));
});
// Empty present and empty absent both fingerprint as empty so devices
// do not ping-pong uploading omitted vs explicit-empty markers.
if (entries.length === 0) {
return JSON.stringify({ present: false, version: null, entries: [] });
}
return JSON.stringify({
present,
version: present ? (bundle?.version ?? 1) : null,
entries,
});
};
const localSidecarFp = sidecarFingerprint(
pluginSidecars,
Object.prototype.hasOwnProperty.call(inputPayload, 'pluginSidecars') || mergedSidecars.length > 0
? { ...inputPayload, pluginSidecars }
: inputPayload,
);
const providerSidecarMismatch = (runtime: { provider: string }) => {
const decoded = preflightVerified.get(runtime.provider);
if (!decoded) return true; // no preflight match → need upload
return sidecarFingerprint(decoded.payload?.pluginSidecars, decoded.payload) !== localSidecarFp;
};
const remoteSidecarMismatch = usable.some((runtime) => {
if (runtime.error) return false;
return providerSidecarMismatch(runtime);
});
const needsUpload = usable.some((runtime) => (
!runtime.error && !preflightVerified.has(runtime.provider)
)) || remoteSidecarMismatch;
));
const outgoingPayload = needsUpload
? withConvergentSyncEnvelope(expected, {
syncedAt: now(),
pluginSidecars,
})
? withConvergentSyncEnvelope(expected, { syncedAt: now() })
: null;
await Promise.all(usable.map(async (runtime) => {
// Upload when CRDT preflight missed OR sidecars diverge for this provider.
if (runtime.error) return;
if (preflightVerified.has(runtime.provider) && !providerSidecarMismatch(runtime)) return;
if (!outgoingPayload) return;
if (runtime.error || preflightVerified.has(runtime.provider)) return;
try {
const remoteVersion = runtime.latestRemote?.meta.version ?? this.state.localVersion;
const file = await EncryptionService.encryptPayload(
@@ -584,17 +451,9 @@ export async function syncConvergentProvidersUnlockedImpl(
remoteVersion,
);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
runtime.resourceId = await runtime.adapter.upload(file, {
signal: this.activeSyncAbortSignal,
});
runtime.resourceId = await runtime.adapter.upload(file);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
runtime.latestRemote = file;
// Sidecar-only (or any) upload invalidates preflight verification so the
// post-write payload is re-downloaded and baselines stay current.
preflightVerified.delete(runtime.provider);
runtime.verifiedState = undefined;
runtime.verifiedPayload = undefined;
runtime.verifiedFile = undefined;
} catch (error) {
runtime.error = errorMessage(error);
}
@@ -637,39 +496,8 @@ export async function syncConvergentProvidersUnlockedImpl(
&& versionVectorDominates(runtime.verifiedState.vector, canonical.vector)
));
const hasSuccess = successfulRuntimes.length > 0;
const {
mergePluginSyncSidecarsThreeWay: mergeSidecarsThreeWayFinal,
} = await import('../../../domain/pluginSyncSidecar');
const finalLocalSidecars = Array.isArray(inputPayload.pluginSidecars?.entries)
? [...inputPayload.pluginSidecars.entries]
: [];
let finalSidecarEntries = [...finalLocalSidecars];
for (const runtime of successfulRuntimes) {
const remoteBundle = runtime.verifiedPayload?.pluginSidecars
?? runtime.latestRemotePayload?.pluginSidecars;
if (!remoteBundle) continue;
const baseForProvider = cycleBaseByProvider.get(runtime.provider) ?? [];
const finalSidecarStrategy =
strategy === 'preferCloud' || strategy === 'preferLocal' ? strategy : 'smart';
finalSidecarEntries = mergeSidecarsThreeWayFinal({
base: baseForProvider,
local: finalSidecarEntries,
remote: Array.isArray(remoteBundle.entries) ? remoteBundle.entries : [],
strategy: finalSidecarStrategy,
});
}
const finalPluginSidecars = finalSidecarEntries.length > 0
|| Object.prototype.hasOwnProperty.call(inputPayload, 'pluginSidecars')
? { version: 1 as const, entries: finalSidecarEntries }
: undefined;
const mergedPayloadBase = materializedPayload(canonical, now());
const mergedPayload: SyncPayload = {
...mergedPayloadBase,
...(finalPluginSidecars ? { pluginSidecars: finalPluginSidecars } : {}),
};
const localPayloadChanged = !cloudSyncPayloadsEqual(inputPayload, mergedPayload)
|| JSON.stringify(inputPayload.pluginSidecars ?? null)
!== JSON.stringify(mergedPayload.pluginSidecars ?? null);
const mergedPayload = materializedPayload(canonical, now());
const localPayloadChanged = !cloudSyncPayloadsEqual(inputPayload, mergedPayload);
let mergedPayloadApplied = false;
if (hasSuccess && localPayloadChanged) {
try {
@@ -859,7 +687,6 @@ async function prepareConvergentConflictResolutionImpl(
addressKey: string,
candidateDot: string,
now = Date.now(),
pluginSidecars?: SyncPayload['pluginSidecars'],
): Promise<{ state: ConvergentSyncStateV2; payload: SyncPayload; now: number }> {
const replica = await this.loadConvergentReplica();
if (!replica) throw new Error('Convergent sync replica is unavailable');
@@ -874,15 +701,7 @@ async function prepareConvergentConflictResolutionImpl(
this.state.deviceId,
now,
);
// Prefer caller-provided current sidecars. Never fall back to a stale
// provider baseline — that would revert plugin settings changed after the
// last verified sync. When omitted, materialize without the field so apply
// preserves local sidecars (legacy missing-field semantics).
return {
state: resolved,
payload: materializedPayload(resolved, now, pluginSidecars),
now,
};
return { state: resolved, payload: materializedPayload(resolved, now), now };
}
export async function resolveConvergentConflictAndSyncImpl(
@@ -893,17 +712,12 @@ export async function resolveConvergentConflictAndSyncImpl(
payload: SyncPayload,
commitReplica: () => Promise<void>,
) => Promise<void>,
options?: {
pluginSidecars?: SyncPayload['pluginSidecars'];
},
): Promise<{ payload: SyncPayload; results: Map<CloudProvider, SyncResult> }> {
return withConvergentSyncWebLock(async () => {
const prepared = await prepareConvergentConflictResolutionImpl.call(
this,
addressKey,
candidateDot,
Date.now(),
options?.pluginSidecars,
);
let committed = false;
await applyPayload(prepared.payload, async () => {
@@ -926,11 +740,7 @@ export async function resolveConvergentConflictAndSyncImpl(
if (!finalReplica) {
throw new Error('Convergent sync replica disappeared after conflict propagation');
}
const finalPayload = materializedPayload(
finalReplica.state,
Date.now(),
sidecarBundleFromPayload(prepared.payload),
);
const finalPayload = materializedPayload(finalReplica.state, Date.now());
return { payload: finalPayload, results };
});
}
@@ -952,7 +762,6 @@ export async function downgradeConvergentSyncImpl(
const replica = await this.loadConvergentReplica();
if (!replica) throw new Error('Convergent sync replica is unavailable');
const localPayload = await buildLocalPayload();
const localSidecars = sidecarBundleFromPayload(localPayload);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const results = new Map<CloudProvider, SyncResult>();
this.state.syncState = 'SYNCING';
@@ -981,47 +790,9 @@ export async function downgradeConvergentSyncImpl(
// vault change made since the last persisted replica into causal local
// writes before joining provider states; otherwise downgrade would
// materialize the stale replica and overwrite those paused edits.
// Prefer local sidecars, then three-way-merge each remote against that
// provider's trusted baseline so explicit local resets are not resurrected
// by a stale remote entry during downgrade.
let downgradeSidecars = localSidecars;
{
const { mergePluginSyncSidecarsThreeWay } = await import('../../../domain/pluginSyncSidecar');
let entries = Array.isArray(downgradeSidecars?.entries) ? [...downgradeSidecars.entries] : [];
let sawRemote = false;
for (const runtime of runtimes) {
const remoteBundle = sidecarBundleFromPayload(
runtime.verifiedPayload ?? runtime.latestRemotePayload,
);
if (!remoteBundle) continue;
sawRemote = true;
let baseForProvider: NonNullable<SyncPayload['pluginSidecars']>['entries'] = [];
if (typeof this.loadConvergentProviderBaseline === 'function') {
try {
const baseline = await this.loadConvergentProviderBaseline(runtime.provider) as {
materializedPayload?: SyncPayload;
} | null;
const baseBundle = baseline?.materializedPayload?.pluginSidecars;
if (baseBundle && Array.isArray(baseBundle.entries)) {
baseForProvider = baseBundle.entries;
}
} catch {
// First cycle or missing baseline is fine.
}
}
entries = mergePluginSyncSidecarsThreeWay({
base: baseForProvider,
local: entries,
remote: Array.isArray(remoteBundle.entries) ? remoteBundle.entries : [],
});
}
if (sawRemote || entries.length > 0) {
downgradeSidecars = { version: 1, entries };
}
}
const withLocalWrites = applyLegacySyncPayload(
replica.state,
materializedPayload(replica.state, now, downgradeSidecars),
materializedPayload(replica.state, now),
stripConvergentSyncEnvelope(localPayload),
this.state.deviceId,
now,
@@ -1046,7 +817,7 @@ export async function downgradeConvergentSyncImpl(
return results;
}
const outgoing = materializedPayload(canonical, now, downgradeSidecars);
const outgoing = materializedPayload(canonical, now);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
let committed = false;
try {
@@ -1084,13 +855,9 @@ export async function downgradeConvergentSyncImpl(
before?.meta.version ?? this.state.localVersion,
);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
await adapter.upload(file, {
signal: this.activeSyncAbortSignal,
});
await adapter.upload(file);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const verifiedFile = await adapter.download({
signal: this.activeSyncAbortSignal,
});
const verifiedFile = await adapter.download();
assertSyncSecurityGeneration(this, syncSecurityGeneration);
if (!verifiedFile) throw new Error('Provider returned no file after downgrade');
const verifiedPayload = await EncryptionService.decryptPayload(verifiedFile, this.masterPassword);
@@ -17,12 +17,7 @@ import {
encryptLocalStorageValue,
} from './encryptedLocalStorage';
/** Built-ins plus any dynamic plugin providers currently in manager state. */
function listedProviders(manager: any): CloudProvider[] {
const fromState = Object.keys(manager?.state?.providers ?? {}) as CloudProvider[];
if (fromState.length > 0) return fromState.sort();
return ['github', 'google', 'onedrive', 'webdav', 's3'];
}
const PROVIDERS: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
export function convergentProviderBaselineKeyImpl(this: any, provider: CloudProvider): string {
return `${SYNC_STORAGE_KEYS.CONVERGENT_PROVIDER_BASELINE}_${provider}`;
@@ -112,7 +107,7 @@ export async function loadConvergentProviderBaselineImpl(
export function clearConvergentSyncStorageImpl(this: any, confirmed: boolean): void {
if (!confirmed) throw new Error('Explicit confirmation is required to remove convergent sync state');
this.removeFromStorage(SYNC_STORAGE_KEYS.CONVERGENT_REPLICA);
for (const provider of listedProviders(this)) {
for (const provider of PROVIDERS) {
this.removeFromStorage(this.convergentProviderBaselineKey(provider));
}
}
@@ -123,7 +118,7 @@ function encryptedSyncStorageKeys(manager: any): string[] {
manager.syncSnapshotsKey(),
SYNC_STORAGE_KEYS.CONVERGENT_REPLICA,
]);
for (const provider of listedProviders(manager)) {
for (const provider of PROVIDERS) {
keys.add(manager.syncBaseKey(provider));
keys.add(manager.syncSnapshotsKey(provider));
keys.add(manager.convergentProviderBaselineKey(provider));
@@ -1,442 +0,0 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { CloudProvider, ProviderConnection, SyncedFile } from '../../../domain/sync';
import { SYNC_STORAGE_KEYS } from '../../../domain/sync';
import type { EncryptedObjectStorage } from '../../../domain/encryptedObjectStorage';
import { DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY } from '../../../domain/encryptedObjectStorage';
import {
enforceLegacySingleProviderConnected,
getConnectedAdapterImpl,
listAvailablePluginSyncProviderIdsImpl,
listRegisteredPluginProviderIdsImpl,
loadInitialStateImpl,
loadProviderConnectionImpl,
registerPluginProviderIdImpl,
saveProviderConnectionImpl,
setAvailablePluginSyncProviderIdsImpl,
unregisterPluginProviderIdImpl,
} from './stateAndSecurityMethods';
function makeSyncedFile(version: number, payload = 'cipher'): SyncedFile {
return {
meta: {
version,
updatedAt: 1,
deviceId: 'device',
appVersion: '0.0.0',
iv: 'iv',
salt: 'salt',
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
},
payload,
};
}
function memoryStorage(): Map<string, unknown> {
return new Map();
}
type ManagerHarness = {
adapters: Map<string, unknown>;
providerWriteSeq: Record<string, number>;
providerDecryptSeq: Record<string, number>;
providerDecrypted: Record<string, boolean>;
providerAuthAttemptSeq: Record<string, number>;
providerAuthRestoreState: Record<string, unknown>;
decryptionReady: Promise<void>;
state: { providers: Record<string, ProviderConnection> } | null;
createPluginStorage?: (id: string) => Promise<EncryptedObjectStorage>;
loadFromStorage: <T>(key: string) => T | null;
saveToStorage: (key: string, value: unknown) => boolean;
removeFromStorage: (key: string) => void;
loadProviderConnection: (provider: CloudProvider) => ProviderConnection;
notifyStateChange: () => void;
isActiveAuthAttempt: () => boolean;
};
function createManagerHarness(storage: Map<string, unknown>): ManagerHarness {
const manager: ManagerHarness = {
adapters: new Map(),
providerWriteSeq: {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
},
providerDecryptSeq: {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
},
providerDecrypted: {
github: true, google: true, onedrive: true, webdav: true, s3: true,
},
providerAuthAttemptSeq: {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
},
providerAuthRestoreState: {
github: null, google: null, onedrive: null, webdav: null, s3: null,
},
decryptionReady: Promise.resolve(),
state: null,
loadFromStorage<T>(key: string): T | null {
return (storage.has(key) ? storage.get(key) : null) as T | null;
},
saveToStorage(key: string, value: unknown) {
storage.set(key, value);
return true;
},
removeFromStorage(key: string) {
storage.delete(key);
},
loadProviderConnection(provider: CloudProvider) {
return loadProviderConnectionImpl.call(manager, provider);
},
notifyStateChange() {},
isActiveAuthAttempt() {
return true;
},
};
return manager;
}
describe('plugin provider manager boundary', () => {
it('loads registered plugin providers into initial state and keeps them across restart', () => {
const storage = memoryStorage();
// Seed device identity so loadInitialStateImpl never touches browser globals.
storage.set(SYNC_STORAGE_KEYS.DEVICE_ID, 'test-device');
storage.set(SYNC_STORAGE_KEYS.DEVICE_NAME, 'Test Device');
const manager = createManagerHarness(storage);
registerPluginProviderIdImpl.call(manager, 'com.example.backup.sync');
storage.set('netcatty_provider_plugin_v1:com.example.backup.sync', {
provider: 'com.example.backup.sync',
status: 'connected',
config: { endpoint: 'https://example.test' },
});
const state = loadInitialStateImpl.call(manager);
assert.ok(state.providers['com.example.backup.sync']);
// Without the plugin host IPC surface, dynamic providers keep config but
// must not join sync cycles as "connected".
assert.equal(state.providers['com.example.backup.sync'].status, 'disconnected');
assert.deepEqual(
(state.providers['com.example.backup.sync'].config as { endpoint?: string })?.endpoint,
'https://example.test',
);
assert.deepEqual(listRegisteredPluginProviderIdsImpl.call(manager), [
'com.example.backup.sync',
]);
});
it('setAvailablePluginSyncProviderIds drops missing providers from the ready set', () => {
const storage = memoryStorage();
const manager = createManagerHarness(storage);
manager.state = {
providers: {
'com.example.backup.sync': {
provider: 'com.example.backup.sync',
status: 'connected',
config: { endpoint: 'https://example.test' },
},
},
};
manager.adapters = new Map();
registerPluginProviderIdImpl.call(manager, 'com.example.backup.sync');
assert.deepEqual(listAvailablePluginSyncProviderIdsImpl.call(manager), [
'com.example.backup.sync',
]);
// Plugin uninstalled / contribution gone.
setAvailablePluginSyncProviderIdsImpl.call(manager, []);
assert.deepEqual(listAvailablePluginSyncProviderIdsImpl.call(manager), []);
assert.equal(manager.state.providers['com.example.backup.sync'].status, 'disconnected');
});
it('setAvailablePluginSyncProviderIds keeps adapters when availability membership is unchanged', () => {
const storage = memoryStorage();
const manager = createManagerHarness(storage);
const pluginId = 'com.example.backup.sync';
let signedOut = 0;
manager.state = {
providers: {
[pluginId]: {
provider: pluginId,
status: 'connected',
config: { endpoint: 'https://example.test' },
},
},
};
manager.adapters = new Map([
[pluginId, {
isAuthenticated: true,
accountInfo: { id: 'acct' },
resourceId: null,
signOut() { signedOut += 1; },
async initializeSync() { return null; },
async upload() { return 'ok'; },
async download() { return null; },
async deleteSync() {},
getTokens() { return null; },
}],
]);
registerPluginProviderIdImpl.call(manager, pluginId);
// Same contribution still present (setting-updated noise).
setAvailablePluginSyncProviderIdsImpl.call(manager, [pluginId]);
assert.equal(manager.adapters.has(pluginId), true, 'must keep adapter on no-op membership refresh');
assert.equal(signedOut, 0);
assert.equal(manager.state.providers[pluginId].status, 'connected');
});
it('setAvailablePluginSyncProviderIds drops adapters when a provider re-enters the set', () => {
const storage = memoryStorage();
const manager = createManagerHarness(storage);
const pluginId = 'com.example.backup.sync';
let signedOut = 0;
manager.state = {
providers: {
[pluginId]: {
provider: pluginId,
status: 'disconnected',
config: { endpoint: 'https://example.test' },
error: 'Plugin sync provider is no longer installed or enabled',
},
},
};
manager.adapters = new Map([
[pluginId, {
isAuthenticated: true,
accountInfo: { id: 'acct' },
resourceId: null,
signOut() { signedOut += 1; },
async initializeSync() { return null; },
async upload() { return 'ok'; },
async download() { return null; },
async deleteSync() {},
getTokens() { return null; },
}],
]);
// Not in available set yet.
setAvailablePluginSyncProviderIdsImpl.call(manager, []);
assert.equal(manager.adapters.has(pluginId), false);
// Stale adapter reattached (should not happen in prod) then re-enter.
manager.adapters.set(pluginId, {
isAuthenticated: true,
accountInfo: { id: 'acct' },
resourceId: null,
signOut() { signedOut += 1; },
async initializeSync() { return null; },
async upload() { return 'ok'; },
async download() { return null; },
async deleteSync() {},
getTokens() { return null; },
});
setAvailablePluginSyncProviderIdsImpl.call(manager, [pluginId]);
assert.equal(manager.adapters.has(pluginId), false, 'must drop adapter when provider re-enters');
assert.equal(signedOut >= 1, true);
assert.equal(manager.state.providers[pluginId].status, 'connected');
});
it('saveProviderConnection registers plugin IDs and disconnect unregisters them', async () => {
const storage = memoryStorage();
const manager = createManagerHarness(storage);
manager.state = { providers: {} };
const connection: ProviderConnection = {
provider: 'com.example.backup.sync',
status: 'connected',
// Non-secret plugin config: encryptProviderSecrets is a no-op without bridge.
config: { endpoint: 'https://example.test' } as never,
};
await saveProviderConnectionImpl.call(manager, 'com.example.backup.sync', connection);
assert.deepEqual(
storage.get(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS),
['com.example.backup.sync'],
);
assert.ok(storage.get('netcatty_provider_plugin_v1:com.example.backup.sync'));
unregisterPluginProviderIdImpl.call(manager, 'com.example.backup.sync');
assert.equal(storage.has(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS), false);
});
it('getConnectedAdapter uses createPluginStorage for namespaced provider IDs', async () => {
const storage = memoryStorage();
const manager = createManagerHarness(storage);
const pluginId = 'com.example.backup.sync';
const objectStore = new Map<string, Uint8Array>();
let factoryCalls = 0;
manager.state = {
providers: {
[pluginId]: {
provider: pluginId,
status: 'connected',
config: { endpoint: 'https://example.test' },
},
},
};
manager.providerDecrypted[pluginId] = true;
manager.createPluginStorage = async (id: string): Promise<EncryptedObjectStorage> => {
factoryCalls += 1;
assert.equal(id, pluginId);
return {
providerId: id,
async connect() {
return { account: { id: 'plugin-acct' } };
},
async disconnect() {},
async getAccount() {
return { account: { id: 'plugin-acct' } };
},
async getCapabilities() {
return {
revisions: true,
conditionalWrites: true,
atomicReplacement: true,
};
},
async readObject(key: string) {
const bytes = objectStore.get(key) ?? null;
return bytes
? { found: true, key, bytes, revision: '1' }
: { found: false, key, bytes: null };
},
async writeObject(key: string, bytes: Uint8Array) {
const created = !objectStore.has(key);
objectStore.set(key, bytes);
return { created, revision: '2' };
},
async deleteObject(key: string) {
return { deleted: objectStore.delete(key) };
},
};
};
const adapter = await getConnectedAdapterImpl.call(manager, pluginId);
assert.equal(factoryCalls, 1);
// Config-backed plugin connections report authenticated for cache reuse.
assert.equal(adapter.isAuthenticated, true);
const reused = await getConnectedAdapterImpl.call(manager, pluginId);
assert.equal(reused, adapter);
assert.equal(factoryCalls, 1, 'must not recreate when cached adapter is authenticated');
await adapter.initializeSync();
assert.equal(adapter.accountInfo?.id, 'plugin-acct');
const file = makeSyncedFile(7, 'plugin-cipher');
await adapter.upload(file);
const downloaded = await adapter.download();
assert.equal(downloaded?.payload, 'plugin-cipher');
assert.equal(downloaded?.meta.version, 7);
assert.ok(objectStore.has(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY));
});
it('getConnectedAdapter accepts falsy scalar plugin configs as present', async () => {
for (const scalar of [false, 0, ''] as const) {
const storage = memoryStorage();
const manager = createManagerHarness(storage);
const pluginId = 'com.example.scalar.sync';
let factoryCalls = 0;
manager.state = {
providers: {
[pluginId]: {
provider: pluginId,
status: 'connected',
config: scalar as never,
},
},
};
manager.providerDecrypted[pluginId] = true;
manager.createPluginStorage = async (id: string): Promise<EncryptedObjectStorage> => {
factoryCalls += 1;
assert.equal(id, pluginId);
return {
providerId: id,
async connect() {
return { account: { id: 'scalar-acct' } };
},
async disconnect() {},
async getAccount() {
return { account: { id: 'scalar-acct' } };
},
async getCapabilities() {
return {
revisions: false,
conditionalWrites: false,
atomicReplacement: true,
};
},
async readObject() {
return { found: false, key: 'x', bytes: null };
},
async writeObject() {
return { created: true, revision: '1' };
},
async deleteObject() {
return { deleted: false };
},
};
};
const adapter = await getConnectedAdapterImpl.call(manager, pluginId);
assert.equal(factoryCalls, 1);
assert.equal(adapter.isAuthenticated, true);
}
const empty = createManagerHarness(memoryStorage());
empty.state = {
providers: {
'com.example.empty.sync': {
provider: 'com.example.empty.sync',
status: 'connected',
},
},
};
empty.providerDecrypted['com.example.empty.sync'] = true;
await assert.rejects(
() => getConnectedAdapterImpl.call(empty, 'com.example.empty.sync'),
/Provider not connected/,
);
});
});
describe('createAdapter WebDAV production path', () => {
it('returns an authenticated adapter and reuses it via getConnectedAdapter cache semantics', async () => {
const storage = memoryStorage();
const manager = createManagerHarness(storage);
manager.state = {
providers: {
webdav: {
provider: 'webdav',
status: 'connected',
config: {
endpoint: 'https://webdav.example.test',
authType: 'basic',
username: 'user',
password: 'secret',
},
resourceId: '/netcatty-vault.json',
},
},
};
manager.providerDecrypted.webdav = true;
const a1 = await getConnectedAdapterImpl.call(manager, 'webdav');
assert.equal(a1.isAuthenticated, true);
assert.equal(a1.resourceId, '/netcatty-vault.json');
const a2 = await getConnectedAdapterImpl.call(manager, 'webdav');
assert.equal(a1, a2, 'must reuse cached adapter when isAuthenticated is true');
});
it('enforceLegacySingleProviderConnected keeps builtin and disconnects plugin', () => {
const providers: Record<string, ProviderConnection> = {
github: { provider: 'github', status: 'connected' },
'com.example.backup.sync': {
provider: 'com.example.backup.sync',
status: 'connected',
config: { endpoint: 'https://example.test' },
},
};
enforceLegacySingleProviderConnected(providers);
assert.equal(providers.github?.status, 'connected');
assert.equal(providers['com.example.backup.sync']?.status, 'disconnected');
assert.deepEqual(
providers['com.example.backup.sync']?.config,
{ endpoint: 'https://example.test' },
);
});
});
@@ -21,7 +21,6 @@ import type {
SyncPayload,
SyncResult,
} from '../../../domain/sync';
import { isConditionalWriteConflictError } from '../adapters/encryptedObjectStorageBridge';
function getSyncSecurityGeneration(manager: any): number | undefined {
return typeof manager.getSyncSecurityGeneration === 'function'
@@ -118,8 +117,6 @@ async function uploadLocalPayloadImpl(this: any,
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.state.lastShrinkFinding = undefined;
} else if (result.conflictDetected) {
// Conflict UI / CONFLICT state already set by uploadToProvider.
} else {
this.state.syncState = 'ERROR';
if (result.error) {
@@ -172,9 +169,7 @@ export async function uploadToProviderImpl(this: any,
): Promise<SyncResult> {
try {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const resourceId = await adapter.upload(syncedFile, {
signal: this.activeSyncAbortSignal,
});
const resourceId = await adapter.upload(syncedFile);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
this.state.lastError = null;
@@ -227,53 +222,6 @@ export async function uploadToProviderImpl(this: any,
this.emit({ type: 'SYNC_COMPLETED', provider, result });
return result;
} catch (error) {
// Conditional write rejected: surface as conflict so the user can pick
// local vs remote instead of a generic sync failure.
if (isConditionalWriteConflictError(error)) {
try {
const remoteFile = await adapter.download({
signal: this.activeSyncAbortSignal,
});
if (remoteFile) {
this.state.syncState = 'CONFLICT';
this.state.currentConflict = {
provider,
localVersion: this.state.localVersion,
localUpdatedAt: this.state.localUpdatedAt,
localDeviceName: this.state.deviceName,
remoteVersion: remoteFile.meta.version,
remoteUpdatedAt: remoteFile.meta.updatedAt,
remoteDeviceName: remoteFile.meta.deviceName,
};
this.emit({
type: 'CONFLICT_DETECTED',
conflict: this.state.currentConflict,
});
// Leave the provider ready (not stuck on "syncing") while the user resolves.
this.updateProviderStatus(provider, 'connected');
this.addSyncHistoryEntry({
timestamp: Date.now(),
provider,
action: 'upload',
success: false,
localVersion: this.state.localVersion,
remoteVersion: remoteFile.meta.version,
deviceName: this.state.deviceName,
error: String(error),
});
return {
success: false,
provider,
action: 'none',
conflictDetected: true,
error: String(error),
};
}
} catch {
// Fall through to generic ERROR if we cannot load remote for the UI.
}
}
this.state.lastError = String(error);
if (!handleProviderReauthRequired(this, provider, error)) {
this.updateProviderStatus(provider, 'error', String(error));
@@ -543,12 +491,9 @@ export async function syncToProviderImpl(this: any,
};
}
// Upload after merge failed — preserve CONFLICT when conditional write
// already surfaced a conflict; otherwise mark ERROR so we leave SYNCING.
if (!uploadResult.conflictDetected) {
this.state.syncState = 'ERROR';
this.state.lastError = uploadResult.error || 'Upload failed after merge';
}
// Upload after merge failed — set ERROR so sync isn't stuck in SYNCING
this.state.syncState = 'ERROR';
this.state.lastError = uploadResult.error || 'Upload failed after merge';
return uploadResult;
} catch (mergeError) {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
@@ -636,9 +581,7 @@ export async function downloadFromProviderImpl(this: any,provider: CloudProvider
try {
let remoteFile: SyncedFile | null;
try {
remoteFile = await adapter.download({
signal: this.activeSyncAbortSignal,
});
remoteFile = await adapter.download();
} catch (downloadError) {
throw new Error(`Download failed: ${downloadError instanceof Error ? downloadError.message : String(downloadError)}`);
}
@@ -1,27 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BUILTIN_CLOUD_PROVIDERS,
SYNC_CONSTANTS,
SYNC_STORAGE_KEYS,
cleanOneDriveErrorMessage,
generateDeviceId,
getDefaultDeviceName,
isBuiltinCloudProvider,
isOneDriveReauthRequiredMessage,
providerConnectionStorageKey,
type CloudProvider,
type MasterKeyConfig,
type ProviderConnection,
type SecurityState,
type SyncHistoryEntry,
} from '../../../domain/sync';
import { isPluginCloudProviderId } from '../../../domain/cloudProviderIds';
import { createPluginSyncObjectStorage } from '../adapters/pluginSyncObjectStorage';
import {
createPluginSyncIpcHost,
isPluginSyncIpcAvailable,
} from '../adapters/pluginSyncIpcHost';
import type { EncryptedObjectStorage } from '../../../domain/encryptedObjectStorage';
import {
DEFAULT_CLOUD_SYNC_STRATEGY,
normalizeCloudSyncStrategy,
@@ -34,34 +19,17 @@ import {
encryptProviderSecrets,
} from '../../persistence/secureFieldAdapter';
import type { CloudAdapter } from '../adapters';
import type {
CloudProvider,
MasterKeyConfig,
ProviderConnection,
SecurityState,
SyncHistoryEntry,
} from '../../../domain/sync';
import type { SyncManagerState } from '../CloudSyncManager';
import { getConvergentSyncLocalConfig } from '../convergentSyncConfig';
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
/** Ensure per-provider sequence counters exist (dynamic plugins arrive late). */
function ensureProviderSeqCounters(manager: any, provider: CloudProvider): void {
// Lightweight test harnesses may omit the maps entirely; initialize before indexing.
if (manager.providerDecryptSeq == null || typeof manager.providerDecryptSeq !== 'object') {
manager.providerDecryptSeq = {};
}
if (manager.providerWriteSeq == null || typeof manager.providerWriteSeq !== 'object') {
manager.providerWriteSeq = {};
}
if (manager.providerDecrypted == null || typeof manager.providerDecrypted !== 'object') {
manager.providerDecrypted = {};
}
if (manager.providerDecryptSeq[provider] == null || Number.isNaN(manager.providerDecryptSeq[provider])) {
manager.providerDecryptSeq[provider] = 0;
}
if (manager.providerWriteSeq[provider] == null || Number.isNaN(manager.providerWriteSeq[provider])) {
manager.providerWriteSeq[provider] = 0;
}
if (manager.providerDecrypted[provider] == null) {
manager.providerDecrypted[provider] = false;
}
}
export function loadInitialStateImpl(this: any): SyncManagerState {
// Load persisted configuration
const masterKeyConfig = this.loadFromStorage<MasterKeyConfig>(
@@ -90,7 +58,7 @@ export function loadInitialStateImpl(this: any): SyncManagerState {
// Determine initial security state
const securityState: SecurityState = masterKeyConfig ? 'LOCKED' : 'NO_KEY';
// Load provider connections (built-ins + registered plugin provider IDs)
// Load provider connections
const providers: Record<CloudProvider, ProviderConnection> = {
github: this.loadProviderConnection('github'),
google: this.loadProviderConnection('google'),
@@ -98,17 +66,6 @@ export function loadInitialStateImpl(this: any): SyncManagerState {
webdav: this.loadProviderConnection('webdav'),
s3: this.loadProviderConnection('s3'),
};
for (const pluginProviderId of listRegisteredPluginProviderIdsImpl.call(this)) {
providers[pluginProviderId] = this.loadProviderConnection(pluginProviderId);
if (this.providerWriteSeq[pluginProviderId] == null) this.providerWriteSeq[pluginProviderId] = 0;
if (this.providerDecryptSeq[pluginProviderId] == null) this.providerDecryptSeq[pluginProviderId] = 0;
if (this.providerDecrypted[pluginProviderId] == null) this.providerDecrypted[pluginProviderId] = false;
if (this.providerAuthAttemptSeq[pluginProviderId] == null) this.providerAuthAttemptSeq[pluginProviderId] = 0;
if (this.providerAuthRestoreState[pluginProviderId] === undefined) {
this.providerAuthRestoreState[pluginProviderId] = null;
}
}
enforceLegacySingleProviderConnected(providers);
// Save device ID if new
this.saveToStorage(SYNC_STORAGE_KEYS.DEVICE_ID, deviceId);
@@ -138,24 +95,14 @@ export function loadInitialStateImpl(this: any): SyncManagerState {
}
export function loadProviderConnectionImpl(this: any,provider: CloudProvider): ProviderConnection {
const key = providerConnectionStorageKey(provider);
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
const stored = this.loadFromStorage<Partial<ProviderConnection>>(key);
// Config may be a valid scalar including JSON null (schema type: "null").
// Presence is property existence; only a missing property means absent.
const hasCreds = stored?.tokens != null
|| (stored != null && Object.prototype.hasOwnProperty.call(stored, 'config'));
// Determine the correct status: if tokens or config exist, should be 'connected'
// Never restore 'syncing' or 'error' status - those are transient.
// Dynamic plugin providers only rejoin sync when the host is ready AND the
// provider was recorded as contribution-available (survives restart).
// Host-off or uninstalled providers stay disconnected with config retained.
let status: ProviderConnection['status'] = hasCreds ? 'connected' : 'disconnected';
if (isPluginCloudProviderId(provider) && hasCreds) {
const hostReady = isPluginSyncIpcAvailable();
const available = listAvailablePluginSyncProviderIdsImpl.call(this);
status = hostReady && available.includes(provider) ? 'connected' : 'disconnected';
}
// Never restore 'syncing' or 'error' status - those are transient
const status: ProviderConnection['status'] = (stored?.tokens || stored?.config)
? 'connected'
: 'disconnected';
return {
provider,
@@ -164,192 +111,12 @@ export function loadProviderConnectionImpl(this: any,provider: CloudProvider): P
} as ProviderConnection;
}
/**
* Legacy (non-convergent) mode allows only one ready provider. After restart,
* retained plugin configs can race with a builtin the user switched to — keep
* config but force extras offline (same policy as setAvailablePluginSyncProviderIds).
*/
export function enforceLegacySingleProviderConnected(
providers: Record<string, ProviderConnection>,
): void {
let convergentActive = false;
try {
convergentActive = getConvergentSyncLocalConfig().initialized === true;
} catch {
convergentActive = false;
}
if (convergentActive) return;
const readyIds = Object.entries(providers)
.filter(([, conn]) => conn != null && (conn.status === 'connected' || conn.status === 'syncing'))
.map(([id]) => id);
if (readyIds.length <= 1) return;
// Prefer a builtin (stable UI order), else first ready id.
const preferred = BUILTIN_CLOUD_PROVIDERS.find((id) => readyIds.includes(id))
?? readyIds[0]!;
for (const id of readyIds) {
if (id === preferred) continue;
const conn = providers[id];
if (!conn) continue;
providers[id] = {
...conn,
status: 'disconnected',
};
}
}
export function listRegisteredPluginProviderIdsImpl(this: any): string[] {
const raw = this.loadFromStorage<unknown>(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS);
if (!Array.isArray(raw)) return [];
return raw
.filter((id: unknown): id is string => typeof id === 'string' && isPluginCloudProviderId(id))
.sort();
}
export function listAvailablePluginSyncProviderIdsImpl(this: any): string[] {
if (typeof this.loadFromStorage !== 'function') return [];
const raw = this.loadFromStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS) as unknown;
if (!Array.isArray(raw)) return [];
return raw
.filter((id: unknown): id is string => typeof id === 'string' && isPluginCloudProviderId(id))
.sort();
}
export function markPluginSyncProviderAvailableImpl(this: any, provider: CloudProvider): void {
if (!isPluginCloudProviderId(provider)) return;
const next = new Set(listAvailablePluginSyncProviderIdsImpl.call(this));
next.add(provider);
this.saveToStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS, [...next].sort());
}
export function markPluginSyncProviderUnavailableImpl(this: any, provider: CloudProvider): void {
if (!isPluginCloudProviderId(provider)) return;
const next = listAvailablePluginSyncProviderIdsImpl.call(this).filter((id) => id !== provider);
if (next.length === 0) {
this.removeFromStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS);
} else {
this.saveToStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS, next);
}
}
/**
* Replace the live contribution-available set. Called when the renderer
* refreshes plugin sync provider contributions so disabled/uninstalled
* providers stop rejoining sync cycles after restart.
*/
export function setAvailablePluginSyncProviderIdsImpl(
this: any,
providerIds: readonly string[],
): void {
const previous = listAvailablePluginSyncProviderIdsImpl.call(this);
const previousSet = new Set(previous);
const next = [...new Set(
providerIds.filter((id): id is string => typeof id === 'string' && isPluginCloudProviderId(id)),
)].sort();
if (next.length === 0) {
this.removeFromStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS);
} else {
this.saveToStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS, next);
}
if (!this.state?.providers) return;
let changed = false;
for (const id of Object.keys(this.state.providers)) {
if (!isPluginCloudProviderId(id)) continue;
const conn = this.state.providers[id];
if (!conn) continue;
if (!next.includes(id)) {
// Disabled/uninstalled: leave config, force offline ready-state.
if (conn.status !== 'disconnected') {
this.state.providers[id] = {
...conn,
status: 'disconnected',
error: 'Plugin sync provider is no longer installed or enabled',
};
changed = true;
}
const adapter = this.adapters?.get?.(id);
if (adapter) {
try { adapter.signOut(); } catch { /* ignore */ }
this.adapters.delete(id);
}
continue;
}
// Provider is still contributed. Only drop the cached adapter when the
// provider newly re-entered the available set (install/enable/restart of
// the package). Setting-only contribution noise keeps membership stable
// and must not signOut mid-sync — plugin adapters rebind via rebindSession.
if (!previousSet.has(id) && this.adapters?.has?.(id)) {
const adapter = this.adapters.get(id);
try { adapter?.signOut?.(); } catch { /* ignore */ }
this.adapters.delete(id);
}
// Re-enable retained configs whenever the contribution reappears, but only
// when doing so cannot create a second active provider in legacy
// single-provider mode (convergent multi-provider allows it).
// Config may be a valid falsy scalar (false, 0, "") — only null/undefined means absent.
const hasRetainedConfig = conn.tokens != null
|| Object.prototype.hasOwnProperty.call(conn, 'config');
if (hasRetainedConfig && conn.status === 'disconnected') {
let convergentActive = false;
try {
convergentActive = getConvergentSyncLocalConfig().initialized === true;
} catch {
convergentActive = false;
}
const anotherReady = Object.entries(this.state.providers as Record<string, ProviderConnection>)
.some(([otherId, other]) => (
otherId !== id
&& other != null
&& (other.status === 'connected' || other.status === 'syncing')
));
if (!convergentActive && anotherReady) {
// Keep disconnected with retained config until the user reconnects
// explicitly; auto-reactivation would mirror two legacy providers.
continue;
}
this.state.providers[id] = {
...conn,
status: 'connected',
error: undefined,
};
changed = true;
}
}
if (changed && typeof this.notifyStateChange === 'function') {
this.notifyStateChange();
}
}
export function registerPluginProviderIdImpl(this: any, provider: CloudProvider): void {
if (!isPluginCloudProviderId(provider)) return;
const next = new Set(listRegisteredPluginProviderIdsImpl.call(this));
next.add(provider);
this.saveToStorage(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS, [...next].sort());
markPluginSyncProviderAvailableImpl.call(this, provider);
}
export function unregisterPluginProviderIdImpl(this: any, provider: CloudProvider): void {
if (!isPluginCloudProviderId(provider)) return;
const next = listRegisteredPluginProviderIdsImpl.call(this).filter((id) => id !== provider);
if (next.length === 0) {
this.removeFromStorage(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS);
} else {
this.saveToStorage(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS, next);
}
markPluginSyncProviderUnavailableImpl.call(this, provider);
}
export async function initProviderDecryptionImpl(this: any): Promise<void> {
const providers: CloudProvider[] = [
...BUILTIN_CLOUD_PROVIDERS,
...listRegisteredPluginProviderIdsImpl.call(this),
];
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
for (const p of providers) {
try {
const conn = this.state.providers[p];
// Config may be a valid scalar including JSON null — presence is property existence.
if (conn.tokens != null || Object.prototype.hasOwnProperty.call(conn, 'config')) {
if (conn.tokens || conn.config) {
const seq = ++this.providerDecryptSeq[p];
const decrypted = await decryptProviderSecrets(conn);
// Only apply if no newer update has occurred during the async gap
@@ -374,10 +141,9 @@ export async function saveProviderConnectionImpl(this: any,
connection: ProviderConnection,
authAttemptId?: number
): Promise<void> {
const key = providerConnectionStorageKey(provider);
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
// Use write-specific counter so status-only updates cannot discard
// an in-flight encrypted write that must be persisted.
ensureProviderSeqCounters(this, provider);
const seq = ++this.providerWriteSeq[provider];
const encrypted = await encryptProviderSecrets(connection);
// Only persist if no newer save has started during the async gap
@@ -386,16 +152,6 @@ export async function saveProviderConnectionImpl(this: any,
(authAttemptId == null || this.isActiveAuthAttempt(provider, authAttemptId))
) {
this.saveToStorage(key, encrypted);
// Keep dynamic plugin providers in the restart registry while connected
// (or while credentials/config remain so a missing plugin cannot drop them).
if (isPluginCloudProviderId(provider)) {
// Config may be a valid scalar including JSON null — presence is property existence.
const hasData = encrypted.tokens != null
|| Object.prototype.hasOwnProperty.call(encrypted, 'config');
if (hasData || encrypted.status === 'connected' || encrypted.status === 'syncing') {
registerPluginProviderIdImpl.call(this, provider);
}
}
}
}
@@ -514,35 +270,6 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
return;
}
// Plugin provider registry changes (connect/disconnect of dynamic IDs).
if (key === SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS) {
const registered = listRegisteredPluginProviderIdsImpl.call(this);
const nextProviders = { ...this.state.providers } as Record<CloudProvider, ProviderConnection>;
for (const id of registered) {
if (!nextProviders[id]) {
nextProviders[id] = this.loadProviderConnection(id);
if (this.providerWriteSeq[id] == null) this.providerWriteSeq[id] = 0;
if (this.providerDecryptSeq[id] == null) this.providerDecryptSeq[id] = 0;
if (this.providerDecrypted[id] == null) this.providerDecrypted[id] = false;
if (this.providerAuthAttemptSeq[id] == null) this.providerAuthAttemptSeq[id] = 0;
}
}
for (const id of Object.keys(nextProviders)) {
if (isBuiltinCloudProvider(id)) continue;
if (!registered.includes(id)) {
const adapter = this.adapters.get(id);
if (adapter) {
adapter.signOut();
this.adapters.delete(id);
}
delete nextProviders[id];
}
}
this.state.providers = nextProviders;
this.notifyStateChange();
return;
}
// Sync provider connections (connect/disconnect, account, tokens, last sync)
const providerByKey: Partial<Record<string, CloudProvider>> = {
[SYNC_STORAGE_KEYS.PROVIDER_GITHUB]: 'github',
@@ -551,15 +278,8 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
[SYNC_STORAGE_KEYS.PROVIDER_WEBDAV]: 'webdav',
[SYNC_STORAGE_KEYS.PROVIDER_S3]: 's3',
};
let provider = providerByKey[key] as CloudProvider | undefined;
if (!provider && key.startsWith('netcatty_provider_plugin_v1:')) {
provider = key.slice('netcatty_provider_plugin_v1:'.length) as CloudProvider;
}
const provider = providerByKey[key];
if (provider) {
// Dynamic plugin providers may receive their first storage event before
// the registry handler initializes counters; undefined++ becomes NaN and
// every subsequent decrypt result is discarded (NaN !== NaN).
ensureProviderSeqCounters(this, provider);
const rawNext = this.loadProviderConnection(provider);
const seq = ++this.providerDecryptSeq[provider];
// Also bump write seq so any in-flight save from this window for the
@@ -572,10 +292,7 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
decryptProviderSecrets(rawNext).then((next) => {
if (seq !== this.providerDecryptSeq[provider]) return; // stale — discard
const prev = this.state.providers[provider] ?? {
provider,
status: 'disconnected' as const,
};
const prev = this.state.providers[provider];
const preserveTransientStatus =
prev.status === 'connecting' || prev.status === 'syncing';
@@ -588,10 +305,7 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
const nextTokens = next.tokens;
const nextConfig = next.config;
const adapter = this.adapters.get(provider);
// Config may be a valid falsy scalar including JSON null — presence is
// property existence (matches hasProviderConnectionData).
const hasConfigProperty = Object.prototype.hasOwnProperty.call(next, 'config');
if (nextTokens == null && !hasConfigProperty && next.credential == null) {
if (!nextTokens && !nextConfig) {
if (adapter) {
adapter.signOut();
this.adapters.delete(provider);
@@ -601,40 +315,21 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
}
const tokenChanged =
(prev.tokens?.accessToken ?? null) !== (nextTokens?.accessToken ?? null) ||
(prev.tokens?.refreshToken ?? null) !== (nextTokens?.refreshToken ?? null) ||
(prev.tokens?.expiresAt ?? null) !== (nextTokens?.expiresAt ?? null) ||
(prev.tokens?.tokenType ?? null) !== (nextTokens?.tokenType ?? null) ||
(prev.tokens?.scope ?? null) !== (nextTokens?.scope ?? null);
(prev.tokens?.accessToken || null) !== (nextTokens?.accessToken || null) ||
(prev.tokens?.refreshToken || null) !== (nextTokens?.refreshToken || null) ||
(prev.tokens?.expiresAt || null) !== (nextTokens?.expiresAt || null) ||
(prev.tokens?.tokenType || null) !== (nextTokens?.tokenType || null) ||
(prev.tokens?.scope || null) !== (nextTokens?.scope || null);
// Config may be a valid falsy scalar (false, 0, "") — use nullish, not ||.
const configChanged =
JSON.stringify(prev.config ?? null) !== JSON.stringify(nextConfig ?? null);
const prevCredential = prev.credential;
const nextCredential = next.credential;
const credentialChanged =
(prevCredential?.kind ?? null) !== (nextCredential?.kind ?? null)
|| (prevCredential?.id ?? null) !== (nextCredential?.id ?? null)
|| (prevCredential?.key ?? null) !== (nextCredential?.key ?? null);
JSON.stringify(prev.config || null) !== JSON.stringify(nextConfig || null);
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
if (adapter && (tokenChanged || configChanged || credentialChanged || resourceChanged)) {
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
adapter.signOut();
this.adapters.delete(provider);
}
// Credential identity changes invalidate merge anchors the same way
// config/resource changes do on the connect path (authMethods).
if (credentialChanged) {
try {
this.removeFromStorage(this.syncBaseKey(provider));
this.removeFromStorage(this.convergentProviderBaselineKey(provider));
this.clearSyncAnchor(provider);
} catch {
// Best-effort; adapter eviction above already forces reconnect.
}
}
this.notifyStateChange();
}).catch(() => {
@@ -646,14 +341,12 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
export async function getConnectedAdapterImpl(this: any,provider: CloudProvider): Promise<CloudAdapter> {
// Ensure startup decryption has finished before reading tokens
await this.decryptionReady;
ensureProviderSeqCounters(this, provider);
// If this provider's secrets were not successfully decrypted at
// startup (IPC handler not registered yet), retry now.
if (!this.providerDecrypted[provider]) {
const conn = this.state.providers[provider];
// Config may be a valid scalar including JSON null — presence is property existence.
if (conn.tokens != null || Object.prototype.hasOwnProperty.call(conn, 'config')) {
if (conn.tokens || conn.config) {
try {
const seq = ++this.providerDecryptSeq[provider];
const decrypted = await decryptProviderSecrets(conn);
@@ -678,10 +371,7 @@ export async function getConnectedAdapterImpl(this: any,provider: CloudProvider)
const connection = this.state.providers[provider];
const tokens = connection?.tokens;
const config = connection?.config;
// Config may be a valid scalar including JSON null — presence is property existence.
const hasConfigProperty = connection != null
&& Object.prototype.hasOwnProperty.call(connection, 'config');
if (tokens == null && !hasConfigProperty && connection?.credential == null) {
if (!tokens && !config) {
throw new Error('Provider not connected');
}
@@ -691,37 +381,7 @@ export async function getConnectedAdapterImpl(this: any,provider: CloudProvider)
return existing;
}
const createPluginStorage = async (providerId: string): Promise<EncryptedObjectStorage> => {
if (typeof this.createPluginStorage === 'function') {
return this.createPluginStorage(providerId, connection);
}
if (!isPluginSyncIpcAvailable()) {
throw new Error(
`Plugin sync provider ${providerId} is unavailable (plugin host not enabled)`,
);
}
const host = createPluginSyncIpcHost();
// Preserve explicit null configuration (schema type: "null"); only
// default to {} when the config property is truly absent.
const configuration = connection != null
&& Object.prototype.hasOwnProperty.call(connection, 'config')
? connection.config
: {};
return createPluginSyncObjectStorage({
providerId,
host,
configuration,
credential: connection?.credential,
});
};
const adapter = await createAdapter(
provider,
tokens,
connection.resourceId,
config,
isBuiltinCloudProvider(provider) ? undefined : { createPluginStorage },
);
const adapter = await createAdapter(provider, tokens, connection.resourceId, config);
attachTokenRefreshPersistence.call(this, provider, adapter);
this.adapters.set(provider, adapter);
return adapter;
@@ -769,7 +429,6 @@ export function persistRefreshedProviderTokensImpl(
// Invalidate any in-flight decrypt (startup / cross-window) so it cannot
// overwrite the rotated tokens we are about to commit.
ensureProviderSeqCounters(this, provider);
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...existing,
@@ -27,7 +27,6 @@ import type {
SyncPayload,
SyncResult,
} from '../../../domain/sync';
// CloudProvider used when clearing dynamic plugin-provider bases/anchors.
import {
decryptLocalStorageValue,
encryptLocalStorageValue,
@@ -597,21 +596,8 @@ export async function syncAllProvidersImpl(this: any,
await Promise.all(uploadTasks);
// 5. Final State Update
const resultList = Array.from(results.values());
const hasSuccess = resultList.some((r) => r.success);
const hasConflict = resultList.some((r) => r.conflictDetected);
if (hasConflict) {
// Prefer CONFLICT over IDLE even when another provider succeeded, so the
// conflict UI from uploadToProvider is not wiped by a mixed multi-provider run.
this.state.syncState = 'CONFLICT';
if (wasMerged && payload) {
for (const [p, r] of results) {
if (r.success) {
results.set(p, { ...r, action: 'merge', mergedPayload: payload });
}
}
}
} else if (hasSuccess) {
const hasSuccess = Array.from(results.values()).some((r) => r.success);
if (hasSuccess) {
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.state.lastShrinkFinding = undefined;
@@ -794,18 +780,7 @@ export function clearSyncBaseImpl(this: any): void {
if (typeof this.syncSnapshotsKey === 'function') {
this.removeFromStorage(this.syncSnapshotsKey());
}
const providers = new Set<CloudProvider>([
'github', 'google', 'onedrive', 'webdav', 's3',
]);
for (const id of Object.keys(this.state?.providers ?? {})) {
providers.add(id as CloudProvider);
}
if (typeof this.listRegisteredPluginProviderIds === 'function') {
for (const id of this.listRegisteredPluginProviderIds()) {
providers.add(id as CloudProvider);
}
}
for (const p of providers) {
for (const p of ['github', 'google', 'onedrive', 'webdav', 's3'] as const) {
this.removeFromStorage(this.syncBaseKey(p));
this.removeFromStorage(this.convergentProviderBaselineKey(p));
if (typeof this.syncSnapshotsKey === 'function') {
@@ -79,15 +79,6 @@
"maxRecords": 10000
}
},
"SyncLimits": {
"description": "Host-enforced byte and identifier limits for one encrypted sync-object operation. inlineObjectBytes is the maximum ciphertext size that may travel inline on the control plane (below the 128 KiB provider JSON budget after base64 expansion).",
"const": {
"maxObjectBytes": 67108864,
"maxObjectKeyLength": 1024,
"maxRevisionLength": 256,
"inlineObjectBytes": 92160
}
},
"StreamId": {
"type": "string",
"minLength": 1,
@@ -1204,196 +1195,6 @@
}
]
},
"SyncObjectKey": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"SyncObjectRevision": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"SyncAccount": {
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "string", "minLength": 1, "maxLength": 512 },
"email": { "type": "string", "maxLength": 512 },
"name": { "type": "string", "maxLength": 512 },
"avatarUrl": { "type": "string", "maxLength": 2048 }
},
"additionalProperties": false
},
"SyncConnectPayload": {
"type": "object",
"required": ["configuration", "operationId"],
"properties": {
"configuration": { "$ref": "#/$defs/JsonValue" },
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 },
"credential": {
"oneOf": [
{ "$ref": "#/$defs/SecretRef" },
{ "$ref": "#/$defs/CredentialRef" },
{ "$ref": "#/$defs/SecretLeaseRef" }
]
}
},
"additionalProperties": false
},
"SyncConnectResult": {
"type": "object",
"required": ["account"],
"properties": {
"account": { "$ref": "#/$defs/SyncAccount" }
},
"additionalProperties": false
},
"SyncDisconnectPayload": {
"type": "object",
"properties": {
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 }
},
"additionalProperties": false
},
"SyncDisconnectResult": {
"type": "null"
},
"SyncGetAccountPayload": {
"type": "object",
"properties": {
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 }
},
"additionalProperties": false
},
"SyncGetAccountResult": {
"type": "object",
"required": ["account"],
"properties": {
"account": {
"oneOf": [
{ "$ref": "#/$defs/SyncAccount" },
{ "type": "null" }
]
}
},
"additionalProperties": false
},
"SyncGetCapabilitiesPayload": {
"type": "object",
"properties": {
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 }
},
"additionalProperties": false
},
"SyncCapabilitiesResult": {
"type": "object",
"required": ["revisions", "conditionalWrites", "atomicReplacement"],
"properties": {
"revisions": { "type": "boolean" },
"conditionalWrites": { "type": "boolean" },
"atomicReplacement": { "type": "boolean" },
"maxObjectBytes": { "type": "integer", "minimum": 1, "maximum": 67108864 },
"maxObjects": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }
},
"additionalProperties": false
},
"SyncReadObjectPayload": {
"type": "object",
"required": ["key", "operationId"],
"properties": {
"key": { "$ref": "#/$defs/SyncObjectKey" },
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 },
"outputStreamId": { "type": "string", "minLength": 1, "maxLength": 128 },
"windowBytes": { "type": "integer", "minimum": 1024, "maximum": 16777216 }
},
"additionalProperties": false
},
"SyncReadObjectResult": {
"oneOf": [
{
"type": "object",
"required": ["found"],
"properties": {
"found": { "const": false }
},
"additionalProperties": false
},
{
"type": "object",
"required": ["found", "byteLength", "encoding", "data"],
"properties": {
"found": { "const": true },
"byteLength": { "type": "integer", "minimum": 0, "maximum": 67108864 },
"encoding": { "const": "base64" },
"data": { "type": "string", "maxLength": 9007199254740991 },
"revision": { "$ref": "#/$defs/SyncObjectRevision" },
"contentType": { "type": "string", "maxLength": 256 }
},
"additionalProperties": false
},
{
"type": "object",
"required": ["found", "byteLength", "streamed"],
"properties": {
"found": { "const": true },
"byteLength": { "type": "integer", "minimum": 0, "maximum": 67108864 },
"streamed": { "const": true },
"revision": { "$ref": "#/$defs/SyncObjectRevision" },
"contentType": { "type": "string", "maxLength": 256 }
},
"additionalProperties": false
}
]
},
"SyncWriteObjectPayload": {
"type": "object",
"required": ["key", "operationId", "byteLength"],
"properties": {
"key": { "$ref": "#/$defs/SyncObjectKey" },
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 },
"byteLength": { "type": "integer", "minimum": 0, "maximum": 67108864 },
"expectedRevision": {
"oneOf": [
{ "$ref": "#/$defs/SyncObjectRevision" },
{ "type": "null" }
]
},
"encoding": { "const": "base64" },
"data": { "type": "string", "maxLength": 9007199254740991 },
"inputStreamId": { "type": "string", "minLength": 1, "maxLength": 128 },
"windowBytes": { "type": "integer", "minimum": 1024, "maximum": 16777216 }
},
"additionalProperties": false
},
"SyncWriteObjectResult": {
"type": "object",
"required": ["created"],
"properties": {
"created": { "type": "boolean" },
"revision": { "$ref": "#/$defs/SyncObjectRevision" }
},
"additionalProperties": false
},
"SyncDeleteObjectPayload": {
"type": "object",
"required": ["key", "operationId"],
"properties": {
"key": { "$ref": "#/$defs/SyncObjectKey" },
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 },
"expectedRevision": { "$ref": "#/$defs/SyncObjectRevision" }
},
"additionalProperties": false
},
"SyncDeleteObjectResult": {
"type": "object",
"required": ["deleted"],
"properties": {
"deleted": { "type": "boolean" }
},
"additionalProperties": false
},
"CompanionPlatform": {
"type": "string",
"enum": [
@@ -59,69 +59,6 @@ test("plugin manifest schema accepts the internal contract", () => {
assert.equal(validate({ ...validManifest, version: "1.0.0-01" }), false);
});
test("sync provider encrypted-object payloads and results are canonical and bounded", () => {
const connect = validator("SyncConnectPayload");
const capabilities = validator("SyncCapabilitiesResult");
const readResult = validator("SyncReadObjectResult");
const writePayload = validator("SyncWriteObjectPayload");
const writeResult = validator("SyncWriteObjectResult");
const deleteResult = validator("SyncDeleteObjectResult");
const disconnectResult = validator("SyncDisconnectResult");
assert.equal(connect({
configuration: { endpoint: "https://example.test" },
operationId: "sync:1",
}), true, JSON.stringify(connect.errors));
assert.equal(connect({ configuration: {}, operationId: "" }), false);
assert.equal(capabilities({
revisions: true,
conditionalWrites: true,
atomicReplacement: false,
maxObjectBytes: 1024,
}), true, JSON.stringify(capabilities.errors));
assert.equal(capabilities({
revisions: true,
conditionalWrites: true,
atomicReplacement: false,
maxObjectBytes: 0,
}), false);
assert.equal(readResult({ found: false }), true, JSON.stringify(readResult.errors));
assert.equal(readResult({
found: true,
byteLength: 4,
encoding: "base64",
data: "qQECAw==",
revision: "rev-1",
}), true, JSON.stringify(readResult.errors));
assert.equal(readResult({
found: true,
byteLength: 4,
streamed: true,
revision: "rev-1",
}), true, JSON.stringify(readResult.errors));
assert.equal(readResult({ found: true, byteLength: 4 }), false);
assert.equal(writePayload({
key: "netcatty-vault.json",
operationId: "sync:write:1",
byteLength: 4,
encoding: "base64",
data: "qQECAw==",
expectedRevision: null,
}), true, JSON.stringify(writePayload.errors));
assert.equal(writePayload({
key: "netcatty-vault.json",
operationId: "sync:write:1",
byteLength: -1,
}), false);
assert.equal(writeResult({ created: true, revision: "rev-2" }), true, JSON.stringify(writeResult.errors));
assert.equal(deleteResult({ deleted: true }), true, JSON.stringify(deleteResult.errors));
assert.equal(disconnectResult(null), true, JSON.stringify(disconnectResult.errors));
assert.equal(disconnectResult({}), false);
});
test("connection authentication and importer provider payloads are canonical and bounded", () => {
const connectionOpen = validator("ConnectionOpenPayload");
const authenticationChallenge = validator("AuthenticationChallenge");
@@ -19,7 +19,3 @@ export const PLUGIN_IMPORTER_MAX_INPUT_BYTES = 67108864 as const;
export const PLUGIN_IMPORTER_MAX_OUTPUT_BYTES = 67108864 as const;
export const PLUGIN_IMPORTER_MAX_RECORD_BYTES = 67108864 as const;
export const PLUGIN_IMPORTER_MAX_RECORDS = 10000 as const;
export const PLUGIN_SYNC_MAX_OBJECT_BYTES = 67108864 as const;
export const PLUGIN_SYNC_MAX_OBJECT_KEY_LENGTH = 1024 as const;
export const PLUGIN_SYNC_MAX_REVISION_LENGTH = 256 as const;
export const PLUGIN_SYNC_INLINE_OBJECT_BYTES = 92160 as const;
@@ -747,105 +747,6 @@ export type StreamLimits = {"maxStreamIdLength":128,"maxChunkBytes":16777216,"ma
export type StreamWindowBytes = number;
export type SyncAccount = {
id: string;
email?: string;
name?: string;
avatarUrl?: string;
};
export type SyncCapabilitiesResult = {
revisions: boolean;
conditionalWrites: boolean;
atomicReplacement: boolean;
maxObjectBytes?: number;
maxObjects?: number;
};
export type SyncConnectPayload = {
configuration: JsonValue;
operationId: string;
credential?: (SecretRef) | (CredentialRef) | (SecretLeaseRef);
};
export type SyncConnectResult = {
account: SyncAccount;
};
export type SyncDeleteObjectPayload = {
key: SyncObjectKey;
operationId: string;
expectedRevision?: SyncObjectRevision;
};
export type SyncDeleteObjectResult = {
deleted: boolean;
};
export type SyncDisconnectPayload = {
operationId?: string;
};
export type SyncDisconnectResult = null;
export type SyncGetAccountPayload = {
operationId?: string;
};
export type SyncGetAccountResult = {
account: (SyncAccount) | (null);
};
export type SyncGetCapabilitiesPayload = {
operationId?: string;
};
export type SyncLimits = {"maxObjectBytes":67108864,"maxObjectKeyLength":1024,"maxRevisionLength":256,"inlineObjectBytes":92160};
export type SyncObjectKey = string;
export type SyncObjectRevision = string;
export type SyncReadObjectPayload = {
key: SyncObjectKey;
operationId: string;
outputStreamId?: string;
windowBytes?: number;
};
export type SyncReadObjectResult = ({
found: false;
}) | ({
found: true;
byteLength: number;
encoding: "base64";
data: string;
revision?: SyncObjectRevision;
contentType?: string;
}) | ({
found: true;
byteLength: number;
streamed: true;
revision?: SyncObjectRevision;
contentType?: string;
});
export type SyncWriteObjectPayload = {
key: SyncObjectKey;
operationId: string;
byteLength: number;
expectedRevision?: (SyncObjectRevision) | (null);
encoding?: "base64";
data?: string;
inputStreamId?: string;
windowBytes?: number;
};
export type SyncWriteObjectResult = {
created: boolean;
revision?: SyncObjectRevision;
};
export type TerminalInterceptorAttachmentDescriptor = {
providerId: ContributionId;
direction: "input" | "output";
-4
View File
@@ -16,10 +16,6 @@ export {
PLUGIN_IMPORTER_MAX_RECORDS,
PLUGIN_RPC_ERROR_CODES,
PLUGIN_RPC_MAX_JSON_BYTES,
PLUGIN_SYNC_INLINE_OBJECT_BYTES,
PLUGIN_SYNC_MAX_OBJECT_BYTES,
PLUGIN_SYNC_MAX_OBJECT_KEY_LENGTH,
PLUGIN_SYNC_MAX_REVISION_LENGTH,
PLUGIN_TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES,
PLUGIN_TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES,
PLUGIN_WIRE_MAX_SAFE_INTEGER,
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more