agentlas 1.0.48 → 1.0.50

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.
@@ -37,14 +37,22 @@ const {
37
37
  normalizeCloudAssetDescriptor,
38
38
  } = require("../hub/install.cjs");
39
39
  const { SECRET_PATTERNS } = require("../agentlas-secret-patterns.cjs");
40
+ const {
41
+ SECRET_SCAN_TEXT_EXTENSIONS,
42
+ UPLOAD_AGENT_DEFINITION_FILES,
43
+ UPLOAD_SKIP_DIRECTORIES,
44
+ } = require("./upload-scan-catalog.generated.cjs");
40
45
  const { userDataDir } = require("../core/paths.cjs");
41
46
  const state = require("./state.cjs");
42
47
  const { cargoSearchAgents } = require("./cargo.cjs");
43
48
  const cas = require("./cas.cjs");
44
49
 
45
- const CLOUD_TEXT_EXTS = new Set([".cfg", ".cjs", ".conf", ".config", ".css", ".csv", ".env", ".html", ".ini", ".js", ".json", ".jsonl", ".md", ".mjs", ".properties", ".ps1", ".psd1", ".psm1", ".py", ".sh", ".toml", ".ts", ".tsx", ".txt", ".xml", ".yaml", ".yml"]);
46
- const CLOUD_AGENT_FILES = new Set(["AGENT.md", "AGENTS.md", "CLAUDE.md", "GEMINI.md", "README.md", "agent.md", "manifest.md", "system-prompt.md"]);
47
- const CLOUD_SKIP_DIRS = new Set([".git", ".next", ".studio-runtime", ".turbo", "build", "coverage", "dist", "node_modules", "out", "release"]);
50
+ // 업로드/시크릿 스캔 어휘는 업로드 계약에서 온다. 제품이 각자 손으로 적어
51
+ // 두었고 이미 갈려 있었다 .bat/.cmd/.jsx 데스크탑만 스캔해서 서버 스캔과
52
+ // 터미널 업로드가 파일들을 아예 열지 않았다.
53
+ const CLOUD_TEXT_EXTS = new Set(SECRET_SCAN_TEXT_EXTENSIONS);
54
+ const CLOUD_AGENT_FILES = new Set(UPLOAD_AGENT_DEFINITION_FILES);
55
+ const CLOUD_SKIP_DIRS = new Set(UPLOAD_SKIP_DIRECTORIES);
48
56
  const CLOUD_BLOCKED_FILE_RE = [/^\.env(?:\..*)?$/i, /^id_rsa(?:\.pub)?$/i, /^credentials(?:\..*)?$/i, /^secrets?(?:\..*)?$/i, /^cloud-asset-state\.v1\.json$/i, /(?:^|[._-])service-account(?:[._-]|$)/i, /\.(?:key|pem|p12|pfx|mobileprovision)$/i];
49
57
  const CLOUD_ROUTING_CARD_PATH = ".agentlas/routing-card.json";
50
58
  const CLOUD_ROUTING_CARD_CAPABILITY_RE = /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/;
