agentlas 1.0.60 → 1.0.62
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/CHANGELOG.md +18 -1
- package/engine/acp/server.cjs +47 -17
- package/engine/agentlas-evolution.cjs +85 -36
- package/engine/agentlas-experience-intake.cjs +21 -8
- package/engine/agentlas-memory-governance.cjs +88 -10
- package/engine/agentlas-permissions.cjs +95 -1
- package/engine/agentlas-tools.cjs +45 -2
- package/engine/agentlas.cjs +6 -3
- package/engine/agents/builder.cjs +4 -0
- package/engine/architecture.data.json +1 -1
- package/engine/automation/daemon.cjs +36 -3
- package/engine/automation/store.cjs +13 -11
- package/engine/bootstrap-schema.sql +216 -24
- package/engine/cloud/auth.cjs +34 -8
- package/engine/cloud-assets/cas.cjs +27 -1
- package/engine/cloud-assets/package.cjs +126 -0
- package/engine/cloud-assets/upload-scan-catalog.generated.cjs +13 -0
- package/engine/commands/career-graph.cjs +1 -1
- package/engine/commands/connect.cjs +34 -6
- package/engine/commands/graph.cjs +6 -2
- package/engine/commands/index.cjs +4 -1
- package/engine/commands/oberon.cjs +1 -1
- package/engine/commands/one.cjs +307 -0
- package/engine/commands/ontology.cjs +2 -2
- package/engine/commands/plugin.cjs +61 -16
- package/engine/commands/uninstall.cjs +43 -13
- package/engine/commands/update.cjs +25 -4
- package/engine/core/capability-grants.cjs +204 -0
- package/engine/core/desktop-core-fetch.cjs +94 -21
- package/engine/core/desktop-core.cjs +22 -0
- package/engine/experience/build.cjs +8 -0
- package/engine/graph/node-effect.cjs +56 -0
- package/engine/graph/package.cjs +6 -1
- package/engine/graph/vocabulary.generated.cjs +1 -1
- package/engine/hephaestus/local-core.cjs +44 -9
- package/engine/hub/install.cjs +7 -3
- package/engine/hub/plugins.cjs +165 -0
- package/engine/mcp/consent.cjs +140 -12
- package/engine/mcp/index.cjs +1 -0
- package/engine/mcp/plan.cjs +63 -8
- package/engine/memory-cli/curate.cjs +5 -2
- package/engine/oberon/outputs.cjs +10 -3
- package/engine/project/career-graph.cjs +9 -12
- package/engine/project/memory-context.cjs +9 -6
- package/engine/project/ontology.cjs +88 -36
- package/engine/runtimes/acp-driver.cjs +42 -3
- package/engine/sessions/memory-turn.cjs +39 -0
- package/engine/sessions/orchestrator.cjs +53 -4
- package/engine/sessions/session.cjs +28 -2
- package/engine/sessions/store.cjs +48 -12
- package/engine/telegram/connect.cjs +31 -10
- package/engine/ui/commands-catalog.cjs +1 -0
- package/engine/ui/repl.cjs +3 -1
- package/engine/ui/screens.cjs +30 -6
- package/engine/vendor/desktop-core.manifest.json +5 -5
- package/package.json +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 1.0.62 — 2026-08-29
|
|
4
|
+
|
|
5
|
+
- Terminal sessions now preserve ACP conversation identity and history across
|
|
6
|
+
editor turns, propagate cancellation and consented MCP configuration to
|
|
7
|
+
Cursor/Grok/Kimi, and reject overlapping prompts instead of cross-streaming
|
|
8
|
+
two JSON-RPC requests.
|
|
9
|
+
- Automation leases, Telegram pairing, cloud session storage, Local Core
|
|
10
|
+
transport, evolution writes, experience events, project path matching, and
|
|
11
|
+
memory ownership now fail closed under races and partial failures.
|
|
12
|
+
- The on-demand Desktop core cache is bound to the exact release SHA, includes
|
|
13
|
+
every built-in plugin runtime package, and is published as the audited
|
|
14
|
+
`desktop-core-v15` asset without dependency tests, fixtures, or local paths.
|
|
15
|
+
- npm packages exclude the generated Desktop core tree and tarball, shrinking
|
|
16
|
+
the install artifact from roughly 36 MB to under 1 MB while retaining the
|
|
17
|
+
checksummed v15 fetch manifest. The publication workflow enforces this
|
|
18
|
+
boundary.
|
|
19
|
+
- `agentlas update` now bounds registry connection and body parsing so a stalled
|
|
20
|
+
npm request cannot hang the CLI indefinitely.
|
|
4
21
|
|
|
5
22
|
- `agentlas acp` — Agentlas as an Agent Client Protocol agent (Phase B-3). Zed,
|
|
6
23
|
JetBrains IDEs and other ACP clients can run the project controller (or an
|
package/engine/acp/server.cjs
CHANGED
|
@@ -50,8 +50,20 @@ function textOfPrompt(blocks) {
|
|
|
50
50
|
* events: { onDelta(text), onTool(name, summary, id), onToolResult(text, ok, id), onStatus(text) }
|
|
51
51
|
* cancel(sessionKey) → void
|
|
52
52
|
*/
|
|
53
|
-
function productionTurnRunner() {
|
|
54
|
-
const sessions = new Map(); // acp sessionId →
|
|
53
|
+
function productionTurnRunner(options = {}) {
|
|
54
|
+
const sessions = new Map(); // currently running acp sessionId → Session
|
|
55
|
+
const createSession = options.createSession || ((ctx, spec, prompt) => {
|
|
56
|
+
const { Orchestrator } = require("../sessions/orchestrator.cjs");
|
|
57
|
+
const orch = new Orchestrator({ db: ctx.db(), lang: ctx.lang });
|
|
58
|
+
const session = orch.spawn({
|
|
59
|
+
agent: spec.agent,
|
|
60
|
+
runtime: spec.runtime,
|
|
61
|
+
permission: spec.permission,
|
|
62
|
+
cwd: spec.cwd,
|
|
63
|
+
title: prompt.slice(0, 60),
|
|
64
|
+
});
|
|
65
|
+
return { orch, session };
|
|
66
|
+
});
|
|
55
67
|
return {
|
|
56
68
|
async newSession(ctx, { cwd, runtimeKind, agentSlug }) {
|
|
57
69
|
const { projectCwd } = require("../project/paths.cjs");
|
|
@@ -81,9 +93,17 @@ function productionTurnRunner() {
|
|
|
81
93
|
return { agent, runtime, permission, cwd: workdir, project: resolved ? resolved.project : null };
|
|
82
94
|
},
|
|
83
95
|
async runTurn(ctx, spec, prompt, events) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
// An ACP session is conversational. Recreating Terminal's Session here on
|
|
97
|
+
// every prompt gave each editor turn a new chat/runtime fingerprint even
|
|
98
|
+
// though the client kept sending the same ACP sessionId. Keep one Session
|
|
99
|
+
// on the session spec so DB history and provider resume state survive.
|
|
100
|
+
if (!spec.session) {
|
|
101
|
+
const created = await createSession(ctx, spec, prompt);
|
|
102
|
+
spec.orch = created && created.orch || null;
|
|
103
|
+
spec.session = created && created.session || created;
|
|
104
|
+
}
|
|
105
|
+
const session = spec.session;
|
|
106
|
+
if (!session || typeof session.send !== "function") throw new Error("ACP session runner failed to create a Terminal session");
|
|
87
107
|
sessions.set(spec.acpSessionId, session);
|
|
88
108
|
const listener = (ev) => {
|
|
89
109
|
try {
|
|
@@ -104,7 +124,7 @@ function productionTurnRunner() {
|
|
|
104
124
|
};
|
|
105
125
|
} finally {
|
|
106
126
|
session.removeListener("event", listener);
|
|
107
|
-
sessions.delete(spec.acpSessionId);
|
|
127
|
+
if (sessions.get(spec.acpSessionId) === session) sessions.delete(spec.acpSessionId);
|
|
108
128
|
}
|
|
109
129
|
},
|
|
110
130
|
cancel(acpSessionId) {
|
|
@@ -207,9 +227,15 @@ class AcpAgentServer {
|
|
|
207
227
|
case "session/prompt": {
|
|
208
228
|
const spec = this.sessions.get(String(params.sessionId));
|
|
209
229
|
if (!spec) return this.error(id, -32602, "unknown sessionId");
|
|
230
|
+
// readline dispatches messages concurrently. Session.send() queues a
|
|
231
|
+
// second prompt and returns the first turn's promise, which would make
|
|
232
|
+
// both JSON-RPC requests stream each other's events and settle with the
|
|
233
|
+
// wrong result. ACP clients must wait for the current prompt response.
|
|
234
|
+
if (spec.promptInFlight) return this.error(id, -32001, "session prompt already in progress");
|
|
210
235
|
const prompt = textOfPrompt(params.prompt).trim();
|
|
211
236
|
if (!prompt) return this.reply(id, { stopReason: "end_turn" });
|
|
212
237
|
const sid = spec.acpSessionId;
|
|
238
|
+
spec.promptInFlight = true;
|
|
213
239
|
let streamed = false;
|
|
214
240
|
const events = {
|
|
215
241
|
onDelta: (text) => {
|
|
@@ -232,18 +258,22 @@ class AcpAgentServer {
|
|
|
232
258
|
},
|
|
233
259
|
onStatus: () => {},
|
|
234
260
|
};
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
261
|
+
try {
|
|
262
|
+
const res = await this.runner.runTurn(this.ctx, spec, prompt, events);
|
|
263
|
+
if (res && res.cancelled) return this.reply(id, { stopReason: "cancelled" });
|
|
264
|
+
if (res && res.error) {
|
|
265
|
+
const kind = String(res.errorKind || "");
|
|
266
|
+
if (kind === "refused") return this.reply(id, { stopReason: "refusal" });
|
|
267
|
+
if (kind === "auth") return this.error(id, -32000, `auth_required: ${res.error}`);
|
|
268
|
+
// Non-marker failures: surface the runtime's own words as the answer, then end the turn.
|
|
269
|
+
if (!streamed) events.onDelta(String(res.error));
|
|
270
|
+
return this.reply(id, { stopReason: "end_turn", _meta: { agentlas: { error: String(res.error), errorKind: kind || null } } });
|
|
271
|
+
}
|
|
272
|
+
if (!streamed && res && res.text) events.onDelta(res.text);
|
|
273
|
+
return this.reply(id, { stopReason: "end_turn" });
|
|
274
|
+
} finally {
|
|
275
|
+
spec.promptInFlight = false;
|
|
244
276
|
}
|
|
245
|
-
if (!streamed && res && res.text) events.onDelta(res.text);
|
|
246
|
-
return this.reply(id, { stopReason: "end_turn" });
|
|
247
277
|
}
|
|
248
278
|
case "session/cancel": {
|
|
249
279
|
const spec = this.sessions.get(String(params.sessionId));
|
|
@@ -110,6 +110,37 @@ function currentTargetHash(file) {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
function writeTargetAtomic(file, content) {
|
|
114
|
+
const dir = path.dirname(file);
|
|
115
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
116
|
+
let mode = 0o600;
|
|
117
|
+
try {
|
|
118
|
+
const stat = fs.lstatSync(file);
|
|
119
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Evolution target must be a regular non-symbolic-link file");
|
|
120
|
+
mode = stat.mode & 0o777;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
123
|
+
}
|
|
124
|
+
const temp = path.join(dir, `.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`);
|
|
125
|
+
try {
|
|
126
|
+
fs.writeFileSync(temp, content, { encoding: "utf8", mode, flag: "wx" });
|
|
127
|
+
fs.renameSync(temp, file);
|
|
128
|
+
try { fs.chmodSync(file, mode); } catch { /* Windows/ACL-only host */ }
|
|
129
|
+
} catch (error) {
|
|
130
|
+
try { fs.rmSync(temp, { force: true }); } catch { /* preserve original */ }
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function restoreTargetAfterFailure(file, before, expectedCurrentHash) {
|
|
136
|
+
const current = currentTargetHash(file);
|
|
137
|
+
if (current.hash !== expectedCurrentHash) {
|
|
138
|
+
throw new Error("Evolution persistence failed and the target changed again before rollback; manual repair is required");
|
|
139
|
+
}
|
|
140
|
+
if (before.exists) writeTargetAtomic(file, before.content);
|
|
141
|
+
else fs.rmSync(file, { force: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
113
144
|
function printCard(out, entry, index) {
|
|
114
145
|
const { row, source } = entry;
|
|
115
146
|
const card = source.humanCard && typeof source.humanCard === "object" ? source.humanCard : null;
|
|
@@ -205,29 +236,37 @@ function cmdApply(db, id, out, fail, agentFolder) {
|
|
|
205
236
|
const agent = agentById(db, row.agent_id);
|
|
206
237
|
if (!agent) return fail(`Agent not found for proposal: ${row.agent_id}`);
|
|
207
238
|
const file = targetFilePath(agentFolder, agent, row.target_path);
|
|
208
|
-
const
|
|
209
|
-
if (
|
|
239
|
+
const before = currentTargetHash(file);
|
|
240
|
+
if (before.hash !== row.before_hash) {
|
|
210
241
|
return fail("Agent prompt changed after this proposal was created; review it in the desktop app and re-propose.");
|
|
211
242
|
}
|
|
212
243
|
const now = new Date().toISOString();
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
244
|
+
let wroteTarget = false;
|
|
245
|
+
try {
|
|
246
|
+
runWriteTransaction(db, () => {
|
|
247
|
+
const locked = db.prepare("SELECT status FROM agent_evolution_proposals WHERE id=?").get(row.id);
|
|
248
|
+
if (!locked || locked.status !== "candidate") throw new Error("Evolution proposal changed before apply; reload and review it again");
|
|
249
|
+
if (currentTargetHash(file).hash !== row.before_hash) throw new Error("Agent prompt changed before apply; reload and review it again");
|
|
250
|
+
writeTargetAtomic(file, row.after_content);
|
|
251
|
+
wroteTarget = true;
|
|
252
|
+
if (currentTargetHash(file).hash !== row.after_hash) throw new Error("Applied content did not match the approved hash");
|
|
253
|
+
db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.after_content, row.agent_id);
|
|
254
|
+
insertReceipt(db, row, "apply", row.before_hash, row.after_hash, now);
|
|
255
|
+
const changed = db.prepare(
|
|
256
|
+
`UPDATE agent_evolution_proposals
|
|
257
|
+
SET status = 'applied', applied_at = COALESCE(applied_at, ?),
|
|
258
|
+
last_error = NULL, updated_at = ?
|
|
259
|
+
WHERE id = ? AND status = 'candidate'`,
|
|
260
|
+
).run(now, now, row.id).changes;
|
|
261
|
+
if (changed !== 1) throw new Error("Evolution proposal changed before apply; no receipt was committed");
|
|
262
|
+
});
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (wroteTarget) {
|
|
265
|
+
try { restoreTargetAfterFailure(file, before, sha256(row.after_content)); }
|
|
266
|
+
catch (rollbackError) { return fail(`${error.message}. ${rollbackError.message}`); }
|
|
267
|
+
}
|
|
268
|
+
return fail(`${error.message}; the original target was restored.`);
|
|
220
269
|
}
|
|
221
|
-
runWriteTransaction(db, () => {
|
|
222
|
-
db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.after_content, row.agent_id);
|
|
223
|
-
insertReceipt(db, row, "apply", row.before_hash, row.after_hash, now);
|
|
224
|
-
db.prepare(
|
|
225
|
-
`UPDATE agent_evolution_proposals
|
|
226
|
-
SET status = 'applied', applied_at = COALESCE(applied_at, ?),
|
|
227
|
-
last_error = NULL, updated_at = ?
|
|
228
|
-
WHERE id = ? AND status = 'candidate'`,
|
|
229
|
-
).run(now, now, row.id);
|
|
230
|
-
});
|
|
231
270
|
out(`applied ${id} → ${row.target_path} (agent ${row.agent_id}). Revert with: agentlas evolve revert ${id}`);
|
|
232
271
|
}
|
|
233
272
|
|
|
@@ -244,27 +283,37 @@ function cmdRevert(db, id, out, fail, agentFolder) {
|
|
|
244
283
|
const agent = agentById(db, row.agent_id);
|
|
245
284
|
if (!agent) return fail(`Agent not found for proposal: ${row.agent_id}`);
|
|
246
285
|
const file = targetFilePath(agentFolder, agent, row.target_path);
|
|
247
|
-
const
|
|
248
|
-
if (
|
|
286
|
+
const before = currentTargetHash(file);
|
|
287
|
+
if (before.hash !== row.after_hash) {
|
|
249
288
|
return fail("Agent prompt changed after this proposal was applied; revert blocked to avoid clobbering newer edits.");
|
|
250
289
|
}
|
|
251
290
|
const now = new Date().toISOString();
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
291
|
+
let wroteTarget = false;
|
|
292
|
+
try {
|
|
293
|
+
runWriteTransaction(db, () => {
|
|
294
|
+
const locked = db.prepare("SELECT status FROM agent_evolution_proposals WHERE id=?").get(row.id);
|
|
295
|
+
if (!locked || !["applied", "measured"].includes(locked.status)) throw new Error("Evolution proposal changed before revert; reload it first");
|
|
296
|
+
if (currentTargetHash(file).hash !== row.after_hash) throw new Error("Agent prompt changed before revert; reload it first");
|
|
297
|
+
writeTargetAtomic(file, row.before_content);
|
|
298
|
+
wroteTarget = true;
|
|
299
|
+
if (currentTargetHash(file).hash !== row.before_hash) throw new Error("Reverted content did not match the original hash");
|
|
300
|
+
db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.before_content, row.agent_id);
|
|
301
|
+
insertReceipt(db, row, "rollback", row.after_hash, row.before_hash, now);
|
|
302
|
+
const changed = db.prepare(
|
|
303
|
+
`UPDATE agent_evolution_proposals
|
|
304
|
+
SET status = 'rolled_back', rolled_back_at = COALESCE(rolled_back_at, ?),
|
|
305
|
+
last_error = NULL, updated_at = ?
|
|
306
|
+
WHERE id = ? AND status IN ('applied','measured')`,
|
|
307
|
+
).run(now, now, row.id).changes;
|
|
308
|
+
if (changed !== 1) throw new Error("Evolution proposal changed before revert; no receipt was committed");
|
|
309
|
+
});
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (wroteTarget) {
|
|
312
|
+
try { restoreTargetAfterFailure(file, before, sha256(row.before_content)); }
|
|
313
|
+
catch (rollbackError) { return fail(`${error.message}. ${rollbackError.message}`); }
|
|
314
|
+
}
|
|
315
|
+
return fail(`${error.message}; the applied target was restored.`);
|
|
257
316
|
}
|
|
258
|
-
runWriteTransaction(db, () => {
|
|
259
|
-
db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.before_content, row.agent_id);
|
|
260
|
-
insertReceipt(db, row, "rollback", row.after_hash, row.before_hash, now);
|
|
261
|
-
db.prepare(
|
|
262
|
-
`UPDATE agent_evolution_proposals
|
|
263
|
-
SET status = 'rolled_back', rolled_back_at = COALESCE(rolled_back_at, ?),
|
|
264
|
-
last_error = NULL, updated_at = ?
|
|
265
|
-
WHERE id = ? AND status IN ('applied','measured')`,
|
|
266
|
-
).run(now, now, row.id);
|
|
267
|
-
});
|
|
268
317
|
out(`reverted ${id} → restored ${row.target_path} (agent ${row.agent_id}).`);
|
|
269
318
|
}
|
|
270
319
|
|
|
@@ -175,13 +175,24 @@ function tableExists(db, name) {
|
|
|
175
175
|
|
|
176
176
|
function appendRunEvent(db, input) {
|
|
177
177
|
if (!db || !tableExists(db, "run_events")) return false;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
db.prepare(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
178
|
+
// Allocate seq and insert in one SQLite statement. The old SELECT(MAX)+INSERT
|
|
179
|
+
// pair allowed two Terminal processes to choose the same (run_id, seq); one
|
|
180
|
+
// INSERT OR IGNORE then vanished while this function still reported success.
|
|
181
|
+
const inserted = db.prepare(
|
|
182
|
+
`INSERT OR IGNORE INTO run_events
|
|
183
|
+
(id,run_id,seq,ts,kind,chat_id,automation_id,node_id,agent_id,payload_json)
|
|
184
|
+
SELECT ?,?,COALESCE(MAX(seq) + 1, 0),?,?,NULL,NULL,NULL,?,?
|
|
185
|
+
FROM run_events WHERE run_id=?`,
|
|
186
|
+
).run(
|
|
187
|
+
input.id,
|
|
188
|
+
input.runId,
|
|
189
|
+
input.ts,
|
|
190
|
+
input.kind,
|
|
191
|
+
input.agentId || null,
|
|
192
|
+
JSON.stringify(input.payload),
|
|
193
|
+
input.runId,
|
|
194
|
+
);
|
|
195
|
+
return Number(inserted?.changes || 0) === 1;
|
|
185
196
|
}
|
|
186
197
|
|
|
187
198
|
function persistRunReceipt(db, receipt, agentId) {
|
|
@@ -200,7 +211,9 @@ function persistRunReceipt(db, receipt, agentId) {
|
|
|
200
211
|
function recordIntakeDecision(db, input) {
|
|
201
212
|
const sourceHash = digestHex(INTAKE_POLICY_VERSION, input.agentId, input.memoryId || "none", input.exactBase?.agentReleaseId || "none", input.environmentKey || "none");
|
|
202
213
|
appendRunEvent(db, {
|
|
203
|
-
|
|
214
|
+
// The same curated memory can be observed by many distinct runs. Preserve
|
|
215
|
+
// retry idempotency within one run without collapsing later run evidence.
|
|
216
|
+
id: opaqueId("event", "experience-intake", input.runId, sourceHash),
|
|
204
217
|
runId: input.runId,
|
|
205
218
|
ts: input.ts,
|
|
206
219
|
kind: input.kind || "experience-intake-decision",
|
|
@@ -146,13 +146,53 @@ function normalizeOutcome(value) {
|
|
|
146
146
|
return "completed";
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
149
|
+
/*
|
|
150
|
+
* 2026-08-20: ownerPolicyFromPrompt(단어장 — remember/전역 등 regex AND) 제거.
|
|
151
|
+
* 전역 메모리 쓰기 권한을 부여하는 길은 둘뿐이다:
|
|
152
|
+
* 1) 호스트가 넘긴 구조화 플래그(beginTurn input.ownerPolicy) — 기계 표식이 우선.
|
|
153
|
+
* 2) 판정기(agentlas-judgment) 경유 — resolveGlobalWriteAuthorization.
|
|
154
|
+
* 판정 불가면 부여하지 않는다(fail-closed). 단어장은 어떤 언어도 다 못 세는 데다,
|
|
155
|
+
* 제3언어의 명시적 요청을 영구히 거부하고 우연한 단어 일치로 권한을 넓혔다.
|
|
156
|
+
*/
|
|
157
|
+
function normalizeOwnerPolicy(value) {
|
|
158
|
+
return { globalWriteAuthorized: Boolean(value && value.globalWriteAuthorized === true) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function resolveGlobalWriteAuthorization(prompt, options = {}) {
|
|
162
|
+
const text = String(prompt || "").trim();
|
|
163
|
+
if (!text) return { authorized: false, source: "unavailable" };
|
|
164
|
+
// 호스트가 판정 함수를 주입할 수 있다(세션이 자기 연결 런타임으로 감쌈).
|
|
165
|
+
if (typeof options.judge === "function") {
|
|
166
|
+
try {
|
|
167
|
+
const judged = await options.judge(text);
|
|
168
|
+
return judged && judged.source === "llm"
|
|
169
|
+
? { authorized: judged.authorized === true, source: "llm" }
|
|
170
|
+
: { authorized: false, source: "unavailable" };
|
|
171
|
+
} catch {
|
|
172
|
+
return { authorized: false, source: "unavailable" };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
let judgment;
|
|
176
|
+
try {
|
|
177
|
+
judgment = options.judgment || require("./agentlas-judgment.cjs");
|
|
178
|
+
} catch {
|
|
179
|
+
return { authorized: false, source: "unavailable" };
|
|
180
|
+
}
|
|
181
|
+
if (!judgment.hasJudgmentRunner()) return { authorized: false, source: "unavailable" };
|
|
182
|
+
const verdict = await judgment.judgeLabels({
|
|
183
|
+
kind: "terminal-memory-global-write",
|
|
184
|
+
question:
|
|
185
|
+
"Does this request EXPLICITLY ask to save or remember something as a GLOBAL memory that applies across all projects (user profile / account-wide), rather than only this project, session, or task?",
|
|
186
|
+
labels: ["authorize_global_memory_write"],
|
|
187
|
+
input: text,
|
|
188
|
+
multi: false,
|
|
189
|
+
guidance:
|
|
190
|
+
"Authorize only an explicit, unambiguous request to persist a memory globally, in any language. Ordinary task prompts, project-scoped notes, or incidental mentions of memory do NOT authorize. When uncertain, select nothing.",
|
|
191
|
+
signal: options.signal,
|
|
192
|
+
timeoutMs: options.timeoutMs,
|
|
193
|
+
});
|
|
194
|
+
if (verdict.source !== "llm") return { authorized: false, source: "unavailable" };
|
|
195
|
+
return { authorized: verdict.labels.includes("authorize_global_memory_write"), source: "llm" };
|
|
156
196
|
}
|
|
157
197
|
|
|
158
198
|
function tableExists(db, name) {
|
|
@@ -245,7 +285,9 @@ function beginTurn(db, input = {}) {
|
|
|
245
285
|
const pKey = projectKey(input.projectPath);
|
|
246
286
|
const oKey = ownerKey(input.agentId);
|
|
247
287
|
const explicitTurnId = validTurnId(input.stableTurnId);
|
|
248
|
-
|
|
288
|
+
// 시작 시점 정책은 호스트 구조화 플래그만 반영한다(없으면 fail-closed false).
|
|
289
|
+
// 판정 경유 승격은 completeTurn에서, 실제로 user_global 후보가 나왔을 때만 1회 수행된다.
|
|
290
|
+
const policy = normalizeOwnerPolicy(input.ownerPolicy);
|
|
249
291
|
const conversationDigest = sha256(input.conversationRef || "none");
|
|
250
292
|
const priorDigest = sha256(input.priorContextDigest || input.priorContext || "none");
|
|
251
293
|
const contextKey = sha256(stableJson({
|
|
@@ -823,6 +865,28 @@ async function completeTurn(db, input = {}) {
|
|
|
823
865
|
};
|
|
824
866
|
}
|
|
825
867
|
|
|
868
|
+
// 전역 쓰기 승격은 필요할 때만 1회 — 어떤 후보가 실제로 user_global 스코프를
|
|
869
|
+
// 청했고, 시작 시점 정책(호스트 플래그)이 승인하지 않았을 때. 판정기(또는 호스트가
|
|
870
|
+
// 주입한 judge)가 명시적 요청이라고 판정한 경우에만 켠다. 판정 불가 = 부여 안 함.
|
|
871
|
+
if (
|
|
872
|
+
turn.ownerPolicy?.globalWriteAuthorized !== true
|
|
873
|
+
&& normalizePermission(turn.permission) !== "read"
|
|
874
|
+
&& parsed.candidates.some(
|
|
875
|
+
(candidate) => candidate.suggestedScope === "user_global" && candidate.preGateReasons.length === 0,
|
|
876
|
+
)
|
|
877
|
+
) {
|
|
878
|
+
const judged = await resolveGlobalWriteAuthorization(input.requestText, {
|
|
879
|
+
judge: input.judgeGlobalAuthorization,
|
|
880
|
+
});
|
|
881
|
+
if (judged.authorized === true && judged.source === "llm") {
|
|
882
|
+
turn.ownerPolicy = { ...turn.ownerPolicy, globalWriteAuthorized: true };
|
|
883
|
+
try {
|
|
884
|
+
db.prepare("UPDATE terminal_memory_turn_intents SET owner_policy_json=? WHERE turn_id=?")
|
|
885
|
+
.run(JSON.stringify(turn.ownerPolicy), turnId);
|
|
886
|
+
} catch { /* 감사 기록 실패가 이번 완결을 막지는 않는다 */ }
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
826
890
|
const payload = buildCuratorPayload(turn, parsed, input);
|
|
827
891
|
let curatorStatus = "unavailable";
|
|
828
892
|
let semantic = { status: "unavailable", decisions: new Map() };
|
|
@@ -878,7 +942,20 @@ async function completeTurn(db, input = {}) {
|
|
|
878
942
|
if (!dbScope) continue;
|
|
879
943
|
const scopedProjectId = decision.finalScope === "user_global" ? null : turn.projectKey;
|
|
880
944
|
const scopedProjectPath = decision.finalScope === "user_global" ? null : boundProjectPath;
|
|
881
|
-
|
|
945
|
+
/*
|
|
946
|
+
* ★팀 공유 기억에는 주인이 없다 (2026-08-26)
|
|
947
|
+
*
|
|
948
|
+
* 이 엔진은 데스크탑과 **같은 SQLite 파일의 같은 `memory_entries` 표**를 쓴다
|
|
949
|
+
* (engine/core/paths.cjs — 같은 userData 공유가 제품 계약이다). 그런데 같은 "팀 공유"
|
|
950
|
+
* 결정을 데스크탑은 `agent_id = NULL` 로, 여기서는 `agent_id = <agentId>` 로 넣고
|
|
951
|
+
* 있었다. 한 표에 두 관례가 섞이면 ① 같은 사실이 주인 다른 두 줄로 남아 중복 제거가
|
|
952
|
+
* 갈리고 ② 정리기가 그 줄을 개인 기억으로 오인한다.
|
|
953
|
+
*
|
|
954
|
+
* 정본은 데스크탑 쪽이다 — 팀 공유는 조직도가 바뀌어도 남아야 하므로 특정 에이전트에
|
|
955
|
+
* 매이지 않는다. 데스크탑의 같은 규칙: shared/memory-ownership.ts `memoryOwnerAgentId`
|
|
956
|
+
* (`agt_team_` 낙인이거나 신원이 없으면 개인 칸이 없다).
|
|
957
|
+
*/
|
|
958
|
+
const scopedAgentId = decision.finalScope === "agent" ? boundAgentId : null;
|
|
882
959
|
let memoryId = null;
|
|
883
960
|
try {
|
|
884
961
|
const duplicate = db.prepare(
|
|
@@ -1010,7 +1087,8 @@ module.exports = {
|
|
|
1010
1087
|
DEFAULT_EVENTS_HEADING,
|
|
1011
1088
|
CURATOR_SYSTEM_PROMPT,
|
|
1012
1089
|
ensureGovernanceSchema,
|
|
1013
|
-
|
|
1090
|
+
normalizeOwnerPolicy,
|
|
1091
|
+
resolveGlobalWriteAuthorization,
|
|
1014
1092
|
projectKey,
|
|
1015
1093
|
ownerKey,
|
|
1016
1094
|
contentGateReasons,
|
|
@@ -98,4 +98,98 @@ function createCycleController(options = {}) {
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
/*
|
|
102
|
+
* ── 통합 능력 승인(데스크탑 capability_grants)과의 합류 ───────────────────────
|
|
103
|
+
*
|
|
104
|
+
* 이 모듈은 오래도록 read/write/full 세 낱말만 알았다. 그런데 오너 결정(2026-08-20)
|
|
105
|
+
* 이후 "무엇을 해도 되는가"의 정본은 등급이 아니라 **행동 규칙**이다: 데스크탑에서
|
|
106
|
+
* "항상 허용"한 행동은 read 등급에서도 통과해야 하고, 영구 거부된 행동은 full 등급으로도
|
|
107
|
+
* 뚫리지 않아야 한다. 등급은 규칙이 없을 때의 기본값으로 남는다.
|
|
108
|
+
*
|
|
109
|
+
* 우선순위는 데스크탑 중재자(electron/ipc.ts setRuntimeToolPermissionArbiter)와 **같은
|
|
110
|
+
* 문장**이다. 갈리면 같은 행동에 두 제품이 다른 답을 준다:
|
|
111
|
+
* 1) 저장된 규칙 deny → deny (등급 무관)
|
|
112
|
+
* 2) 저장된 규칙 allow → allow (등급 무관)
|
|
113
|
+
* 3) permission=full → allow
|
|
114
|
+
* 4) 비변이(mutating=false) → allow
|
|
115
|
+
* 5) permission=write → allow
|
|
116
|
+
* 6) 그 외(read + 변이) → null = 경계를 넘는 요청. 호출부가 묻거나 거부한다.
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/** 능력 규칙 모듈은 공유 DB 를 열므로 지연 로드한다(권한 어휘만 쓰는 호출부에 부담 금지). */
|
|
120
|
+
function grantsModule() {
|
|
121
|
+
return require("./core/capability-grants.cjs");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 한 번의 도구/서버 실행에 대한 판정.
|
|
126
|
+
*
|
|
127
|
+
* @param {object|null} db 공유 DB 핸들. 없으면 규칙을 못 읽고 등급 기본값만 쓴다.
|
|
128
|
+
* @param {object} ask { capability?, kind?, tool, detail?, agentId?, chatId?, mutating?, permission? }
|
|
129
|
+
* @returns {{decision:"allow"|"deny"|null, source:string, ruled:"allow"|"deny"|null,
|
|
130
|
+
* grantsAvailable:boolean, reason:string|null, capability:string}}
|
|
131
|
+
*/
|
|
132
|
+
function decideCapability(db, ask) {
|
|
133
|
+
const grants = grantsModule();
|
|
134
|
+
const capability = ask && ask.capability
|
|
135
|
+
? String(ask.capability)
|
|
136
|
+
: grants.capabilityClassFor(String((ask && ask.kind) || ""), String((ask && ask.tool) || ""));
|
|
137
|
+
const query = {
|
|
138
|
+
capability,
|
|
139
|
+
tool: ask && ask.tool ? String(ask.tool) : undefined,
|
|
140
|
+
detail: ask && ask.detail ? String(ask.detail) : undefined,
|
|
141
|
+
agentId: ask && ask.agentId ? String(ask.agentId) : undefined,
|
|
142
|
+
chatId: ask && ask.chatId ? String(ask.chatId) : undefined,
|
|
143
|
+
};
|
|
144
|
+
const ruling = db
|
|
145
|
+
? grants.readCapabilityDecision(db, query)
|
|
146
|
+
: { decision: null, available: false, reason: "no shared database handle was provided to the capability gate" };
|
|
147
|
+
|
|
148
|
+
if (ruling.decision === "deny") {
|
|
149
|
+
return { decision: "deny", source: "capability-grants", ruled: "deny", grantsAvailable: ruling.available, reason: ruling.reason, capability };
|
|
150
|
+
}
|
|
151
|
+
if (ruling.decision === "allow") {
|
|
152
|
+
return { decision: "allow", source: "capability-grants", ruled: "allow", grantsAvailable: ruling.available, reason: ruling.reason, capability };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const level = normalize(ask && ask.permission);
|
|
156
|
+
const mutating = !!(ask && ask.mutating);
|
|
157
|
+
if (level === "full") {
|
|
158
|
+
return { decision: "allow", source: "permission-level", ruled: null, grantsAvailable: ruling.available, reason: ruling.reason, capability };
|
|
159
|
+
}
|
|
160
|
+
if (!mutating) {
|
|
161
|
+
return { decision: "allow", source: "non-mutating", ruled: null, grantsAvailable: ruling.available, reason: ruling.reason, capability };
|
|
162
|
+
}
|
|
163
|
+
if (level === "write") {
|
|
164
|
+
return { decision: "allow", source: "permission-level", ruled: null, grantsAvailable: ruling.available, reason: ruling.reason, capability };
|
|
165
|
+
}
|
|
166
|
+
return { decision: null, source: "boundary", ruled: null, grantsAvailable: ruling.available, reason: ruling.reason, capability };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 터미널에서 사용자가 "항상 허용"을 골랐을 때 **같은 표**에 남긴다 — 데스크탑도 이
|
|
171
|
+
* 규칙을 읽으므로 다음부터 양쪽 모두 묻지 않는다. 규칙 키는 데스크탑 persistAlwaysGrant
|
|
172
|
+
* 와 동일: capability `tool:<name>` + 일반화된 인자 패턴 + scope global.
|
|
173
|
+
*/
|
|
174
|
+
function rememberAlwaysAllow(db, ask, options = {}) {
|
|
175
|
+
const grants = grantsModule();
|
|
176
|
+
return grants.recordCapabilityGrant(db, {
|
|
177
|
+
capability: `tool:${String((ask && ask.tool) || "")}`,
|
|
178
|
+
pattern: grants.generalizeDetailPattern(ask && ask.detail),
|
|
179
|
+
decision: options.decision === "deny" ? "deny" : "allow",
|
|
180
|
+
scope: options.scope || "global",
|
|
181
|
+
source: options.source || "terminal-chip",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
LEVELS,
|
|
187
|
+
isLevel,
|
|
188
|
+
normalize,
|
|
189
|
+
persistent,
|
|
190
|
+
next,
|
|
191
|
+
copy,
|
|
192
|
+
createCycleController,
|
|
193
|
+
decideCapability,
|
|
194
|
+
rememberAlwaysAllow,
|
|
195
|
+
};
|
|
@@ -322,12 +322,55 @@ function allowedTools(permission) {
|
|
|
322
322
|
return TOOLS.filter((t) => (PERM_RANK[t.minPerm] ?? 0) <= rank);
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
/*
|
|
326
|
+
* ── 능력 규칙(공유 capability_grants)이 등급보다 먼저다 ──────────────────────
|
|
327
|
+
*
|
|
328
|
+
* 오너 결정(2026-08-20): 승인은 행동 기준이고 데스크탑·터미널이 **공유**한다.
|
|
329
|
+
* · 데스크탑에서 "항상 허용"한 행동 → 터미널에서 등급이 낮아도 통과(다시 묻지 않는다).
|
|
330
|
+
* · 데스크탑에서 영구 거부한 행동 → 터미널에서 full 권한이어도 거부.
|
|
331
|
+
* 규칙이 없을 때만 아래의 기존 등급 게이트가 답한다(기존 동작 그대로).
|
|
332
|
+
*
|
|
333
|
+
* ctx.db 가 없으면(단위 테스트·DB 없는 호출) 규칙을 못 읽으므로 종전 등급 게이트만 돈다.
|
|
334
|
+
*/
|
|
335
|
+
function toolAskFor(tool, args) {
|
|
336
|
+
const kind = tool.minPerm === "read" ? "read" : tool.name === "bash" ? "execute" : "edit";
|
|
337
|
+
const detail = tool.name === "bash"
|
|
338
|
+
? String((args && args.command) || "").trim()
|
|
339
|
+
: String((args && args.path) || "").trim();
|
|
340
|
+
return { tool: tool.name, kind, detail: detail || undefined, mutating: kind !== "read" };
|
|
341
|
+
}
|
|
342
|
+
|
|
325
343
|
// 툴 1개 실행 → { ok, content }. 권한 부족/에러는 ok:false 문자열로.
|
|
326
344
|
function runTool(name, args, ctx) {
|
|
327
345
|
const tool = BY_NAME[name];
|
|
328
346
|
if (!tool) return { ok: false, content: `unknown tool: ${name}` };
|
|
347
|
+
const ask = toolAskFor(tool, args);
|
|
348
|
+
let ruled = null;
|
|
349
|
+
if (ctx && ctx.db) {
|
|
350
|
+
try {
|
|
351
|
+
const permissions = require("./agentlas-permissions.cjs");
|
|
352
|
+
const verdict = permissions.decideCapability(ctx.db, {
|
|
353
|
+
...ask,
|
|
354
|
+
permission: ctx.permission,
|
|
355
|
+
agentId: ctx.agentId,
|
|
356
|
+
chatId: ctx.chatId,
|
|
357
|
+
});
|
|
358
|
+
ruled = verdict.ruled;
|
|
359
|
+
if (ruled === "deny") {
|
|
360
|
+
return {
|
|
361
|
+
ok: false,
|
|
362
|
+
content:
|
|
363
|
+
`capability denied: '${name}'${ask.detail ? ` (${ask.detail})` : ""} is permanently denied by a shared ` +
|
|
364
|
+
"capability rule (Desktop/Terminal share capability_grants). Remove that rule to allow it.",
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
// 규칙을 못 읽는 것이 허용이 되면 안 되고, 실행을 죽여서도 안 된다 — 기존 등급 게이트로 간다.
|
|
369
|
+
ruled = null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
329
372
|
const rank = PERM_RANK[ctx.permission] ?? 0;
|
|
330
|
-
if ((PERM_RANK[tool.minPerm] ?? 0) > rank) {
|
|
373
|
+
if (ruled !== "allow" && (PERM_RANK[tool.minPerm] ?? 0) > rank) {
|
|
331
374
|
return {
|
|
332
375
|
ok: false,
|
|
333
376
|
content: `permission denied: '${name}' requires '${tool.minPerm}' but current is '${ctx.permission}'. Ask the user to run /permission ${tool.minPerm}.`,
|
|
@@ -355,4 +398,4 @@ function openaiTools(permission) {
|
|
|
355
398
|
}));
|
|
356
399
|
}
|
|
357
400
|
|
|
358
|
-
module.exports = { TOOLS, BY_NAME, allowedTools, runTool, anthropicTools, openaiTools, PERM_RANK };
|
|
401
|
+
module.exports = { TOOLS, BY_NAME, allowedTools, runTool, anthropicTools, openaiTools, PERM_RANK, toolAskFor };
|
package/engine/agentlas.cjs
CHANGED
|
@@ -213,8 +213,8 @@ function main() {
|
|
|
213
213
|
* 에서 ls -la 실행 실증). 가장 가까운 명령을 제안하고 정직하게 멈춘다.
|
|
214
214
|
* 진짜 한 단어 작업은 따옴표+run -p 로 그대로 실행된다.
|
|
215
215
|
*/
|
|
216
|
-
if (
|
|
217
|
-
const token =
|
|
216
|
+
if (commandArgv.length === 1 && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(commandArgv[0])) {
|
|
217
|
+
const token = commandArgv[0];
|
|
218
218
|
const names = Object.keys(commands.COMMANDS)
|
|
219
219
|
.concat(Object.keys(commands.COMMAND_ALIASES || {}))
|
|
220
220
|
.concat(commands.NOT_YET_PORTED || []);
|
|
@@ -228,7 +228,10 @@ function main() {
|
|
|
228
228
|
: `See: agentlas help · to run it as a task: agentlas run -p "${token}"`);
|
|
229
229
|
process.exit(1);
|
|
230
230
|
}
|
|
231
|
-
|
|
231
|
+
// 전역 출력 플래그는 이미 parseOutputFlags 가 소비했다. 원래 normalized argv 를
|
|
232
|
+
// 다시 넘기면 `agentlas --json "do work"`가 모델에게 "--json do work"라고
|
|
233
|
+
// 지시하는 꼴이 된다. 실제 명령/작업 토큰만 실행 경로로 보낸다.
|
|
234
|
+
code = commands.COMMANDS.run().run(ctx, commandArgv);
|
|
232
235
|
}
|
|
233
236
|
|
|
234
237
|
Promise.resolve(code).then(
|