zigmini 0.8.0-beta.5 → 0.8.0-beta.7
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.ko.md +78 -19
- package/README.md +79 -19
- package/assets/agent/gateway-request-rules.md +1 -0
- package/assets/agent/gateway-rules.md +6 -0
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +25 -2
- package/docs/manual.md +25 -2
- package/host/omo-bridge/task-control.ts +4 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
- package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +171 -8
- package/host/omo-check/patches/omo-5.1.35-phantom-attach.patch +31 -0
- package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
- package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
- package/package.json +4 -1
- package/scripts/secret-rules.ts +101 -0
- package/scripts/secret-scan.allow.json +155 -0
- package/scripts/secret-scan.rules.json +30 -0
- package/src/agent-request-rules.ts +82 -0
- package/src/cli/access.ts +31 -34
- package/src/cli/launchd.ts +30 -4
- package/src/cli/main.ts +13 -39
- package/src/cli/messages.ts +160 -0
- package/src/cli/pair.ts +32 -25
- package/src/cli/rotate-main.ts +411 -0
- package/src/cli/setup.ts +164 -75
- package/src/decisions.ts +20 -1
- package/src/hub.ts +140 -30
- package/src/main.ts +45 -4
- package/src/omo/client.ts +28 -0
- package/src/protocol.ts +33 -1
- package/src/reply-notifications.ts +144 -0
- package/src/runtime/paths.ts +2 -0
- package/src/security-check.ts +241 -0
- package/src/store.ts +71 -1
- package/src/turn-stats.ts +49 -0
- package/src/work-watch.ts +36 -10
- package/src/work.ts +29 -4
- package/web/app.js +197 -10
- package/web/decisions.js +3 -0
- package/web/gc.d.ts +2 -0
- package/web/gc.js +11 -0
- package/web/index.html +26 -10
- package/web/notifications.d.ts +25 -0
- package/web/notifications.js +118 -0
- package/web/style.css +16 -0
- package/web/tooltip.d.ts +0 -6
- package/web/tooltip.js +8 -11
- package/web/turn-stats.d.ts +26 -0
- package/web/turn-stats.js +58 -0
- package/web/update.d.ts +1 -1
- package/web/update.js +10 -3
package/src/cli/setup.ts
CHANGED
|
@@ -2,16 +2,16 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync
|
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
|
-
import { engine } from "../engine/index.ts";
|
|
5
|
+
import { engine, ENGINE_PACKAGE } from "../engine/index.ts";
|
|
6
6
|
import { installEngine } from "../engine/install.ts";
|
|
7
7
|
import { ensureInstallRules, INSTALL_RULES_FILE } from "../agent-rules.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { ensureConsent, EXIT_DECLINED, loadNotice, type PermissionNotice } from "../consent/consent.ts";
|
|
9
9
|
import { type InstallKind, installKind } from "../runtime/install-kind.ts";
|
|
10
10
|
import { agentDir as defaultAgentDir, providerCount } from "../model-accounts.ts";
|
|
11
11
|
import type { Env, RuntimePaths } from "../runtime/paths.ts";
|
|
12
12
|
import { writeVoiceSetting } from "../voice-setting.ts";
|
|
13
13
|
import { parseConfigEnv, startCommand, systemContext, systemDeps } from "./launchd.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { isLanguage, LANGUAGE_QUESTION, type Language, localize, message, resolveLanguage, saveLanguage } from "./messages.ts";
|
|
15
15
|
import { SYSTEM_NETWORK, type Network } from "../enroll.ts";
|
|
16
16
|
import {
|
|
17
17
|
ACCESS_CHOICES, type AccessChoice, type AccessRecord, appPortEnv, isAccessChoice, parseDomain, pingAccess, type PingResult, proxyGuide, readAccess,
|
|
@@ -38,21 +38,24 @@ export interface SetupOptions {
|
|
|
38
38
|
readonly domain?: string;
|
|
39
39
|
readonly notionTokenFile?: string;
|
|
40
40
|
readonly agentDir?: string;
|
|
41
|
+
readonly lang?: Language;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export interface Platform { readonly platform: string; readonly arch: string; readonly bunVersion: string }
|
|
44
45
|
|
|
45
46
|
export interface SetupDeps {
|
|
47
|
+
readonly lang?: Language;
|
|
46
48
|
readonly platform: Platform;
|
|
47
49
|
readonly env: Env;
|
|
48
50
|
readonly paths: RuntimePaths;
|
|
49
51
|
readonly bunPath: string;
|
|
50
52
|
readonly installEngine: (options: { home: string; acceptLicense: boolean; engineOnly: boolean; confirmLicense?: (summary: string, path: string) => Promise<boolean> }) => Promise<string>;
|
|
53
|
+
readonly globalOmoVersion: () => Promise<string | null>;
|
|
54
|
+
readonly installGlobalOmo: (version: string) => Promise<void>;
|
|
55
|
+
/** The executable checked or installed above; injected installers may keep the plain omo command. */
|
|
56
|
+
readonly globalOmoCommand?: () => string;
|
|
57
|
+
readonly providerCount: (agent: string) => number;
|
|
51
58
|
readonly start: () => Promise<number>;
|
|
52
|
-
/** The phone enrollment QR text after the services are up (plan Todo 15); rejects when the gateway gives no token. */
|
|
53
|
-
readonly enrollQr: () => Promise<string>;
|
|
54
|
-
/** The app download QR shown before the enrollment QR (plan Todo 20); rejects when the gateway serves no APK. */
|
|
55
|
-
readonly apkQr: () => Promise<string>;
|
|
56
59
|
readonly prompt: (question: string) => Promise<string | null>;
|
|
57
60
|
readonly network: Network;
|
|
58
61
|
readonly tailscale: () => TailscaleRun;
|
|
@@ -67,10 +70,10 @@ export interface SetupDeps {
|
|
|
67
70
|
readonly err: (text: string) => void;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
const VALUE_FLAGS = { "--access": "access", "--domain": "domain", "--notion-token-file": "notionTokenFile", "--agent-dir": "agentDir" } as const;
|
|
73
|
+
const VALUE_FLAGS = { "--access": "access", "--domain": "domain", "--notion-token-file": "notionTokenFile", "--agent-dir": "agentDir", "--lang": "lang" } as const;
|
|
71
74
|
const BOOLEAN_FLAGS = { "--non-interactive": "nonInteractive", "--accept-license": "acceptLicense", "--engine-only": "engineOnly" } as const;
|
|
72
75
|
|
|
73
|
-
export function parseSetupArgs(argv: readonly string[]): SetupOptions | string {
|
|
76
|
+
export function parseSetupArgs(argv: readonly string[], lang: Language = "en"): SetupOptions | string {
|
|
74
77
|
const options: Record<string, string | boolean> = { nonInteractive: false, acceptLicense: false, engineOnly: false };
|
|
75
78
|
for (let i = 0; i < argv.length; i++) {
|
|
76
79
|
const flag = argv[i]!;
|
|
@@ -78,18 +81,19 @@ export function parseSetupArgs(argv: readonly string[]): SetupOptions | string {
|
|
|
78
81
|
options[BOOLEAN_FLAGS[flag as keyof typeof BOOLEAN_FLAGS]] = true;
|
|
79
82
|
continue;
|
|
80
83
|
}
|
|
81
|
-
if (!(flag in VALUE_FLAGS)) return
|
|
84
|
+
if (!(flag in VALUE_FLAGS)) return message(lang, "unknownOption", { flag });
|
|
82
85
|
const value = argv[i + 1];
|
|
83
|
-
if (value === undefined || value === "" || value.startsWith("--")) return
|
|
84
|
-
if (/[\0\n\r]/.test(value)) return
|
|
86
|
+
if (value === undefined || value === "" || value.startsWith("--")) return message(lang, "missingValue", { flag });
|
|
87
|
+
if (/[\0\n\r]/.test(value)) return message(lang, "badValue", { flag });
|
|
85
88
|
options[VALUE_FLAGS[flag as keyof typeof VALUE_FLAGS]] = value;
|
|
86
89
|
i++;
|
|
87
90
|
}
|
|
88
|
-
if (options.
|
|
89
|
-
if (options.
|
|
90
|
-
if (options.
|
|
91
|
+
if (options.lang !== undefined && !isLanguage(options.lang)) return message(lang, "badLanguage");
|
|
92
|
+
if (options.access !== undefined && !(ACCESS_CHOICES as readonly unknown[]).includes(options.access)) return message(lang, "badAccess", { choices: ACCESS_CHOICES.join(", ") });
|
|
93
|
+
if (options.domain !== undefined && options.access !== "domain") return message(lang, "domainTogether");
|
|
94
|
+
if (options.access === "domain" && options.domain === undefined) return message(lang, "domainRequired");
|
|
91
95
|
if (typeof options.domain === "string") {
|
|
92
|
-
const domain = parseDomain(options.domain);
|
|
96
|
+
const domain = parseDomain(options.domain, lang);
|
|
93
97
|
if ("error" in domain) return domain.error;
|
|
94
98
|
}
|
|
95
99
|
return options as unknown as SetupOptions;
|
|
@@ -103,11 +107,11 @@ export function bunAtLeast(version: string, min: readonly [number, number] = MIN
|
|
|
103
107
|
return major > min[0] || (major === min[0] && minor >= min[1]);
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
export function platformProblem(platform: Platform): string | null {
|
|
110
|
+
export function platformProblem(platform: Platform, lang: Language = "en"): string | null {
|
|
107
111
|
if (platform.platform !== "darwin" || platform.arch !== "arm64") {
|
|
108
|
-
return
|
|
112
|
+
return message(lang, "platform", { ...platform });
|
|
109
113
|
}
|
|
110
|
-
if (!bunAtLeast(platform.bunVersion)) return
|
|
114
|
+
if (!bunAtLeast(platform.bunVersion)) return message(lang, "bunVersion", { min: MIN_BUN.join("."), version: platform.bunVersion });
|
|
111
115
|
return null;
|
|
112
116
|
}
|
|
113
117
|
|
|
@@ -142,10 +146,61 @@ function quote(text: string): string {
|
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
/** Never throws: the engine's pinned launcher path (or the ZGW_OMO_BIN test candidate), whether or not it exists yet. */
|
|
145
|
-
export function loginHint(agent: string, home: string, bunPath: string, env: Env): string {
|
|
149
|
+
export function loginHint(agent: string, home: string, bunPath: string, env: Env, lang: Language = "en"): string {
|
|
146
150
|
const assignments = Object.entries(engine.env(agent)).map(([key, value]) => `${key}=${quote(value)}`).join(" ");
|
|
147
151
|
const bin = env.ZGW_OMO_BIN || join(home, "omo", "current", "node_modules", ".bin", "omo");
|
|
148
|
-
return
|
|
152
|
+
return message(lang, "loginHint", { command: `env ${assignments} ${quote(bunPath)} ${quote(bin)}` });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Login uses stock OMO; the gateway continues to use its separate, patched engine. */
|
|
156
|
+
export function stockOmoVersion(version = engine.pinnedVersion()): string {
|
|
157
|
+
return version.replace(/-zgw(?:\.\d+)?$/, "");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function loginStep(options: SetupOptions, deps: SetupDeps, agent: string, dedicated: boolean): Promise<void> {
|
|
161
|
+
const lang = deps.lang ?? "en";
|
|
162
|
+
const version = stockOmoVersion();
|
|
163
|
+
let global = true;
|
|
164
|
+
try {
|
|
165
|
+
const current = await deps.globalOmoVersion();
|
|
166
|
+
let install = current === null;
|
|
167
|
+
if (current !== null && current !== version) {
|
|
168
|
+
if (!options.nonInteractive) {
|
|
169
|
+
const answer = await deps.prompt(message(lang, "globalOverwrite", { current, version }));
|
|
170
|
+
install = answer?.trim().toLowerCase() === "y";
|
|
171
|
+
}
|
|
172
|
+
if (!install) deps.out(message(lang, "globalKeep", { current, version }));
|
|
173
|
+
}
|
|
174
|
+
if (install) await deps.installGlobalOmo(version);
|
|
175
|
+
if (current === version || install) deps.out(message(lang, "globalDone", { version }));
|
|
176
|
+
} catch (error) {
|
|
177
|
+
global = false;
|
|
178
|
+
deps.err(message(lang, "globalFailed", { error: error instanceof Error ? error.message : String(error) }));
|
|
179
|
+
}
|
|
180
|
+
let providers = deps.providerCount(agent);
|
|
181
|
+
if (providers >= 1) {
|
|
182
|
+
deps.out(message(lang, "providers", { count: providers }));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const command = quote(deps.globalOmoCommand?.() ?? "omo");
|
|
186
|
+
const hint = global
|
|
187
|
+
? message(lang, "loginHint", { command: dedicated ? `env OMO_CODING_AGENT_DIR=${quote(agent)} ${command}` : command })
|
|
188
|
+
: loginHint(agent, deps.paths.home, deps.bunPath, deps.env, lang);
|
|
189
|
+
deps.out(message(lang, "loginBlock", { hint }));
|
|
190
|
+
if (options.nonInteractive) return;
|
|
191
|
+
for (;;) {
|
|
192
|
+
const answer = await deps.prompt(message(lang, "loginWait"));
|
|
193
|
+
if (answer === null || answer.trim().toLowerCase() === "s") {
|
|
194
|
+
deps.out(message(lang, "loginWarning"));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
providers = deps.providerCount(agent);
|
|
198
|
+
if (providers >= 1) {
|
|
199
|
+
deps.out(message(lang, "providers", { count: providers }));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
deps.out(message(lang, "loginPending"));
|
|
203
|
+
}
|
|
149
204
|
}
|
|
150
205
|
|
|
151
206
|
function ensureDir(path: string, mode?: number): void {
|
|
@@ -159,116 +214,134 @@ function writeSecret(path: string, value: string): void {
|
|
|
159
214
|
}
|
|
160
215
|
|
|
161
216
|
async function engineStep(options: SetupOptions, deps: SetupDeps): Promise<number> {
|
|
217
|
+
const lang = deps.lang ?? "en";
|
|
162
218
|
const confirmLicense = options.nonInteractive ? undefined : async (summary: string) => {
|
|
163
|
-
deps.out(`${summary}\n`);
|
|
164
|
-
const answer = await deps.prompt(
|
|
219
|
+
deps.out(`${localize(summary, lang)}\n`);
|
|
220
|
+
const answer = await deps.prompt(message(lang, "engineLicense"));
|
|
165
221
|
return answer?.trim().toLowerCase() === "y";
|
|
166
222
|
};
|
|
167
223
|
try {
|
|
168
224
|
const target = await deps.installEngine({ home: deps.paths.home, acceptLicense: options.acceptLicense, engineOnly: options.engineOnly, confirmLicense });
|
|
169
|
-
deps.out(
|
|
225
|
+
deps.out(message(lang, "engineDone", { target }));
|
|
170
226
|
return 0;
|
|
171
227
|
} catch (error) {
|
|
172
|
-
const
|
|
173
|
-
deps.err(
|
|
174
|
-
?
|
|
175
|
-
:
|
|
228
|
+
const detail = localize(error instanceof Error ? error.message : String(error), lang);
|
|
229
|
+
deps.err(detail === "engine_license_declined"
|
|
230
|
+
? message(lang, "engineDeclined")
|
|
231
|
+
: message(lang, "engineFailed", { error: detail }));
|
|
176
232
|
return EXIT_FAILED;
|
|
177
233
|
}
|
|
178
234
|
}
|
|
179
235
|
|
|
180
236
|
async function notionStep(options: SetupOptions, deps: SetupDeps): Promise<number> {
|
|
237
|
+
const lang = deps.lang ?? "en";
|
|
181
238
|
const file = deps.paths.notionTokenFile;
|
|
182
239
|
let token: string | null = null;
|
|
183
240
|
if (options.notionTokenFile !== undefined) {
|
|
184
241
|
try {
|
|
185
242
|
token = readFileSync(options.notionTokenFile, "utf8").trim();
|
|
186
243
|
} catch {
|
|
187
|
-
deps.err(
|
|
244
|
+
deps.err(message(lang, "notionFile", { path: options.notionTokenFile }));
|
|
188
245
|
return EXIT_FAILED;
|
|
189
246
|
}
|
|
190
247
|
} else if (!options.nonInteractive && !existsSync(file)) {
|
|
191
|
-
token = (await deps.prompt(
|
|
248
|
+
token = (await deps.prompt(message(lang, "notionPrompt")))?.trim() ?? "";
|
|
192
249
|
}
|
|
193
250
|
if (token) {
|
|
194
251
|
ensureDir(dirname(file), 0o700);
|
|
195
252
|
writeSecret(file, token);
|
|
196
253
|
}
|
|
197
|
-
deps.out(
|
|
254
|
+
deps.out(message(lang, "notionState", { state: message(lang, existsSync(file) ? "tokenOn" : "tokenOff") }));
|
|
198
255
|
return 0;
|
|
199
256
|
}
|
|
200
257
|
|
|
201
258
|
/** The "외부 접속" step: a new choice is resolved and saved; without one, the saved choice is kept (or asked for interactively). */
|
|
202
259
|
async function accessStep(options: SetupOptions, deps: SetupDeps): Promise<AccessRecord | null | number> {
|
|
260
|
+
const lang = deps.lang ?? "en";
|
|
203
261
|
let choice = options.access;
|
|
204
262
|
let domain = options.domain;
|
|
205
263
|
if (choice === undefined) {
|
|
206
264
|
const saved = readAccess(deps.paths.home);
|
|
207
265
|
if (saved !== null && saved !== "invalid") {
|
|
208
|
-
deps.out(
|
|
266
|
+
deps.out(message(lang, "accessSaved", { ...saved }));
|
|
209
267
|
return saved;
|
|
210
268
|
}
|
|
211
269
|
if (options.nonInteractive) return null;
|
|
212
|
-
const answer = (await deps.prompt(
|
|
270
|
+
const answer = (await deps.prompt(message(lang, "accessPrompt", { choices: ACCESS_CHOICES.join("/") })))?.trim() ?? "";
|
|
213
271
|
if (answer === "") {
|
|
214
|
-
deps.out(
|
|
272
|
+
deps.out(message(lang, "accessNone"));
|
|
215
273
|
return null;
|
|
216
274
|
}
|
|
217
275
|
if (!isAccessChoice(answer)) {
|
|
218
|
-
deps.err(
|
|
276
|
+
deps.err(message(lang, "accessInvalid", { choices: ACCESS_CHOICES.join(", "), answer }));
|
|
219
277
|
return EXIT_USAGE;
|
|
220
278
|
}
|
|
221
279
|
choice = answer;
|
|
222
|
-
if (choice === "domain") domain = (await deps.prompt(
|
|
280
|
+
if (choice === "domain") domain = (await deps.prompt(message(lang, "domainPrompt")))?.trim() ?? "";
|
|
223
281
|
}
|
|
224
|
-
const resolved = resolveAccess(choice, domain, deps.paths.ports.app, deps);
|
|
282
|
+
const resolved = resolveAccess(choice, domain, deps.paths.ports.app, deps, lang);
|
|
225
283
|
if (!resolved.ok) {
|
|
226
|
-
deps.err(
|
|
284
|
+
deps.err(message(lang, "accessFailed", { error: resolved.message }));
|
|
227
285
|
return EXIT_FAILED;
|
|
228
286
|
}
|
|
229
287
|
writeAccess(deps.paths.home, resolved.record);
|
|
230
|
-
deps.out(
|
|
288
|
+
deps.out(message(lang, "accessDone", { ...resolved.record, port: deps.paths.ports.app }));
|
|
231
289
|
return resolved.record;
|
|
232
290
|
}
|
|
233
291
|
|
|
234
292
|
/** A domain is checked only once the services are up; a failure keeps the install and points at the proxy and doctor. */
|
|
235
293
|
async function domainCheckStep(access: AccessRecord | null, deps: SetupDeps): Promise<void> {
|
|
294
|
+
const lang = deps.lang ?? "en";
|
|
236
295
|
if (access?.choice !== "domain") return;
|
|
237
296
|
const result = await deps.ping(access.url);
|
|
238
297
|
if (result.ok) {
|
|
239
|
-
deps.out(
|
|
298
|
+
deps.out(message(lang, "domainOk", { url: access.url }));
|
|
240
299
|
return;
|
|
241
300
|
}
|
|
242
|
-
deps.err(
|
|
301
|
+
deps.err(message(lang, "domainWarning", { url: access.url, detail: result.detail, guide: proxyGuide(access.url, deps.paths.ports.app, lang) }));
|
|
243
302
|
}
|
|
244
303
|
|
|
245
304
|
export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise<number> {
|
|
246
|
-
|
|
305
|
+
let lang = options.lang ?? resolveLanguage(deps.paths.home, deps.env);
|
|
306
|
+
if (!options.nonInteractive && options.lang === undefined) {
|
|
307
|
+
for (;;) {
|
|
308
|
+
const answer = await deps.prompt(LANGUAGE_QUESTION);
|
|
309
|
+
if (answer === null || answer.trim() === "") break;
|
|
310
|
+
if (answer.trim() === "1" || answer.trim() === "en") { lang = "en"; break; }
|
|
311
|
+
if (answer.trim() === "2" || answer.trim() === "ko") { lang = "ko"; break; }
|
|
312
|
+
deps.err(message(lang, "chooseLanguage"));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
deps = { ...deps, lang };
|
|
316
|
+
const problem = platformProblem(deps.platform, lang);
|
|
247
317
|
if (problem !== null) {
|
|
248
318
|
deps.err(`${problem}\n`);
|
|
249
319
|
return EXIT_USAGE;
|
|
250
320
|
}
|
|
251
321
|
const { paths } = deps;
|
|
252
322
|
const consent = { home: paths.home, kind: deps.installKind, notice: deps.notice, zigminiVersion: deps.zigminiVersion, now: deps.now };
|
|
253
|
-
if (!await ensureConsent(consent, deps)) {
|
|
254
|
-
deps.err(`${
|
|
323
|
+
if (!await ensureConsent(consent, { ...deps, out: (text) => deps.out(localize(text, lang)), prompt: (question) => deps.prompt(localize(question, lang)) })) {
|
|
324
|
+
deps.err(`${message(lang, "consentDeclined")}\n`);
|
|
255
325
|
return EXIT_DECLINED;
|
|
256
326
|
}
|
|
257
327
|
if (options.engineOnly) {
|
|
258
328
|
ensureDir(paths.omo);
|
|
259
|
-
|
|
329
|
+
const code = await engineStep(options, deps);
|
|
330
|
+
if (code === 0) saveLanguage(paths.home, lang);
|
|
331
|
+
return code;
|
|
260
332
|
}
|
|
261
333
|
|
|
262
334
|
for (const dir of [paths.home, paths.data, paths.logs, paths.omo, paths.sessions]) ensureDir(dir);
|
|
263
335
|
for (const dir of [paths.secrets, paths.run]) ensureDir(dir, 0o700);
|
|
264
|
-
deps.out(
|
|
265
|
-
if (ensureInstallRules(paths.home)) deps.out(
|
|
336
|
+
deps.out(message(lang, "home", { path: paths.home }));
|
|
337
|
+
if (ensureInstallRules(paths.home)) deps.out(message(lang, "rules", { path: join(paths.home, INSTALL_RULES_FILE) }));
|
|
266
338
|
|
|
267
339
|
if (!existsSync(paths.omoTokenFile)) writeSecret(paths.omoTokenFile, deps.randomToken());
|
|
268
340
|
chmodSync(paths.omoTokenFile, 0o600);
|
|
269
341
|
|
|
270
342
|
const engineCode = await engineStep(options, deps);
|
|
271
343
|
if (engineCode !== 0) return engineCode;
|
|
344
|
+
saveLanguage(paths.home, lang);
|
|
272
345
|
|
|
273
346
|
const configFile = join(paths.home, "config.env");
|
|
274
347
|
const text = existsSync(configFile) ? readFileSync(configFile, "utf8") : "";
|
|
@@ -276,16 +349,15 @@ export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise<
|
|
|
276
349
|
try {
|
|
277
350
|
config = parseConfigEnv(text);
|
|
278
351
|
} catch (error) {
|
|
279
|
-
deps.err(`${(error as Error).message}\n`);
|
|
352
|
+
deps.err(`${localize((error as Error).message, lang)}\n`);
|
|
280
353
|
return EXIT_FAILED;
|
|
281
354
|
}
|
|
282
355
|
|
|
283
356
|
const dedicated = options.agentDir ?? config.ZGW_OMO_AGENT_DIR;
|
|
284
357
|
const agent = dedicated ? resolve(dedicated) : defaultAgentDir(deps.env);
|
|
285
358
|
if (options.agentDir !== undefined) ensureDir(agent, 0o700);
|
|
286
|
-
deps.out(
|
|
287
|
-
|
|
288
|
-
deps.out(providers > 0 ? `모델 계정: 로그인됨(제공자 ${providers}개)\n` : `${loginHint(agent, paths.home, deps.bunPath, deps.env)}\n`);
|
|
359
|
+
deps.out(message(lang, "agent", { path: agent, kind: message(lang, dedicated ? "dedicated" : "shared") }));
|
|
360
|
+
await loginStep(options, deps, agent, Boolean(dedicated));
|
|
289
361
|
|
|
290
362
|
const notionCode = await notionStep(options, deps);
|
|
291
363
|
if (notionCode !== 0) return notionCode;
|
|
@@ -302,34 +374,26 @@ export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise<
|
|
|
302
374
|
chmodSync(configFile, 0o600);
|
|
303
375
|
|
|
304
376
|
if (!existsSync(paths.voiceSettingFile)) writeVoiceSetting(paths.voiceSettingFile, false);
|
|
305
|
-
deps.out(
|
|
377
|
+
deps.out(message(lang, "voice", { state: message(lang, readFileSync(paths.voiceSettingFile, "utf8").trim() === "off" ? "off" : "on") }));
|
|
306
378
|
|
|
307
379
|
const started = await deps.start();
|
|
308
380
|
if (started !== 0) {
|
|
309
|
-
deps.err(
|
|
381
|
+
deps.err(message(lang, "startFailed", { path: paths.logs }));
|
|
310
382
|
return started;
|
|
311
383
|
}
|
|
312
|
-
deps.out(
|
|
384
|
+
deps.out(message(lang, "setupDone", { port: paths.ports.web }));
|
|
313
385
|
await domainCheckStep(access, deps);
|
|
314
|
-
|
|
386
|
+
phoneStep(access, deps);
|
|
315
387
|
return 0;
|
|
316
388
|
}
|
|
317
389
|
|
|
318
390
|
/**
|
|
319
|
-
*
|
|
320
|
-
* hint, not a failed setup.
|
|
391
|
+
* Terminal font metrics can break QR scanning. Direct the user to the real web settings sections instead.
|
|
321
392
|
*/
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
deps.out(`${apkFailureLine(error)} 게이트웨이가 준비되면 \`zigmini pair --apk\`로 다시 만듭니다.\n`);
|
|
327
|
-
}
|
|
328
|
-
try {
|
|
329
|
-
deps.out(`\n${await deps.enrollQr()}`);
|
|
330
|
-
} catch (error) {
|
|
331
|
-
deps.out(`${pairFailureLine(error)} 게이트웨이가 준비되면 \`zigmini pair\`로 다시 만듭니다.\n`);
|
|
332
|
-
}
|
|
393
|
+
function phoneStep(access: AccessRecord | null, deps: SetupDeps): void {
|
|
394
|
+
const lang = deps.lang ?? "en";
|
|
395
|
+
if (access === null) deps.out(`${message(lang, "setupAccessGuidance")}\n`);
|
|
396
|
+
deps.out(message(lang, "webQrGuide", { port: deps.paths.ports.web }));
|
|
333
397
|
}
|
|
334
398
|
|
|
335
399
|
async function* stdinLines(): AsyncGenerator<string> {
|
|
@@ -356,19 +420,40 @@ export function stdinPrompt(): (question: string) => Promise<string | null> {
|
|
|
356
420
|
|
|
357
421
|
export function systemSetupDeps(zigminiVersion: string, env: Env = process.env): SetupDeps {
|
|
358
422
|
const ctx = systemContext(env);
|
|
423
|
+
let globalBin: string | null = null;
|
|
359
424
|
return {
|
|
360
425
|
platform: { platform: process.platform, arch: process.arch, bunVersion: Bun.version },
|
|
361
426
|
env,
|
|
362
427
|
paths: ctx.paths,
|
|
363
428
|
bunPath: ctx.bunPath,
|
|
364
429
|
installEngine: (options) => installEngine(options),
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
430
|
+
globalOmoVersion: async () => {
|
|
431
|
+
const bin = env.BUN_INSTALL ? join(env.BUN_INSTALL, "bin/omo") : Bun.which("omo", { PATH: env.PATH });
|
|
432
|
+
if (!bin || !existsSync(bin)) return null;
|
|
433
|
+
const proc = Bun.spawn([ctx.bunPath, bin, "--version"], { env: { ...env }, stdout: "pipe", stderr: "pipe", signal: AbortSignal.timeout(10_000) });
|
|
434
|
+
const [code, stdout, stderr] = await Promise.all([proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
435
|
+
const version = /^omo\s+(\S+)/.exec(stdout.trim())?.[1];
|
|
436
|
+
if (code !== 0 || version === undefined) throw new Error(stderr.trim() || "global_omo_version_unknown");
|
|
437
|
+
globalBin = bin;
|
|
438
|
+
return version;
|
|
439
|
+
},
|
|
440
|
+
installGlobalOmo: async (version) => {
|
|
441
|
+
const proc = Bun.spawn([ctx.bunPath, "add", "-g", `${ENGINE_PACKAGE}@${version}`], { env: { ...env }, stdout: "pipe", stderr: "pipe", signal: AbortSignal.timeout(180_000) });
|
|
442
|
+
const [code, stdout, stderr] = await Promise.all([proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
443
|
+
if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `bun_add_exit_${code}`);
|
|
444
|
+
globalBin = join(env.BUN_INSTALL || join(env.HOME || homedir(), ".bun"), "bin/omo");
|
|
445
|
+
},
|
|
446
|
+
globalOmoCommand: () => globalBin === null || Bun.which("omo", { PATH: env.PATH }) === globalBin ? "omo" : globalBin,
|
|
447
|
+
providerCount,
|
|
448
|
+
start: () => {
|
|
449
|
+
const deps = systemDeps();
|
|
450
|
+
const lang = resolveLanguage(ctx.paths.home, env);
|
|
451
|
+
return startCommand({ ...deps, out: (text) => deps.out(localize(text, lang)), err: (text) => deps.err(localize(text, lang)) }, ctx);
|
|
452
|
+
},
|
|
368
453
|
prompt: stdinPrompt(),
|
|
369
454
|
network: SYSTEM_NETWORK,
|
|
370
455
|
tailscale: systemTailscale,
|
|
371
|
-
ping: (url) => pingAccess((target, init) => fetch(target, init), url),
|
|
456
|
+
ping: (url) => pingAccess((target, init) => fetch(target, init), url, undefined, resolveLanguage(ctx.paths.home, env)),
|
|
372
457
|
isDir: (path) => {
|
|
373
458
|
try {
|
|
374
459
|
return statSync(path).isDirectory();
|
|
@@ -387,10 +472,14 @@ export function systemSetupDeps(zigminiVersion: string, env: Env = process.env):
|
|
|
387
472
|
}
|
|
388
473
|
|
|
389
474
|
export async function setupCommand(argv: readonly string[], deps: () => SetupDeps, usage: string): Promise<number> {
|
|
390
|
-
const
|
|
475
|
+
const runtime = deps();
|
|
476
|
+
const at = argv.indexOf("--lang");
|
|
477
|
+
const requested = at < 0 ? undefined : argv[at + 1];
|
|
478
|
+
const lang = isLanguage(requested) ? requested : resolveLanguage(runtime.paths.home, runtime.env);
|
|
479
|
+
const options = parseSetupArgs(argv, lang);
|
|
391
480
|
if (typeof options === "string") {
|
|
392
|
-
process.stderr.write(`${options}\n\n${usage}`);
|
|
481
|
+
process.stderr.write(`${options}\n\n${localize(usage, lang)}`);
|
|
393
482
|
return EXIT_USAGE;
|
|
394
483
|
}
|
|
395
|
-
return await runSetup(options,
|
|
484
|
+
return await runSetup(options, runtime);
|
|
396
485
|
}
|
package/src/decisions.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
2
|
import { decisionContext } from "./decision-context.ts";
|
|
3
|
+
import { SecurityCheckError, type MessageGuard } from "./security-check.ts";
|
|
3
4
|
import type { OmoEvent } from "./omo/client.ts";
|
|
4
5
|
import type { ActionAnswerData, DecisionAnswerRequest, DecisionInfo, DecisionsPageData, DecisionUpdatedData } from "./protocol.ts";
|
|
5
6
|
import { blockedMainQuestion, blockedQuestion, DecisionStoreError, RESUMABLE_CLOSED, Store, type Decision, type DecisionMethod, type DecisionSubmission, type StoredEvent } from "./store.ts";
|
|
@@ -35,6 +36,15 @@ export interface DecisionServiceOptions {
|
|
|
35
36
|
readonly receiptTimeoutMs?: number;
|
|
36
37
|
readonly onHistory?: (event: StoredEvent) => void;
|
|
37
38
|
readonly onError?: (error: unknown) => void;
|
|
39
|
+
/** Improvement 165: the security check of answer text (value, comment, free text of each question). */
|
|
40
|
+
readonly guard?: Pick<MessageGuard, "holdOrPass">;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function answerTexts(request: DecisionAnswerRequest): string[] {
|
|
44
|
+
if (!("response" in request)) return [];
|
|
45
|
+
const response = request.response as { value?: unknown; comment?: unknown; answers?: Record<string, { text?: unknown }> };
|
|
46
|
+
const texts = [response.value, response.comment, ...Object.values(response.answers ?? {}).map((answer) => answer.text)];
|
|
47
|
+
return texts.filter((text): text is string => typeof text === "string");
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
function record(value: unknown): value is Record<string, unknown> {
|
|
@@ -189,8 +199,14 @@ export class DecisionService {
|
|
|
189
199
|
return this.info(this.required(id));
|
|
190
200
|
}
|
|
191
201
|
|
|
192
|
-
answer(id: string, input: unknown): DecisionInfo {
|
|
202
|
+
answer(id: string, input: unknown, checked = false): DecisionInfo {
|
|
193
203
|
const request = parseDecisionAnswer(input);
|
|
204
|
+
if (!checked) {
|
|
205
|
+
const owner = this.store.getDecision(id)?.ownerConv ?? "main";
|
|
206
|
+
this.options.guard?.holdOrPass(owner, "answer", answerTexts(request), async () => {
|
|
207
|
+
this.answer(id, input, true);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
194
210
|
this.refresh();
|
|
195
211
|
const before = this.required(id);
|
|
196
212
|
const binding = this.ports.liveBinding(before);
|
|
@@ -630,6 +646,9 @@ export class DecisionService {
|
|
|
630
646
|
}
|
|
631
647
|
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
632
648
|
} catch (error) {
|
|
649
|
+
if (error instanceof SecurityCheckError) {
|
|
650
|
+
return Response.json({ error: error.code, message: error.message, held: error.held }, { status: 422 });
|
|
651
|
+
}
|
|
633
652
|
if (error instanceof DecisionStoreError) {
|
|
634
653
|
return Response.json({
|
|
635
654
|
error: error.code, decision: error.decision ? this.info(error.decision) : null,
|