@notis_ai/cli 0.2.13 → 0.2.14

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.
Files changed (53) hide show
  1. package/README.md +45 -3
  2. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +40 -2
  3. package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +21 -0
  4. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  5. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  6. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useHandover.ts +75 -0
  7. package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +17 -0
  8. package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +132 -1
  9. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +40 -2
  10. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +21 -0
  11. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  12. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  13. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useHandover.ts +75 -0
  14. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +17 -0
  15. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +132 -1
  16. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +2 -0
  17. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +40 -2
  18. package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +21 -0
  19. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  20. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  21. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useHandover.ts +75 -0
  22. package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +17 -0
  23. package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +132 -1
  24. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +40 -2
  25. package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +21 -0
  26. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  27. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  28. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useHandover.ts +75 -0
  29. package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +17 -0
  30. package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +132 -1
  31. package/package.json +1 -1
  32. package/skills/notis-apps/SKILL.md +11 -7
  33. package/skills/notis-apps/cli.md +8 -3
  34. package/src/command-specs/apps.js +238 -50
  35. package/src/command-specs/handover.js +374 -0
  36. package/src/command-specs/index.js +3 -0
  37. package/src/command-specs/tools.js +6 -0
  38. package/src/runtime/app-dev-server.js +17 -8
  39. package/src/runtime/app-platform.js +218 -6
  40. package/src/runtime/delegated-context.js +68 -0
  41. package/src/runtime/git.js +233 -0
  42. package/src/runtime/transport.js +19 -2
  43. package/template/.harness/index.html.tmpl +116 -47
  44. package/template/packages/sdk/src/config.ts +52 -0
  45. package/template/packages/sdk/src/documents.ts +21 -0
  46. package/template/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  47. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  48. package/template/packages/sdk/src/hooks/useHandover.ts +75 -0
  49. package/template/packages/sdk/src/index.ts +17 -0
  50. package/template/packages/sdk/src/runtime.ts +132 -1
  51. package/template/metadata/screenshot-1.png +0 -0
  52. package/template/metadata/screenshot-2.png +0 -0
  53. package/template/metadata/screenshot-3.png +0 -0
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { spawn } from 'node:child_process';
10
- import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
10
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
11
11
  import { createRequire } from 'node:module';
12
12
  import { dirname, join, relative, resolve } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
@@ -15,7 +15,7 @@ import { gunzipSync } from 'node:zlib';
15
15
 
16
16
  import { usageError } from './errors.js';
17
17
  import { validateArtifactBoundary, validateProjectBoundary } from './app-boundary-validator.js';
18
- import { readAppChangelog } from './app-changelog.js';
18
+ import { CHANGELOG_MERGE_DATE, readAppChangelog } from './app-changelog.js';
19
19
 
20
20
  const NOTIS_DIR = '.notis';
21
21
  const STATE_FILE = join(NOTIS_DIR, 'state.json');
@@ -44,6 +44,9 @@ const SOURCE_COPY_EXCLUDES = new Set([
44
44
  'dist',
45
45
  'tsconfig.tsbuildinfo',
46
46
  '.DS_Store',
47
+ // Interpreter droppings: a stray `python -m py_compile` in a skill's
48
+ // scripts/ directory must not ship version-specific bytecode in the bundle.
49
+ '__pycache__',
47
50
  ]);
48
51
  const SCAFFOLD_COPY_EXCLUDES = new Set([
49
52
  ...SOURCE_COPY_EXCLUDES,
@@ -51,6 +54,17 @@ const SCAFFOLD_COPY_EXCLUDES = new Set([
51
54
  '.next',
52
55
  '.turbo',
53
56
  ]);
57
+ // Listing media describes the scaffold's own Store entry, so a new project must
58
+ // never inherit it. Scaffold packaging still ships these files -- only the
59
+ // `apps init` copy drops them. Screenshots only: `screenshot-fixtures.json` is
60
+ // not listing media, it is the stub data the dev harness serves, and dropping
61
+ // it would make every route of a fresh project render its empty state.
62
+ const SCAFFOLD_LISTING_MEDIA = /^metadata\/screenshot-\d+\.png$/i;
63
+ // A directory-declared skill ships every supporting file to the sandbox, so it
64
+ // needs a ceiling of its own. Kept well above the 512 KB SKILL.md limit so a
65
+ // handful of scripts always fits, and far below the bundle machinery's own
66
+ // limits so an accidental asset dump fails on the client with a clear message.
67
+ export const MAX_APP_SKILL_BUNDLE_BYTES = 5 * 1024 * 1024;
54
68
  let appConfigImportNonce = 0;
55
69
 
56
70
  // ---------------------------------------------------------------------------
@@ -427,6 +441,39 @@ export function resolveListingScreenshots(projectDir, appConfig = null) {
427
441
  });
428
442
  }
