@north-light/sdk 0.3.557 → 0.3.559

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.
@@ -1,5 +1,6 @@
1
1
  import { type JWK } from 'jose';
2
2
  import { type IdTokenClaims } from '@north-light/identity';
3
+ import { type ExecRunDTO } from '@north-light/api';
3
4
  import { type AppClient, type CrouterOptions } from '../client.js';
4
5
  import { RunWatcher, type RunWatchOptions } from '../resources/run-watcher.js';
5
6
  import { GrantWatcher, type GrantWatchOptions } from '../resources/grant-watcher.js';
@@ -118,6 +119,12 @@ export declare class ConnectedCrouter extends AppCrouter {
118
119
  /** The person's runtime URL this client calls, as the stored connection named it. */
119
120
  readonly runtimeUrl: string;
120
121
  constructor(connection: Pick<Connection, 'userId' | 'runtimeUrl'>, options: Pick<CrouterOptions, 'tokenSource' | 'onUnauthorized' | 'fetch'>);
122
+ /** Run `crtr <argv…>` in this app's sandbox on the person's runtime (`POST /v1/exec`, scopes `crtr:act` and `crtr:schedule`).
123
+ * No shell and no stdin; the process starts in the app's files folder. A non-zero exit or a timeout resolves; a refusal
124
+ * throws `APIError` (`busy` 429 past 4 live execs for this app, `sandbox_unavailable` 503 on a runtime without a root launcher). */
125
+ exec(argv: string[], options?: {
126
+ timeoutS?: number;
127
+ }): Promise<ExecRunDTO>;
121
128
  }
122
129
  /** The default deadline of one directory operation (`OAuth2Options.timeoutMs`): a discovery read is forgotten when it passes. */
123
130
  export declare const DEFAULT_DIRECTORY_TIMEOUT_MS = 10000;
@@ -1,6 +1,6 @@
1
1
  import { calculateJwkThumbprint, decodeJwt, decodeProtectedHeader, exportJWK, importJWK, importPKCS8, SignJWT } from 'jose';
2
2
  import { boundedFetch, FetchDeadlineError, JwksCache, parseScope, verifyIdToken } from '@north-light/identity';
3
- import { deadlineExceeded, sleepWithin } from '@north-light/api';
3
+ import { deadlineExceeded, routes, sleepWithin } from '@north-light/api';
4
4
  import { APIError, CrouterError, NoHostedRuntimeError, NotGrantedError, mapError } from '../errors.js';
5
5
  import { Crouter } from '../client.js';
6
6
  import { withinDeadline } from '../resources/request.js';
@@ -94,6 +94,13 @@ export class ConnectedCrouter extends AppCrouter {
94
94
  Object.defineProperty(this, 'customObjects', { value: { register: customObjects.register.bind(customObjects), publish: customObjects.publish.bind(customObjects), unregister: customObjects.unregister.bind(customObjects) } });
95
95
  Object.defineProperty(this, 'canvas', { value: { watch: canvas.watch.bind(canvas), unwatch: canvas.unwatch.bind(canvas) } });
96
96
  }
97
+ /** Run `crtr <argv…>` in this app's sandbox on the person's runtime (`POST /v1/exec`, scopes `crtr:act` and `crtr:schedule`).
98
+ * No shell and no stdin; the process starts in the app's files folder. A non-zero exit or a timeout resolves; a refusal
99
+ * throws `APIError` (`busy` 429 past 4 live execs for this app, `sandbox_unavailable` 503 on a runtime without a root launcher). */
100
+ exec(argv, options) {
101
+ const timeoutS = options?.timeoutS ?? 60;
102
+ return this.request('POST', routes.exec(), { argv, ...(options?.timeoutS === undefined ? {} : { timeout_s: options.timeoutS }) }, { timeout: timeoutS * 1000 + 15_000 });
103
+ }
97
104
  }
98
105
  /** The default deadline of one directory operation (`OAuth2Options.timeoutMs`): a discovery read is forgotten when it passes. */
