atris 3.5.0 → 3.11.0

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.
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const readline = require('readline');
4
4
  const { getLogPath, ensureLogDirectory, createLogFile } = require('../lib/journal');
5
5
  const { loadConfig } = require('../utils/config');
6
- const { loadCredentials } = require('../utils/auth');
6
+ const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
7
7
  const { apiRequestJson } = require('../utils/api');
8
8
  const { planAtris, doAtris, reviewAtris } = require('./workflow');
9
9
 
@@ -58,8 +58,9 @@ async function brainstormAtris() {
58
58
  // Optional: fetch journal context from backend (for hints only)
59
59
  let remoteJournalContext = '';
60
60
  const config = loadConfig();
61
- const credentials = loadCredentials();
62
-
61
+ const ensured1 = await ensureValidCredentials(apiRequestJson);
62
+ const credentials = ensured1.error ? null : ensured1.credentials;
63
+
63
64
  if (useCloudJournal && config.agent_id && credentials && credentials.token) {
64
65
  try {
65
66
  console.log('📖 Fetching latest journal entry from AtrisOS...');
@@ -623,8 +624,9 @@ async function autopilotAtris(initialIdea = null) {
623
624
  // Try to fetch latest journal entry from backend
624
625
  let journalContext = '';
625
626
  const config = loadConfig();
626
- const credentials = loadCredentials();
627
-
627
+ const ensured2 = await ensureValidCredentials(apiRequestJson);
628
+ const credentials = ensured2.error ? null : ensured2.credentials;
629
+
628
630
  if (config.agent_id && credentials && credentials.token) {
629
631
  try {
630
632
  const journalResult = await apiRequestJson(`/agents/${config.agent_id}/journal/today`, {
package/commands/clean.js CHANGED
@@ -197,6 +197,15 @@ function healBrokenMapRefs(cwd, atrisDir, dryRun = false) {
197
197
  // Required delimiter [(,[,—,-] prevents bleeding into adjacent refs on same line
198
198
  const refPattern = /(`?)([a-zA-Z0-9_\-./\\]+\.(js|ts|py|go|rs|rb|java|c|cpp|h|hpp|md|json|yaml|yml)):(\d+)(?:-(\d+))?(`?)([^\S\n]*[\(\[—\-][^\S\n]*([^)\]\n]+))?/g;
199
199
 
200
+ // Function Inventory form: `symbolName()` → `file:line[-line]`
201
+ // Pre-scan to build a (file:line) → symbol map so refs with the symbol BEFORE them still verify.
202
+ const preRefSymbols = {};
203
+ const preRefPattern = /`([a-zA-Z_][a-zA-Z0-9_]*)\s*\(?\s*\)?`\s*(?:→|->)\s*`([a-zA-Z0-9_\-./\\]+\.(?:js|ts|py|go|rs|rb|java|c|cpp|h|hpp|md|json|yaml|yml)):(\d+)(?:-\d+)?`/g;
204
+ let preMatch;
205
+ while ((preMatch = preRefPattern.exec(mapContent)) !== null) {
206
+ preRefSymbols[`${preMatch[2]}:${preMatch[3]}`] = preMatch[1];
207
+ }
208
+
200
209
  // Cache file reads
201
210
  const fileCache = {};
202
211
  function readFileCached(filePath) {
@@ -228,7 +237,8 @@ function healBrokenMapRefs(cwd, atrisDir, dryRun = false) {
228
237
  continue;
229
238
  }
230
239
 
231
- const symbol = extractSymbol(contextPart);
240
+ let symbol = extractSymbol(contextPart);
241
+ if (!symbol) symbol = preRefSymbols[`${filePath}:${startLine}`] || null;
232
242
 
233
243
  // Check if reference is still accurate
234
244
  const outOfBounds = startLine > file.lines.length || (endLine && endLine > file.lines.length);
@@ -445,9 +455,15 @@ function checkPageStaleness(cwd, filePath) {
445
455
  .map(line => line.replace(/^\s+-\s+/, '').trim())
446
456
  .filter(Boolean);
447
457
 
448
- // Check each source's mtime against last_compiled
458
+ // Check each source's mtime against last_compiled.
459
+ // Source entries can include a line range and/or a parenthesized annotation,
460
+ // e.g. "bin/atris.js:199-340 (showHelp function)" — strip both before stat.
449
461
  for (const source of sources) {
450
- const sourcePath = path.isAbsolute(source) ? source : path.join(cwd, source);
462
+ const filePart = source
463
+ .replace(/\s*\([^)]*\)\s*$/, '') // drop trailing "(annotation)"
464
+ .replace(/:\d+(-\d+)?$/, '') // drop trailing ":N" or ":N-M"
465
+ .trim();
466
+ const sourcePath = path.isAbsolute(filePart) ? filePart : path.join(cwd, filePart);
451
467
  try {
452
468
  const stat = fs.statSync(sourcePath);
453
469
  if (stat.mtime > compiledDate) {
@@ -693,6 +693,36 @@ async function resolveBusinessContext(token) {
693
693
  return null;
694
694
  }
695
695
 
696
+ async function resolveBusinessContextBySlug(token, slug) {
697
+ if (!slug) return null;
698
+
699
+ const businesses = loadBusinesses();
700
+ const list = await apiRequestJson('/business/', { method: 'GET', token });
701
+ if (list.ok) {
702
+ const match = (list.data || []).find(
703
+ (b) => b.slug === slug || (b.name || '').toLowerCase() === slug.toLowerCase()
704
+ );
705
+ if (match) {
706
+ businesses[match.slug || slug] = {
707
+ business_id: match.id,
708
+ workspace_id: match.workspace_id,
709
+ name: match.name,
710
+ slug: match.slug,
711
+ added_at: new Date().toISOString(),
712
+ };
713
+ saveBusinesses(businesses);
714
+ return {
715
+ slug: match.slug,
716
+ businessId: match.id,
717
+ workspaceId: match.workspace_id,
718
+ businessName: match.name || match.slug,
719
+ };
720
+ }
721
+ }
722
+
723
+ return null;
724
+ }
725
+
696
726
  function shellQuote(value) {
697
727
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
698
728
  }
@@ -2288,6 +2318,7 @@ async function runComputer() {
2288
2318
  console.log(' local-byo Open LOCAL BYO Claude mode; Anthropic tokens, no cloud audit');
2289
2319
  console.log(' --cloud Open CLOUD workspace mode in the bound business workspace');
2290
2320
  console.log(' cloud Open CLOUD workspace mode in the bound business workspace');
2321
+ console.log(' codeops Open Atris CodeOps cloud workspace if your account has access');
2291
2322
  console.log(' --worker Cloud worker override: claude | openai');
2292
2323
  console.log(' --model Cloud model override');
2293
2324
  console.log(' claude|codex Legacy local console backends');
@@ -2319,6 +2350,7 @@ async function runComputer() {
2319
2350
  console.log(' atris computer --cloud');
2320
2351
  console.log(' atris computer --cloud --worker openai --model gpt-5.4');
2321
2352
  console.log(' atris computer cloud');
2353
+ console.log(' atris computer codeops');
2322
2354
  console.log(' atris computer status');
2323
2355
  console.log(' atris computer wake');
2324
2356
  console.log(' atris computer run "ls -la /workspace"');
@@ -2331,6 +2363,17 @@ async function runComputer() {
2331
2363
  const token = getToken();
2332
2364
  const ctx = await resolveBusinessContext(token);
2333
2365
 
2366
+ if (sub === 'codeops') {
2367
+ const codeopsCtx = await resolveBusinessContextBySlug(token, 'atris-codeops');
2368
+ if (!codeopsCtx) {
2369
+ console.error('Atris CodeOps is not available for this account.');
2370
+ console.error('Ask an Atris CodeOps admin to add you to the atris-codeops business.');
2371
+ return;
2372
+ }
2373
+ await computerChat(token, codeopsCtx, { worker: 'claude', ...cloudOptions });
2374
+ return;
2375
+ }
2376
+
2334
2377
  if (sub === '--cloud' || sub === 'cloud') {
2335
2378
  await computerChat(token, ctx, cloudOptions);
2336
2379
  return;
@@ -10,20 +10,25 @@
10
10
  * atris slack channels - List Slack channels
11
11
  */
12
12
 
13
- const { loadCredentials } = require('../utils/auth');
13
+ const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
14
14
  const { apiRequestJson } = require('../utils/api');
15
15
 
16
- function getAuth() {
17
- const creds = loadCredentials();
16
+ async function getAuth() {
17
+ const ensured = await ensureValidCredentials(apiRequestJson);
18
+ const creds = ensured.error ? null : ensured.credentials;
18
19
  if (!creds || !creds.token) {
19
- console.error('Not logged in. Run: atris login');
20
+ if (ensured.error && ensured.error !== 'not_logged_in') {
21
+ console.error(`Authentication failed: ${ensured.detail || ensured.error}. Run: atris login`);
22
+ } else {
23
+ console.error('Not logged in. Run: atris login');
24
+ }
20
25
  process.exit(1);
21
26
  }
22
27
  return { token: creds.token, email: creds.email || 'unknown' };
23
28
  }
24
29
 
25
30
  async function getAuthToken() {
26
- return getAuth().token;
31
+ return (await getAuth()).token;
27
32
  }
28
33
 
29
34
  // ============================================================================
@@ -31,7 +36,7 @@ async function getAuthToken() {
31
36
  // ============================================================================
32
37
 
33
38
  async function gmailInbox(options = {}) {
34
- const { token, email } = getAuth();
39
+ const { token, email } = await getAuth();
35
40
  const limit = options.limit || 10;
36
41
 
37
42
  console.log('📬 Fetching inbox...\n');
@@ -129,7 +134,7 @@ async function gmailCommand(subcommand, ...args) {
129
134
  // ============================================================================
130
135
 
131
136
  async function calendarToday() {
132
- const { token, email } = getAuth();
137
+ const { token, email } = await getAuth();
133
138
 
134
139
  console.log('📅 Today\'s events:\n');
135
140
 
@@ -224,7 +229,7 @@ async function twitterPost(text) {
224
229
 
225
230
  if (!result.ok) {
226
231
  if (result.status === 400 || result.status === 401) {
227
- const { email } = getAuth();
232
+ const { email } = await getAuth();
228
233
  console.error(`Twitter not connected for ${email}.`);
229
234
  console.error('Connect at: https://atris.ai/dashboard/settings');
230
235
  console.error(`Make sure you're signed in as ${email} on the web.`);
@@ -268,7 +273,7 @@ async function slackChannels() {
268
273
 
269
274
  if (!result.ok) {
270
275
  if (result.status === 400 || result.status === 401) {
271
- const { email } = getAuth();
276
+ const { email } = await getAuth();
272
277
  console.error(`Slack not connected for ${email}.`);
273
278
  console.error('Connect at: https://atris.ai/dashboard/settings');
274
279
  console.error(`Make sure you're signed in as ${email} on the web.`);
package/commands/pull.js CHANGED
@@ -10,6 +10,7 @@ const { loadBusinesses } = require('./business');
10
10
  const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare } = require('../lib/manifest');
11
11
  const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
12
12
  const { emitSyncEvent, startTimer } = require('../lib/sync-telemetry');
13
+ const { resolveSafeOutputDir } = require('../lib/workspace-safety');
13
14
 
14
15
  function pruneEmptyParentDirs(filePath, stopDir) {
15
16
  let current = path.dirname(filePath);
@@ -163,22 +164,44 @@ async function pullBusiness(slug) {
163
164
  }
164
165
  const timeoutMs = timeoutSec * 1000;
165
166
 
166
- // Determine output directory
167
+ // Determine output directory.
168
+ //
169
+ // We only reuse the current working directory when we can prove it's the
170
+ // correct workspace for THIS business — i.e. it has a `.atris/business.json`
171
+ // whose slug matches `slug`. Any other signal (a stray `atris/` folder, a
172
+ // business.json for a different business, etc.) is NOT enough: pulling
173
+ // atris-labs-1 on top of a pallet workspace would mix two businesses into
174
+ // one directory and write pallet's manifest over atris-labs-1's (or vice
175
+ // versa), causing the next sync to do strange things.
176
+ //
177
+ // Fallback: create a fresh ./{slug}/ subdir. Always safe — even if cwd is
178
+ // $HOME or /tmp, we land in a dedicated subfolder.
167
179
  const intoIdx = process.argv.indexOf('--into');
168
180
  let outputDir;
169
181
  if (intoIdx !== -1 && process.argv[intoIdx + 1]) {
170
182
  outputDir = path.resolve(process.argv[intoIdx + 1]);
171
- } else if (fs.existsSync(path.join(process.cwd(), '.atris', 'business.json'))) {
172
- // Inside a pulled workspace — pull into current dir (no nesting)
173
- outputDir = process.cwd();
174
- } else if (fs.existsSync(path.join(process.cwd(), 'atris')) && fs.statSync(path.join(process.cwd(), 'atris')).isDirectory()) {
175
- // Inside an atris init'd workspace — merge business into current dir
176
- outputDir = process.cwd();
177
183
  } else {
178
- // Default: ./{slug}/ in current directory
179
- outputDir = path.join(process.cwd(), slug);
184
+ const bizFile = path.join(process.cwd(), '.atris', 'business.json');
185
+ let cwdMatchesSlug = false;
186
+ if (fs.existsSync(bizFile)) {
187
+ try {
188
+ const biz = JSON.parse(fs.readFileSync(bizFile, 'utf8'));
189
+ const cwdSlug = biz.slug || biz.name;
190
+ cwdMatchesSlug = cwdSlug === slug;
191
+ } catch {
192
+ // Corrupt business.json — ignore, treat as no match.
193
+ }
194
+ }
195
+ outputDir = cwdMatchesSlug ? process.cwd() : path.join(process.cwd(), slug);
180
196
  }
181
197
 
198
+ // Auto-relocate if the resolved outputDir is dangerous ($HOME, /, /Users,
199
+ // system dirs, reserved home folders). Non-technical users shouldn't have
200
+ // to know about mkdir/cd — atris picks a safe subdir and tells them.
201
+ // Paired with the manifest-scoped sweep below, this makes it impossible
202
+ // for a stray cwd to cause atris to delete user files.
203
+ ({ dir: outputDir } = resolveSafeOutputDir(outputDir, { slug, op: 'pull into' }));
204
+
182
205
  // Resolve business ID — always refresh from API to avoid stale workspace_id
183
206
  let businessId, workspaceId, businessName, resolvedSlug;
184
207
  const businesses = loadBusinesses();
@@ -564,14 +587,27 @@ async function pullBusiness(slug) {
564
587
  }
565
588
  if (force) {
566
589
  const remotePathSet = new Set(Object.keys(remoteFiles));
590
+
591
+ // SAFETY: only sweep files atris previously wrote (recorded in the
592
+ // manifest). Local-only files that were never on cloud are the user's
593
+ // own — atris didn't put them there, atris doesn't delete them. This
594
+ // makes it impossible for a stray cwd (e.g. $HOME) to cause atris to
595
+ // wipe ~/Library, ~/Downloads, etc.
596
+ //
597
+ // If there's no prior manifest (first pull), there's nothing to sweep —
598
+ // threeWayCompare already handled newRemote/conflicts/newLocal, and we
599
+ // have no basis for claiming ownership of any local-only file.
600
+ const managedPaths = manifest && manifest.files ? Object.keys(manifest.files) : [];
601
+ const sweepCandidates = managedPaths.filter(isInScope).filter((p) => localFiles[p]);
567
602
  const inScopeLocal = Object.keys(localFiles).filter(isInScope);
603
+
568
604
  if (remotePathSet.size === 0 && inScopeLocal.length > 0) {
569
605
  console.log('');
570
606
  console.log(' ⚠ Cloud reported zero files but local has in-scope content. Refusing to sweep.');
571
607
  console.log(' This usually means the snapshot endpoint glitched. Try again,');
572
608
  console.log(' or run `atris align --hard` if you really want to nuke local.');
573
609
  } else {
574
- for (const p of inScopeLocal) {
610
+ for (const p of sweepCandidates) {
575
611
  if (remotePathSet.has(p)) continue;
576
612
  if (SERVER_HIDDEN_BASENAMES.has(basename(p))) continue;
577
613
  const localPath = path.join(outputDir, p.replace(/^\//, ''));
package/commands/push.js CHANGED
@@ -7,6 +7,7 @@ const { loadBusinesses, saveBusinesses } = require('./business');
7
7
  const { loadManifest, saveManifest, buildManifest, computeLocalHashes } = require('../lib/manifest');
8
8
  const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
9
9
  const { emitSyncEvent, startTimer } = require('../lib/sync-telemetry');
10
+ const { assertSafeWorkspaceRoot } = require('../lib/workspace-safety');
10
11
 
11
12
  async function pushAtris() {
12
13
  const elapsedMs = startTimer();
@@ -83,6 +84,9 @@ async function pushAtris() {
83
84
 
84
85
  if (!fs.existsSync(sourceDir)) { console.error(`Source not found: ${sourceDir}`); process.exit(1); }
85
86
 
87
+ // Refuse to walk/upload dangerous paths ($HOME, /, /Users, system dirs).
88
+ assertSafeWorkspaceRoot(sourceDir, { slug, op: 'push from' });
89
+
86
90
  // Resolve business — always refresh from API
87
91
  let businessId, workspaceId, businessName, resolvedSlug;
88
92
  const businesses = loadBusinesses();
@@ -292,13 +296,73 @@ async function pushAtris() {
292
296
  let pushed = 0;
293
297
  let deleted = 0;
294
298
  let skipped = [];
299
+ // Files we sent that the server did not confirm as written/unchanged.
300
+ // The /sync endpoint can silently drop files (role-based filters on the
301
+ // business workspace route, path-rejection inside the warm runner, etc.)
302
+ // and still return HTTP 200. If we don't cross-check per-file results,
303
+ // the CLI prints "Pushed" for files that never actually landed — and
304
+ // the manifest records a hash that makes the next push skip them too,
305
+ // losing them permanently. `failedToLand` collects those casualties so
306
+ // we can warn the user AND keep them out of the manifest update.
307
+ let failedToLand = [];
308
+ const landedPaths = new Set();
295
309
  let result = { ok: true };
296
310
 
311
+ // Server-canonical path format for the /sync endpoint: NO leading slash.
312
+ // The warm runner's _safe_path rejects `/atris/...` with "Absolute path
313
+ // outside workspace" (it only accepts paths under `/workspace/...`). All
314
+ // our internal bookkeeping uses a leading slash (manifest keys, localFiles
315
+ // keys, snapshot response paths), so we strip only at the wire.
316
+ const toWirePath = (p) => (p || '').replace(/^\/+/, '');
317
+ const fromWirePath = (p) => {
318
+ const s = String(p || '');
319
+ return s.startsWith('/') ? s : `/${s}`;
320
+ };
321
+ const wireFiles = (files) => files.map((f) => ({ path: toWirePath(f.path), content: f.content }));
322
+
323
+ // Inspect per-file results from a /sync response. Treat "written" and
324
+ // "unchanged" as success; everything else (including missing-from-results,
325
+ // which is how silent server-side drops look) is a failure.
326
+ const recordSyncResults = (sentFiles, response) => {
327
+ const resultsArr = response && response.data && Array.isArray(response.data.results)
328
+ ? response.data.results
329
+ : null;
330
+ const seen = new Set();
331
+ if (resultsArr) {
332
+ for (const r of resultsArr) {
333
+ if (!r || !r.path) continue;
334
+ const status = String(r.status || '').toLowerCase();
335
+ const canonical = fromWirePath(r.path);
336
+ if (status === 'written' || status === 'unchanged') {
337
+ landedPaths.add(canonical);
338
+ seen.add(canonical);
339
+ } else {
340
+ failedToLand.push({ path: canonical, status: status || 'error', error: r.error || '' });
341
+ seen.add(canonical);
342
+ }
343
+ }
344
+ } else {
345
+ // No results array in response — old server. Best-effort: assume
346
+ // everything sent landed. This preserves existing behavior when
347
+ // talking to a server that doesn't return per-file status.
348
+ for (const f of sentFiles) {
349
+ landedPaths.add(f.path);
350
+ seen.add(f.path);
351
+ }
352
+ }
353
+ // Any file we sent that the server didn't mention = silently dropped.
354
+ for (const f of sentFiles) {
355
+ if (!seen.has(f.path)) {
356
+ failedToLand.push({ path: f.path, status: 'dropped', error: 'server did not confirm write' });
357
+ }
358
+ }
359
+ };
360
+
297
361
  if (filesToPush.length > 0) {
298
- // Push files to server
362
+ // Push files to server (strip leading slash — server requires workspace-relative paths)
299
363
  result = await apiRequestJson(
300
364
  `/business/${businessId}/workspaces/${workspaceId}/sync`,
301
- { method: 'POST', token: creds.token, body: { files: filesToPush }, headers: { 'X-Atris-Actor-Source': 'cli' } }
365
+ { method: 'POST', token: creds.token, body: { files: wireFiles(filesToPush) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
302
366
  );
303
367
 
304
368
  if (!result.ok) {
@@ -310,10 +374,11 @@ async function pushAtris() {
310
374
  if (allowed.length > 0) {
311
375
  const retry = await apiRequestJson(
312
376
  `/business/${businessId}/workspaces/${workspaceId}/sync`,
313
- { method: 'POST', token: creds.token, body: { files: allowed }, headers: { 'X-Atris-Actor-Source': 'cli' } }
377
+ { method: 'POST', token: creds.token, body: { files: wireFiles(allowed) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
314
378
  );
315
379
  if (retry.ok) {
316
- pushed = allowed.length;
380
+ recordSyncResults(allowed, retry);
381
+ pushed = landedPaths.size;
317
382
  } else {
318
383
  console.error(`\n Push failed: ${retry.errorMessage || retry.error || retry.status}`);
319
384
  await emit('access_denied', { error_detail: `403 retry failed: ${retry.status}` });
@@ -334,7 +399,8 @@ async function pushAtris() {
334
399
  process.exit(1);
335
400
  }
336
401
  } else {
337
- pushed = filesToPush.length;
402
+ recordSyncResults(filesToPush, result);
403
+ pushed = landedPaths.size;
338
404
  }
339
405
  }
340
406
 
@@ -387,10 +453,15 @@ async function pushAtris() {
387
453
  if (deleteFailed.length > 10) console.log(` ... +${deleteFailed.length - 10} more`);
388
454
  }
389
455
 
390
- // Display results
456
+ // Display results — only for files the server confirmed as landed.
457
+ // A non-technical user seeing "+ atris/ideas/foo.md new file" naturally
458
+ // assumes foo.md is on cloud. So we only print that line when the server
459
+ // actually confirmed it via per-file status. Anything else goes into the
460
+ // loud failure block below.
391
461
  console.log('');
392
462
  for (const f of filesToPush) {
393
463
  if (skipped.includes(f)) continue;
464
+ if (!landedPaths.has(f.path)) continue;
394
465
  const isNew = !baseFiles[f.path];
395
466
  console.log(` ${isNew ? '+' : '\u2191'} ${f.path.replace(/^\//, '')} ${isNew ? 'new file' : 'updated'}`);
396
467
  }
@@ -402,6 +473,26 @@ async function pushAtris() {
402
473
  console.log(` x ${filePath.replace(/^\//, '')} deleted`);
403
474
  }
404
475
 
476
+ // Loud failure block — files the server silently dropped or rejected.
477
+ // These did NOT land on cloud even though the HTTP call returned 200.
478
+ if (failedToLand.length > 0) {
479
+ console.log('');
480
+ console.log(` ⚠ ${failedToLand.length} file(s) did NOT land on cloud (server returned 200 but`);
481
+ console.log(` dropped or rejected these files):`);
482
+ const shown = failedToLand.slice(0, 15);
483
+ for (const f of shown) {
484
+ const detail = f.error ? ` — ${f.error}` : ` (${f.status})`;
485
+ console.log(` ✗ ${f.path.replace(/^\//, '')}${detail}`);
486
+ }
487
+ if (failedToLand.length > shown.length) {
488
+ console.log(` ... +${failedToLand.length - shown.length} more`);
489
+ }
490
+ console.log('');
491
+ console.log(' Common causes: path is outside the workspace (e.g. absolute /Users/... path),');
492
+ console.log(' your role lacks write permission for that folder, or warm runner returned an error.');
493
+ console.log(' These files will appear as drift on your next push so you can retry.');
494
+ }
495
+
405
496
  // Summary
406
497
  console.log('');
407
498
  const parts = [];
@@ -414,11 +505,17 @@ async function pushAtris() {
414
505
 
415
506
  // Update manifest — mark pushed files with their new hash, drop ONLY confirmed deletes.
416
507
  // Failed deletes stay in the manifest so the next push will retry them.
508
+ //
509
+ // CRITICAL: only record manifest entries for files the server confirmed as
510
+ // landed (landedPaths). If we recorded the local hash for a file that the
511
+ // server silently dropped, the next push would compare local==manifest and
512
+ // skip it — the file would never land. Keeping it OUT of the manifest means
513
+ // the next push sees it as new/changed and retries automatically.
417
514
  const updatedFiles = { ...baseFiles };
418
515
  for (const f of filesToPush) {
419
- if (!skipped.includes(f)) {
420
- updatedFiles[f.path] = localFiles[f.path];
421
- }
516
+ if (skipped.includes(f)) continue;
517
+ if (!landedPaths.has(f.path)) continue;
518
+ updatedFiles[f.path] = localFiles[f.path];
422
519
  }
423
520
  for (const filePath of deletedConfirmed) {
424
521
  delete updatedFiles[filePath];
@@ -431,7 +528,10 @@ async function pushAtris() {
431
528
  const bytesChanged = filesToPush.reduce((acc, f) => acc + (f.content ? Buffer.byteLength(f.content, 'utf8') : 0), 0);
432
529
  let finalOutcome;
433
530
  let finalDetail;
434
- if (deleteFailed.length > 0) {
531
+ if (failedToLand.length > 0) {
532
+ finalOutcome = 'status_unknown';
533
+ finalDetail = `${failedToLand.length} file(s) silently dropped by server (statuses: ${[...new Set(failedToLand.map(f => f.status))].join(',')})`;
534
+ } else if (deleteFailed.length > 0) {
435
535
  finalOutcome = 'status_unknown';
436
536
  finalDetail = `${deleteFailed.length} delete(s) failed (statuses: ${[...new Set(deleteFailed.map(f => f.status))].join(',')})`;
437
537
  } else if (_rateLimitedDeletes > 0) {
@@ -43,7 +43,8 @@ function printWorkflowBrief(lines) {
43
43
 
44
44
  async function planAtris(userInput = null) {
45
45
  const { loadConfig } = require('../utils/config');
46
- const { loadCredentials } = require('../utils/auth');
46
+ const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
47
+ const { apiRequestJson } = require('../utils/api');
47
48
  const { executeCodeExecution } = require('../utils/claude_sdk');
48
49
  const args = process.argv.slice(3);
49
50
  const executeFlag = args.includes('--execute');
@@ -243,10 +244,14 @@ async function planAtris(userInput = null) {
243
244
  if (!config.agent_id) {
244
245
  throw new Error('No agent selected. Run "atris agent" first.');
245
246
  }
246
- const credentials = loadCredentials();
247
- if (!credentials || !credentials.token) {
247
+ const ensured = await ensureValidCredentials(apiRequestJson);
248
+ if (ensured.error === 'not_logged_in' || !ensured.credentials?.token) {
248
249
  throw new Error('Not logged in. Run "atris login" first.');
249
250
  }
251
+ if (ensured.error) {
252
+ throw new Error(`Authentication failed: ${ensured.detail || ensured.error}. Run "atris login" to re-authenticate.`);
253
+ }
254
+ const credentials = ensured.credentials;
250
255
 
251
256
  // Build system prompt
252
257
  let systemPrompt = '';
@@ -362,7 +367,8 @@ async function planAtris(userInput = null) {
362
367
 
363
368
  async function doAtris() {
364
369
  const { loadConfig } = require('../utils/config');
365
- const { loadCredentials } = require('../utils/auth');
370
+ const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
371
+ const { apiRequestJson } = require('../utils/api');
366
372
  const { executeCodeExecution } = require('../utils/claude_sdk');
367
373
  const args = process.argv.slice(3);
368
374
  const executeFlag = args.includes('--execute');
@@ -603,10 +609,14 @@ async function doAtris() {
603
609
  if (!config.agent_id) {
604
610
  throw new Error('No agent selected. Run "atris agent" first.');
605
611
  }
606
- const credentials = loadCredentials();
607
- if (!credentials || !credentials.token) {
612
+ const ensured = await ensureValidCredentials(apiRequestJson);
613
+ if (ensured.error === 'not_logged_in' || !ensured.credentials?.token) {
608
614
  throw new Error('Not logged in. Run "atris login" first.');
609
615
  }
616
+ if (ensured.error) {
617
+ throw new Error(`Authentication failed: ${ensured.detail || ensured.error}. Run "atris login" to re-authenticate.`);
618
+ }
619
+ const credentials = ensured.credentials;
610
620
 
611
621
  // Build system prompt
612
622
  let systemPrompt = '';
@@ -704,7 +714,8 @@ async function doAtris() {
704
714
 
705
715
  async function reviewAtris() {
706
716
  const { loadConfig } = require('../utils/config');
707
- const { loadCredentials } = require('../utils/auth');
717
+ const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
718
+ const { apiRequestJson } = require('../utils/api');
708
719
  const { executeCodeExecution } = require('../utils/claude_sdk');
709
720
  const args = process.argv.slice(3);
710
721
  const executeFlag = args.includes('--execute');
@@ -959,10 +970,14 @@ async function reviewAtris() {
959
970
  if (!config.agent_id) {
960
971
  throw new Error('No agent selected. Run "atris agent" first.');
961
972
  }
962
- const credentials = loadCredentials();
963
- if (!credentials || !credentials.token) {
973
+ const ensured = await ensureValidCredentials(apiRequestJson);
974
+ if (ensured.error === 'not_logged_in' || !ensured.credentials?.token) {
964
975
  throw new Error('Not logged in. Run "atris login" first.');
965
976
  }
977
+ if (ensured.error) {
978
+ throw new Error(`Authentication failed: ${ensured.detail || ensured.error}. Run "atris login" to re-authenticate.`);
979
+ }
980
+ const credentials = ensured.credentials;
966
981
 
967
982
  // Build system prompt
968
983
  let systemPrompt = '';
package/lib/manifest.js CHANGED
@@ -62,6 +62,9 @@ function buildManifest(files, commitHash) {
62
62
  const SKIP_DIRS = new Set([
63
63
  'node_modules', '__pycache__', '.git', 'venv', '.venv',
64
64
  'lost+found', '.cache', '.atris',
65
+ // Defense-in-depth: macOS/system dirs that should never be scanned as
66
+ // part of any atris workspace, even if outputDir is accidentally wide.
67
+ 'Library', 'Applications', 'System',
65
68
  ]);
66
69
 
67
70
  function computeLocalHashes(localDir) {