429
443
 
444
+ /**
445
+ * Screenshot scenarios named in notis.config.ts that metadata/screenshot-fixtures.json
446
+ * does not define. A missing scenario is silent at capture time -- the harness
447
+ * simply falls back to the default fixtures -- so it is reported as a warning.
448
+ */
449
+ export function findUnknownScreenshotScenarios(projectDir, screenshots = []) {
450
+ const named = screenshots.filter((entry) => entry?.scenario);
451
+ if (named.length === 0) {
452
+ return [];
453
+ }
454
+ const fixturesPath = join(projectDir, METADATA_DIR, 'screenshot-fixtures.json');
455
+ if (!existsSync(fixturesPath)) {
456
+ return ['metadata/screenshot-fixtures.json is missing, so screenshot scenarios cannot be applied.'];
457
+ }
458
+ let defined;
459
+ try {
460
+ const parsed = JSON.parse(readFileSync(fixturesPath, 'utf-8'));
461
+ const scenarios = parsed?.scenarios && typeof parsed.scenarios === 'object' ? parsed.scenarios : {};
462
+ defined = new Set(Object.keys(scenarios));
463
+ } catch {
464
+ return ['metadata/screenshot-fixtures.json is not valid JSON, so screenshot scenarios cannot be applied.'];
465
+ }
466
+ const warnings = [];
467
+ for (const entry of named) {
468
+ if (!defined.has(entry.scenario)) {
469
+ warnings.push(
470
+ `${entry.path} names scenario "${entry.scenario}", which metadata/screenshot-fixtures.json does not define.`,
471
+ );
472
+ }
473
+ }
474
+ return warnings;
475
+ }
476
+
430
477
  export function inspectListingReadiness(projectDir, appConfig = null) {
431
478
  const config = appConfig || {};
432
479
  const warnings = [];
@@ -738,7 +785,7 @@ export function generateManifest(appConfig, projectDir) {
738
785
  const displayTitle = appConfig.title || appConfig.displayName || appConfig.name;
739
786
  const skills = (Array.isArray(appConfig.skills) ? appConfig.skills : []).map((skill) => ({
740
787
  key: skill.key,
741
- path: String(skill.path || '').replace(/^\.\/+/, ''),
788
+ path: normalizeAppSkillManifestPath(skill.path),
742
789
  name: skill.name,
743
790
  description: skill.description || null,
744
791
  }));
@@ -830,12 +877,54 @@ export function normalizeAppCapabilities(capabilities) {
830
877
  if (capabilities.workspaceDatabases === 'read') {
831
878
  normalized.workspaceDatabases = 'read';
832
879
  }
880
+ if (capabilities.cloudComputer === 'read' || capabilities.cloudComputer === 'shell') {
881
+ normalized.cloudComputer = capabilities.cloudComputer;
882
+ }
833
883
  return normalized;
834
884
  }
835
885
 
886
+ /**
887
+ * Manifest form of a declared skill path: no leading `./`, no trailing slash.
888
+ * A directory declaration is the same string as the source-tree prefix the
889
+ * server matches the uploaded source files against.
890
+ */
891
+ export function normalizeAppSkillManifestPath(sourcePath) {
892
+ return String(sourcePath || '').replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
893
+ }
894
+
895
+ /**
896
+ * Every packageable file under a declared skill directory, relative to that
897
+ * directory, in stable order. Excludes match `readSourceFiles` so the files a
898
+ * dev session sends inline are exactly the ones a deploy uploads as source.
899
+ */
900
+ function readAppSkillDirectoryFiles(skillDir) {
901
+ const entries = [];
902
+
903
+ function walk(dir, prefix) {
904
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
905
+ if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
906
+ continue;
907
+ }
908
+ const fullPath = join(dir, entry.name);
909
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
910
+ if (entry.isDirectory()) {
911
+ walk(fullPath, relPath);
912
+ } else if (entry.isFile()) {
913
+ entries.push({ path: relPath, absolutePath: fullPath });
914
+ }
915
+ }
916
+ }
917
+
918
+ walk(skillDir, '');
919
+ // Byte order, not locale order: the server sorts the same file set the same
920
+ // way before hashing it, so a dev session and a deploy agree on the hash.
921
+ return entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
922
+ }
923
+
836
924
  export function resolveConfiguredAppSkills(appConfig, projectDir) {
837
925
  const configured = Array.isArray(appConfig.skills) ? appConfig.skills : [];
838
926
  const projectRoot = resolve(projectDir);
927
+ const realProjectRoot = realpathSync(projectRoot);
839
928
  const seenKeys = new Set();
840
929
 
841
930
  return configured.map((skill, index) => {
@@ -855,7 +944,49 @@ export function resolveConfiguredAppSkills(appConfig, projectDir) {
855
944
  if (!relativePath || relativePath.startsWith('../') || relativePath === '..') {
856
945
  throw usageError(`App skill path must stay inside the project: ${sourcePath}`);
857
946
  }
858
- if (!existsSync(absolutePath) || !statSync(absolutePath).isFile()) {
947
+ if (!existsSync(absolutePath)) {
948
+ throw usageError(`App skill entrypoint not found: ${sourcePath}`);
949
+ }
950
+ if (lstatSync(absolutePath).isSymbolicLink()) {
951
+ throw usageError(`App skill entrypoint cannot be a symbolic link: ${sourcePath}`);
952
+ }
953
+ const realAbsolutePath = realpathSync(absolutePath);
954
+ const realRelativePath = relative(realProjectRoot, realAbsolutePath).replace(/\\/g, '/');
955
+ if (!realRelativePath || realRelativePath.startsWith('../') || realRelativePath === '..') {
956
+ throw usageError(`App skill path must stay inside the project after resolving links: ${sourcePath}`);
957
+ }
958
+
959
+ const description = typeof skill.description === 'string' ? skill.description.trim() : null;
960
+ const stats = statSync(absolutePath);
961
+ if (stats.isDirectory()) {
962
+ const entries = readAppSkillDirectoryFiles(absolutePath);
963
+ if (!entries.some((entry) => entry.path === 'SKILL.md')) {
964
+ throw usageError(`App skill directory must contain SKILL.md: ${sourcePath}`);
965
+ }
966
+ const bundleFiles = entries.map((entry) => ({
967
+ path: entry.path,
968
+ content: readFileSync(entry.absolutePath),
969
+ }));
970
+ const totalBytes = bundleFiles.reduce((total, entry) => total + entry.content.length, 0);
971
+ if (totalBytes > MAX_APP_SKILL_BUNDLE_BYTES) {
972
+ throw usageError(
973
+ `App skill "${key}" bundles ${totalBytes} bytes, above the ${MAX_APP_SKILL_BUNDLE_BYTES} byte limit.`,
974
+ );
975
+ }
976
+ const skillMd = bundleFiles.find((entry) => entry.path === 'SKILL.md');
977
+ return {
978
+ key,
979
+ path: relativePath,
980
+ name,
981
+ description,
982
+ skill_md: skillMd.content.toString('utf8'),
983
+ bundle_files: bundleFiles.map((entry) => ({
984
+ path: entry.path,
985
+ content_b64: entry.content.toString('base64'),
986
+ })),
987
+ };
988
+ }
989
+ if (!stats.isFile()) {
859
990
  throw usageError(`App skill entrypoint not found: ${sourcePath}`);
860
991
  }
861
992
 
@@ -863,7 +994,7 @@ export function resolveConfiguredAppSkills(appConfig, projectDir) {
863
994
  key,
864
995
  path: relativePath,
865
996
  name,
866
- description: typeof skill.description === 'string' ? skill.description.trim() : null,
997
+ description,
867
998
  skill_md: readFileSync(absolutePath, 'utf8'),
868
999
  };
869
1000
  });
@@ -1024,12 +1155,85 @@ export function scaffoldProject({ projectDir, appName, fromSlug = null }) {
1024
1155
  config = config.replace(/'My Notis App'/, displayName);
1025
1156
  }
1026
1157
  }
1158
+ config = removeConfigArrayProperty(config, 'screenshots');
1027
1159
  writeFileSync(configPath, config);
1028
1160
  }
1029
1161
 
1162
+ resetScaffoldChangelog(projectDir, appName);
1163
+
1030
1164
  return { projectDir };
1031
1165
  }