99
106
  export const DEFAULT_DIRECTORY_TIMEOUT_MS = 10_000;
@@ -2,7 +2,7 @@
2
2
  name: northlight
3
3
  description: "Use when building, debugging or shipping an app that signs people in with Northlight, starts runs on their Northlight runtime, or calls Northlight providers (sms, search, integrations, context, device) with @north-light/sdk."
4
4
  metadata:
5
- sdk-version: "0.3.557"
5
+ sdk-version: "0.3.559"
6
6
  ---
7
7
 
8
8
  # Building a Northlight app
@@ -27,7 +27,7 @@ Never text a person first: Comms refuses a send to anyone who has not texted you
27
27
 
28
28
  ## First: check this skill matches the SDK
29
29
 
30
- This skill is for SDK version `0.3.557`. If the project has `node_modules/@north-light/sdk/package.json`, compare its `version` with that number. If they differ, run `npx @north-light/sdk skill` in the project, then read the rewritten `.agents/skills/northlight/SKILL.md` (or `.claude/skills/northlight/SKILL.md`) instead of this copy. It needs no account. A project that has no SDK yet starts with `npx @north-light/sdk@latest init <dir>`.
30
+ This skill is for SDK version `0.3.559`. If the project has `node_modules/@north-light/sdk/package.json`, compare its `version` with that number. If they differ, run `npx @north-light/sdk skill` in the project, then read the rewritten `.agents/skills/northlight/SKILL.md` (or `.claude/skills/northlight/SKILL.md`) instead of this copy. It needs no account. A project that has no SDK yet starts with `npx @north-light/sdk@latest init <dir>`.
31
31
 
32
32
  ## Where things run
33
33
 
@@ -1,4 +1,4 @@
1
- import type { ProviderDependency } from '@north-light/api';
1
+ import type { ExecRunDTO, ProviderDependency } from '@north-light/api';
2
2
  import { Crouter } from '../client.js';
3
3
  import { type ConnectionStore } from '../oauth/index.js';
4
4
  import { type FakeNotice, type RunNoticeOptions } from './notices.js';
@@ -7,7 +7,7 @@ import { type SpaceOp } from './space.js';
7
7
  import { type FakeRefusalCode, type FakeScope, type FakeUser, type Responder } from './users.js';
8
8
  import { type FakeError } from './wire.js';
9
9
  /** A runtime operation, named as the daemon's `OPERATIONS` table names it. */
10
- export type FakeOp = 'grant.get' | 'run.start' | 'run.list' | 'run.get' | 'run.message' | 'run.events' | 'run.interrupt' | 'run.cancel' | 'run.delete' | 'run.rename' | 'question.get' | 'question.answer' | 'question.cancel' | 'trace.get' | 'memory.list' | 'memory.get' | 'provider.tools' | 'provider.call' | 'profile.list' | SpaceOp;
10
+ export type FakeOp = 'grant.get' | 'run.start' | 'run.list' | 'run.get' | 'run.message' | 'run.events' | 'run.interrupt' | 'run.cancel' | 'run.delete' | 'run.rename' | 'question.get' | 'question.answer' | 'question.cancel' | 'trace.get' | 'memory.list' | 'memory.get' | 'provider.tools' | 'provider.call' | 'profile.list' | 'exec' | SpaceOp;
11
11
  /** A directory operation. */
12
12
  export type FakeDirectoryOp = 'directory.discovery' | 'directory.token' | 'directory.jwks';
13
13
  /** What `intercept` / `failNext` / `gate` match: a runtime operation, `'*'` (every runtime operation), or a directory operation. */
@@ -53,6 +53,14 @@ export type FakeToolHandler = (input: {
53
53
  arguments: Record<string, unknown>;
54
54
  signal: AbortSignal | null;
55
55
  }) => unknown;
