@openchambery/web 1.18.2-beta.2 → 1.18.2-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentAvatar-B2kZjgaf.js → AgentAvatar-B4fj9PIe.js} +1 -1
- package/dist/assets/{AssistantView-DvoyOGkx.js → AssistantView-C00aiEHK.js} +1 -1
- package/dist/assets/{ChatContainer-D6yfuigV.js → ChatContainer-BDFeDKu-.js} +39 -39
- package/dist/assets/{DiagramView-MGGo9iME.js → DiagramView-CUfSFI3e.js} +1 -1
- package/dist/assets/{FadeInOnReveal-aeWlaqVB.js → FadeInOnReveal-CKKU6fKI.js} +1 -1
- package/dist/assets/{Icon-AXqa-fYB.js → Icon-DiIuWOuC.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-jVOkZkyX.js → MarkdownRendererImpl-Cen6E9Lj.js} +2 -2
- package/dist/assets/{MobileDetailNavigation-Q2IzHqGA.js → MobileDetailNavigation-DILi0tN7.js} +1 -1
- package/dist/assets/{MobileShareBridge-apXM34Lb.js → MobileShareBridge-PZMRiieI.js} +1 -1
- package/dist/assets/MobileSurface-BLYcR0tO.js +2 -0
- package/dist/assets/{MultiRunWindow-B-l_O-bd.js → MultiRunWindow-D_2mWZyv.js} +1 -1
- package/dist/assets/{OnboardingScreen-CUsgwt4y.js → OnboardingScreen-CYeRZznl.js} +1 -1
- package/dist/assets/{OverlayScrollbar-D6NySAxi.js → OverlayScrollbar-BgMLtMIq.js} +1 -1
- package/dist/assets/SettingsView-CbDBfQp1.js +25 -0
- package/dist/assets/{SettingsWindow-DfVgTQ4I.js → SettingsWindow-Br7XLcSd.js} +1 -1
- package/dist/assets/{TerminalView-DNyjfkvT.js → TerminalView-CM5jRKlj.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DLG1iThm.js → ToolOutputDialog-D3U6aTZ4.js} +1 -1
- package/dist/assets/appThemeRegistry-CDxBJNNB.js +2 -0
- package/dist/assets/assistant-guide-hero-BeUHQcxE.jpg +0 -0
- package/dist/assets/{button-CDvMVaiO.js → button-DaAoVF4X.js} +1 -1
- package/dist/assets/{clipboard-BW7nk_Cx.js → clipboard-BnElHSnO.js} +1 -1
- package/dist/assets/{collapsible-C2aamDvD.js → collapsible-BmuQu0dq.js} +1 -1
- package/dist/assets/{context-menu-BaxmodD8.js → context-menu-DBOMervd.js} +1 -1
- package/dist/assets/{desktopHosts-CBoBpJ1Z.js → desktopHosts-ZK_vkdzL.js} +1 -1
- package/dist/assets/{desktopRelayRestore-l2R9muux.js → desktopRelayRestore-CewM6-g5.js} +1 -1
- package/dist/assets/{diagram-CEon0BQs.js → diagram-DAE4CZy2.js} +1 -1
- package/dist/assets/{dialog-DjYfj1Sn.js → dialog-COrLBm9O.js} +1 -1
- package/dist/assets/{es-5Lhudh0n.js → es-DIVYdoxi.js} +1 -1
- package/dist/assets/{fileQueries-CE_DaZWQ.js → fileQueries-CmWYnRVz.js} +1 -1
- package/dist/assets/{fr-Q_UhFmKT.js → fr-CNTlVypq.js} +2 -2
- package/dist/assets/{gitApi-BYxg_qGJ.js → gitApi-BQT3QXD2.js} +1 -1
- package/dist/assets/gitApiHttp-BxGAFLTf.js +2 -0
- package/dist/assets/{input-k9DGGyZ6.js → input-XiL2-2gG.js} +1 -1
- package/dist/assets/insertionBoundaries-9a0RuIDc.js +1 -0
- package/dist/assets/{ja-CzMPz7bT.js → ja-C_eW5ZM8.js} +1 -1
- package/dist/assets/{ko-B-EslUsR.js → ko-CE3cOv7v.js} +1 -1
- package/dist/assets/{main-LDphyc-v.js → main-BGfX81Y9.js} +20 -20
- package/dist/assets/{main-CO6z5yRB.js → main-CEqSDnE2.js} +2 -2
- package/dist/assets/{markdown-worker-DkeWiER6.js → markdown-worker-BDyY0vt6.js} +1 -1
- package/dist/assets/{markdownTheme-DNT93i5L.js → markdownTheme-D-aLQZaM.js} +1 -1
- package/dist/assets/{miniChat-BZQwTZpx.js → miniChat-CKucSAhg.js} +2 -2
- package/dist/assets/mobile-C4bTWKry.js +2 -0
- package/dist/assets/{multirun-ZlwlkIy5.js → multirun-DeNtSkgb.js} +1 -1
- package/dist/assets/{persistence-DPNMIbF5.js → persistence-D1lIuyqO.js} +1 -1
- package/dist/assets/{pl-Dnea6Ppc.js → pl-BZKZOE99.js} +1 -1
- package/dist/assets/projectMeta-BunN9pH5.js +3 -0
- package/dist/assets/{pt-BR-Ca6QTem7.js → pt-BR-DHDgp8S3.js} +1 -1
- package/dist/assets/{radio-CJuhbQ5a.js → radio-Bziafaje.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-D8-FDgWE.js +2 -0
- package/dist/assets/renderMobileApp-CdU0aJ-e.js +23 -0
- package/dist/assets/{runtimeConfig-BtWi74Pm.js → runtimeConfig-BXCt7ZsQ.js} +1 -1
- package/dist/assets/runtimeEndpointReset-CK1GhIsm.js +145 -0
- package/dist/assets/{sortable-tabs-strip-D8JHUI0z.js → sortable-tabs-strip-pWlBorwQ.js} +1 -1
- package/dist/assets/src-8kQFmtjw.css +2 -0
- package/dist/assets/{store-wa1ersRf.js → store-CUWqzRt7.js} +2 -2
- package/dist/assets/{terminalApi-tB3qLcfa.js → terminalApi-DpiAJlil.js} +1 -1
- package/dist/assets/{tooltip-DMNR5rN0.js → tooltip-BLugzWq-.js} +1 -1
- package/dist/assets/{uk-xGc9Pzge.js → uk-CfQK6cz1.js} +1 -1
- package/dist/assets/{url-7MqoLs9X.js → url-DMs_PgIC.js} +1 -1
- package/dist/assets/useAppFontEffects-IEUQDvE8.js +7 -0
- package/dist/assets/useDesktopWindowControlsLayout-MlqJX89k.js +1 -0
- package/dist/assets/{useEffectiveDirectory-aUC3zzl_.js → useEffectiveDirectory-GbhkdO6m.js} +1 -1
- package/dist/assets/{useFontPreferences-CIurCeNv.js → useFontPreferences-92Qg-98H.js} +1 -1
- package/dist/assets/useMobileNavigationStore-DQHF_ETu.js +476 -0
- package/dist/assets/useSessionAutoCleanup-BuU_S_Sh.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-CiYQjBe6.js → useWorkerHighlightedLines-Ca6OgNHF.js} +1 -1
- package/dist/assets/{utils-Co19EJEL.js → utils-BxJEIWv5.js} +1 -1
- package/dist/assets/{zh-CN-sL4ktGB3.js → zh-CN-DvY2fKWp.js} +1 -1
- package/dist/assets/{zh-TW-Cfxps4gq.js → zh-TW-eWe0lm3-.js} +1 -1
- package/dist/index.html +8 -8
- package/dist/mini-chat.html +8 -8
- package/dist/mobile.html +8 -8
- package/package.json +1 -1
- package/server/index.d.ts +6 -0
- package/server/index.js +13 -0
- package/server/lib/config-sync/DOCUMENTATION.md +70 -0
- package/server/lib/config-sync/config-sync.test.js +197 -0
- package/server/lib/config-sync/constants.js +39 -0
- package/server/lib/config-sync/contract.js +130 -0
- package/server/lib/config-sync/credential-auth.js +39 -0
- package/server/lib/config-sync/engine.js +108 -0
- package/server/lib/config-sync/index.js +67 -0
- package/server/lib/config-sync/local-backup.js +171 -0
- package/server/lib/config-sync/plan.js +318 -0
- package/server/lib/config-sync/receiver.js +330 -0
- package/server/lib/config-sync/routes.js +141 -0
- package/server/lib/config-sync/routes.test.js +154 -0
- package/server/lib/config-sync/scripts.js +264 -0
- package/server/lib/config-sync/selections.js +112 -0
- package/server/lib/config-sync/tar.js +35 -0
- package/server/lib/config-sync/target-id.js +24 -0
- package/server/lib/event-stream/DOCUMENTATION.md +2 -2
- package/server/lib/event-stream/diff-summary.js +39 -11
- package/server/lib/event-stream/diff-summary.test.js +73 -6
- package/server/lib/event-stream/protocol.test.js +70 -7
- package/server/lib/notifications/DOCUMENTATION.md +2 -2
- package/server/lib/notifications/runtime.js +68 -27
- package/server/lib/notifications/runtime.test.js +176 -0
- package/server/lib/opencode/DOCUMENTATION.md +4 -1
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/core-routes.js +1 -0
- package/server/lib/opencode/feature-routes-runtime.test.js +10 -1
- package/server/lib/opencode/openchamber-routes.js +7 -0
- package/server/lib/opencode/settings-runtime.js +82 -51
- package/server/lib/opencode/settings-runtime.test.js +42 -0
- package/server/lib/relay/DOCUMENTATION.md +1 -1
- package/server/lib/session-index/DOCUMENTATION.md +12 -6
- package/server/lib/session-index/routes.js +34 -0
- package/server/lib/session-index/routes.test.js +76 -0
- package/server/lib/session-index/service.js +53 -4
- package/server/lib/session-index/service.test.js +72 -2
- package/server/lib/session-title/DOCUMENTATION.md +19 -14
- package/server/lib/session-title/runtime.js +8 -8
- package/server/lib/session-title/runtime.test.js +231 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +26 -6
- package/server/lib/session-turn-pages/changes.service.js +156 -0
- package/server/lib/session-turn-pages/changes.service.test.js +188 -0
- package/server/lib/session-turn-pages/reconcile.routes.test.js +52 -0
- package/server/lib/session-turn-pages/reconcile.service.js +3 -1
- package/server/lib/session-turn-pages/reconcile.service.test.js +44 -0
- package/server/lib/session-turn-pages/routes.js +273 -12
- package/server/lib/session-turn-pages/routes.test.js +387 -0
- package/server/lib/session-turn-pages/service.js +31 -7
- package/server/lib/session-turn-pages/service.test.js +40 -0
- package/server/lib/small-model/DOCUMENTATION.md +93 -38
- package/server/lib/small-model/catalog.js +10 -2
- package/server/lib/small-model/catalog.test.js +3 -1
- package/server/lib/small-model/index.js +52 -28
- package/server/lib/small-model/opencode-session.js +339 -0
- package/server/lib/small-model/opencode-session.test.js +177 -0
- package/server/lib/small-model/resolve.js +116 -5
- package/server/lib/small-model/resolve.test.js +76 -10
- package/server/lib/small-model/summary-settings.test.js +60 -3
- package/dist/assets/MobileSurface-htGkf7Ua.js +0 -2
- package/dist/assets/SettingsView-DxMpVLlt.js +0 -25
- package/dist/assets/appThemeRegistry-CgfOR_0L.js +0 -2
- package/dist/assets/gitApiHttp-D8j1qRH4.js +0 -2
- package/dist/assets/insertionBoundaries-DzXMTleb.js +0 -1
- package/dist/assets/mobile-BktO3_4R.js +0 -2
- package/dist/assets/projectMeta-v-LP2mzz.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-Db8uO-6q.js +0 -2
- package/dist/assets/renderMobileApp-BCV81Ryl.js +0 -23
- package/dist/assets/runtimeEndpointReset-Dg3DMy9v.js +0 -145
- package/dist/assets/src-oHrYIqHF.css +0 -2
- package/dist/assets/useAppFontEffects-CM0VKsdo.js +0 -7
- package/dist/assets/useDesktopWindowControlsLayout-Cq-r4Hcb.js +0 -1
- package/dist/assets/useMobileNavigationStore-DnzT4jO2.js +0 -475
- package/dist/assets/useSessionAutoCleanup-DxZeceKO.js +0 -1
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
OPENCODE_AGENTS_ROOT_PROBE_MARKER,
|
|
9
|
+
OPENCODE_AUTH_FILE_PROBE_MARKER,
|
|
10
|
+
OPENCODE_CONFIG_SYNC_ALLOWLIST,
|
|
11
|
+
OPENCODE_CONFIG_SYNC_MAX_BYTES,
|
|
12
|
+
OPENCODE_CONFIG_SYNC_MAX_FILES,
|
|
13
|
+
} from './constants.js';
|
|
14
|
+
import { CREDENTIAL_SYNC_UNAUTHORIZED_CODE } from './credential-auth.js';
|
|
15
|
+
import {
|
|
16
|
+
extractTarGzBuffer,
|
|
17
|
+
finalizeLocalSyncDestination,
|
|
18
|
+
prepareLocalSyncDestination,
|
|
19
|
+
} from './local-backup.js';
|
|
20
|
+
import { walkAllowlistDirectory } from './plan.js';
|
|
21
|
+
import { sanitizeSyncRunIdForPath } from './scripts.js';
|
|
22
|
+
|
|
23
|
+
const INBOUND_CREDENTIAL_SETTINGS_KEY = 'configSyncInboundCredentialAuthorized';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* In-process receiver for HTTP config-sync (direct/relay hosts).
|
|
27
|
+
* One inflight sync at a time; backups use the same local generational helpers as pull.
|
|
28
|
+
*/
|
|
29
|
+
export const createConfigSyncReceiver = (deps = {}) => {
|
|
30
|
+
const {
|
|
31
|
+
homedir = () => os.homedir(),
|
|
32
|
+
readSettings = async () => ({}),
|
|
33
|
+
writeSettingsPatch = async () => {},
|
|
34
|
+
} = deps;
|
|
35
|
+
|
|
36
|
+
let inflight = null;
|
|
37
|
+
|
|
38
|
+
const beginInflight = (syncRunId) => {
|
|
39
|
+
const id = sanitizeSyncRunIdForPath(syncRunId);
|
|
40
|
+
if (inflight) {
|
|
41
|
+
const error = new Error(`Config sync already in progress (${inflight.syncRunId})`);
|
|
42
|
+
error.code = 'sync_in_progress';
|
|
43
|
+
error.syncRunId = inflight.syncRunId;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
inflight = { syncRunId: id, startedAt: Date.now(), prepared: false, kinds: new Set() };
|
|
47
|
+
return inflight;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const requireInflight = (syncRunId) => {
|
|
51
|
+
const id = sanitizeSyncRunIdForPath(syncRunId);
|
|
52
|
+
if (!inflight || inflight.syncRunId !== id) {
|
|
53
|
+
const error = new Error('No matching in-flight config sync');
|
|
54
|
+
error.code = 'sync_not_prepared';
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
return inflight;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const clearInflight = (syncRunId) => {
|
|
61
|
+
if (inflight && inflight.syncRunId === sanitizeSyncRunIdForPath(syncRunId)) {
|
|
62
|
+
inflight = null;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const buildInventory = () => {
|
|
67
|
+
const home = homedir();
|
|
68
|
+
const root = path.join(home, '.config', 'opencode');
|
|
69
|
+
const files = [];
|
|
70
|
+
const directories = [];
|
|
71
|
+
const remoteExisting = [];
|
|
72
|
+
|
|
73
|
+
for (const group of OPENCODE_CONFIG_SYNC_ALLOWLIST.fileGroups) {
|
|
74
|
+
for (const member of group) {
|
|
75
|
+
const abs = path.join(root, member);
|
|
76
|
+
try {
|
|
77
|
+
const st = fs.statSync(abs);
|
|
78
|
+
if (!st.isFile()) continue;
|
|
79
|
+
files.push({ path: member, bytes: st.size });
|
|
80
|
+
remoteExisting.push(member);
|
|
81
|
+
break;
|
|
82
|
+
} catch {
|
|
83
|
+
// missing
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const name of OPENCODE_CONFIG_SYNC_ALLOWLIST.singleFiles) {
|
|
88
|
+
const abs = path.join(root, name);
|
|
89
|
+
try {
|
|
90
|
+
const st = fs.statSync(abs);
|
|
91
|
+
if (!st.isFile()) continue;
|
|
92
|
+
files.push({ path: name, bytes: st.size });
|
|
93
|
+
remoteExisting.push(name);
|
|
94
|
+
} catch {
|
|
95
|
+
// missing
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const dirSpec of OPENCODE_CONFIG_SYNC_ALLOWLIST.directories) {
|
|
99
|
+
const abs = path.join(root, dirSpec.path);
|
|
100
|
+
try {
|
|
101
|
+
const st = fs.statSync(abs);
|
|
102
|
+
if (!st.isDirectory()) continue;
|
|
103
|
+
const excludeNames = new Set(Array.isArray(dirSpec.excludeNames) ? dirSpec.excludeNames : []);
|
|
104
|
+
const walked = walkAllowlistDirectory(abs, excludeNames, new Set());
|
|
105
|
+
directories.push({ path: dirSpec.path, fileCount: walked.fileCount, bytes: walked.bytes });
|
|
106
|
+
remoteExisting.push(dirSpec.path);
|
|
107
|
+
} catch {
|
|
108
|
+
// missing
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let agentsRoot = null;
|
|
113
|
+
let remoteAgentsRootExists = false;
|
|
114
|
+
try {
|
|
115
|
+
const agentsAbs = path.join(home, '.agents');
|
|
116
|
+
const st = fs.statSync(agentsAbs);
|
|
117
|
+
if (st.isDirectory()) {
|
|
118
|
+
remoteAgentsRootExists = true;
|
|
119
|
+
const walked = walkAllowlistDirectory(agentsAbs, new Set(), new Set());
|
|
120
|
+
agentsRoot = { fileCount: walked.fileCount, bytes: walked.bytes };
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// missing
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let authFile = null;
|
|
127
|
+
let remoteAuthFileExists = false;
|
|
128
|
+
try {
|
|
129
|
+
const authAbs = path.join(home, '.local', 'share', 'opencode', 'auth.json');
|
|
130
|
+
const st = fs.statSync(authAbs);
|
|
131
|
+
if (st.isFile()) {
|
|
132
|
+
remoteAuthFileExists = true;
|
|
133
|
+
authFile = { bytes: st.size };
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// missing
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
inventory: { files, directories, agentsRoot, authFile },
|
|
141
|
+
remoteExisting,
|
|
142
|
+
remoteAgentsRootExists,
|
|
143
|
+
remoteAuthFileExists,
|
|
144
|
+
markers: {
|
|
145
|
+
agentsRoot: OPENCODE_AGENTS_ROOT_PROBE_MARKER,
|
|
146
|
+
authFile: OPENCODE_AUTH_FILE_PROBE_MARKER,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const isInboundCredentialAuthorized = async () => {
|
|
152
|
+
const settings = await readSettings();
|
|
153
|
+
return settings?.[INBOUND_CREDENTIAL_SETTINGS_KEY] === true;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const setInboundCredentialAuthorized = async (authorized) => {
|
|
157
|
+
await writeSettingsPatch({
|
|
158
|
+
[INBOUND_CREDENTIAL_SETTINGS_KEY]: authorized === true,
|
|
159
|
+
});
|
|
160
|
+
return { authorized: authorized === true };
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const probe = async (plan) => {
|
|
164
|
+
const snapshot = buildInventory();
|
|
165
|
+
const existing = new Set(snapshot.remoteExisting);
|
|
166
|
+
const planPaths = [
|
|
167
|
+
...(Array.isArray(plan?.files) ? plan.files.map((entry) => entry.path) : []),
|
|
168
|
+
...(Array.isArray(plan?.directories) ? plan.directories.map((entry) => entry.path) : []),
|
|
169
|
+
...(Array.isArray(plan?.deletes) ? plan.deletes : []),
|
|
170
|
+
];
|
|
171
|
+
return {
|
|
172
|
+
...snapshot,
|
|
173
|
+
remoteExisting: [...new Set(planPaths.filter((rel) => existing.has(rel)))],
|
|
174
|
+
inboundCredentialAuthorized: await isInboundCredentialAuthorized(),
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const prepare = async (plan, { syncRunId }) => {
|
|
179
|
+
const flight = beginInflight(syncRunId);
|
|
180
|
+
try {
|
|
181
|
+
await prepareLocalSyncDestination(homedir(), plan, { syncRunId: flight.syncRunId });
|
|
182
|
+
flight.prepared = true;
|
|
183
|
+
flight.plan = plan;
|
|
184
|
+
return { ok: true, syncRunId: flight.syncRunId, ready: true };
|
|
185
|
+
} catch (error) {
|
|
186
|
+
clearInflight(syncRunId);
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const readStreamToBuffer = async (stream, { maxBytes }) => {
|
|
192
|
+
const chunks = [];
|
|
193
|
+
let total = 0;
|
|
194
|
+
for await (const chunk of stream) {
|
|
195
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
196
|
+
total += buf.byteLength;
|
|
197
|
+
if (total > maxBytes) {
|
|
198
|
+
const error = new Error(`Config sync payload exceeds size limit (${maxBytes} bytes)`);
|
|
199
|
+
error.code = 'sync_payload_too_large';
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
chunks.push(buf);
|
|
203
|
+
}
|
|
204
|
+
return Buffer.concat(chunks, total);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const put = async ({ syncRunId, kind, stream }) => {
|
|
208
|
+
const flight = requireInflight(syncRunId);
|
|
209
|
+
if (!flight.prepared) {
|
|
210
|
+
const error = new Error('Config sync prepare must complete before put');
|
|
211
|
+
error.code = 'sync_not_prepared';
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
if (kind === 'auth') {
|
|
215
|
+
const authorized = await isInboundCredentialAuthorized();
|
|
216
|
+
if (!authorized) {
|
|
217
|
+
const error = new Error('Credential sync is not authorized on this host');
|
|
218
|
+
error.code = CREDENTIAL_SYNC_UNAUTHORIZED_CODE;
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const buffer = await readStreamToBuffer(stream, { maxBytes: OPENCODE_CONFIG_SYNC_MAX_BYTES });
|
|
223
|
+
const home = homedir();
|
|
224
|
+
if (kind === 'config') {
|
|
225
|
+
await extractTarGzBuffer(buffer, path.join(home, '.config', 'opencode'));
|
|
226
|
+
} else if (kind === 'agents') {
|
|
227
|
+
await extractTarGzBuffer(buffer, home);
|
|
228
|
+
} else if (kind === 'auth') {
|
|
229
|
+
await fsp.mkdir(path.join(home, '.local', 'share', 'opencode'), { recursive: true });
|
|
230
|
+
await extractTarGzBuffer(buffer, path.join(home, '.local', 'share', 'opencode'));
|
|
231
|
+
} else {
|
|
232
|
+
const error = new Error(`Unsupported put kind: ${String(kind)}`);
|
|
233
|
+
error.code = 'sync_invalid_kind';
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
flight.kinds.add(kind);
|
|
237
|
+
return { ok: true, kind, bytes: buffer.byteLength };
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const collectTarGz = (cwd, entries) => new Promise((resolve, reject) => {
|
|
241
|
+
if (!entries.length) {
|
|
242
|
+
resolve(Buffer.alloc(0));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const child = spawn('tar', ['-h', '-czf', '-', '-C', cwd, ...entries], {
|
|
246
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
247
|
+
...(process.platform === 'win32' ? { windowsHide: true } : {}),
|
|
248
|
+
});
|
|
249
|
+
const chunks = [];
|
|
250
|
+
let stderr = '';
|
|
251
|
+
child.stdout?.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
252
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
253
|
+
child.on('error', reject);
|
|
254
|
+
child.on('close', (code) => {
|
|
255
|
+
if (code !== 0) {
|
|
256
|
+
reject(new Error((stderr || 'Local tar failed').trim()));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
resolve(Buffer.concat(chunks));
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const download = async ({ kind }) => {
|
|
264
|
+
const home = homedir();
|
|
265
|
+
if (kind === 'config') {
|
|
266
|
+
const snapshot = buildInventory();
|
|
267
|
+
const entries = [
|
|
268
|
+
...snapshot.inventory.files.map((entry) => entry.path),
|
|
269
|
+
...snapshot.inventory.directories.map((entry) => entry.path),
|
|
270
|
+
];
|
|
271
|
+
if (entries.length > OPENCODE_CONFIG_SYNC_MAX_FILES) {
|
|
272
|
+
const error = new Error('Config sync exceeds file count limit');
|
|
273
|
+
error.code = 'sync_too_many_files';
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
return collectTarGz(path.join(home, '.config', 'opencode'), entries);
|
|
277
|
+
}
|
|
278
|
+
if (kind === 'agents') {
|
|
279
|
+
return collectTarGz(home, ['.agents']);
|
|
280
|
+
}
|
|
281
|
+
if (kind === 'auth') {
|
|
282
|
+
const authorized = await isInboundCredentialAuthorized();
|
|
283
|
+
if (!authorized) {
|
|
284
|
+
const error = new Error('Credential sync is not authorized on this host');
|
|
285
|
+
error.code = CREDENTIAL_SYNC_UNAUTHORIZED_CODE;
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
return collectTarGz(path.join(home, '.local', 'share', 'opencode'), ['auth.json']);
|
|
289
|
+
}
|
|
290
|
+
const error = new Error(`Unsupported download kind: ${String(kind)}`);
|
|
291
|
+
error.code = 'sync_invalid_kind';
|
|
292
|
+
throw error;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const finalize = async ({ syncRunId }) => {
|
|
296
|
+
const flight = requireInflight(syncRunId);
|
|
297
|
+
await finalizeLocalSyncDestination(homedir(), { syncRunId: flight.syncRunId });
|
|
298
|
+
const receipt = {
|
|
299
|
+
ok: true,
|
|
300
|
+
syncRunId: flight.syncRunId,
|
|
301
|
+
kinds: [...flight.kinds],
|
|
302
|
+
endedAt: new Date().toISOString(),
|
|
303
|
+
};
|
|
304
|
+
clearInflight(syncRunId);
|
|
305
|
+
return receipt;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const abort = ({ syncRunId } = {}) => {
|
|
309
|
+
if (!syncRunId) {
|
|
310
|
+
inflight = null;
|
|
311
|
+
return { ok: true };
|
|
312
|
+
}
|
|
313
|
+
clearInflight(syncRunId);
|
|
314
|
+
return { ok: true };
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
INBOUND_CREDENTIAL_SETTINGS_KEY,
|
|
319
|
+
probe,
|
|
320
|
+
prepare,
|
|
321
|
+
put,
|
|
322
|
+
download,
|
|
323
|
+
finalize,
|
|
324
|
+
abort,
|
|
325
|
+
getInflight: () => inflight,
|
|
326
|
+
isInboundCredentialAuthorized,
|
|
327
|
+
setInboundCredentialAuthorized,
|
|
328
|
+
buildInventory,
|
|
329
|
+
};
|
|
330
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createConfigSyncReceiver } from './receiver.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Register HTTP config-sync receive/download routes for direct (and later relay) hosts.
|
|
5
|
+
* Auth is the global `/api` UI/client-token gate. Credential kinds additionally
|
|
6
|
+
* require inbound credential authorization on this host.
|
|
7
|
+
*
|
|
8
|
+
* @param {import('express').Express} app
|
|
9
|
+
* @param {{
|
|
10
|
+
* receiver?: ReturnType<typeof createConfigSyncReceiver>,
|
|
11
|
+
* readSettingsFromDiskMigrated?: () => Promise<object>,
|
|
12
|
+
* persistSettings?: (changes: object) => Promise<object>,
|
|
13
|
+
* express?: typeof import('express'),
|
|
14
|
+
* }} deps
|
|
15
|
+
*/
|
|
16
|
+
export const registerConfigSyncRoutes = (app, deps = {}) => {
|
|
17
|
+
const {
|
|
18
|
+
readSettingsFromDiskMigrated,
|
|
19
|
+
persistSettings,
|
|
20
|
+
express,
|
|
21
|
+
} = deps;
|
|
22
|
+
|
|
23
|
+
const receiver = deps.receiver || createConfigSyncReceiver({
|
|
24
|
+
readSettings: async () => (typeof readSettingsFromDiskMigrated === 'function'
|
|
25
|
+
? readSettingsFromDiskMigrated()
|
|
26
|
+
: {}),
|
|
27
|
+
writeSettingsPatch: async (patch) => {
|
|
28
|
+
if (typeof persistSettings === 'function') {
|
|
29
|
+
await persistSettings(patch);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const sendError = (res, error, fallbackStatus = 500) => {
|
|
35
|
+
const code = typeof error?.code === 'string' ? error.code : undefined;
|
|
36
|
+
const status = code === 'credential_sync_unauthorized' ? 403
|
|
37
|
+
: code === 'sync_in_progress' ? 409
|
|
38
|
+
: code === 'sync_not_prepared' ? 409
|
|
39
|
+
: code === 'sync_payload_too_large' || code === 'sync_too_many_files' ? 413
|
|
40
|
+
: code === 'sync_invalid_kind' ? 400
|
|
41
|
+
: fallbackStatus;
|
|
42
|
+
res.status(status).json({
|
|
43
|
+
error: error instanceof Error ? error.message : String(error),
|
|
44
|
+
...(code ? { code } : {}),
|
|
45
|
+
...(error?.syncRunId ? { syncRunId: error.syncRunId } : {}),
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
app.post('/api/openchamber/config-sync/probe', express?.json?.({ limit: '1mb' }) || ((req, _res, next) => next()), async (req, res) => {
|
|
50
|
+
try {
|
|
51
|
+
const plan = req.body?.plan && typeof req.body.plan === 'object' ? req.body.plan : {};
|
|
52
|
+
const result = await receiver.probe(plan);
|
|
53
|
+
res.json(result);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
sendError(res, error);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
app.post('/api/openchamber/config-sync/prepare', express?.json?.({ limit: '1mb' }) || ((req, _res, next) => next()), async (req, res) => {
|
|
60
|
+
try {
|
|
61
|
+
const plan = req.body?.plan && typeof req.body.plan === 'object' ? req.body.plan : null;
|
|
62
|
+
const syncRunId = typeof req.body?.syncRunId === 'string' ? req.body.syncRunId.trim() : '';
|
|
63
|
+
if (!plan || !syncRunId) {
|
|
64
|
+
return res.status(400).json({ error: 'plan and syncRunId are required' });
|
|
65
|
+
}
|
|
66
|
+
const result = await receiver.prepare(plan, { syncRunId });
|
|
67
|
+
res.json(result);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
sendError(res, error);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
app.put('/api/openchamber/config-sync/put/:kind', async (req, res) => {
|
|
74
|
+
try {
|
|
75
|
+
const kind = String(req.params.kind || '').trim();
|
|
76
|
+
const syncRunId = typeof req.query.syncRunId === 'string' ? req.query.syncRunId.trim() : '';
|
|
77
|
+
if (!syncRunId) {
|
|
78
|
+
return res.status(400).json({ error: 'syncRunId is required' });
|
|
79
|
+
}
|
|
80
|
+
const result = await receiver.put({ syncRunId, kind, stream: req });
|
|
81
|
+
res.json(result);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
sendError(res, error);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
app.get('/api/openchamber/config-sync/download/:kind', async (req, res) => {
|
|
88
|
+
try {
|
|
89
|
+
const kind = String(req.params.kind || '').trim();
|
|
90
|
+
const buffer = await receiver.download({ kind });
|
|
91
|
+
res.setHeader('Content-Type', 'application/gzip');
|
|
92
|
+
res.setHeader('Content-Length', String(buffer.byteLength));
|
|
93
|
+
res.send(buffer);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
sendError(res, error);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
app.post('/api/openchamber/config-sync/finalize', express?.json?.({ limit: '64kb' }) || ((req, _res, next) => next()), async (req, res) => {
|
|
100
|
+
try {
|
|
101
|
+
const syncRunId = typeof req.body?.syncRunId === 'string' ? req.body.syncRunId.trim() : '';
|
|
102
|
+
if (!syncRunId) {
|
|
103
|
+
return res.status(400).json({ error: 'syncRunId is required' });
|
|
104
|
+
}
|
|
105
|
+
const result = await receiver.finalize({ syncRunId });
|
|
106
|
+
res.json(result);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
sendError(res, error);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
app.post('/api/openchamber/config-sync/abort', express?.json?.({ limit: '64kb' }) || ((req, _res, next) => next()), async (req, res) => {
|
|
113
|
+
try {
|
|
114
|
+
const syncRunId = typeof req.body?.syncRunId === 'string' ? req.body.syncRunId.trim() : '';
|
|
115
|
+
res.json(receiver.abort({ syncRunId: syncRunId || undefined }));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
sendError(res, error);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
app.get('/api/openchamber/config-sync/credential-auth', async (_req, res) => {
|
|
122
|
+
try {
|
|
123
|
+
const authorized = await receiver.isInboundCredentialAuthorized();
|
|
124
|
+
res.json({ authorized });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
sendError(res, error);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
app.put('/api/openchamber/config-sync/credential-auth', express?.json?.({ limit: '16kb' }) || ((req, _res, next) => next()), async (req, res) => {
|
|
131
|
+
try {
|
|
132
|
+
const authorized = req.body?.authorized === true;
|
|
133
|
+
const result = await receiver.setInboundCredentialAuthorized(authorized);
|
|
134
|
+
res.json(result);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
sendError(res, error);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return { receiver };
|
|
141
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import fsp from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { Readable } from 'node:stream';
|
|
7
|
+
import request from 'supertest';
|
|
8
|
+
|
|
9
|
+
import { createConfigSyncReceiver } from './receiver.js';
|
|
10
|
+
import { registerConfigSyncRoutes } from './routes.js';
|
|
11
|
+
import { CREDENTIAL_SYNC_UNAUTHORIZED_CODE } from './credential-auth.js';
|
|
12
|
+
|
|
13
|
+
const tempDirs = [];
|
|
14
|
+
|
|
15
|
+
const makeTempDir = async () => {
|
|
16
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'oc-config-sync-http-'));
|
|
17
|
+
tempDirs.push(dir);
|
|
18
|
+
return dir;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
while (tempDirs.length > 0) {
|
|
23
|
+
await fsp.rm(tempDirs.pop(), { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const createApp = async () => {
|
|
28
|
+
const home = await makeTempDir();
|
|
29
|
+
const configDir = path.join(home, '.config', 'opencode');
|
|
30
|
+
await fsp.mkdir(configDir, { recursive: true });
|
|
31
|
+
await fsp.writeFile(path.join(configDir, 'opencode.jsonc'), '{}\n');
|
|
32
|
+
await fsp.writeFile(path.join(configDir, 'AGENTS.md'), 'hi\n');
|
|
33
|
+
|
|
34
|
+
let settings = {};
|
|
35
|
+
const receiver = createConfigSyncReceiver({
|
|
36
|
+
homedir: () => home,
|
|
37
|
+
readSettings: async () => ({ ...settings }),
|
|
38
|
+
writeSettingsPatch: async (patch) => {
|
|
39
|
+
settings = { ...settings, ...patch };
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const app = express();
|
|
44
|
+
registerConfigSyncRoutes(app, { receiver, express });
|
|
45
|
+
return { app, home, receiver, getSettings: () => settings };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe('config-sync HTTP routes', () => {
|
|
49
|
+
it('runs probe → prepare → finalize (empty payload) and keeps backup generation', async () => {
|
|
50
|
+
const { app, home } = await createApp();
|
|
51
|
+
const plan = {
|
|
52
|
+
direction: 'push',
|
|
53
|
+
files: [{ path: 'opencode.jsonc', bytes: 3 }],
|
|
54
|
+
directories: [],
|
|
55
|
+
deletes: ['config.json', 'opencode.json'],
|
|
56
|
+
agentsRoot: null,
|
|
57
|
+
authFile: null,
|
|
58
|
+
totalBytes: 3,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const probe = await request(app).post('/api/openchamber/config-sync/probe').send({ plan });
|
|
62
|
+
expect(probe.status).toBe(200);
|
|
63
|
+
expect(probe.body.inventory.files.some((entry) => entry.path === 'opencode.jsonc')).toBe(true);
|
|
64
|
+
|
|
65
|
+
const prepare = await request(app)
|
|
66
|
+
.post('/api/openchamber/config-sync/prepare')
|
|
67
|
+
.send({ plan, syncRunId: 'run-http-1' });
|
|
68
|
+
expect(prepare.status).toBe(200);
|
|
69
|
+
expect(prepare.body.ready).toBe(true);
|
|
70
|
+
|
|
71
|
+
const finalize = await request(app)
|
|
72
|
+
.post('/api/openchamber/config-sync/finalize')
|
|
73
|
+
.send({ syncRunId: 'run-http-1' });
|
|
74
|
+
expect(finalize.status).toBe(200);
|
|
75
|
+
expect(finalize.body.ok).toBe(true);
|
|
76
|
+
expect(finalize.body.syncRunId).toBe('run-http-1');
|
|
77
|
+
|
|
78
|
+
const backup = path.join(home, '.config', 'opencode', '.openchamber.sync-backup', 'run-http-1');
|
|
79
|
+
await expect(fsp.stat(backup)).resolves.toMatchObject({ });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('rejects auth put without inbound credential authorization', async () => {
|
|
83
|
+
const { app } = await createApp();
|
|
84
|
+
const plan = {
|
|
85
|
+
direction: 'push',
|
|
86
|
+
files: [],
|
|
87
|
+
directories: [],
|
|
88
|
+
deletes: [],
|
|
89
|
+
agentsRoot: null,
|
|
90
|
+
authFile: { bytes: 2 },
|
|
91
|
+
totalBytes: 2,
|
|
92
|
+
};
|
|
93
|
+
await request(app).post('/api/openchamber/config-sync/prepare').send({ plan, syncRunId: 'run-auth' });
|
|
94
|
+
const put = await request(app)
|
|
95
|
+
.put('/api/openchamber/config-sync/put/auth?syncRunId=run-auth')
|
|
96
|
+
.set('Content-Type', 'application/gzip')
|
|
97
|
+
.send(Buffer.from('xx'));
|
|
98
|
+
expect(put.status).toBe(403);
|
|
99
|
+
expect(put.body.code).toBe(CREDENTIAL_SYNC_UNAUTHORIZED_CODE);
|
|
100
|
+
await request(app).post('/api/openchamber/config-sync/abort').send({ syncRunId: 'run-auth' });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects a second concurrent prepare', async () => {
|
|
104
|
+
const { app } = await createApp();
|
|
105
|
+
const plan = {
|
|
106
|
+
direction: 'push',
|
|
107
|
+
files: [{ path: 'AGENTS.md', bytes: 2 }],
|
|
108
|
+
directories: [],
|
|
109
|
+
deletes: [],
|
|
110
|
+
agentsRoot: null,
|
|
111
|
+
authFile: null,
|
|
112
|
+
totalBytes: 2,
|
|
113
|
+
};
|
|
114
|
+
const first = await request(app).post('/api/openchamber/config-sync/prepare').send({ plan, syncRunId: 'run-a' });
|
|
115
|
+
expect(first.status).toBe(200);
|
|
116
|
+
const second = await request(app).post('/api/openchamber/config-sync/prepare').send({ plan, syncRunId: 'run-b' });
|
|
117
|
+
expect(second.status).toBe(409);
|
|
118
|
+
expect(second.body.code).toBe('sync_in_progress');
|
|
119
|
+
await request(app).post('/api/openchamber/config-sync/abort').send({ syncRunId: 'run-a' });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('aggregates chunked put streams and rejects oversized payloads', async () => {
|
|
123
|
+
const { app, receiver } = await createApp();
|
|
124
|
+
const plan = {
|
|
125
|
+
direction: 'push',
|
|
126
|
+
files: [],
|
|
127
|
+
directories: [],
|
|
128
|
+
deletes: [],
|
|
129
|
+
agentsRoot: null,
|
|
130
|
+
authFile: null,
|
|
131
|
+
totalBytes: 0,
|
|
132
|
+
};
|
|
133
|
+
await request(app).post('/api/openchamber/config-sync/prepare').send({ plan, syncRunId: 'run-stream' });
|
|
134
|
+
await receiver.setInboundCredentialAuthorized(true);
|
|
135
|
+
|
|
136
|
+
// Tiny invalid tar still proves chunk aggregation reached extract.
|
|
137
|
+
await expect(receiver.put({
|
|
138
|
+
syncRunId: 'run-stream',
|
|
139
|
+
kind: 'auth',
|
|
140
|
+
stream: Readable.from([Buffer.from('aa'), Buffer.from('bb')]),
|
|
141
|
+
})).rejects.toThrow(/tar|failed|Local tar extract/i);
|
|
142
|
+
|
|
143
|
+
await request(app).post('/api/openchamber/config-sync/abort').send({ syncRunId: 'run-stream' });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('persists inbound credential authorization flag', async () => {
|
|
147
|
+
const { app, getSettings } = await createApp();
|
|
148
|
+
const before = await request(app).get('/api/openchamber/config-sync/credential-auth');
|
|
149
|
+
expect(before.body.authorized).toBe(false);
|
|
150
|
+
const after = await request(app).put('/api/openchamber/config-sync/credential-auth').send({ authorized: true });
|
|
151
|
+
expect(after.body.authorized).toBe(true);
|
|
152
|
+
expect(getSettings().configSyncInboundCredentialAuthorized).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|