1032
1166
 
1167
+ /**
1168
+ * Drop `property: [ ... ]` from a notis.config.ts source.
1169
+ *
1170
+ * The scan tracks bracket depth while skipping string literals, so entries
1171
+ * whose text contains a bracket (alt text, selectors) cannot end the array
1172
+ * early. When the array cannot be resolved the source is returned untouched --
1173
+ * a stale screenshots list is a warning at verify time, a broken config is not.
1174
+ */
1175
+ function removeConfigArrayProperty(source, property) {
1176
+ const start = source.search(new RegExp(`^[ \\t]*${property}[ \\t]*:[ \\t]*\\[`, 'm'));
1177
+ if (start === -1) {
1178
+ return source;
1179
+ }
1180
+ let index = source.indexOf('[', start);
1181
+ let depth = 0;
1182
+ let quote = null;
1183
+ for (; index < source.length; index += 1) {
1184
+ const char = source[index];
1185
+ if (quote) {
1186
+ if (char === '\\') {
1187
+ index += 1;
1188
+ } else if (char === quote) {
1189
+ quote = null;
1190
+ }
1191
+ continue;
1192
+ }
1193
+ if (char === '\'' || char === '"' || char === '`') {
1194
+ quote = char;
1195
+ continue;
1196
+ }
1197
+ if (char === '[') {
1198
+ depth += 1;
1199
+ } else if (char === ']') {
1200
+ depth -= 1;
1201
+ if (depth === 0) {
1202
+ break;
1203
+ }
1204
+ }
1205
+ }
1206
+ if (depth !== 0) {
1207
+ return source;
1208
+ }
1209
+ let end = index + 1;
1210
+ if (source[end] === ',') {
1211
+ end += 1;
1212
+ }
1213
+ while (end < source.length && (source[end] === ' ' || source[end] === '\t')) {
1214
+ end += 1;
1215
+ }
1216
+ if (source[end] === '\n') {
1217
+ end += 1;
1218
+ }
1219
+ return source.slice(0, start) + source.slice(end);
1220
+ }
1221
+
1222
+ /**
1223
+ * A new project starts its own release history: the scaffold's entries describe
1224
+ * releases of a different app.
1225
+ */
1226
+ function resetScaffoldChangelog(projectDir, appName) {
1227
+ const changelogPath = join(projectDir, 'CHANGELOG.md');
1228
+ if (!existsSync(changelogPath)) {
1229
+ return;
1230
+ }
1231
+ writeFileSync(
1232
+ changelogPath,
1233
+ `# ${appName} Changelog\n\n## [Initial Release] - ${CHANGELOG_MERGE_DATE}\n\n- First Store release.\n`,
1234
+ );
1235
+ }
1236
+
1033
1237
  function normalizeScaffoldLockfile(projectDir, pkg) {
1034
1238
  const lockPath = join(projectDir, 'package-lock.json');
1035
1239
  if (!existsSync(lockPath)) {
@@ -1126,6 +1330,9 @@ function copyScaffoldSource(sourceDir, targetDir) {
1126
1330
  function shouldCopy(path) {
1127
1331
  const name = path.split(/[\\/]/).pop();
1128
1332
  if (!name) return true;
1333
+ if (SCAFFOLD_LISTING_MEDIA.test(relative(sourceDir, path).replace(/\\/g, '/'))) {
1334
+ return false;
1335
+ }
1129
1336
  return !SCAFFOLD_COPY_EXCLUDES.has(name)
1130
1337
  && !name.startsWith('.env')
1131
1338
  && !/\.(test|spec)\.[cm]?[jt]sx?$/i.test(name);
@@ -1187,7 +1394,12 @@ export function collectArtifactFiles(projectDir) {
1187
1394
  }
1188
1395
 
1189
1396
  function shouldExcludeSourceEntry(name) {
1190
- return SOURCE_COPY_EXCLUDES.has(name) || name.startsWith('.env');
1397
+ return (
1398
+ SOURCE_COPY_EXCLUDES.has(name)
1399
+ || name.startsWith('.env')
1400
+ || name.endsWith('.pyc')
1401
+ || name.endsWith('.pyo')
1402
+ );
1191
1403
  }
1192
1404
 
1193
1405
  function readSourceFiles(projectDir) {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Detect that this CLI is running *as* a Notis agent rather than for a person.
3
+ *
4
+ * Hand-over is the one command where that distinction matters: an agent Notis
5
+ * is already running has no business handing the same work back to Notis, and
6
+ * nothing about the loop it would create is self-limiting.
7
+ *
8
+ * This is the polite half of the guard. The server refuses the same call unless
9
+ * it carries a purpose-scoped CLI/MCP OAuth token, which a delegated runtime
10
+ * never has -- that is the half that actually holds. What this adds is a clear
11
+ * message at the point of the mistake, instead of a permission error the agent
12
+ * will try to work around.
13
+ */
14
+
15
+ import { existsSync } from 'node:fs';
16
+ import { CliError, EXIT_CODES } from './errors.js';
17
+
18
+ // The cloud computer's root. Present only inside the user's Vercel sandbox.
19
+ const SANDBOX_ROOT = '/vercel/sandbox';
20
+
21
+ export function delegatedContextReason(env = process.env, { fileExists = existsSync } = {}) {
22
+ if (env.NOTIS_DELEGATED_CONTEXT === '1') {
23
+ // Set by Notis Desktop on every coding agent it spawns. Those runs use the
24
+ // user's own OAuth profile, so nothing else distinguishes them.
25
+ return 'this process was started by Notis as a delegated coding agent';
26
+ }
27
+ if (fileExists(SANDBOX_ROOT)) {
28
+ return 'this process is running on the Notis cloud computer';
29
+ }
30
+ if (env.NOTIS_AGENT === '1' && env.NOTIS_JWT) {
31
+ return 'this process is authenticated as a Notis agent, not as you';
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export function assertNotDelegated(commandLabel, env = process.env) {
37
+ const reason = delegatedContextReason(env);
38
+ if (!reason) {
39
+ return;
40
+ }
41
+ throw new CliError({
42
+ code: 'handover_from_delegated_context',
43
+ message:
44
+ `\`notis ${commandLabel}\` hands work to a Notis agent, and ${reason}. ` +
45
+ 'An agent cannot hand its own task back to Notis.',
46
+ exitCode: EXIT_CODES.usage,
47
+ hints: [
48
+ { message: 'Run the hand-over from the terminal on your own machine.' },
49
+ { message: 'If you are the agent: just do the work here, in this workspace.' },
50
+ ],
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Tools that hand work to a Notis agent, by canonical name.
56
+ *
57
+ * `notis handover` is not the only way to reach these: `notis tools exec` takes
58
+ * any tool name, and `tools exec-parallel` takes a list of them. Guarding only
59
+ * the friendly command would leave the escape hatch it exists to wrap.
60
+ */
61
+ export const HANDOVER_TOOL_NAMES = new Set(['LOCAL_NOTIS_HAND_OVER']);
62
+
63
+ export function assertToolNotDelegated(toolName, env = process.env) {
64
+ if (!HANDOVER_TOOL_NAMES.has(String(toolName || '').toUpperCase())) {
65
+ return;
66
+ }
67
+ assertNotDelegated(`tools exec ${toolName}`, env);
68
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Local git inspection for `notis handover`.
3
+ *
4
+ * The CLI has no other reason to know about git, so this stays deliberately
5
+ * small: read where we are, push the branch, and report what the remote is.
6
+ * Everything else about the repository is the cloud workspace's problem.
7
+ *
8
+ * Every call is `spawnSync` on the real `git` binary rather than a library.
9
+ * The user's credentials, hooks, SSH agent and includeIf config are what make a
10
+ * push succeed on their machine, and only their own git honors all of them.
11
+ */
12
+
13
+ import { spawnSync } from 'node:child_process';
14
+ import { lstatSync, readFileSync } from 'node:fs';
15
+ import { basename, relative, resolve, sep } from 'node:path';
16
+ import { CliError, EXIT_CODES } from './errors.js';
17
+
18
+ const GIT_TIMEOUT_MS = 120_000;
19
+ const MAX_SECRET_SCAN_BYTES = 1_000_000;
20
+ const SAFE_ENV_TEMPLATES = /^\.env\.(?:example|sample|template)$/i;
21
+ const SENSITIVE_PATH = /^(?:\.env(?:\..+)?|\.envrc|\.git-credentials|\.npmrc|\.pypirc|\.netrc|credentials(?:\..+)?|secrets?(?:\..+)?|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|.*\.(?:pem|key|p12|pfx|jks))$/i;
22
+ const SENSITIVE_CONTENT = [
23
+ /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
24
+ /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/i,
25
+ /\bAKIA[0-9A-Z]{16}\b/,
26
+ /\bgh[pousr]_[A-Za-z0-9]{36,}\b/,
27
+ /\bsk-[A-Za-z0-9_-]{20,}\b/,
28
+ ];
29
+
30
+ export function runGit(
31
+ args,
32
+ { cwd = process.cwd(), timeoutMs = GIT_TIMEOUT_MS, trimOutput = true } = {},
33
+ ) {
34
+ const result = spawnSync('git', args, {
35
+ cwd,
36
+ encoding: 'utf8',
37
+ timeout: timeoutMs,
38
+ env: {
39
+ ...process.env,
40
+ // A push that stops to ask for a password would hang a non-interactive
41
+ // agent run forever. Fail fast instead and let the hint explain.
42
+ GIT_TERMINAL_PROMPT: '0',
43
+ },
44
+ });
45
+ if (result.error && result.error.code === 'ENOENT') {
46
+ throw new CliError({
47
+ code: 'git_not_found',
48
+ message: 'git is not installed or not on PATH.',
49
+ exitCode: EXIT_CODES.usage,
50
+ });
51
+ }
52
+ return {
53
+ exitCode: result.status ?? 1,
54
+ stdout: trimOutput ? (result.stdout || '').trim() : (result.stdout || ''),
55
+ stderr: trimOutput ? (result.stderr || '').trim() : (result.stderr || ''),
56
+ };
57
+ }
58
+
59
+ function gitOrNull(args, options) {
60
+ const result = runGit(args, options);
61
+ return result.exitCode === 0 ? result.stdout : null;
62
+ }
63
+
64
+ function nulSeparatedGitPaths(args, repository) {
65
+ const result = runGit([...args, '-z'], { cwd: repository.toplevel, trimOutput: false });
66
+ if (result.exitCode !== 0 || !result.stdout) return [];
67
+ return result.stdout.split('\0').filter(Boolean);
68
+ }
69
+
70
+ /** Exact files `git add -A` would include, expanding untracked directories. */
71
+ function filesToAutoCommit(repository) {
72
+ return [...new Set([
73
+ ...nulSeparatedGitPaths(['diff', '--name-only'], repository),
74
+ ...nulSeparatedGitPaths(['diff', '--cached', '--name-only'], repository),
75
+ ...nulSeparatedGitPaths(['ls-files', '--others', '--exclude-standard'], repository),
76
+ ])];
77
+ }
78
+
79
+ /** Refuse files that are unsafe to publish automatically without user review. */
80
+ export function sensitiveAutoCommitFiles(repository) {
81
+ const root = resolve(repository.toplevel);
82
+ const sensitive = [];
83
+ for (const path of filesToAutoCommit(repository)) {
84
+ const absolute = resolve(root, path);
85
+ const insideRoot = relative(root, absolute);
86
+ if (insideRoot === '..' || insideRoot.startsWith(`..${sep}`) || insideRoot === '') continue;
87
+ const name = basename(path);
88
+ if (SENSITIVE_PATH.test(name) && !SAFE_ENV_TEMPLATES.test(name)) {
89
+ sensitive.push(path);
90
+ continue;
91
+ }
92
+ try {
93
+ const stat = lstatSync(absolute);
94
+ if (!stat.isFile() || stat.size > MAX_SECRET_SCAN_BYTES) continue;
95
+ const content = readFileSync(absolute, 'utf8');
96
+ if (SENSITIVE_CONTENT.some((pattern) => pattern.test(content))) sensitive.push(path);
97
+ } catch {
98
+ // Deleted files and paths racing with an editor have no content to leak.
99
+ }
100
+ }
101
+ return sensitive;
102
+ }
103
+
104
+ /**
105
+ * Parse an origin URL into {owner, repo}. Handles the three forms git remotes
106
+ * actually take: scp-style ssh, ssh:// and https://.
107
+ */
108
+ export function parseRemoteUrl(url) {
109
+ if (typeof url !== 'string' || !url) {
110
+ return null;
111
+ }
112
+ const trimmed = url.trim().replace(/\.git$/, '');
113
+ const scp = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
114
+ const path = scp ? scp[2] : trimmed.replace(/^[a-z+]+:\/\/(?:[^@/]+@)?[^/]+\//i, '');
115
+ const host = scp ? scp[1] : (trimmed.match(/^[a-z+]+:\/\/(?:[^@/]+@)?([^/]+)/i) || [])[1];
116
+ const segments = path.split('/').filter(Boolean);
117
+ if (segments.length < 2) {
118
+ return null;
119
+ }
120
+ return {
121
+ host: host || null,
122
+ owner: segments[segments.length - 2],
123
+ repo: segments[segments.length - 1],
124
+ };
125
+ }
126
+
127
+ /** Everything `handover start` needs to know about where it is running. */
128
+ export function inspectRepository(cwd = process.cwd()) {
129
+ const toplevel = gitOrNull(['rev-parse', '--show-toplevel'], { cwd });
130
+ if (!toplevel) {
131
+ throw new CliError({
132
+ code: 'not_a_git_repository',
133
+ message: 'Hand-over runs from inside a git repository.',
134
+ exitCode: EXIT_CODES.usage,
135
+ hints: [{ message: 'cd into the repository you want Notis to work on, then run the command again.' }],
136
+ });
137
+ }
138
+
139
+ const branch = gitOrNull(['branch', '--show-current'], { cwd: toplevel });
140
+ if (!branch) {
141
+ throw new CliError({
142
+ code: 'detached_head',
143
+ message: 'HEAD is detached, so there is no branch to hand over.',
144
+ exitCode: EXIT_CODES.usage,
145
+ hints: [{ message: 'Check out a branch first: git switch -c my-feature' }],
146
+ });
147
+ }
148
+
149
+ const remoteUrl = gitOrNull(['remote', 'get-url', 'origin'], { cwd: toplevel });
150
+ if (!remoteUrl) {
151
+ throw new CliError({
152
+ code: 'no_origin_remote',
153
+ message: 'This repository has no "origin" remote, so the cloud agent cannot fetch the branch.',
154
+ exitCode: EXIT_CODES.usage,
155
+ hints: [{ message: 'Add one: git remote add origin <url>' }],
156
+ });
157
+ }
158
+
159
+ const status = runGit(['status', '--porcelain'], { cwd: toplevel });
160
+ const dirtyFiles = status.stdout
161
+ ? status.stdout.split('\n').map((line) => line.slice(3).trim()).filter(Boolean)
162
+ : [];
163
+
164
+ return {
165
+ toplevel,
166
+ branch,
167
+ remoteUrl,
168
+ remote: parseRemoteUrl(remoteUrl),
169
+ dirtyFiles,
170
+ head: gitOrNull(['rev-parse', 'HEAD'], { cwd: toplevel }),
171
+ upstream: gitOrNull(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], {
172
+ cwd: toplevel,
173
+ }),
174
+ };
175
+ }
176
+
177
+ /** Commit everything in the working tree, including untracked files. */
178
+ export function commitWorkingTree(repository, message) {
179
+ const sensitiveFiles = sensitiveAutoCommitFiles(repository);
180
+ if (sensitiveFiles.length) {
181
+ throw new CliError({
182
+ code: 'sensitive_working_tree',
183
+ message: 'Refusing to publish files that may contain credentials or private keys.',
184
+ exitCode: EXIT_CODES.conflict,
185
+ details: { sensitive_files: sensitiveFiles.slice(0, 20) },
186
+ hints: [
187
+ { message: 'Review, remove, or ignore these files before handing over.' },
188
+ { message: 'To publish them deliberately, commit and push them yourself first.' },
189
+ ],
190
+ });
191
+ }
192
+ const add = runGit(['add', '-A'], { cwd: repository.toplevel });
193
+ if (add.exitCode !== 0) {
194
+ throw new CliError({
195
+ code: 'git_add_failed',
196
+ message: `Could not stage the working tree: ${add.stderr || add.stdout}`,
197
+ exitCode: EXIT_CODES.unexpected,
198
+ });
199
+ }
200
+ const commit = runGit(['commit', '-m', message], { cwd: repository.toplevel });
201
+ if (commit.exitCode !== 0) {
202
+ throw new CliError({
203
+ code: 'git_commit_failed',
204
+ message: `Could not commit the working tree: ${commit.stderr || commit.stdout}`,
205
+ exitCode: EXIT_CODES.unexpected,
206
+ hints: [{ message: 'Commit the changes yourself, then run the hand-over again.' }],
207
+ });
208
+ }
209
+ return gitOrNull(['rev-parse', 'HEAD'], { cwd: repository.toplevel });
210
+ }
211
+
212
+ /**
213
+ * Push the branch to origin. The cloud workspace fetches from origin, so an
214
+ * unpushed commit simply does not exist as far as the hand-over is concerned.
215
+ */
216
+ export function pushBranch(repository, branch) {
217
+ const args = repository.upstream
218
+ ? ['push', 'origin', branch]
219
+ : ['push', '--set-upstream', 'origin', branch];
220
+ const result = runGit(args, { cwd: repository.toplevel });
221
+ if (result.exitCode !== 0) {
222
+ throw new CliError({
223
+ code: 'git_push_failed',
224
+ message: `Could not push ${branch} to origin: ${result.stderr || result.stdout}`,
225
+ exitCode: EXIT_CODES.conflict,
226
+ hints: [
227
+ { message: 'The cloud agent works from origin, so the branch has to be pushed first.' },
228
+ { message: 'If the remote moved ahead, reconcile locally (git pull --rebase) and retry.' },
229
+ ],
230
+ });
231
+ }
232
+ return true;
233
+ }
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { createReadStream } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import { CliError, EXIT_CODES } from './errors.js';
5
+ import { delegatedContextReason } from './delegated-context.js';
5
6
  import {
6
7
  credentialIsExpired,
7
8
  getJwtExpiration,
@@ -327,9 +328,11 @@ export async function httpRequest({
327
328
  });
328
329
 
329
330
  let payload = null;
331
+ let payloadReadError = null;
330
332
  try {
331
333
  payload = await response.json();
332
- } catch {
334
+ } catch (error) {
335
+ payloadReadError = error;
333
336
  payload = null;
334
337
  }
335
338
 
@@ -350,9 +353,11 @@ export async function httpRequest({
350
353
  signal: controller.signal,
351
354
  ...(requestBody.duplex ? { duplex: requestBody.duplex } : {}),
352
355
  });
356
+ payloadReadError = null;
353
357
  try {
354
358
  payload = await response.json();
355
- } catch {
359
+ } catch (error) {
360
+ payloadReadError = error;
356
361
  payload = null;
357
362
  }
358
363
  }
@@ -376,6 +381,14 @@ export async function httpRequest({
376
381
  throw normalizeBackendError(response.status, payload, runtime);
377
382
  }
378
383
 
384
+ // A successful status is not a successful tool call until its response
385
+ // body has been read. Mutations may already have committed before a socket
386
+ // reset truncates the JSON; surface that as an ambiguous network failure
387
+ // so callers never persist an undefined result or attempt a second write.
388
+ if (response.status !== 204 && payloadReadError) {
389
+ throw payloadReadError;
390
+ }
391
+
379
392
  return {
380
393
  requestId,
381
394
  payload: payload || {},
@@ -428,6 +441,10 @@ export async function callTool({
428
441
  cwd: process.cwd(),
429
442
  agent_mode: runtime.agentMode,
430
443
  cli_version: runtime.cliVersion,
444
+ // Reported so the server can refuse work-handing tools from a run Notis
445
+ // is itself driving. Advisory -- a determined agent can unset the markers
446
+ // -- so the server treats it as one layer, not the whole guard.
447
+ ...(delegatedContextReason() ? { delegated_context: true } : {}),
431
448
  ...(runtime.debugEntitlementOverride
432
449
  ? { debug_entitlement_override: runtime.debugEntitlementOverride }
433
450
  : {}),