@lasso-ai/cli 2.1.14 → 2.1.15

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.
@@ -101,9 +101,7 @@ class LocalRuntime {
101
101
  try {
102
102
  // Fresh capture per run — corrective re-runs capture the post-apply state.
103
103
  const guard = await index_js_1.WorkspaceGuard.capture(projectPath);
104
- record.backend =
105
- record.backend ||
106
- (await (0, index_js_1.createLocalAgentBackend)({ ...record.backendConfig, type: "local_agent", projectPath }));
104
+ record.backend = record.backend || (await (0, index_js_1.createLocalAgentBackend)({ ...record.backendConfig, type: "local_agent", projectPath }));
107
105
  const previousErrors = (record.errors || []);
108
106
  const externalSessionId = typeof record.metadata.backendSessionId === "string" ? record.metadata.backendSessionId : undefined;
109
107
  const runner = new index_js_1.LocalSessionRunner({
@@ -173,8 +171,7 @@ class LocalRuntime {
173
171
  pendingChanges: outcome.changes,
174
172
  binaryChanges: outcome.binaryPaths,
175
173
  };
176
- record.status =
177
- outcome.status === "waiting" ? "waiting" : outcome.status === "completed" ? "completed" : "failed";
174
+ record.status = outcome.status === "waiting" ? "waiting" : outcome.status === "completed" ? "completed" : "failed";
178
175
  record.phase = "complete";
179
176
  record.updatedAt = new Date();
180
177
  }
@@ -257,9 +254,7 @@ class LocalRuntime {
257
254
  previousErrors: [],
258
255
  currentPhase: "execute",
259
256
  constraints: {},
260
- sessionRef: typeof record.metadata.backendSessionId === "string"
261
- ? { id: record.metadata.backendSessionId }
262
- : undefined,
257
+ sessionRef: typeof record.metadata.backendSessionId === "string" ? { id: record.metadata.backendSessionId } : undefined,
263
258
  });
264
259
  }
265
260
  record.backend = nextBackend;
@@ -29,7 +29,10 @@ exports.LASSO_CONFIG_FILE = "lasso.config.json";
29
29
  const PROJECT_STATE_FILE = ".lasso-project.json";
30
30
  function readEnvFile(filename, cwd) {
31
31
  try {
32
- return node_fs_1.default.readFileSync(node_path_1.default.join(cwd, filename), "utf8").split(/\r?\n/).reduce((values, line) => {
32
+ return node_fs_1.default
33
+ .readFileSync(node_path_1.default.join(cwd, filename), "utf8")
34
+ .split(/\r?\n/)
35
+ .reduce((values, line) => {
33
36
  const match = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
34
37
  if (match) {
35
38
  let value = match[2].trim();
@@ -202,7 +205,7 @@ async function initProject(cwd, fileEnv, forcedDomain) {
202
205
  }
203
206
  const registry = (0, registry_1.loadRegistry)();
204
207
  const existingHost = (0, registry_1.findByDirectory)(registry, cwd);
205
- const domain = forcedDomain || (existingConfig?.domain ? existingConfig.domain : (existingHost ? existingHost.domain : (0, registry_1.generateUniqueDomain)(cwd, registry)));
208
+ const domain = forcedDomain || (existingConfig?.domain ? existingConfig.domain : existingHost ? existingHost.domain : (0, registry_1.generateUniqueDomain)(cwd, registry));
206
209
  payload.domain = domain;
207
210
  let apiKey = resolveLassoApiKey(fileEnv);
208
211
  if (!apiKey)
@@ -322,11 +325,7 @@ async function resolveCollabSession(cwd, fileEnv, projectId) {
322
325
  }
323
326
  if (!response || !response.ok) {
324
327
  const status = response?.status;
325
- const reason = status === 401
326
- ? "Your Lasso API key is invalid or inactive."
327
- : status === 403
328
- ? "This project belongs to another workspace."
329
- : `The server rejected the project session (${status || "unreachable"}).`;
328
+ const reason = status === 401 ? "Your Lasso API key is invalid or inactive." : status === 403 ? "This project belongs to another workspace." : `The server rejected the project session (${status || "unreachable"}).`;
330
329
  console.warn(chalk_1.default.yellow("!") + ` Realtime collaboration unavailable: ${reason}`);
331
330
  return config;
332
331
  }
@@ -341,10 +340,7 @@ async function resolveCollabSession(cwd, fileEnv, projectId) {
341
340
  config.registeredAt = new Date().toISOString();
342
341
  config.plan = body.project?.plan || "free";
343
342
  config.configuredProviders = body.project?.configuredProviders || [];
344
- console.log(chalk_1.default.green("✓") +
345
- " Project session opened " +
346
- chalk_1.default.cyan(config.name || projectId) +
347
- `${config.workspaceId ? ` · workspace ${config.workspaceId}` : ""}`);
343
+ console.log(chalk_1.default.green("✓") + " Project session opened " + chalk_1.default.cyan(config.name || projectId) + `${config.workspaceId ? ` · workspace ${config.workspaceId}` : ""}`);
348
344
  return config;
349
345
  }
350
346
  catch (error) {
@@ -425,7 +421,7 @@ async function fetchModels(fileEnv) {
425
421
  if (!response.ok) {
426
422
  return { anthropic: [], openai: [], google: [], nvidia: [] };
427
423
  }
428
- const body = await response.json();
424
+ const body = (await response.json());
429
425
  return body;
430
426
  }
431
427
  catch {
@@ -316,6 +316,8 @@ export declare class RuntimeClient {
316
316
  providerApiKey?: string;
317
317
  browserContextProvider?: BrowserContextProvider;
318
318
  }): Promise<void>;
319
+ /** Parse and dispatch one complete SSE frame. */
320
+ private processSseFrame;
319
321
  /** Execute a tool_call request locally against the real project. */
320
322
  private executeToolCallLocally;
321
323
  /** Post a tool result back to the server (resolves its pending request). */
@@ -36,7 +36,7 @@ class RuntimeClient {
36
36
  method: "POST",
37
37
  headers: {
38
38
  "Content-Type": "application/json",
39
- ...(this.apiKey && { "Authorization": `Bearer ${this.apiKey}` }),
39
+ ...(this.apiKey && { Authorization: `Bearer ${this.apiKey}` }),
40
40
  },
41
41
  body: JSON.stringify(input),
42
42
  });
@@ -53,13 +53,13 @@ class RuntimeClient {
53
53
  async getSession(sessionId) {
54
54
  const response = await fetch(`${this.serverUrl}/api/v1/agent-runtime/sessions/${sessionId}`, {
55
55
  headers: {
56
- ...(this.apiKey && { "Authorization": `Bearer ${this.apiKey}` }),
56
+ ...(this.apiKey && { Authorization: `Bearer ${this.apiKey}` }),
57
57
  },
58
58
  });
59
59
  if (!response.ok) {
60
60
  throw new Error(`Failed to get session: ${response.statusText}`);
61
61
  }
62
- const data = await response.json();
62
+ const data = (await response.json());
63
63
  const local = this.localReviewCache.get(sessionId);
64
64
  if (local && local.length > 0) {
65
65
  data.metadata = { ...(data.metadata || {}), pendingChanges: local };
@@ -79,20 +79,16 @@ class RuntimeClient {
79
79
  */
80
80
  async runSession(sessionId, projectPath, onEvent, signal, opts) {
81
81
  // CLI-side snapshot: nothing the agent does is left on disk before review.
82
- let guard = null;
83
- try {
84
- guard = await index_js_1.WorkspaceGuard.capture(projectPath);
85
- }
86
- catch (error) {
87
- console.error("WorkspaceGuard capture failed:", error);
88
- }
82
+ // Review safety is mandatory. Never allow an agent to run if the
83
+ // pre-run workspace capture could not be established.
84
+ const guard = await index_js_1.WorkspaceGuard.capture(projectPath);
89
85
  const executor = new index_js_1.ActionExecutor(projectPath, opts?.browserContextProvider);
90
86
  const verifier = new index_js_1.VerificationEngine(projectPath);
91
87
  // Idempotent: called on agent_completed AND on stream failure, so the
92
88
  // diff is always cached before the safety-net restore wipes the edits.
93
89
  let finalized = false;
94
90
  const finalizeGuard = async () => {
95
- if (!guard || finalized)
91
+ if (finalized)
96
92
  return;
97
93
  finalized = true;
98
94
  try {
@@ -100,8 +96,8 @@ class RuntimeClient {
100
96
  const applyable = changes.filter((c) => !c.binary);
101
97
  const pending = applyable.map((change) => ({
102
98
  filePath: change.filePath,
103
- oldString: change.operation === "created" ? "" : change.oldContent ?? "",
104
- newString: change.operation === "deleted" ? "" : change.newContent ?? "",
99
+ oldString: change.operation === "created" ? "" : (change.oldContent ?? ""),
100
+ newString: change.operation === "deleted" ? "" : (change.newContent ?? ""),
105
101
  operation: change.operation,
106
102
  }));
107
103
  this.localReviewCache.set(sessionId, pending);
@@ -125,59 +121,46 @@ class RuntimeClient {
125
121
  const result = await this.executeToolCallLocally(request, executor, verifier);
126
122
  await this.postToolResult(sessionId, result);
127
123
  };
128
- const response = await fetch(`${this.serverUrl}/api/v1/agent-runtime/sessions/${sessionId}/run`, {
129
- method: "POST",
130
- headers: {
131
- "Content-Type": "application/json",
132
- ...(this.apiKey && { "Authorization": `Bearer ${this.apiKey}` }),
133
- },
134
- // providerApiKey is the user's own model-provider key (BYOK): the
135
- // server calls the hosted model with it and never stores it.
136
- body: JSON.stringify({ projectPath, apiKey: opts?.providerApiKey }),
137
- signal,
138
- });
139
- if (!response.ok) {
140
- throw new Error(`Failed to run session: ${response.statusText}`);
141
- }
142
- const reader = response.body?.getReader();
143
- if (!reader) {
144
- throw new Error("No response body");
145
- }
146
- const decoder = new TextDecoder();
124
+ let reader;
147
125
  try {
126
+ const response = await fetch(`${this.serverUrl}/api/v1/agent-runtime/sessions/${sessionId}/run`, {
127
+ method: "POST",
128
+ headers: {
129
+ "Content-Type": "application/json",
130
+ ...(this.apiKey && { Authorization: `Bearer ${this.apiKey}` }),
131
+ },
132
+ // providerApiKey is the user's own model-provider key (BYOK): the
133
+ // server calls the hosted model with it and never stores it.
134
+ body: JSON.stringify({ projectPath, apiKey: opts?.providerApiKey }),
135
+ signal,
136
+ });
137
+ if (!response.ok) {
138
+ throw new Error(`Failed to run session: ${response.statusText}`);
139
+ }
140
+ reader = response.body?.getReader();
141
+ if (!reader) {
142
+ throw new Error("No response body");
143
+ }
144
+ const decoder = new TextDecoder();
145
+ let buffer = "";
148
146
  while (true) {
149
147
  const { done, value } = await reader.read();
150
148
  if (done)
151
149
  break;
152
- const chunk = decoder.decode(value);
153
- const lines = chunk.split("\n");
154
- for (const line of lines) {
155
- if (!line.trim() || !line.startsWith("data: "))
156
- continue;
157
- const data = line.slice(6);
158
- if (data === "[DONE]")
159
- continue;
160
- try {
161
- const event = JSON.parse(data);
162
- if (event.type === "tool_call") {
163
- // Execute locally and answer the server — never forwarded raw.
164
- await handleToolCall(event.data);
165
- continue;
166
- }
167
- if (event.type === "agent_completed") {
168
- // Diff + rollback BEFORE the review flow reads the session.
169
- await finalizeGuard();
170
- }
171
- await onEvent(event);
172
- }
173
- catch (error) {
174
- console.error("Failed to parse SSE event:", error);
175
- }
150
+ buffer += decoder.decode(value, { stream: true });
151
+ const frames = buffer.split(/\r?\n\r?\n/);
152
+ buffer = frames.pop() || "";
153
+ for (const frame of frames) {
154
+ await this.processSseFrame(frame, handleToolCall, finalizeGuard, onEvent);
176
155
  }
177
156
  }
157
+ buffer += decoder.decode();
158
+ if (buffer.trim()) {
159
+ await this.processSseFrame(buffer, handleToolCall, finalizeGuard, onEvent);
160
+ }
178
161
  }
179
162
  finally {
180
- reader.releaseLock();
163
+ reader?.releaseLock();
181
164
  // Stream died before agent_completed (network cut, server crash, model
182
165
  // error surfaced as an error event): cache the diff first so the user's
183
166
  // changes are recoverable, then restore the workspace. On the normal
@@ -185,6 +168,29 @@ class RuntimeClient {
185
168
  await finalizeGuard();
186
169
  }
187
170
  }
171
+ /** Parse and dispatch one complete SSE frame. */
172
+ async processSseFrame(frame, handleToolCall, finalizeGuard, onEvent) {
173
+ const data = frame
174
+ .split(/\r?\n/)
175
+ .filter((line) => line.startsWith("data:"))
176
+ .map((line) => line.slice(5).trimStart())
177
+ .join("\n");
178
+ if (!data || data === "[DONE]")
179
+ return;
180
+ try {
181
+ const event = JSON.parse(data);
182
+ if (event.type === "tool_call") {
183
+ await handleToolCall(event.data);
184
+ return;
185
+ }
186
+ if (event.type === "agent_completed")
187
+ await finalizeGuard();
188
+ await onEvent(event);
189
+ }
190
+ catch (error) {
191
+ console.error("Failed to process SSE event:", error);
192
+ }
193
+ }
188
194
  /** Execute a tool_call request locally against the real project. */
189
195
  async executeToolCallLocally(request, executor, verifier) {
190
196
  try {
@@ -207,7 +213,8 @@ class RuntimeClient {
207
213
  output: result.output,
208
214
  error: result.error,
209
215
  observations: result.observation
210
- ? [{
216
+ ? [
217
+ {
211
218
  id: node_crypto_1.default.randomUUID(),
212
219
  type: result.observation.type,
213
220
  summary: result.observation.summary,
@@ -215,7 +222,8 @@ class RuntimeClient {
215
222
  timestamp: Date.now(),
216
223
  phase: "execute",
217
224
  actionId: request.action.id,
218
- }]
225
+ },
226
+ ]
219
227
  : undefined,
220
228
  };
221
229
  }
@@ -234,7 +242,7 @@ class RuntimeClient {
234
242
  method: "POST",
235
243
  headers: {
236
244
  "Content-Type": "application/json",
237
- ...(this.apiKey && { "Authorization": `Bearer ${this.apiKey}` }),
245
+ ...(this.apiKey && { Authorization: `Bearer ${this.apiKey}` }),
238
246
  },
239
247
  body: JSON.stringify(result),
240
248
  });
@@ -253,7 +261,7 @@ class RuntimeClient {
253
261
  const response = await fetch(`${this.serverUrl}/api/v1/agent-runtime/sessions/${sessionId}/cancel`, {
254
262
  method: "POST",
255
263
  headers: {
256
- ...(this.apiKey && { "Authorization": `Bearer ${this.apiKey}` }),
264
+ ...(this.apiKey && { Authorization: `Bearer ${this.apiKey}` }),
257
265
  },
258
266
  });
259
267
  if (!response.ok) {
@@ -268,7 +276,7 @@ class RuntimeClient {
268
276
  method: "POST",
269
277
  headers: {
270
278
  "Content-Type": "application/json",
271
- ...(this.apiKey && { "Authorization": `Bearer ${this.apiKey}` }),
279
+ ...(this.apiKey && { Authorization: `Bearer ${this.apiKey}` }),
272
280
  },
273
281
  body: JSON.stringify({ ...model, apiKey, backendConfig }),
274
282
  });
@@ -295,7 +303,7 @@ class RuntimeClient {
295
303
  method: "POST",
296
304
  headers: {
297
305
  "Content-Type": "application/json",
298
- ...(this.apiKey && { "Authorization": `Bearer ${this.apiKey}` }),
306
+ ...(this.apiKey && { Authorization: `Bearer ${this.apiKey}` }),
299
307
  },
300
308
  body: JSON.stringify({ projectPath, localResults: { passed, errors, observations } }),
301
309
  });
@@ -317,7 +325,7 @@ class RuntimeClient {
317
325
  const url = `${this.serverUrl}/api/v1/agent-runtime/backends${projectPath ? `?projectPath=${encodeURIComponent(projectPath)}` : ""}`;
318
326
  const response = await fetch(url, {
319
327
  headers: {
320
- ...(this.apiKey && { "Authorization": `Bearer ${this.apiKey}` }),
328
+ ...(this.apiKey && { Authorization: `Bearer ${this.apiKey}` }),
321
329
  },
322
330
  });
323
331
  if (!response.ok) {
@@ -332,7 +340,7 @@ class RuntimeClient {
332
340
  const response = await fetch(`${this.serverUrl}/api/v1/agent-runtime/sessions/${sessionId}`, {
333
341
  method: "DELETE",
334
342
  headers: {
335
- ...(this.apiKey && { "Authorization": `Bearer ${this.apiKey}` }),
343
+ ...(this.apiKey && { Authorization: `Bearer ${this.apiKey}` }),
336
344
  },
337
345
  });
338
346
  if (!response.ok) {
@@ -26,7 +26,7 @@ function lassoInjectPlugin(bridgePort = 3056, cwd) {
26
26
  res.setHeader("Content-Type", "application/javascript");
27
27
  res.end((0, node_fs_1.readFileSync)(bundlePath, "utf-8"));
28
28
  });
29
- }
29
+ },
30
30
  };
31
31
  }
32
32
  async function startViteServer(cwd) {
@@ -20,6 +20,16 @@ const node_crypto_1 = __importDefault(require("node:crypto"));
20
20
  const SNAPSHOTS_DIR = ".lasso/snapshots";
21
21
  const TIMELINE_FILE = ".lasso/timeline.json";
22
22
  const MAX_SNAPSHOTS = 50;
23
+ function resolveSnapshotPath(cwd, filePath) {
24
+ if (!filePath || node_path_1.default.isAbsolute(filePath))
25
+ throw new Error(`Invalid snapshot path: ${filePath}`);
26
+ const root = node_path_1.default.resolve(cwd);
27
+ const fullPath = node_path_1.default.resolve(root, filePath);
28
+ if (fullPath !== root && !fullPath.startsWith(`${root}${node_path_1.default.sep}`)) {
29
+ throw new Error(`Snapshot path escapes workspace: ${filePath}`);
30
+ }
31
+ return fullPath;
32
+ }
23
33
  // Ensure snapshots directory exists
24
34
  function ensureSnapshotsDir(cwd) {
25
35
  const dir = node_path_1.default.join(cwd, SNAPSHOTS_DIR);
@@ -66,7 +76,7 @@ function createSnapshot(cwd, taskId, summary, filePaths, parentSnapshotId, sessi
66
76
  const files = [];
67
77
  const timestamp = new Date().toISOString();
68
78
  for (const filePath of filePaths) {
69
- const fullPath = node_path_1.default.resolve(cwd, filePath);
79
+ const fullPath = resolveSnapshotPath(cwd, filePath);
70
80
  let content = null;
71
81
  let hash = "";
72
82
  try {
@@ -118,7 +128,7 @@ function restoreSnapshot(cwd, snapshotId) {
118
128
  }
119
129
  try {
120
130
  for (const file of snapshot.files) {
121
- const fullPath = node_path_1.default.resolve(cwd, file.filePath);
131
+ const fullPath = resolveSnapshotPath(cwd, file.filePath);
122
132
  if (file.content === null) {
123
133
  // File didn't exist, delete if it exists now
124
134
  if (node_fs_1.default.existsSync(fullPath)) {
@@ -143,7 +153,7 @@ function restoreSnapshot(cwd, snapshotId) {
143
153
  function detectConflicts(cwd, expectedFiles) {
144
154
  const conflicts = [];
145
155
  for (const expected of expectedFiles) {
146
- const fullPath = node_path_1.default.resolve(cwd, expected.filePath);
156
+ const fullPath = resolveSnapshotPath(cwd, expected.filePath);
147
157
  const expectedHash = expected.hash || (expected.content ? hashContent(expected.content) : "");
148
158
  try {
149
159
  if (!node_fs_1.default.existsSync(fullPath)) {