56
+ /** What `exec.respond` installs: the answer to one `POST /v1/exec`. Fields left out default to a clean exit with empty
57
+ * output. Throw `FakeCloud.error(...)` for a refusal (e.g. 409 `model_sign_in_required` with `details: { url }`). */
58
+ export type FakeExecHandler = (input: {
59
+ userId: string;
60
+ argv: string[];
61
+ timeoutS: number;
62
+ signal: AbortSignal | null;
63
+ }) => Partial<ExecRunDTO> | Promise<Partial<ExecRunDTO>>;
56
64
  /** `providers.define` input. */
57
65
  export interface FakeProvider {
58
66
  displayName?: string;
@@ -95,7 +103,8 @@ export interface FakeCloudOptions {
95
103
  * Whether the runtime can start an `isolated: true` run. `available` (default) models a Northlight guest, where
96
104
  * the Linux root launcher always exists: the run starts, `run.params.isolated` is true, and a `cwd` outside the run's
97
105
  * root folder (`/apps/<clientId>/nodes/<run id>`) is 400 `invalid_request` `param: cwd`. `unavailable` models a
98
- * runtime without the launcher (a developer's Mac): every isolated start is 503 `isolated_unavailable`, retryable.
106
+ * runtime without the launcher (a developer's Mac): every isolated start is 503 `isolated_unavailable`, retryable, and
107
+ * every `exec` is 503 `sandbox_unavailable`, not retryable.
99
108
  */
100
109
  isolatedRuns?: 'available' | 'unavailable';
101
110
  /**
@@ -172,12 +181,24 @@ export declare class FakeCloud {
172
181
  readonly providers: {
173
182
  define(provider: string, definition: FakeProvider): void;
174
183
  };
184
+ /** `POST /v1/exec`: `respond` sets how every exec answers; `calls` is every accepted exec, oldest first. Unset, an exec exits 0 with empty output. */
185
+ readonly exec: {
186
+ respond(handler: FakeExecHandler | null): void;
187
+ readonly calls: readonly {
188
+ userId: string;
189
+ argv: string[];
190
+ timeoutS: number;
191
+ }[];
192
+ };
175
193
  private readonly options;
176
194
  private readonly clock;
177
195
  private readonly users;
178
196
  private readonly handles;
179
197
  private readonly allRuns;
180
198
  private readonly providerDefs;
199
+ private execHandler;
200
+ private readonly execCalls;
201
+ private liveExecs;
181
202
  private intercepts;
182
203
  private readonly gates;
183
204
  private responder;
@@ -306,5 +327,6 @@ export declare class FakeCloud {
306
327
  private memoryList;
307
328
  private providerEntry;
308
329
  private providerTools;
330
+ private execCall;
309
331
  private providerCall;
310
332
  }
@@ -36,6 +36,7 @@ const ROUTES = [
36
36
  ['GET', /^\/v1\/providers\/([^/]+)\/tools$/, 'provider.tools'],
37
37
  ['POST', /^\/v1\/provider\/call$/, 'provider.call'],
38
38
  ['GET', /^\/v1\/profiles$/, 'profile.list'],
39
+ ['POST', /^\/v1\/exec$/, 'exec'],
39
40
  ['POST', /^\/v1\/schemas$/, 'schema.register'],
40
41
  ['POST', /^\/v1\/relations$/, 'relations.write'],
41
42
  ['PUT', /^\/v1\/relations$/, 'relations.replace'],
@@ -50,7 +51,9 @@ const ROUTES = [
50
51
  ];
51
52
  const DIRECTORY_OPS = new Set(['directory.discovery', 'directory.token', 'directory.jwks']);
52
53
  const RUN_OPS = new Set(['run.get', 'run.delete', 'run.message', 'run.events', 'run.interrupt', 'run.cancel', 'run.rename', 'trace.get']);
53
- const ACT_OPS = new Set(['run.start', 'run.message']);
54
+ const ACT_OPS = new Set(['run.start', 'run.message', 'exec']);
55
+ /** The runtime's live exec processes per app (`handlers/exec.ts`); one more is 429 `busy`. */
56
+ const MAX_LIVE_EXECS = 4;
54
57
  /** The served memory body cap (`MEMORY_BODY_MAX_BYTES`, src/daemon/api/handlers/memory.ts). */
55
58
  const MEMORY_BODY_MAX_BYTES = 1024 * 1024;
56
59
  const MEMORY_NAME = /^[A-Za-z0-9_-]+(\/[A-Za-z0-9_-]+)*$/;
@@ -87,12 +90,17 @@ export class FakeCloud {
87
90
  /** Testing mode for this cloud's app. */
88
91
  testing;
89
92
  providers;
93
+ /** `POST /v1/exec`: `respond` sets how every exec answers; `calls` is every accepted exec, oldest first. Unset, an exec exits 0 with empty output. */
94
+ exec;
90
95
  options;
91
96
  clock;
92
97
  users = new Map();
93
98
  handles = new Map();
94
99
  allRuns = [];
95
100
  providerDefs = new Map();
101
+ execHandler = null;
102
+ execCalls = [];
103
+ liveExecs = 0;
96
104
  intercepts = [];
97
105
  gates = new Set();
98
106
  responder = null;
@@ -145,6 +153,8 @@ export class FakeCloud {
145
153
  throw new TypeError(`invalid provider id ${provider}`);
146
154
  this.providerDefs.set(provider, definition);
147
155
  } };
156
+ const execCalls = this.execCalls;
157
+ this.exec = { respond: (handler) => { this.execHandler = handler; }, get calls() { return execCalls; } };
148
158
  const cloud = this;
149
159
  this.testing = {
150
160
  get active() { return cloud.inTesting; },
@@ -820,6 +830,7 @@ export class FakeCloud {
820
830
  }
821
831
  case 'provider.tools': return this.providerTools(user, id);
822
832
  case 'provider.call': return this.providerCall(user, body, signal);
833
+ case 'exec': return this.execCall(user, body, signal);
823
834
  case 'profile.list': return { status: 200, body: { profiles: [{ profile_id: 'default', name: 'default', grantee: this.host.grantee, default_kind: 'general', default: true }] } };
824
835
  default:
825
836
  if (SPACE_OPS.has(op))
@@ -1068,6 +1079,36 @@ export class FakeCloud {
1068
1079
  }
1069
1080
  return { status: 200, body: { providers: visible } };
1070
1081
  }
1082
+ async execCall(user, body, signal) {
1083
+ if (!user.scopeStrings().some((scope) => covers(scope, 'crtr:schedule')))
1084
+ throw refuse(403, 'scope_missing', 'missing scope crtr:schedule', { scope: 'crtr:schedule' });
1085
+ const params = objectBody(body);
1086
+ const argv = params['argv'];
1087
+ if (!Array.isArray(argv) || argv.length === 0 || !argv.every((arg) => typeof arg === 'string'))
1088
+ throw invalid('argv must be a non-empty array of strings', 'argv');
1089
+ if (Buffer.byteLength(JSON.stringify(argv)) >= 32 * 1024)
1090
+ throw invalid('argv must be less than 32 KB as JSON', 'argv');
1091
+ const timeoutS = params['timeout_s'] ?? 60;
1092
+ if (typeof timeoutS !== 'number' || !Number.isFinite(timeoutS) || timeoutS <= 0 || timeoutS > 100)
1093
+ throw invalid('timeout_s must be greater than 0 and at most 100', 'timeout_s');
1094
+ if (this.options.isolatedRuns === 'unavailable')
1095
+ throw refuse(503, 'sandbox_unavailable', 'this runtime has no root launcher, so it cannot sandbox an app process', { retryable: false });
1096
+ // One app per cloud, so the runtime's per-app count is the cloud's.
1097
+ if (this.liveExecs >= MAX_LIVE_EXECS)
1098
+ throw refuse(429, 'busy', `${this.host.grantee} already has ${MAX_LIVE_EXECS} exec processes running; retry when one finishes`, { retryable: true });
1099
+ this.liveExecs++;
1100
+ const started = this.clock.now();
1101
+ try {
1102
+ this.execCalls.push({ userId: user.id, argv, timeoutS });
1103
+ // Raced with the request's signal: an abort frees the slot even when the handler never settles, as the runtime's killed process does.
1104
+ const answer = this.execHandler === null ? {} : await withSignal(Promise.resolve(this.execHandler({ userId: user.id, argv, timeoutS, signal })), signal);
1105
+ return { status: 200, body: { exit_code: 0, signal: null, stdout: '', stdout_truncated: false, stderr: '', stderr_truncated: false, timed_out: false,
1106
+ duration_ms: this.clock.now() - started, ...answer } };
1107
+ }
1108
+ finally {
1109
+ this.liveExecs--;
1110
+ }
1111
+ }
1071
1112
  async providerCall(user, body, signal) {
1072
1113
  const params = objectBody(body);
1073
1114
  const provider = params['provider'];
@@ -1,7 +1,7 @@
1
1
  export { FakeCloud, FAKE_TESTER_LIMIT } from './cloud.js';
2
2
  export { manualClock } from './clock.js';
3
3
  export type { ManualClock } from './clock.js';
4
- export type { FakeAnswer, FakeCall, FakeCloudOptions, FakeDirectoryCall, FakeDirectoryOp, FakeInterceptOp, FakeOp, FakeProvider, FakeRuns, FakeTester, FakeTesterRef, FakeTesting, FakeToolHandler, Intercept, } from './cloud.js';
4
+ export type { FakeAnswer, FakeCall, FakeCloudOptions, FakeDirectoryCall, FakeDirectoryOp, FakeExecHandler, FakeInterceptOp, FakeOp, FakeProvider, FakeRuns, FakeTester, FakeTesterRef, FakeTesting, FakeToolHandler, Intercept, } from './cloud.js';
5
5
  export type { FakeRun, FakeRunEnding, FakeRunMessage, FakeRunParams, FakeQuestion } from './run.js';
6
6
  export type { FakeNotice, RunNoticeOptions } from './notices.js';
7
7
  export type { FakeAccount, FakeMemoryDoc, FakeRefusalCode, FakeScope, FakeUser, GrantPatch, Responder, ResponderInput } from './users.js';
package/dist/version.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
2
- export declare const SDK_VERSION = "0.3.557";
2
+ export declare const SDK_VERSION = "0.3.559";
3
3
  /** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
4
4
  * contract level existed (which only knows this header) refuses this SDK, while every later runtime accepts it. */
5
5
  export declare const LEGACY_FLOOR = "0.3.423";
package/dist/version.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // SDK_VERSION is written by scripts/release-version.mjs on every release; do not edit by hand.
2
2
  /** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
3
- export const SDK_VERSION = '0.3.557';
3
+ export const SDK_VERSION = '0.3.559';
4
4
  // LEGACY_FLOOR is written once by `scripts/contract.mjs release`, at the first release that carries the contract level
5
5
  // (the `0.0.0` placeholder is replaced there and never again); do not edit by hand.
6
6
  /** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@north-light/sdk",
3
- "version": "0.3.557",
4
- "crouterContract": "1.33",
3
+ "version": "0.3.559",
4
+ "crouterContract": "1.34",
5
5
  "description": "Typed Node and browser client for running crouter agents through the crtrd /v1 API.",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -44,9 +44,9 @@
44
44
  "prepublishOnly": "node -e \"if (process.env.npm_config_tag !== 'next') { console.error('@north-light/sdk publishes only with --tag next; scripts/promote-sdk.mjs is the only mover of latest.'); process.exit(1); }\""
45
45
  },
46
46
  "dependencies": {
47
- "@north-light/api": "0.3.557",
48
- "@north-light/identity": "0.3.557",
49
- "@north-light/plugin": "0.3.557",
47
+ "@north-light/api": "0.3.559",
48
+ "@north-light/identity": "0.3.559",
49
+ "@north-light/plugin": "0.3.559",
50
50
  "jose": "^6.2.1"
51
51
  },
52
52
  "peerDependencies": {