@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.
@@ -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, cleanup, deadlineAt) {
107
+ async function codexInvocation(directory, env, deadlineAt, outputSchema = PREPARATION_SCHEMA) {
109
108
  const command = resolveNativeBinary("codex", env);
110
- await requireFlags(command, ["exec"], ["--ephemeral", "--ignore-user-config", "--ignore-rules", "--output-schema", "--profile"], { cwd: directory, env, deadlineAt });
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 } }, tools: { view_image: false }, sqlite_home: directory,
120
- features: { shell_tool: false, shell_snapshot: false, hooks: false, apps: false, plugins: false,
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 profile = `shifu-preparation-${randomUUID()}`;
125
- const profileFile = path.join(home, `${profile}.config.toml`);
126
- privateFile(profileFile, toml.stringify(config));
127
- cleanup.push(profileFile);
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(PREPARATION_SCHEMA));
130
- return { command, env: { ...env, CODEX_HOME: home }, args: ["exec", "--ignore-user-config", "--ignore-rules", "--profile", profile,
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(PREPARATION_SCHEMA)] };
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 requireFlags(command, ["run"], ["--pure", "--format", "--agent", "--dir"], { cwd: directory, env, deadlineAt });
172
- const version = (await runNativeCommand(command, ["--version"], { cwd: directory, env, deadlineAt, timeoutMs: 10_000 })).trim().match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
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: `${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(PREPARATION_SCHEMA)}`, permission: "deny", tools: { "*": false }, steps: 1 } } });
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
- return { command, env: isolatedEnv, args: ["run", "--pure", "--format", "json", "--agent", "shifu-preparation", "--title", "Shifu extraction preparation", "--dir", directory] };
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
- const cleanup = [];
276
+ let invocation;
252
277
  try {
253
278
  const env = { ...process.env };
254
- const invocation = harness === "codex" ? await codexInvocation(directory, env, cleanup, deadlineAt)
255
- : harness === "claude_code" ? await claudeInvocation(directory, env, deadlineAt) : await opencodeInvocation(directory, env, deadlineAt);
256
- const input = preparationPrompt(packet, options.validationFeedback);
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
- try { for (const file of cleanup) fs.rmSync(file, { force: true }); }
264
- finally { fs.rmSync(directory, { recursive: true, force: true }); }
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 };