@@ -0,0 +1,102 @@
1
+ // ⚠️ GENERATED FILE — do not hand-edit; the next generation erases your change.
2
+ // 정본: agentlas/AgentsAtlas/app/src/lib/agentlas-cloud/upload-scan-catalog.json
3
+ // 생성: (agentlas/AgentsAtlas/app) node scripts/gen-upload-scan-catalog.mjs
4
+ //
5
+ // Cloud-agent upload + secret-scan catalog. Three products used to restate
6
+ // this by hand and drifted; the server-side scan was the one that lost.
7
+
8
+ "use strict";
9
+
10
+ const SECRET_SCAN_TEXT_EXTENSIONS = Object.freeze([
11
+ ".bat",
12
+ ".cfg",
13
+ ".cjs",
14
+ ".cmd",
15
+ ".conf",
16
+ ".config",
17
+ ".css",
18
+ ".csv",
19
+ ".env",
20
+ ".html",
21
+ ".ini",
22
+ ".js",
23
+ ".json",
24
+ ".jsonl",
25
+ ".jsx",
26
+ ".md",
27
+ ".mjs",
28
+ ".properties",
29
+ ".ps1",
30
+ ".psd1",
31
+ ".psm1",
32
+ ".py",
33
+ ".sh",
34
+ ".toml",
35
+ ".ts",
36
+ ".tsx",
37
+ ".txt",
38
+ ".xml",
39
+ ".yaml",
40
+ ".yml",
41
+ ]);
42
+
43
+ const UPLOAD_SKIP_DIRECTORIES = Object.freeze([
44
+ ".git",
45
+ ".next",
46
+ ".studio-runtime",
47
+ ".turbo",
48
+ "build",
49
+ "coverage",
50
+ "dist",
51
+ "node_modules",
52
+ "out",
53
+ "release",
54
+ ]);
55
+
56
+ const AGENT_DEFINITION_FILES = Object.freeze([
57
+ "AGENT.md",
58
+ "AGENTS.md",
59
+ "CLAUDE.md",
60
+ "GEMINI.md",
61
+ "README.md",
62
+ "agent.md",
63
+ "manifest.md",
64
+ "system-prompt.md",
65
+ "system.md",
66
+ "soul.md",
67
+ "prompt.md",
68
+ "persona.md",
69
+ ]);
70
+
71
+ const UPLOAD_AGENT_DEFINITION_FILES = Object.freeze([
72
+ "AGENT.md",
73
+ "AGENTS.md",
74
+ "CLAUDE.md",
75
+ "GEMINI.md",
76
+ "README.md",
77
+ "agent.md",
78
+ "manifest.md",
79
+ "system-prompt.md",
80
+ ]);
81
+
82
+ const FOLDER_SCAN_AGENT_DEFINITION_FILES = Object.freeze([
83
+ "AGENT.md",
84
+ "AGENTS.md",
85
+ "CLAUDE.md",
86
+ "GEMINI.md",
87
+ "agent.md",
88
+ "manifest.md",
89
+ "system-prompt.md",
90
+ "system.md",
91
+ "soul.md",
92
+ "prompt.md",
93
+ "persona.md",
94
+ ]);
95
+
96
+ module.exports = {
97
+ SECRET_SCAN_TEXT_EXTENSIONS,
98
+ UPLOAD_SKIP_DIRECTORIES,
99
+ AGENT_DEFINITION_FILES,
100
+ UPLOAD_AGENT_DEFINITION_FILES,
101
+ FOLDER_SCAN_AGENT_DEFINITION_FILES,
102
+ };
@@ -506,6 +506,47 @@ async function runGraph(ctx, needle, flags) {
506
506
  return 1;
507
507
  }
508
508
 
509
+ const fallbackRow = {
510
+ id: row.id,
511
+ name: row.name,
512
+ scheduleHuman: row.schedule,
513
+ targetType: row.target_type,
514
+ targetId: row.target_id,
515
+ enabled: Boolean(row.enabled),
516
+ createdBy: row.created_by || "terminal",
517
+ graph,
518
+ };
519
+ const initialVars = requirement && flags.input ? { [requirement.varName]: flags.input } : {};
520
+
521
+ /*
522
+ * ★데몬 우선 (Phase 3). 데몬이 떠 있으면 이 터미널은 코어를 **로드하지 않는다** —
523
+ * 64MB 벤더 사본도, 두 번째 DB 주인도 그 순간에는 없다. 그래프 실행은 완주 후
524
+ * 결과 JSON 하나라 요청/응답 소켓으로 손실 없이 옮겨진다.
525
+ *
526
+ * 데몬이 있는데 실행이 **실패한 것**은 폴백 사유가 아니다 — 같은 그래프를 코어로
527
+ * 다시 돌리면 부작용(외부 게시·파일 쓰기)이 두 번 난다. 폴백은 "데몬 없음"에만 걸린다.
528
+ */
529
+ const daemon = require("../core/daemon-client.cjs");
530
+ if (await daemon.daemonAvailable()) {
531
+ try {
532
+ const result = await daemon.callDaemon("graph.run", {
533
+ automationId: row.id,
534
+ automation: fallbackRow,
535
+ graph,
536
+ initialVars,
537
+ }, 30 * 60 * 1000);
538
+ ctx.out(JSON.stringify(result, null, 2));
539
+ return result && result.ok === true ? 0 : 1;
540
+ } catch (error) {
541
+ ctx.err(JSON.stringify({
542
+ ok: false,
543
+ via: "daemon",
544
+ error: error instanceof Error ? error.message : String(error),
545
+ }, null, 2));
546
+ return 1;
547
+ }
548
+ }
549
+
509
550
  const core = ctx.desktopCore || desktopCore.loadDesktopCore();
