@useshifu/coding-harness 0.3.28 → 0.3.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -5
- package/bin/continuity-preparation.js +186 -0
- package/bin/continuity-state.js +202 -0
- package/bin/native-preparation.js +63 -31
- package/bin/opencode-v2-account.js +186 -0
- package/bin/shifu-harness.js +228 -54
- package/bin/source-review.js +28 -2
- package/package.json +1 -1
- package/skills/shifu-context/SKILL.md +1 -1
- package/skills/shifu-sync/SKILL.md +7 -1
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
2
|
const os = require("node:os");
|
|
3
3
|
const path = require("node:path");
|
|
4
|
-
const { randomUUID } = require("node:crypto");
|
|
5
4
|
const { spawn } = require("node:child_process");
|
|
6
5
|
const toml = require("smol-toml");
|
|
7
6
|
const jsonc = require("jsonc-parser");
|
|
@@ -105,33 +104,43 @@ async function requireFlags(command, prefix, flags, options) {
|
|
|
105
104
|
}
|
|
106
105
|
}
|
|
107
106
|
|
|
108
|
-
async function codexInvocation(directory, env,
|
|
107
|
+
async function codexInvocation(directory, env, deadlineAt, outputSchema = PREPARATION_SCHEMA) {
|
|
109
108
|
const command = resolveNativeBinary("codex", env);
|
|
110
|
-
await requireFlags(command, ["exec"], ["--ephemeral", "--ignore-
|
|
109
|
+
await requireFlags(command, ["exec"], ["--ephemeral", "--ignore-rules", "--output-schema"], { cwd: directory, env, deadlineAt });
|
|
111
110
|
const home = env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
112
111
|
let original = optionalConfig(path.join(home, "config.toml"), toml.parse);
|
|
113
112
|
if (typeof original.profile === "string" && /^[A-Za-z0-9_-]+$/.test(original.profile)) {
|
|
114
113
|
original = { ...original, ...optionalConfig(path.join(home, `${original.profile}.config.toml`), toml.parse) };
|
|
115
114
|
}
|
|
116
|
-
const config = pick(original, ["model", "model_provider", "model_providers", "model_reasoning_effort", "model_reasoning_summary", "model_verbosity", "service_tier"]);
|
|
115
|
+
const config = pick(original, ["model", "model_provider", "model_providers", "model_reasoning_effort", "model_reasoning_summary", "model_verbosity", "service_tier", "cli_auth_credentials_store"]);
|
|
117
116
|
Object.assign(config, {
|
|
118
117
|
approval_policy: "never", sandbox_mode: "read-only", web_search: "disabled", project_doc_max_bytes: 0,
|
|
119
|
-
mcp_servers: {}, apps: { _default: { enabled: false } },
|
|
120
|
-
|
|
118
|
+
mcp_servers: {}, apps: { _default: { enabled: false } },
|
|
119
|
+
tools: { experimental_request_user_input: { enabled: false }, update_plan: { enabled: false } }, sqlite_home: directory,
|
|
120
|
+
features: { shell_tool: false, view_image: false, goals: false, shell_snapshot: false, hooks: false, apps: false, plugins: false,
|
|
121
121
|
browser_use: false, browser_use_external: false, computer_use: false, multi_agent: false, memories: false,
|
|
122
122
|
skill_mcp_dependency_install: false },
|
|
123
123
|
});
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
const isolatedHome = path.join(directory, "codex");
|
|
125
|
+
fs.mkdirSync(isolatedHome, { mode: 0o700 });
|
|
126
|
+
const authFile = path.join(home, "auth.json");
|
|
127
|
+
if (original.cli_auth_credentials_store === "keyring") {
|
|
128
|
+
throw new Error("Native preparation requires file-backed Codex authentication for isolated account reuse. Configure Codex file-backed authentication and retry; no source was sent.");
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
if (!fs.statSync(authFile).isFile()) throw new Error("Invalid native account storage");
|
|
132
|
+
fs.symlinkSync(authFile, path.join(isolatedHome, "auth.json"));
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error.code !== "ENOENT") throw new Error("Native preparation could not reuse the existing Codex account. Repair native authentication and retry.");
|
|
135
|
+
}
|
|
136
|
+
privateFile(path.join(isolatedHome, "config.toml"), toml.stringify(config));
|
|
128
137
|
const schema = path.join(directory, "schema.json");
|
|
129
|
-
privateFile(schema, JSON.stringify(
|
|
130
|
-
return { command, env: { ...env, CODEX_HOME:
|
|
138
|
+
privateFile(schema, JSON.stringify(outputSchema));
|
|
139
|
+
return { command, env: { ...env, CODEX_HOME: isolatedHome, HOME: directory, PWD: directory }, args: ["exec", "--ignore-rules",
|
|
131
140
|
"--ephemeral", "--json", "--skip-git-repo-check", "--sandbox", "read-only", "--cd", directory, "--output-schema", schema, "-"] };
|
|
132
141
|
}
|
|
133
142
|
|
|
134
|
-
async function claudeInvocation(directory, env, deadlineAt) {
|
|
143
|
+
async function claudeInvocation(directory, env, deadlineAt, outputSchema = PREPARATION_SCHEMA) {
|
|
135
144
|
const command = resolveNativeBinary("claude_code", env);
|
|
136
145
|
await requireFlags(command, [], ["--safe-mode", "--no-session-persistence", "--tools", "--strict-mcp-config", "--json-schema"], { cwd: directory, env, deadlineAt });
|
|
137
146
|
const isolatedEnv = { ...env };
|
|
@@ -139,7 +148,7 @@ async function claudeInvocation(directory, env, deadlineAt) {
|
|
|
139
148
|
delete isolatedEnv.CLAUDE_CODE_SESSION_ID;
|
|
140
149
|
return { command, env: isolatedEnv, args: ["--print", "--safe-mode", "--no-session-persistence", "--tools", "", "--strict-mcp-config",
|
|
141
150
|
"--mcp-config", '{"mcpServers":{}}', "--disable-slash-commands", "--max-turns", "1", "--output-format", "json",
|
|
142
|
-
"--json-schema", JSON.stringify(
|
|
151
|
+
"--json-schema", JSON.stringify(outputSchema)] };
|
|
143
152
|
}
|
|
144
153
|
|
|
145
154
|
function parseOpenCodeConfig(text) {
|
|
@@ -166,13 +175,14 @@ function readOpenCodeConfig(directory) {
|
|
|
166
175
|
return config;
|
|
167
176
|
}
|
|
168
177
|
|
|
169
|
-
async function opencodeInvocation(directory, env, deadlineAt) {
|
|
178
|
+
async function opencodeInvocation(directory, env, deadlineAt, outputSchema = PREPARATION_SCHEMA) {
|
|
170
179
|
const command = resolveNativeBinary("opencode", env);
|
|
171
|
-
await
|
|
172
|
-
|
|
173
|
-
if (!version || Number(version[1]) < 1 || (Number(version[1]) === 1 && (Number(version[2]) < 18 || (Number(version[2]) === 18 && Number(version[3]) < 30)))) {
|
|
180
|
+
const version = (await runNativeCommand(command, ["--version"], { cwd: directory, env, deadlineAt, timeoutMs: 10_000 })).trim().match(/^(?:opencode\s+)?v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
|
|
181
|
+
if (!version || ![1, 2].includes(Number(version[1])) || (Number(version[1]) === 1 && (Number(version[2]) < 18 || (Number(version[2]) === 18 && Number(version[3]) < 30)))) {
|
|
174
182
|
throw new Error("Native preparation requires OpenCode 1.18.30 or newer for isolated sessions. Update the native CLI and retry; the source remains pending.");
|
|
175
183
|
}
|
|
184
|
+
const v2 = Number(version[1]) === 2;
|
|
185
|
+
await requireFlags(command, ["run"], v2 ? ["--standalone", "--format", "--agent"] : ["--pure", "--format", "--agent", "--dir"], { cwd: directory, env, deadlineAt });
|
|
176
186
|
const originalConfig = path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "opencode");
|
|
177
187
|
const originalData = path.join(env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"), "opencode");
|
|
178
188
|
let original = readOpenCodeConfig(originalConfig);
|
|
@@ -184,11 +194,21 @@ async function opencodeInvocation(directory, env, deadlineAt) {
|
|
|
184
194
|
try { original = mergeConfig(original, parseOpenCodeConfig(env.OPENCODE_CONFIG_CONTENT)); }
|
|
185
195
|
catch { throw new Error("Native preparation could not read the existing inline provider configuration. Repair that configuration and retry."); }
|
|
186
196
|
}
|
|
187
|
-
const config = pick(original, ["model", "small_model", "provider", "enabled_providers", "disabled_providers"]);
|
|
188
|
-
const selectedAgent = original.agent?.[original.default_agent || "build"];
|
|
197
|
+
const config = pick(original, ["model", "small_model", "provider", "providers", "enabled_providers", "disabled_providers"]);
|
|
198
|
+
const selectedAgent = (v2 ? original.agents?.[original.default_agent || "build"] : undefined) || original.agent?.[original.default_agent || "build"];
|
|
189
199
|
if (selectedAgent?.model) config.model = selectedAgent.model;
|
|
200
|
+
const prompt = `${POLICY}\nReturn raw JSON only, without Markdown or code fences. Return JSON matching this schema exactly. Decisions and verification must be arrays of strings, never objects.\n${JSON.stringify(outputSchema)}`;
|
|
190
201
|
Object.assign(config, { snapshot: false, share: "disabled", autoupdate: false, permission: "deny", instructions: [], mcp: {},
|
|
191
|
-
default_agent: "shifu-preparation", agent: { "shifu-preparation": { ...pick(selectedAgent || {}, ["model", "variant", "temperature", "top_p"]), mode: "primary", prompt
|
|
202
|
+
default_agent: "shifu-preparation", agent: { "shifu-preparation": { ...pick(selectedAgent || {}, ["model", "variant", "temperature", "top_p"]), mode: "primary", prompt, permission: "deny", tools: { "*": false }, steps: 1 } } });
|
|
203
|
+
if (v2) {
|
|
204
|
+
delete config.agent;
|
|
205
|
+
Object.assign(config, { snapshots: false, update: "disable", plugins: [], skills: [], mcp: { servers: {} },
|
|
206
|
+
permissions: [{ action: "*", resource: "*", effect: "deny" }],
|
|
207
|
+
policies: [...(original.policies || []).filter((policy) => policy.action === "provider.use"),
|
|
208
|
+
{ action: "tool.use", resource: "*", effect: "deny" }, { action: "integration.use", resource: "*", effect: "deny" }],
|
|
209
|
+
agents: { "shifu-preparation": { ...pick(selectedAgent || {}, ["model", "request"]), mode: "primary", system: prompt,
|
|
210
|
+
permissions: [{ action: "*", resource: "*", effect: "deny" }], steps: 1 } } });
|
|
211
|
+
}
|
|
192
212
|
const configDirectory = path.join(directory, "config", "opencode");
|
|
193
213
|
const dataDirectory = path.join(directory, "data", "opencode");
|
|
194
214
|
const stateDirectory = path.join(directory, "state", "opencode");
|
|
@@ -198,7 +218,7 @@ async function opencodeInvocation(directory, env, deadlineAt) {
|
|
|
198
218
|
try {
|
|
199
219
|
const authFile = path.join(originalData, "auth.json");
|
|
200
220
|
if (!fs.statSync(authFile).isFile()) throw new Error("Invalid native account storage");
|
|
201
|
-
fs.symlinkSync(authFile, path.join(dataDirectory, "auth.json"));
|
|
221
|
+
if (!v2) fs.symlinkSync(authFile, path.join(dataDirectory, "auth.json"));
|
|
202
222
|
}
|
|
203
223
|
catch (error) { if (error.code !== "ENOENT") throw new Error("Native preparation could not read the existing OpenCode account. Repair it and retry."); }
|
|
204
224
|
// The native default uses recent models when no configured model is selected.
|
|
@@ -209,12 +229,17 @@ async function opencodeInvocation(directory, env, deadlineAt) {
|
|
|
209
229
|
.map((entry) => ({ providerID: entry.providerID, modelID: entry.modelID }));
|
|
210
230
|
privateFile(path.join(stateDirectory, "model.json"), JSON.stringify({ recent }));
|
|
211
231
|
}
|
|
212
|
-
const isolatedEnv = { ...env, XDG_CONFIG_HOME: path.join(directory, "config"), XDG_DATA_HOME: path.join(directory, "data"),
|
|
232
|
+
const isolatedEnv = { ...env, PWD: directory, XDG_CONFIG_HOME: path.join(directory, "config"), XDG_DATA_HOME: path.join(directory, "data"),
|
|
213
233
|
XDG_STATE_HOME: path.join(directory, "state"), XDG_CACHE_HOME: path.join(directory, "cache"), OPENCODE_CONFIG_DIR: configDirectory,
|
|
214
234
|
OPENCODE_DB: ":memory:", OPENCODE_TEST_HOME: directory, OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_DISABLE_CLAUDE_CODE: "1",
|
|
215
235
|
OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", OPENCODE_DISABLE_AUTOUPDATE: "1", OPENCODE_AUTO_SHARE: "0" };
|
|
216
236
|
for (const key of ["OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_PERMISSION", "OPENCODE_ATTACH"]) delete isolatedEnv[key];
|
|
217
|
-
|
|
237
|
+
if (v2) isolatedEnv.OPENCODE_DB = path.join(dataDirectory, "preparation.db");
|
|
238
|
+
const finalize = v2 ? await require("./opencode-v2-account.js").prepareOpenCodeAccount({
|
|
239
|
+
command, originalEnv: env, isolatedEnv, directory, deadlineAt, runNativeCommand,
|
|
240
|
+
legacyAuthFile: path.join(originalData, "auth.json"),
|
|
241
|
+
}) : undefined;
|
|
242
|
+
return { command, env: isolatedEnv, finalize, args: ["run", v2 ? "--standalone" : "--pure", "--format", "json", "--agent", "shifu-preparation", "--title", "Shifu extraction preparation", ...v2 ? [] : ["--dir", directory]] };
|
|
218
243
|
}
|
|
219
244
|
|
|
220
245
|
function parseOutput(harness, text) {
|
|
@@ -246,22 +271,29 @@ async function prepareNative(harness, packet, options = {}) {
|
|
|
246
271
|
const startedAt = Date.now();
|
|
247
272
|
const timeoutMs = Math.min(options.timeoutMs || TIMEOUT_MS, TIMEOUT_MS);
|
|
248
273
|
const deadlineAt = startedAt + timeoutMs;
|
|
249
|
-
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "shifu-native-preparation-"));
|
|
274
|
+
const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "shifu-native-preparation-")));
|
|
250
275
|
fs.chmodSync(directory, 0o700);
|
|
251
|
-
|
|
276
|
+
let invocation;
|
|
252
277
|
try {
|
|
253
278
|
const env = { ...process.env };
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
279
|
+
const outputSchema = options.continuity ? require("./continuity-preparation.js").CONTINUITY_SCHEMA : PREPARATION_SCHEMA;
|
|
280
|
+
invocation = harness === "codex" ? await codexInvocation(directory, env, deadlineAt, outputSchema)
|
|
281
|
+
: harness === "claude_code" ? await claudeInvocation(directory, env, deadlineAt, outputSchema) : await opencodeInvocation(directory, env, deadlineAt, outputSchema);
|
|
282
|
+
const input = options.continuity ? require("./continuity-preparation.js").continuityPrompt(packet, options.validationFeedback) : preparationPrompt(packet, options.validationFeedback);
|
|
257
283
|
if (Buffer.byteLength(input) > MAX_OUTPUT_BYTES) throw new Error("Native preparation source packet exceeds the input limit.");
|
|
258
284
|
const remainingMs = timeoutMs - (Date.now() - startedAt);
|
|
259
285
|
if (remainingMs <= 0) throw new Error("Native preparation timed out; the source remains pending.");
|
|
260
286
|
const output = await runNativeCommand(invocation.command, invocation.args, { cwd: directory, env: invocation.env, input, timeoutMs: remainingMs, deadlineAt });
|
|
261
287
|
return parseOutput(harness, output);
|
|
262
288
|
} finally {
|
|
263
|
-
|
|
264
|
-
|
|
289
|
+
let preservePreparation = false;
|
|
290
|
+
try { await invocation?.finalize?.(); }
|
|
291
|
+
catch (error) {
|
|
292
|
+
preservePreparation = error.preservePreparation === true;
|
|
293
|
+
throw error;
|
|
294
|
+
} finally {
|
|
295
|
+
if (!preservePreparation) fs.rmSync(directory, { recursive: true, force: true });
|
|
296
|
+
}
|
|
265
297
|
}
|
|
266
298
|
}
|
|
267
299
|
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { createHash, randomUUID } = require("node:crypto");
|
|
5
|
+
const { pathToFileURL } = require("node:url");
|
|
6
|
+
|
|
7
|
+
const FINALIZATION_TIMEOUT_MS = 10_000;
|
|
8
|
+
const CREDENTIAL_COLUMNS = "id, integration_id, label, value, active, time_created";
|
|
9
|
+
const ACCOUNT_ERROR = "Native preparation could not isolate or preserve the OpenCode account. Check sqlite3 and the native account, then retry; the source remains pending.";
|
|
10
|
+
const LEGACY_OAUTH_ERROR = "Native preparation requires migrated OpenCode 2 OAuth accounts. Open OpenCode 2 once to migrate its native account, then retry; do not reconnect Shifu. The source remains pending.";
|
|
11
|
+
|
|
12
|
+
async function readCredentials(database, options) {
|
|
13
|
+
if (!fs.existsSync(database)) return [];
|
|
14
|
+
const tables = await options.runNativeCommand("sqlite3", ["-readonly", "-json", database], {
|
|
15
|
+
...options, input: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'credential';\n",
|
|
16
|
+
});
|
|
17
|
+
if (!JSON.parse(tables || "[]").length) return [];
|
|
18
|
+
const output = await options.runNativeCommand("sqlite3", ["-readonly", "-json", database], {
|
|
19
|
+
...options, input: `SELECT ${CREDENTIAL_COLUMNS} FROM credential ORDER BY active DESC, time_created DESC, id DESC;\n`,
|
|
20
|
+
});
|
|
21
|
+
const rows = JSON.parse(output || "[]");
|
|
22
|
+
for (const row of rows) {
|
|
23
|
+
if (typeof row.id !== "string" || typeof row.label !== "string" || typeof row.value !== "string") throw new Error(ACCOUNT_ERROR);
|
|
24
|
+
row.parsedValue = JSON.parse(row.value);
|
|
25
|
+
}
|
|
26
|
+
return rows;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function exportedCredentials(rows) {
|
|
30
|
+
const selected = new Set();
|
|
31
|
+
return rows.filter((row) => typeof row.integration_id === "string").map((row) => {
|
|
32
|
+
// Native v2 selects the newest credential when legacy rows lack an active flag.
|
|
33
|
+
const active = !selected.has(row.integration_id);
|
|
34
|
+
selected.add(row.integration_id);
|
|
35
|
+
return { id: row.id, integrationID: row.integration_id, label: row.label, value: row.parsedValue, active };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sqlParameter(name, value) {
|
|
40
|
+
// Hex-encoded bindings cannot introduce SQL or sqlite shell metacommands.
|
|
41
|
+
return `.parameter set @${name} X'${Buffer.from(value, "utf8").toString("hex")}'\n`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function requirePrivateDestination(directory, destination) {
|
|
45
|
+
const relative = path.relative(directory, destination);
|
|
46
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(ACCOUNT_ERROR);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function copyLegacyAccount({ legacyAuthFile, isolatedEnv, directory }) {
|
|
50
|
+
if (!legacyAuthFile || !fs.existsSync(legacyAuthFile)) return;
|
|
51
|
+
const text = fs.readFileSync(legacyAuthFile, "utf8");
|
|
52
|
+
const account = JSON.parse(text);
|
|
53
|
+
if (!account || typeof account !== "object" || Array.isArray(account)) throw new Error(ACCOUNT_ERROR);
|
|
54
|
+
// V2 refreshes SQLite credentials, not legacy auth.json; copying OAuth would lose rotated tokens.
|
|
55
|
+
if (Object.values(account).some((value) => value?.type === "oauth")) throw new Error(LEGACY_OAUTH_ERROR);
|
|
56
|
+
const destination = path.join(isolatedEnv.XDG_DATA_HOME, "opencode", "auth.json");
|
|
57
|
+
requirePrivateDestination(directory, destination);
|
|
58
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
59
|
+
fs.writeFileSync(destination, text, { mode: 0o600, flag: "wx" });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function credentialRecoveryDirectory(env) {
|
|
63
|
+
const configRoot = env.SHIFU_CONFIG_ROOT || path.join(env.XDG_CONFIG_HOME || path.join(env.HOME || os.homedir(), ".config"), "shifu", "coding-harness");
|
|
64
|
+
return path.join(configRoot, "credential-recovery");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requirePrivateRecoveryDirectory(directory) {
|
|
68
|
+
const stat = fs.lstatSync(directory);
|
|
69
|
+
if (!stat.isDirectory() || (stat.mode & 0o077) || (process.getuid && stat.uid !== process.getuid())) throw new Error(ACCOUNT_ERROR);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function recoveryPrefix(database) {
|
|
73
|
+
return createHash("sha256").update(database).digest("hex");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function credentialRecoveryError(file) {
|
|
77
|
+
const error = new Error("OpenCode account refresh could not be saved to its native account. A private credential-only recovery file remains in Shifu's credential-recovery directory. Close concurrent OpenCode processes and retry sync to restore it automatically; do not reconnect Shifu. The source remains pending.");
|
|
78
|
+
error.code = "SHIFU_NATIVE_ACCOUNT_RECOVERY";
|
|
79
|
+
error.recoveryFile = file;
|
|
80
|
+
return error;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function retainCredentialRecovery(database, credentials, env) {
|
|
84
|
+
const recoveryDirectory = credentialRecoveryDirectory(env);
|
|
85
|
+
fs.mkdirSync(recoveryDirectory, { recursive: true, mode: 0o700 });
|
|
86
|
+
requirePrivateRecoveryDirectory(recoveryDirectory);
|
|
87
|
+
const file = path.join(recoveryDirectory, `${recoveryPrefix(database)}-${Date.now()}-${randomUUID()}.credential-recovery`);
|
|
88
|
+
fs.writeFileSync(file, JSON.stringify({ version: 1, harness: "opencode", database, credentials }), { mode: 0o600, flag: "wx" });
|
|
89
|
+
return credentialRecoveryError(file);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function writeCredentialRefreshes(database, credentials, options) {
|
|
93
|
+
const updates = credentials.map((credential) => sqlParameter("id", credential.id) + sqlParameter("integration", credential.integrationID) +
|
|
94
|
+
sqlParameter("previous", credential.previousValue) + sqlParameter("updated", credential.value) +
|
|
95
|
+
"UPDATE credential SET value = CAST(@updated AS TEXT) WHERE id = CAST(@id AS TEXT) AND integration_id = CAST(@integration AS TEXT) AND value = CAST(@previous AS TEXT);\n");
|
|
96
|
+
// Compare-and-swap leaves concurrent logins, refreshes, deletions and account selection untouched.
|
|
97
|
+
await options.runNativeCommand("sqlite3", [`${pathToFileURL(database).href}?mode=rw`], {
|
|
98
|
+
...options, input: `.bail on\n.timeout 1000\n.parameter init\nBEGIN IMMEDIATE;\n${updates.join("")}COMMIT;\n`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function recoverCredentialRefreshes(database, options) {
|
|
103
|
+
const directory = credentialRecoveryDirectory(options.env);
|
|
104
|
+
if (!fs.existsSync(directory)) return;
|
|
105
|
+
requirePrivateRecoveryDirectory(directory);
|
|
106
|
+
const prefix = recoveryPrefix(database);
|
|
107
|
+
const pattern = new RegExp(`^${prefix}-[0-9]+-[a-f0-9-]+\\.credential-recovery$`);
|
|
108
|
+
const files = fs.readdirSync(directory).filter((name) => pattern.test(name)).sort();
|
|
109
|
+
// Recovery is bounded separately from source discovery and never reads unrelated files.
|
|
110
|
+
if (files.length > 32) throw credentialRecoveryError(path.join(directory, files[0]));
|
|
111
|
+
for (const name of files) {
|
|
112
|
+
const file = path.join(directory, name);
|
|
113
|
+
try {
|
|
114
|
+
const stat = fs.lstatSync(file);
|
|
115
|
+
if (!stat.isFile() || (stat.mode & 0o077) || (process.getuid && stat.uid !== process.getuid()) || stat.size > 256 * 1024) throw new Error(ACCOUNT_ERROR);
|
|
116
|
+
const recovery = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
117
|
+
if (recovery.version !== 1 || recovery.harness !== "opencode" || recovery.database !== database ||
|
|
118
|
+
!Array.isArray(recovery.credentials) || !recovery.credentials.length || recovery.credentials.length > 128 ||
|
|
119
|
+
!recovery.credentials.every((credential) => typeof credential.id === "string" && typeof credential.integrationID === "string" &&
|
|
120
|
+
typeof credential.previousValue === "string" && typeof credential.value === "string" &&
|
|
121
|
+
JSON.parse(credential.previousValue)?.type === "oauth" && JSON.parse(credential.value)?.type === "oauth")) throw new Error(ACCOUNT_ERROR);
|
|
122
|
+
await writeCredentialRefreshes(database, recovery.credentials, options);
|
|
123
|
+
fs.unlinkSync(file);
|
|
124
|
+
} catch {
|
|
125
|
+
throw credentialRecoveryError(file);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function preserveRefreshes(originalDatabase, snapshots, options, originalEnv) {
|
|
131
|
+
const isolatedDatabase = options.env.OPENCODE_DB;
|
|
132
|
+
const current = await readCredentials(isolatedDatabase, options);
|
|
133
|
+
const credentials = [];
|
|
134
|
+
for (const row of current) {
|
|
135
|
+
const original = snapshots.find((snapshot) => snapshot.id === row.id);
|
|
136
|
+
if (!original || original.parsedValue.type !== "oauth" || row.parsedValue.type !== "oauth" ||
|
|
137
|
+
original.integration_id !== row.integration_id || JSON.stringify(original.parsedValue) === JSON.stringify(row.parsedValue)) continue;
|
|
138
|
+
credentials.push({ id: row.id, integrationID: original.integration_id, previousValue: original.value, value: row.value });
|
|
139
|
+
}
|
|
140
|
+
if (!credentials.length) return;
|
|
141
|
+
try {
|
|
142
|
+
await writeCredentialRefreshes(originalDatabase, credentials, options);
|
|
143
|
+
} catch {
|
|
144
|
+
throw retainCredentialRecovery(originalDatabase, credentials, originalEnv);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Copy only native v2 credentials into the isolated database, then preserve token refreshes. */
|
|
149
|
+
async function prepareOpenCodeAccount({ command, originalEnv, isolatedEnv, directory, deadlineAt, runNativeCommand, legacyAuthFile }) {
|
|
150
|
+
const originalOptions = { cwd: directory, env: originalEnv, deadlineAt, runNativeCommand };
|
|
151
|
+
const isolatedOptions = { cwd: directory, env: isolatedEnv, deadlineAt, runNativeCommand };
|
|
152
|
+
try {
|
|
153
|
+
requirePrivateDestination(directory, isolatedEnv.OPENCODE_DB);
|
|
154
|
+
const database = (await runNativeCommand(command, ["debug", "paths", "db"], originalOptions)).trim();
|
|
155
|
+
if (!path.isAbsolute(database) || /[\r\n]/.test(database)) throw new Error(ACCOUNT_ERROR);
|
|
156
|
+
await recoverCredentialRefreshes(database, originalOptions);
|
|
157
|
+
const snapshots = await readCredentials(database, originalOptions);
|
|
158
|
+
const credentials = exportedCredentials(snapshots);
|
|
159
|
+
if (!credentials.length) {
|
|
160
|
+
copyLegacyAccount({ legacyAuthFile, isolatedEnv, directory });
|
|
161
|
+
return async () => {};
|
|
162
|
+
}
|
|
163
|
+
await runNativeCommand(command, ["auth", "import", "--standalone"], {
|
|
164
|
+
...isolatedOptions, input: JSON.stringify(credentials),
|
|
165
|
+
});
|
|
166
|
+
if (fs.existsSync(isolatedEnv.OPENCODE_DB)) fs.chmodSync(isolatedEnv.OPENCODE_DB, 0o600);
|
|
167
|
+
if (!snapshots.some((snapshot) => snapshot.parsedValue.type === "oauth")) return async () => {};
|
|
168
|
+
return async () => {
|
|
169
|
+
try {
|
|
170
|
+
const finalizationOptions = { ...isolatedOptions, deadlineAt: Date.now() + FINALIZATION_TIMEOUT_MS };
|
|
171
|
+
await preserveRefreshes(database, snapshots, finalizationOptions, originalEnv);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error.code === "SHIFU_NATIVE_ACCOUNT_RECOVERY") throw error;
|
|
174
|
+
const recoveryError = new Error("OpenCode account recovery could not be saved safely. Private preparation files were retained locally to prevent losing refreshed native credentials. Close concurrent OpenCode processes and ask your assistant to recover the native account before retrying; do not reconnect Shifu. The source remains pending.");
|
|
175
|
+
recoveryError.preservePreparation = true;
|
|
176
|
+
recoveryError.recoveryDirectory = directory;
|
|
177
|
+
throw recoveryError;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error.message === LEGACY_OAUTH_ERROR || error.code === "SHIFU_NATIVE_ACCOUNT_RECOVERY") throw error;
|
|
182
|
+
throw new Error(ACCOUNT_ERROR);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = { prepareOpenCodeAccount };
|