510
551
  if (!core || core.error || typeof core.runGraph !== "function") {
511
552
  const cause = core?.error instanceof Error ? core.error.message : "vendored Desktop Core is unavailable";
@@ -518,18 +559,8 @@ async function runGraph(ctx, needle, flags) {
518
559
  ? core.require("store/automations").getAutomation(row.id)
519
560
  : null;
520
561
  } catch { /* test/fallback row below */ }
521
- automation ||= {
522
- id: row.id,
523
- name: row.name,
524
- scheduleHuman: row.schedule,
525
- targetType: row.target_type,
526
- targetId: row.target_id,
527
- enabled: Boolean(row.enabled),
528
- createdBy: row.created_by || "terminal",
529
- graph,
530
- };
562
+ automation ||= fallbackRow;
531
563
  automation.graph = graph;
532
- const initialVars = requirement && flags.input ? { [requirement.varName]: flags.input } : {};
533
564
  try {
534
565
  const result = await core.runGraph(automation, graph, { initialVars });
535
566
  ctx.out(JSON.stringify(result, null, 2));
@@ -17,6 +17,7 @@
17
17
  */
18
18
  const { RUNTIME_BIN, whichSync } = require("../runtimes/detect.cjs");
19
19
  const { runtimeAuthEvidence } = require("../runtimes/auth-evidence.cjs");
20
+ const { canonicalRuntimeKind, storedRuntimeKind } = require("../runtimes/kinds.cjs");
20
21
  const { MODEL_ROLE_TABLE, VALID_ROLES, resolvedModelRole, roleMembers } = require("../runtimes/roles.cjs");
21
22
  const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
22
23
  const { EFFORTS } = require("../agentlas-workload-routing.cjs");
@@ -137,11 +138,13 @@ function set(ctx, args) {
137
138
  if (existing) {
138
139
  // kind가 바뀌면 이전 모델 id는 새 런타임의 어휘가 아니다(예: kimi에 opus).
139
140
  // --model 미지정 시 유지가 아니라 초기화 — 무의미한 좌표를 승계하지 않는다.
140
- const keepModel = existing.kind === kind ? existing.model : null;
141
+ const keepModel = canonicalRuntimeKind(existing.kind) === kind ? existing.model : null;
141
142
  db.prepare(
142
143
  "UPDATE model_roles SET kind=?, model=?, effort=?, inherit=?, updated_at=? WHERE role=?",
143
144
  ).run(
144
- kind,
145
+ // 공유 DB 는 데스크탑 어휘로 적는다(runtimes/kinds.cjs) — 여기서 이 저장소의
146
+ // 이름을 그대로 넣으면 데스크탑이 그 역할을 못 읽는다.
147
+ storedRuntimeKind(kind),
145
148
  model === undefined ? keepModel : model,
146
149
  flags.effort === undefined ? existing.effort : (flags.effort === "none" ? null : flags.effort),
147
150
  inherit,
@@ -151,7 +154,7 @@ function set(ctx, args) {
151
154
  } else {
152
155
  db.prepare(
153
156
  "INSERT INTO model_roles (role, kind, model, effort, inherit, updated_at) VALUES (?,?,?,?,?,?)",
154
- ).run(role, kind, model === undefined ? null : model, flags.effort === undefined || flags.effort === "none" ? null : flags.effort, inherit, now);
157
+ ).run(role, storedRuntimeKind(kind), model === undefined ? null : model, flags.effort === undefined || flags.effort === "none" ? null : flags.effort, inherit, now);
155
158
  }
156
159
  });
157
160
 
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ /*
3
+ * core/daemon-client — 같은 머신의 Agentlas 데몬(agentlasd)에 일을 시키는 클라이언트.
4
+ *
5
+ * ★왜 있나 (데몬 Phase 3). 이 터미널은 지금 데스크탑 코어를 **자기 프로세스에서**
6
+ * 돌린다 — 64MB 벤더 사본을 로드하고, 같은 DB 를 여는 두 번째 주인이 된다. 데몬이
7
+ * 떠 있으면 그럴 이유가 없다: "이거 해 줘" 한 줄이면 되고, 코어는 한 곳에서만 돈다.
8
+ *
9
+ * 프로토콜은 데몬 쪽(electron/daemon/control-socket.ts)과 같은 줄 단위 JSON-RPC.
10
+ * 주소 규칙도 같은 계산이다 — userDataDir()/daemon.sock (Windows 는 named pipe).
11
+ * **이 두 파일의 주소 계산이 어긋나면 서로를 영영 못 찾는다.** 여기 경로는
12
+ * core/paths.cjs 의 userDataDir 를 쓰므로, 데스크탑 앱과 같은 곳을 본다.
13
+ *
14
+ * 데몬이 없으면? 없다고 답할 뿐이다. 호출자는 예전처럼 벤더 코어로 폴백한다 —
15
+ * 데몬은 빠른 길이지 유일한 길이 아니다(그게 유일한 길이 되는 순간, 데몬이 죽으면
16
+ * 터미널 전체가 죽는다).
17
+ */
18
+ const net = require("node:net");
19
+ const crypto = require("node:crypto");
20
+ const path = require("node:path");
21
+ const { userDataDir } = require("./paths.cjs");
22
+
23
+ /** 데몬 소켓 주소 — control-socket.ts 의 defaultControlSocketPath 와 같은 규칙. */
24
+ function daemonSocketPath() {
25
+ const dir = userDataDir();
26
+ if (process.platform === "win32") {
27
+ const tag = Buffer.from(dir).toString("base64url").slice(-16);
28
+ return `\\\\.\\pipe\\agentlas-daemon-${tag}`;
29
+ }
30
+ // ★유닉스 소켓 경로 ~104바이트 한계(sockaddr_un). 실측: 128바이트에서 bind 는
31
+ // 조용히 지나가고 connect 만 EINVAL — 데몬 쪽(control-socket.ts)과 같은 폴백 규칙.
32
+ const preferred = path.join(dir, "daemon.sock");
33
+ if (Buffer.byteLength(preferred, "utf8") <= 100) return preferred;
34
+ const tag = crypto.createHash("sha256").update(dir).digest("hex").slice(0, 16);
35
+ return path.join(require("node:os").tmpdir(), `agentlas-daemon-${tag}.sock`);
36
+ }
37
+
38
+ /** JSON-RPC 한 번. 데몬이 에러를 주면 그 문장 그대로 throw 한다. */
39
+ function callDaemon(method, params, timeoutMs) {
40
+ const limit = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120000;
41
+ return new Promise((resolve, reject) => {
42
+ const socket = net.connect(daemonSocketPath());
43
+ const id = crypto.randomUUID();
44
+ let buffer = "";
45
+ let settled = false;
46
+ const finish = (fn) => {
47
+ if (settled) return;
48
+ settled = true;
49
+ clearTimeout(timer);
50
+ socket.destroy();
51
+ fn();
52
+ };
53
+ const timer = setTimeout(
54
+ () => finish(() => reject(new Error(`daemon did not answer ${method} within ${limit}ms`))),
55
+ limit,
56
+ );
57
+ socket.on("connect", () => socket.write(`${JSON.stringify({ id, method, params })}\n`));
58
+ socket.on("data", (chunk) => {
59
+ buffer += chunk.toString("utf8");
60
+ let nl;
61
+ while ((nl = buffer.indexOf("\n")) >= 0) {
62
+ const line = buffer.slice(0, nl).trim();
63
+ buffer = buffer.slice(nl + 1);
64
+ if (!line) continue;
65
+ let message;
66
+ try { message = JSON.parse(line); } catch { continue; }
67
+ if (message.id !== id) continue;
68
+ if (message.error) finish(() => reject(new Error(message.error.message || "daemon error")));
69
+ else finish(() => resolve(message.result));
70
+ }
71
+ });
72
+ socket.on("error", (error) => finish(() => reject(error)));
73
+ });
74
+ }
75
+
76
+ /**
77
+ * 데몬이 지금 떠서 답하는가. **접속이 아니라 응답**으로 판정한다 — 죽은 데몬이 남긴
78
+ * 소켓 파일에는 접속 자체가 실패하고, 산 데몬이라도 멎어 있으면 쓸 수 없다.
79
+ */
80
+ async function daemonAvailable() {
81
+ try {
82
+ const pong = await callDaemon("daemon.ping", undefined, 2000);
83
+ if (!pong || pong.ok !== true) return false;
84
+ return sharesOurStore(pong);
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * 데몬이 **우리와 같은 DB** 를 열었는가.
92
+ *
93
+ * ★`AGENTLAS_STORE_PATH` 는 이 프로세스에만 있다 — 소켓 너머 데몬은 그 값을 모른다.
94
+ * 그래서 사본을 지정해 놓고 일을 넘기면, 우리는 사본에 자동화를 만들고 데몬은
95
+ * **라이브에** 실행 기록·부수효과를 남긴다. 둘 다 "성공" 이라고 답하는데 결과는
96
+ * 두 데이터베이스에 반씩 흩어진다. 넘기기 전에 물어보고, 다르면 넘기지 않는다.
97
+ *
98
+ * 경로를 못 받는 옛 데몬은 예전처럼 신뢰한다 — 그때는 양쪽 다 기본 경로였다.
99
+ * 우리가 경로를 명시했는데 상대가 답을 못 하면, 그건 확인할 수 없는 상태이므로 거절한다.
100
+ */
101
+ function sharesOurStore(pong) {
102
+ const ours = String(process.env.AGENTLAS_STORE_PATH || "").trim();
103
+ if (!ours) return true;
104
+ const theirs = typeof pong.storePath === "string" ? pong.storePath.trim() : "";
105
+ if (!theirs) {
106
+ console.error(
107
+ "[daemon] AGENTLAS_STORE_PATH is set here, but the daemon does not report which database it opened.\n"
108
+ + "[daemon] Running the graph locally instead, so the work cannot land in a different store.",
109
+ );
110
+ return false;
111
+ }
112
+ const path = require("node:path");
113
+ if (path.resolve(theirs) === path.resolve(ours)) return true;
114
+ console.error(
115
+ `[daemon] the daemon opened a different database (${theirs}) than this command (${ours}).\n`
116
+ + "[daemon] Running the graph locally instead, so the work cannot land in a different store.",
117
+ );
118
+ return false;
119
+ }
120
+
121
+ module.exports = { daemonSocketPath, callDaemon, daemonAvailable, sharesOurStore };
@@ -7,11 +7,17 @@
7
7
  * 스키마 소유권: 정본 스키마는 데스크탑 앱. 터미널은 bootstrap-schema.sql로
8
8
  * 첫 부트스트랩만 하고(런처가 수행), 이후 마이그레이션은 앱이 한다.
9
9
  * 터미널은 "있으면 쓰는" 방어적 열 확인(columnExists)으로만 전진 호환한다.
10
+ *
11
+ * ★2026-08-18: 위 문단은 계약이었지 배선이 아니었다. 이 파일은 user_version 을 **한 번도
12
+ * 읽지 않으면서** seedBuiltins 로 BEGIN IMMEDIATE 쓰기 트랜잭션을 열었다 — 검사도 거절도
13
+ * 없이 그냥 진행. 이제 openDb() 가 core/store-schema.cjs 로 버전을 확인하고, 낮으면
14
+ * 정직하게 거절한다(승급은 여전히 데스크탑의 일이다).
10
15
  */
11
16
  const fs = require("node:fs");
12
17
  const { configureSqliteConnection } = require("../agentlas-sqlite-policy.cjs");
13
18
  const { parseSemVer, compareSemVer } = require("../semver.cjs");
14
19
  const { dbPath } = require("./paths.cjs");
20
+ const { assertStoreSchemaCompatible } = require("./store-schema.cjs");
15
21
 
16
22
  function loadNodeSqliteQuietly() {
17
23
  const originalEmitWarning = process.emitWarning;
@@ -44,16 +50,70 @@ function openRaw(file) {
44
50
  return db;
45
51
  }
46
52
 
53
+ /**
54
+ * 이 프로세스가 마이그레이션 권위인가. 기본은 **아니다**(follower).
55
+ * `AGENTLAS_STORE_MIGRATION_ROLE=owner` 는 데스크탑이 없는 머신을 위한 명시적 탈출구다 —
56
+ * 사람이 일부러 켜야 하고, 그때만 벤더 코어의 사다리가 한 번 돈다.
57
+ */
58
+ function storeMigrationRole() {
59
+ return String(process.env.AGENTLAS_STORE_MIGRATION_ROLE || "").trim().toLowerCase() === "owner"
60
+ ? "owner"
61
+ : "follower";
62
+ }
63
+
47
64
  /**
48
65
  * DB 파일이 없으면 하드 실패한다. 부트스트랩은 런처(bin/agentlas.cjs)의 책임 —
49
66
  * 엔진이 임의 경로에 빈 DB를 만들면 데스크탑과의 공유 계약이 조용히 깨진다.
67
+ *
68
+ * 연 다음에는 스키마 버전을 **확인**한다. 낮으면 거절 — 조용히 진행하지 않는다.
69
+ * owner 로 명시 지정된 경우에만, 거절 대신 벤더 코어의 사다리를 한 번 돌려 승급한다.
50
70
  */
51
71
  function openDb() {
52
- const file = dbPath();
72
+ /*
73
+ * ★AGENTLAS_STORE_PATH 를 존중한다 — 이 줄이 없어서 CLI 는 무슨 값을 주든 **항상 라이브
74
+ * DB** 를 열었다. 실측 2026-08-19: 복사본 스토어를 지정하고 `graph new` 로 자동화를
75
+ * 만들었는데 사용자의 라이브 데이터베이스에 저장됐다. 벤더 코어(desktop-core.cjs:150)
76
+ * 와 데스크탑(store/db)은 이 환경변수를 이미 존중하므로, 이 모듈만 어긋나 있었다.
77
+ *
78
+ * 그 결과 (1) 격리 QA 가 불가능하고 (2) 같은 명령이 여는 파일이 코어 경로냐 이 경로냐에
79
+ * 따라 갈린다 — 같은 프로세스가 두 DB 를 동시에 보게 된다.
80
+ */
81
+ const file = process.env.AGENTLAS_STORE_PATH || dbPath();
53
82
  if (!fs.existsSync(file)) {
54
83
  throw new Error(`Agentlas database not found: ${file} (run via bin/agentlas.cjs)`);
55
84
  }
56
- return openRaw(file);
85
+ if (storeMigrationRole() === "owner") migrateSharedStoreAsOwner(file);
86
+ const db = openRaw(file);
87
+ try {
88
+ assertStoreSchemaCompatible(db, file);
89
+ } catch (e) {
90
+ try { db.close(); } catch { /* noop */ }
91
+ throw e;
92
+ }
93
+ return db;
94
+ }
95
+
96
+ /**
97
+ * 명시적 탈출구의 실행부. 데스크탑이 없는 머신에서 사람이 일부러 켰을 때만 불린다.
98
+ * 승급 자체는 여전히 **데스크탑 사다리 코드 하나**가 한다 — 터미널은 그 사다리를 손으로
99
+ * 복제하지 않는다(두 번째 사다리 구현이 곧 세 번째 주인이다). 코어가 없으면 정직한 실패.
100
+ */
101
+ function migrateSharedStoreAsOwner(file) {
102
+ let core;
103
+ try {
104
+ core = require("./desktop-core.cjs").loadDesktopCore({ migrationRole: "owner" });
105
+ } catch (e) {
106
+ throw new Error(`AGENTLAS_STORE_MIGRATION_ROLE=owner: could not load the Desktop core to migrate ${file}: ${e && e.message ? e.message : e}`);
107
+ }
108
+ if (!core) {
109
+ throw new Error(
110
+ `AGENTLAS_STORE_MIGRATION_ROLE=owner: no Desktop core is available on this machine, so ${file} cannot be migrated here.\n` +
111
+ "Run `agentlas doctor`, or launch the Agentlas Desktop app once.",
112
+ );
113
+ }
114
+ if (core.error) {
115
+ throw new Error(`AGENTLAS_STORE_MIGRATION_ROLE=owner: Desktop core failed to migrate ${file}: ${core.error.message}`);
116
+ }
57
117
  }
58
118
 
59
119
  function tableExists(db, name) {
@@ -151,6 +211,7 @@ function seedBuiltins(db) {
151
211
  module.exports = {
152
212
  openDb,
153
213
  openRaw,
214
+ storeMigrationRole,
154
215
  tableExists,
155
216
  columnExists,
156
217
  runWriteTransaction,
@@ -141,6 +141,25 @@ function installNativeModuleHook() {
141
141
  _nativeHookInstalled = true;
142
142
  }
143
143
 
144
+ /**
145
+ * 코어의 initStore 를 부르기 전에, 공유 저장소의 user_version 을 **우리 드라이버로** 확인한다.
146
+ * 별도 커넥션을 잠깐 열고 닫을 뿐 아무것도 쓰지 않는다. 파일이 아직 없으면(첫 실행 부트스트랩
147
+ * 이전) 판단할 것이 없으므로 통과 — 코어가 만들게 둔다.
148
+ */
149
+ function assertSharedStoreSchemaBeforeCoreInit() {
150
+ const file = process.env.AGENTLAS_STORE_PATH || dbPath();
151
+ if (!fs.existsSync(file)) return;
152
+ const { openRaw } = require("./db.cjs");
153
+ const { assertStoreSchemaCompatible } = require("./store-schema.cjs");
154
+ let probe = null;
155
+ try {
156
+ probe = openRaw(file);
157
+ assertStoreSchemaCompatible(probe, file);
158
+ } finally {
159
+ try { probe && probe.close(); } catch { /* noop */ }
160
+ }
161
+ }
162
+
144
163
  let _cache = undefined;
145
164
 
146
165
  /**
@@ -148,8 +167,22 @@ let _cache = undefined;
148
167
  * · require(rel): 코어 안의 임의 컴파일 모듈을 상대경로로 로드(예: "store/automations").
149
168
  * · runGraph: electron/workflow/run-graph.js 의 runGraph(automation, graph, opts).
150
169
  * 공유 DB(AGENTLAS_STORE_PATH)를 코어 로드 전에 반드시 세팅한다 — store/db 가 모듈 로드 시 읽는다.
170
+ *
171
+ * ★마이그레이션 권위 (Phase 0, docs/DAEMON-ARCHITECTURE-DESIGN-2026-08-18.md §2/§6).
172
+ *
173
+ * 여기가 **두 번째 마이그레이션 주인**이었다. 이 셰임은 isPackaged:true 를 보고해
174
+ * (설계상) db.ts 의 개발 샌드박스 가드를 무력화하고, 공유 라이브 파일에 데스크탑과
175
+ * 같은 사다리를 돌렸다. 락이 없는 파일에서 두 프로세스가 사다리를 겹쳐 도는 것이
176
+ * 정확히 run_events + 인덱스 4개를 malformed 로 만든 경로다.
177
+ *
178
+ * **선택: (a) 사다리를 돌리지 않는다.** initStore 를 follower 로 부른다 — 코어는 열고
179
+ * 확인만 하며, 낮으면 정직하게 거절한다. (b)("데스크탑이 없으면 터미널이 주인")를 고르지
180
+ * 않은 이유: 데스크탑의 부재는 경합 없이 관측할 수 없다. 터미널이 사다리를 도는 중에
181
+ * 데스크탑이 켜질 수 있고, 틀렸을 때의 비용이 비대칭이다(손상 vs 앱 한 번 실행).
182
+ * 데스크탑이 정말 없는 머신은 `AGENTLAS_STORE_MIGRATION_ROLE=owner` 로 **사람이 일부러**
183
+ * 한 번 켠다 — 사고로 되는 일과 적어서 되는 일은 달라야 한다.
151
184
  */
152
- function loadDesktopCore() {
185
+ function loadDesktopCore(options = {}) {
153
186
  if (_cache !== undefined) return _cache;
154
187
  const root = findCoreRoot();
155
188
  if (!root) { _cache = null; return null; }
@@ -157,13 +190,30 @@ function loadDesktopCore() {
157
190
  installNativeModuleHook();
158
191
  // 코어의 store 가 이 값을 모듈 로드 시점에 읽는다 — require 이전에 세팅해야 한다.
159
192
  if (!process.env.AGENTLAS_STORE_PATH) process.env.AGENTLAS_STORE_PATH = dbPath();
193
+ const migrationRole = options.migrationRole
194
+ || (String(process.env.AGENTLAS_STORE_MIGRATION_ROLE || "").trim().toLowerCase() === "owner"
195
+ ? "owner"
196
+ : "follower");
160
197
  const req = (rel) => require(path.join(root, "electron", rel.replace(/\.js$/, "") + ".js"));
161
198
  let kernel;
162
199
  try {
200
+ const store = req("store/db");
201
+ if (migrationRole === "follower") {
202
+ // 옛 벤더 번들은 이 계약을 모른다(옵션·env 를 무시하고 사다리를 돈다). 조용히 넘어가지
203
+ // 않고 무엇이 위험한지 말한다 — `npm run vendor:core` 로 갱신하면 사라진다.
204
+ if (typeof store.STORE_SCHEMA_VERSION !== "number") {
205
+ console.warn(
206
+ "[store] vendored Desktop core predates the single-migration-authority contract "
207
+ + "(no STORE_SCHEMA_VERSION export); it may migrate the shared database. "
208
+ + "Refresh it with `npm run vendor:core`.",
209
+ );
210
+ }
211
+ // 코어를 부르기 **전에** 우리 쪽에서 먼저 확인한다 — 옛 코어가 옵션을 무시해도
212
+ // 최소한 낮은 스키마에서는 사다리에 닿지 않는다.
213
+ assertSharedStoreSchemaBeforeCoreInit();
214
+ }
163
215
  // 데스크탑은 app.whenReady 에서 initStore() 를 부른다 — 터미널도 코어를 쓰기 전에 부른다.
164
- // initStore 가 스키마 마이그레이션까지 소유하므로(데스크탑과 같은 정본 스키마) 공유 DB 는
165
- // 언제나 코어가 기대하는 모양이 된다. 이것이 "재구현"이 아니라 "재사용"의 핵심.
166
- req("store/db").initStore();
216
+ store.initStore({ migrationRole });
167
217
  kernel = req("workflow/run-graph");
168
218
  } catch (e) { _cache = { root, error: e }; return _cache; }
169
219
  _cache = {
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ /*
3
+ * core/keychain-read — 키체인 읽기를 "죽일 수 있는 곳"에서 한다.
4
+ *
5
+ * ★이 저장소는 이 함정을 이미 한 번 만났다: telegram/connect.cjs 는 "standalone Node 에서
6
+ * keytar 는 macOS 키체인에 막혀 멈추므로" 라며 keytar 를 아예 쓰지 않고 0600 파일로 갔다.
7
+ * 그런데 그 회피는 그 한 곳에만 있었고, 나머지는 `.catch(() => null)` 로 "거부는 없는 키로
8
+ * 본다" 는 가정을 들고 있었다. **거부가 아니라 정지가 실제 실패 모양이다.**
9
+ *
10
+ * macOS 키체인 항목에는 그것을 만든 프로그램의 ACL 이 붙는다. 다른 실행 파일이 읽으려 하면
11
+ * OS 가 승인 창을 띄우고, 띄울 화면이 없는 호스트에서는 답할 사람이 없어 호출이 안 돌아온다.
12
+ * 그리고 그건 **이벤트 루프 정지**다 — 같은 프로세스의 25초 setTimeout 조차 발화하지 않는
13
+ * 것을 측정했다(2026-08-19). 그래서 `Promise.race([call, timeout])` 은 원리적으로 못 막는다.
14
+ * 상한이 실제로 동작하려면 호출이 **다른 프로세스**에 있어야 한다.
15
+ *
16
+ * 데스크탑 쪽 같은 계약: agentlas_desktop/electron/secrets/keychain-host.ts.
17
+ */
18
+ const { execFile } = require("node:child_process");
19
+
20
+ const DEFAULT_TIMEOUT_MS = 10_000;
21
+ const MAX_TIMEOUT_MS = 120_000;
22
+
23
+ function keychainTimeoutMs() {
24
+ const raw = Number(process.env.AGENTLAS_KEYCHAIN_TIMEOUT_MS);
25
+ if (Number.isFinite(raw) && raw > 0) return Math.min(Math.floor(raw), MAX_TIMEOUT_MS);
26
+ return DEFAULT_TIMEOUT_MS;
27
+ }
28
+
29
+ /**
30
+ * 키체인 값 하나를 상한 안에서 읽는다. 못 읽으면 `null` — 호출부는 "키 없음" 갈래를 이미 갖고
31
+ * 있고, 멈춰 서는 것보다 그 갈래가 낫다. 값은 stdout(파이프)으로만 오고 argv 에는 안 실린다.
32
+ */
33
+ function readKeychainPassword(service, account) {
34
+ return new Promise((resolve) => {
35
+ let keytarPath;
36
+ try {
37
+ keytarPath = require.resolve("keytar");
38
+ } catch {
39
+ resolve(null);
40
+ return;
41
+ }
42
+ const script = `
43
+ const [modPath, service, account] = process.argv.slice(1);
44
+ const keytar = require(modPath);
45
+ keytar.getPassword(service, account)
46
+ .then((v) => { process.stdout.write(JSON.stringify({ value: v ?? null })); process.exit(0); })
47
+ .catch(() => { process.stdout.write(JSON.stringify({ value: null })); process.exit(0); });
48
+ `;
49
+ execFile(
50
+ process.execPath,
51
+ ["-e", script, keytarPath, String(service), String(account)],
52
+ { timeout: keychainTimeoutMs(), killSignal: "SIGKILL", maxBuffer: 1024 * 1024 },
53
+ (error, stdout) => {
54
+ if (error) {
55
+ resolve(null);
56
+ return;
57
+ }
58
+ try {
59
+ const parsed = JSON.parse(String(stdout || "{}"));
60
+ resolve(typeof parsed.value === "string" ? parsed.value : null);
61
+ } catch {
62
+ resolve(null);
63
+ }
64
+ },
65
+ );
66
+ });
67
+ }
68
+
69
+ module.exports = { readKeychainPassword, keychainTimeoutMs };