@north-light/cli 0.3.480 → 0.3.481

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.
@@ -25,9 +25,9 @@ The tar has exactly `bundle.json`, `commands.json`, and `memory/<name>.md` membe
25
25
 
26
26
  ## Documents in the `memory` array
27
27
 
28
- Add a `memory` array to `definePlugin` when the app needs to give agents reusable operating knowledge. Each entry has `name`, `kind`, `whenAndWhyToRead`, `body`, and optional `unlisted` and `rules`. `name` is a safe relative path without the `.md` suffix. The package writes the frontmatter required by the archive installer, so the author supplies no YAML.
28
+ Guidance about choosing or calling a command belongs in that command's `whenToUse`, `description`, or parameter text, which the agent reads at the moment it chooses. Add a `memory` array to `definePlugin` only for reusable operating knowledge no single command owns. Each entry has `name`, `kind`, `whenAndWhyToRead`, `body`, and optional `unlisted` and `rules`. `name` is a safe relative path without the `.md` suffix. The package writes the frontmatter required by the archive installer, so the author supplies no YAML.
29
29
 
30
- `whenAndWhyToRead` is one routing sentence in the form `When <circumstance>, this <kind> should be read because <payoff>.` Keep the body to knowledge the agent needs to use the application. When the plugin is installed, crouter imports each entry as a public, read-only document owned by an app named after the plugin (an installed plugin is an app on the canvas), together with the generated commands. An agent reads it with `crtr canvas read <plugin>/<name>`, and `whenAndWhyToRead` becomes the document's preview. The `memory` array and `whenAndWhyToRead` are the plugin API's names; the result is an ordinary canvas document.
30
+ `whenAndWhyToRead` is one routing sentence in the form `When <circumstance>, this <kind> should be read because <payoff>.` Keep the body to knowledge the agent needs to use the application. When the plugin is installed, crouter imports each entry as a public, read-only document owned by the installed plugin's app (an installed plugin is an app on the canvas), together with the generated commands. That app is named after the plugin for an endpoint install, and after the provider's directory id for a provider installed from the directory, so an agent reads it with `crtr canvas read <app>/<name>`, and `whenAndWhyToRead` becomes the document's preview. The `memory` array and `whenAndWhyToRead` are the plugin API's names; the result is an ordinary canvas document.
31
31
 
32
32
  ## Loading a document on its own
33
33
 
@@ -9,7 +9,7 @@ when-and-why-to-read: When you need Commands, this knowledge should be read
9
9
 
10
10
  `definePlugin` declares the top-level crtr command. `name` must be lowercase kebab case. `description`, `whenToUse`, and `summary` are required text for the plugin, every branch, and every leaf. Write them for an agent choosing a command: describe the concrete object or action, state when the command applies, and state the short outcome.
11
11
 
12
- `rootEntry` describes the top-level command and requires `concept`, `description`, and `whenToUse`. `commands` is an object whose keys are camelCase or lowercase names. The package converts each key to the lowercase kebab name crtr exposes. A key must round-trip, so use `appId` for `app-id`, not a spelling that converts ambiguously.
12
+ `rootEntry` describes the top-level command and requires `concept`, `description`, and `whenToUse`. `rootEntry.concept` is the one line `crtr -h` shows for the plugin, so say what its objects are, not its name. `rootEntry.whenToUse` is printed after the words `use when`, so write it as a lowercase clause with no leading "When". `commands` is an object whose keys are camelCase or lowercase names. The package converts each key to the lowercase kebab name crtr exposes. A key must round-trip, so use `appId` for `app-id`, not a spelling that converts ambiguously.
13
13
 
14
14
  Use `defineBranch` to group commands and give it `children`. Use `defineLeaf` for a request that returns one object. A leaf requires `params`, `output`, `effects`, and `handler`; `params` may be omitted when the command has no input. `effects` is a non-empty list shown to the agent. Say whether a command mutates an application, creates billable resources, or is read-only.
15
15
 
@@ -9,6 +9,19 @@ when-and-why-to-read: When you need Release notes, this knowledge should be read
9
9
 
10
10
  Changes to `@north-light/sdk` that can change what an existing app sees. Newest first.
11
11
 
12
+ ## 0.3.481
13
+
14
+ ### `npx @north-light/sdk login`, `logout`, `whoami`, `register` and `apps` (additive, no app change)
15
+
16
+ The `crouter-sdk` bin gains five commands that need neither crtr nor a joined machine. They call only the Northlight directory, never the runtime `/v1` surface, so the contract level does not change. They need a directory that supports developer login; against one that does not, `login` says so and points at the console.
17
+
18
+ - `login [--no-browser] [--new] [--issuer <url>]` prints a link and a code, you approve once in the browser, and a revocable login is saved to `~/.config/northlight/credentials.json` (`$XDG_CONFIG_HOME/northlight/` when set; mode 0600, and a file others can read is refused). Run it again after a timeout: it resumes the same code.
19
+ - `logout` revokes the login on the server and deletes it locally. `whoami` shows the account and the login's expiry.
20
+ - `register [--name …] [--scope …]… [--logo …] [--justification …] [--redirect-uri …]…` registers the app from a project made by `init`, using the public half of `local/app-key.json`, and writes `CROUTER_CLIENT_ID` and `CROUTER_ISSUER` to `local/app.env`.
21
+ - `apps <leaf>` calls one app-management leaf (`mine`, `get`, `update`, `set-redirect-uris`, `asks`, `set-scopes`, `set-requires`, `set-run-notice-url`, `keys …`, `testers …`) and prints its answer as JSON. Deleting an app, its marketplace listing, spend cap, sharing and provider role stay in the console; the CLI refuses them without a request.
22
+
23
+ The package still has one bin.
24
+
12
25
  ## 0.3.472
13
26
 
14
27
  ### `npx @north-light/sdk init`, `skill` and `env --client-id` (additive, no app change)
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  kind: knowledge
3
3
  when-and-why-to-read: When you need CLI reference, this knowledge should be read
4
- because The npx @north-light/sdk commands for scaffolding an app, writing its
5
- signing key and environment file, and installing the agent skill.
4
+ because The npx @north-light/sdk commands for scaffolding an app, logging in,
5
+ registering it, managing it, and installing the agent skill.
6
6
  ---
7
7
 
8
8
  # CLI reference
@@ -15,19 +15,36 @@ when-and-why-to-read: When you need CLI reference, this knowledge should be read
15
15
  | `npx @north-light/sdk skill [--agent claude\|agents\|all] [--global] [--print]` | Write the packaged `northlight` skill into `.agents/skills` and `.claude/skills` (or `~/` with `--global`), replacing an older copy. `--print` writes it to stdout. | No |
16
16
  | `npx @north-light/sdk keygen [--out <path>] [--app <client id>] [--yes]` | Write a private signing key to `local/app-key.json` (mode 0600) and print the public JWK to register. | Only with `--app` |
17
17
  | `npx @north-light/sdk env --client-id <client id> [--origin <url>] [--issuer <url>] [--out <path>]`<br/>`npx @north-light/sdk env --app <client id> [--out <path>]` | Write `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env`, keeping every other line. `--client-id` makes no network call; `--app` reads them through crtr. | Only with `--app` |
18
+ | `npx @north-light/sdk login [--no-browser] [--new] [--issuer <url>]` | Log this computer in to your Northlight account: prints a link and a code, you approve once in the browser, and a revocable login is saved to `~/.config/northlight/credentials.json`. Run it again if it times out: it resumes the same code. Calls only the Northlight directory. | No |
19
+ | `npx @north-light/sdk logout [--issuer <url>]` | Revoke this computer's login on the server and delete it locally. | No |
20
+ | `npx @north-light/sdk whoami [--issuer <url>]` | Show the account this computer is logged in as and when the login expires. Exits 1 when not logged in. | No |
21
+ | `npx @north-light/sdk register [--name <text>] [--scope <scope>]... [--logo <url>] [--justification <text>] [--redirect-uri <uri>]... [--issuer <url>]` | Run in a project made by `init`, after `login`: registers the app with the public key from `local/app-key.json`, adds each `--scope` beyond `crtr:llm crtr:act`, and writes `CROUTER_CLIENT_ID` and `CROUTER_ISSUER` to `local/app.env`. | No |
22
+ | `npx @north-light/sdk apps <leaf> [--app <client id>] [--issuer <url>] [leaf flags] [--json]` | Call one app-management leaf with the login and print its answer as JSON. Leaves: `mine`, `get`, `update`, `set-redirect-uris`, `asks`, `set-scopes`, `set-requires`, `set-run-notice-url`, `keys list\|add\|retire\|delete\|set-jwks-uri\|remove-jwks-uri`, `testers list\|add\|remove`. `--app` defaults to `CROUTER_CLIENT_ID` in `local/app.env`; `keys add` takes `--key-file <path>` and sends only the public half. Deleting an app, its marketplace listing, spend cap, sharing and provider role are console-only. | No |
18
23
 
19
- `keygen` and `env` are the only commands that can call `crtr`, and only with `--app`. The documented path on this site never uses `--app`, so you do not need crtr.
24
+ `keygen` and `env` are the only commands that can call `crtr`, and only with `--app`. The documented path on this site never uses `--app`, so you do not need crtr. `login`, `logout`, `whoami`, `register` and `apps` call only the Northlight directory.
20
25
 
21
26
  ## Details
22
27
 
23
28
  ### `init`
24
29
 
25
- In an empty or new directory it writes `package.json` (with `@north-light/sdk` pinned to the CLI's own exact version, plus `tsx`), `server.ts` (the quickstart server), `.gitignore` (`local/`, `node_modules/`) and `local/app.env`, and runs `keygen` into `local/app-key.json`. It installs the skill. Then it prints the public JWK, the console URL `https://account.northlight.ai/console/new`, the values to type, and `npx @north-light/sdk env --client-id <client id>` as the next step.
30
+ In an empty or new directory it writes `package.json` (with `@north-light/sdk` pinned to the CLI's own exact version, plus `tsx`), `server.ts` (the quickstart server), `.gitignore` (`local/`, `node_modules/`) and `local/app.env`, and runs `keygen` into `local/app-key.json`. It installs the skill. Then it prints the public JWK and the next steps: `login`, then `register`, then the start command. It also prints the console address for registering in the browser instead.
26
31
 
27
- In a directory that already has a `package.json` it writes no app code. It runs `npm i --save-exact @north-light/sdk@<its own version>`, runs `keygen` unless `local/app-key.json` exists, and installs the skill.
32
+ In a directory that already has a `package.json` it writes no app code. It runs `npm i --save-exact @north-light/sdk@<its own version>`, runs `keygen` unless `local/app-key.json` exists, and installs the skill. Because `register` reads `APP_ORIGIN` from `local/app.env`, which `init` writes only for a new app, an existing project without that file gets the console steps and the `env --client-id` command as its next step instead.
28
33
 
29
34
  It never overwrites a file. On a refused step it exits non-zero and names the file it refused.
30
35
 
36
+ ### `login`, `logout`, `whoami`
37
+
38
+ `login` prints a link and a code and waits while you approve in the browser, then saves a revocable login to `~/.config/northlight/credentials.json`. `--no-browser` does not open the link; `--new` discards a pending code and starts a fresh one. `whoami` shows the account and when the login expires, and exits 1 when this computer is not logged in. `logout` revokes the login on the server and deletes the local file. The login can manage your apps; it cannot delete one (not yet checked on production).
39
+
40
+ ### `register`
41
+
42
+ Run it in the project `init` made, after `login`. It sends the app's origin from `local/app.env`, the public half of `local/app-key.json` and the permissions you pass with `--scope`, and writes the client ID and issuer it gets back into `local/app.env`. `crtr:llm` and `crtr:act` are always requested (not yet checked on production).
43
+
44
+ ### `apps`
45
+
46
+ `apps <leaf>` runs one app-management leaf with the login and prints the answer as JSON. It reads the app from `--app`, else from `CROUTER_CLIENT_ID` in `local/app.env`. `apps keys add --key-file local/app-key.json` sends only the public half.
47
+
31
48
  ### `skill`
32
49
 
33
50
  Writes the `northlight` Agent Skill that ships in the package, replacing an older copy. `--agent` picks `.claude/skills`, `.agents/skills` or both. `--global` writes under your home directory instead of the project. The skill's `metadata.sdk-version` is the package version, so run `skill` again after you change the pinned SDK version.
@@ -8,7 +8,7 @@ when-and-why-to-read: When you need Use your coding agent, this knowledge should
8
8
 
9
9
  # Use your coding agent
10
10
 
11
- The `northlight` skill teaches a coding agent the whole path in the [[northlight-apps/quickstart]]: the commands, the console fields, the code patterns and the rules. `npx @north-light/sdk init` installs it into your project. The skill needs no account and no crtr.
11
+ The `northlight` skill teaches a coding agent the whole path in the [[northlight-apps/quickstart]]: the commands, the code patterns and the rules. With it, your agent signs you up, logs in, registers the app and runs it, and you choose **Allow** twice in your browser: once on the login page, once on the app's consent page. `npx @north-light/sdk init` installs it into your project. The skill needs no account and no crtr.
12
12
 
13
13
  ## Install it
14
14
 
@@ -47,9 +47,32 @@ An agent that has no project yet can read the hosted copy:
47
47
 
48
48
  > Read https://sdk.docs.northlight.ai/SKILL.md and follow it.
49
49
 
50
- The agent runs `npx @north-light/sdk@latest init` and then follows the quick setup. It asks you to do the browser steps: sign up, register the app at `https://account.northlight.ai/console/new`, and add permissions. The hosted copy matches the SDK version the docs site was last built from.
50
+ The agent runs `npx @north-light/sdk@latest init`, then `npx @north-light/sdk login`, which prints a link and a code. It shows you both. You open the link, check the page shows the same code, and choose **Allow**. The agent then runs `npx @north-light/sdk register --scope sms:send`, installs, starts the app, and asks you to open `/auth/login` and choose **Allow** on the consent page. You sign up for Northlight first. The hosted copy matches the SDK version the docs site was last built from.
51
51
 
52
- Checked with Claude Code (`claude -p`, no crtr on `PATH`, an empty directory): given only that sentence it read the skill and asked what to build. Given the sentence plus "I want the quickstart app: sign in with Northlight, start a run, text me", it ran `init my-app`, read the generated `server.ts`, and stopped with the console steps and the public key for you to paste, asking for the client ID. Name what you want built in the prompt.
52
+ If the agent's shell call times out while you approve, it runs `login` again and the same code resumes. If it cannot reach `account.northlight.ai`, it falls back to asking you to register in the console.
53
+
54
+ Checked with Claude Code (`claude -p`, no crtr on `PATH`, an empty directory) before `login` existed: given only the skill URL plus "I want the quickstart app: sign in with Northlight, start a run, text me", it ran `init my-app`, read the generated `server.ts`, and stopped with the console steps and the public key for you to paste. Name what you want built in the prompt. The journey with `login` and `register` has not been run by an agent on production (not yet checked on production).
55
+
56
+ ## What the login can do
57
+
58
+ `login` saves a revocable login for your computer. It is for you and the agent on your laptop, not for CI.
59
+
60
+ | It can | It cannot |
61
+ |---|---|
62
+ | Register new apps (at most 10 a day) | Delete an app |
63
+ | Change a name, origin, logo and description | List an app in the marketplace, or change its spend cap or sharing |
64
+ | Change what an app asks for and requires | Set the provider role |
65
+ | Add and remove testers | See private keys, or use your runtime, your AI subscription, your texts or your data |
66
+ | Change signing keys, redirect URIs and the run notice URL on an app in testing | Change those on a verified app: do it in the console |
67
+ | | Approve another login |
68
+
69
+ Deleting an app, its listing, spend cap and sharing stay in the console on purpose. The CLI refuses them without sending a request, and the directory refuses them if a call is made by hand.
70
+
71
+ The login lives in `~/.config/northlight/credentials.json` (`$XDG_CONFIG_HOME/northlight/credentials.json` when set), mode `0600`, outside your project. The CLI refuses a file that other users can read. It lasts 30 days, or until it has gone 14 days unused, and then you run `login` again. An account holds at most 10 logins; an eleventh replaces the oldest.
72
+
73
+ Revoke it on your account's Developer access page (`https://account.northlight.ai/account/developer`), or run `npx @north-light/sdk logout`, which revokes it on the server and deletes the file. The next command then fails and asks for `login`.
74
+
75
+ Not yet checked on production: every statement in this section, including the limits and the page.
53
76
 
54
77
  ## How it stays current
55
78
 
@@ -63,14 +86,15 @@ After `init`:
63
86
 
64
87
  Starting from nothing:
65
88
 
66
- > Read https://sdk.docs.northlight.ai/SKILL.md and build me an app where people sign in with Northlight and the app texts them a daily summary. Tell me which console steps I need to do.
89
+ > Read https://sdk.docs.northlight.ai/SKILL.md and build me an app where people sign in with Northlight and the app texts them a daily summary.
67
90
 
68
91
  ## Rules the skill gives your agent
69
92
 
70
93
  Your agent reads them from the skill. They are worth knowing as the person reviewing its work:
71
94
 
72
95
  - Pin the exact SDK version.
73
- - Take scope strings from the console's Add permissions list, never from memory.
96
+ - Take scope strings from `apps asks` or the console's Add permissions list, never from memory.
97
+ - Never print, paste or commit `~/.config/northlight/credentials.json`, and leave deleting, listing and the spend cap to you in the console.
74
98
  - Never print, paste or commit `local/app-key.json`.
75
99
  - Reach a person's runtime with `oauth.client(userId)`, not `new Crouter()`.
76
100
  - Use a persistent `ConnectionStore` in production.
@@ -7,7 +7,7 @@ when-and-why-to-read: When you need Apps and sign-in, this knowledge should be
7
7
 
8
8
  # Apps and sign-in
9
9
 
10
- An app is a registration in the Northlight directory: a client ID, a public signing key, the redirect URIs people return to after sign-in, and the list of scopes the app may ask for. You create it in the console at `https://account.northlight.ai/console/new`. Your server proves it is that app by signing its requests with the matching private key.
10
+ An app is a registration in the Northlight directory: a client ID, a public signing key, the redirect URIs people return to after sign-in, and the list of scopes the app may ask for. You create it with `npx @north-light/sdk register` after `npx @north-light/sdk login`, or in the console at `https://account.northlight.ai/console/new`. Your server proves it is that app by signing its requests with the matching private key.
11
11
 
12
12
  ## What registration holds
13
13
 
@@ -9,12 +9,27 @@ when-and-why-to-read: When you need Manage your app, this knowledge should be
9
9
 
10
10
  You manage an app in the console at `https://account.northlight.ai/console`. Choose your app in the list to open its page. The tabs are Sign-in, What it asks for, Profile, Sharing, Testers and Provider. (Only the first-party apps use Provider.)
11
11
 
12
- Your **client ID** is shown on the app's page. Copy it into `CROUTER_CLIENT_ID`, or write it to `local/app.env` with `npx @north-light/sdk env --client-id <client id>`.
12
+ Your **client ID** is shown on the app's page. `npx @north-light/sdk register` writes it to `local/app.env`; for an app registered in the browser, use `npx @north-light/sdk env --client-id <client id>`.
13
+
14
+ Most of what the tabs do you can also do from the terminal with `npx @north-light/sdk apps <leaf>` after `npx @north-light/sdk login` (see [[northlight-apps/coding-agents]]). It prints the answer as JSON. `--app` defaults to `CROUTER_CLIENT_ID` in `./local/app.env`. List your apps with `apps mine` and show one with `apps get`.
15
+
16
+ ## Done only in the console
17
+
18
+ The login cannot do these, and the CLI refuses them without a request:
19
+
20
+ - Deleting the app.
21
+ - The marketplace listing, price and welcome text.
22
+ - The monthly spend cap.
23
+ - Sharing (what other apps may use).
24
+ - The provider role.
25
+ - On a verified app: signing keys, the JWKS URL, redirect URIs and the run notice URL.
26
+
27
+ Everything below names its `apps` command when there is one. (Not yet checked on production for any `apps` command.)
13
28
 
14
29
  ## Sign-in
15
30
 
16
- - **Redirect URIs.** Sign-in returns only to a URI listed here, matched exactly: `localhost` and `127.0.0.1` are different URIs, and the port counts. Each sign-in names the URI to return to, so an app can hold several (for example your deployed callback and a localhost one). **Save redirect URIs** replaces the whole list, so keep the ones you still use.
17
- - **Keys.** An app proves who it is with a signing key. There is no client secret. **Add a key** takes a public JWK; the form refuses a pasted private key. You can instead **Use a JWKS URL**: the directory reads the keys from that URL. An app holds at most 2 active keys. **Retire** stops a key signing in; a retired key can be **Deleted** so it can be added again.
31
+ - **Redirect URIs.** Sign-in returns only to a URI listed here, matched exactly: `localhost` and `127.0.0.1` are different URIs, and the port counts. Each sign-in names the URI to return to, so an app can hold several (for example your deployed callback and a localhost one). **Save redirect URIs** replaces the whole list, so keep the ones you still use. From the terminal: `apps set-redirect-uris --redirect-uri <uri> …` replaces the list the same way (apps in testing).
32
+ - **Keys.** An app proves who it is with a signing key. There is no client secret. **Add a key** takes a public JWK; the form refuses a pasted private key. You can instead **Use a JWKS URL**: the directory reads the keys from that URL. An app holds at most 2 active keys. **Retire** stops a key signing in; a retired key can be **Deleted** so it can be added again. From the terminal, for an app in testing: `apps keys list`, `keys add --key-file <path>` (it reads your private key file and sends only the public half), `keys retire`, `keys delete`, `keys set-jwks-uri` and `keys remove-jwks-uri`.
18
33
 
19
34
  ### Rotate a key
20
35
 
@@ -30,20 +45,20 @@ Until you retire the old key it is your rollback. After you retire it, a sign-in
30
45
 
31
46
  ## What it asks for
32
47
 
33
- - **What your app asks for.** The scopes people are asked to approve. **Add permissions** searches the catalog; take scope strings from it and from `providers.tools`, and do not invent them. `crtr:act` needs `crtr:llm`. See [[northlight-apps/concepts/scopes-and-grants]].
34
- - **What your app requires.** Providers the app cannot work without. A person is taken through that provider's setup the first time they sign in. See [[northlight-apps/guides/grant-changes]].
48
+ - **What your app asks for.** The scopes people are asked to approve. **Add permissions** searches the catalog; take scope strings from it and from `providers.tools`, and do not invent them. From the terminal: `apps asks` lists what the app can ask for, and `apps set-scopes --add <scope>` or `--remove <scope>` changes the list. `crtr:act` needs `crtr:llm`. See [[northlight-apps/concepts/scopes-and-grants]].
49
+ - **What your app requires.** Providers the app cannot work without (`apps set-requires`). A person is taken through that provider's setup the first time they sign in. See [[northlight-apps/guides/grant-changes]].
35
50
  - **Monthly spend cap.** The most your app may spend of each person's AI subscription per month, in whole US dollars.
36
- - **Run notice URL.** Where Northlight posts a signed notice when one of your app's background runs ends. Type it in **Notice URL** and choose **Save**; empty it and save to turn notices off. The URL must be `https`, at most 512 characters, with no `#fragment`. The directory refuses a plain `http` URL with `run_notice_url` as the field. The field's hint says `http://localhost` is accepted while developing, but the production directory refuses it (checked in the directory source, not yet checked on production), so test notices with a tunnel that gives you an `https` URL. What your app does with a notice is in [[northlight-apps/guides/background-runs]].
51
+ - **Run notice URL.** Where Northlight posts a signed notice when one of your app's background runs ends. Type it in **Notice URL** and choose **Save**; empty it and save to turn notices off. From the terminal: `apps set-run-notice-url` (apps in testing). The URL must be `https`, at most 512 characters, with no `#fragment`. The directory refuses a plain `http` URL with `run_notice_url` as the field. The field's hint says `http://localhost` is accepted while developing, but the production directory refuses it (checked in the directory source, not yet checked on production), so test notices with a tunnel that gives you an `https` URL. What your app does with a notice is in [[northlight-apps/guides/background-runs]].
37
52
 
38
53
  ## Profile
39
54
 
40
- - **How people see your app**: Name, Origin URL, Logo URL, and **Why your app needs access** (quoted on the consent page as your words). **Save details**.
55
+ - **How people see your app**: Name, Origin URL, Logo URL, and **Why your app needs access** (quoted on the consent page as your words). **Save details**. From the terminal: `apps update`.
41
56
  - **Marketplace**: **Listed in the marketplace**, Price, **Sign-in start URL** and **Text welcome**. Agents can install a listed app for a person by text. A testing app stays out of the marketplace even when listed. The sign-in start URL is where Install sends people; your `/auth/login` route is a valid one, and a `localhost` URL is accepted. The text welcome is one or two plain sentences, at most 120 characters, with no links.
42
57
  - **Delete this app.** At the bottom of the tab; see [Delete an app](#delete-an-app).
43
58
 
44
59
  ## Testers
45
60
 
46
- A new app is in testing: only you and up to 100 testers can sign in. Add a tester by the email or phone number on their Northlight account. An email matches only while it is verified on that account. Nobody gets a message, so tell them. Removing a tester revokes their grant for the app. See [[northlight-apps/concepts/testing-mode]].
61
+ A new app is in testing: only you and up to 100 testers can sign in. Add a tester by the email or phone number on their Northlight account. An email matches only while it is verified on that account. Nobody gets a message, so tell them. Removing a tester revokes their grant for the app. From the terminal: `apps testers list`, `testers add --email <email>` and `testers remove`. See [[northlight-apps/concepts/testing-mode]].
47
62
 
48
63
  ## Sharing
49
64
 
@@ -15,7 +15,7 @@ This command creates a small Node server, a signing key and an env file. About t
15
15
 
16
16
  You need Node.js 20.6 or later (the start command uses `--env-file`) and a phone that can receive iMessage. The server was run on Node 20.6, 20.20, 22.14, 22.22 and 26.12.
17
17
 
18
- Using a coding agent? Run the same `init` command, or tell your agent: *Read https://sdk.docs.northlight.ai/SKILL.md and follow it.* See [[northlight-apps/coding-agents]].
18
+ Using a coding agent? Tell it: *Read https://sdk.docs.northlight.ai/SKILL.md and follow it.* It runs every command below; you sign up, then choose **Allow** twice in your browser. See [[northlight-apps/coding-agents]].
19
19
 
20
20
  Each step below ends with what you should see when it worked. A step marked "(not yet checked on production)" is written from the source code and has not been run end to end against `account.northlight.ai`.
21
21
 
@@ -43,17 +43,110 @@ cd my-app
43
43
  | `.gitignore` | Keeps `local/` and `node_modules/` out of git. |
44
44
  | `.agents/skills/northlight/SKILL.md` and `.claude/skills/northlight/SKILL.md` | The Northlight skill for your coding agent. |
45
45
 
46
- You should see, on the terminal: `Created a Northlight app in my-app:` with the file names, the path of the private key, the next steps in order, and last, on its own line, the **public key** as one line of JSON:
46
+ You should see, on the terminal: `Created a Northlight app in my-app:` with the file names, the path of the private key, the next steps in order (sign up, `login`, `register`, start the server), and last, on its own line, the **public key** as one line of JSON:
47
47
 
48
48
  ```json
49
49
  {"kty":"EC","x":"…","y":"…","crv":"P-256","kid":"…","alg":"ES256","use":"sig"}
50
50
  ```
51
51
 
52
- Copy that line. You paste it into the console in the next step. If a file already exists, `init` refuses, names it and exits non-zero without writing anything.
52
+ Only the browser registration below uses that line: `register` reads the public half from `local/app-key.json` itself. If a file already exists, `init` refuses, names it and exits non-zero without writing anything.
53
53
 
54
54
  Checked on production: `npx @north-light/sdk@latest init my-app` (npm `latest` 0.3.476) in an empty directory with no crtr on `PATH` printed this output and wrote these files, the private key has mode `0600`, and `server.ts` is the one listed at the end of this page. Running it again names the skill file and writes nothing.
55
55
 
56
- ## 3. Register the app in the console
56
+ ## 3. Log in
57
+
58
+ ```bash
59
+ npx @north-light/sdk login
60
+ ```
61
+
62
+ This lets your computer, and the coding agent on it, register and configure your apps. It prints a link and a code:
63
+
64
+ ```text
65
+ To let this computer manage your Northlight apps, open:
66
+
67
+ https://account.northlight.ai/developer/confirm?user_code=BCDF-GH23
68
+
69
+ and check the page shows the code BCDF-GH23.
70
+ Opened it in your browser.
71
+ Waiting for approval (the code expires in 10 minutes)…
72
+ ```
73
+
74
+ Open the link (the command already tried to), sign in to Northlight if asked, check the page shows the same code, and choose **Allow**. The page is titled "Let a coding agent manage your Northlight apps?". Allow it only when you, or your own coding agent, just ran `login`: it lists the computer's name, platform and network address so you can check they are yours. It says what the login can and cannot do:
75
+
76
+ - It can register new apps for you; change the name, origin, logo and description of apps you manage; change their signing keys, redirect URIs and run notice URL (apps in testing only); change what they ask for and require; and add and remove testers.
77
+ - It cannot delete an app; list an app in the marketplace or change its spend cap or sharing; see private keys; use your runtime, your AI subscription, your texts or your data; or approve another login.
78
+
79
+ The login lasts until its expiry date shown on the terminal, or until it has gone 14 days unused. You can revoke it any time on your account's Developer access page.
80
+
81
+ You should see, on the terminal after you choose Allow:
82
+
83
+ ```text
84
+ Signed in as Silas Rhyneer (user:jpqnsu99rlja).
85
+ Saved the login for silas-mbp to ~/.config/northlight/credentials.json (only you can read it).
86
+ It can register and configure the apps you manage. It can't delete an app.
87
+ It expires on 2026-11-15, or after 14 days unused. Revoke it at https://account.northlight.ai/account/developer
88
+ ```
89
+
90
+ and "Done. Go back to your terminal." in the browser. The name, id and dates are examples. If the command times out before you approve, run `npx @north-light/sdk login` again: it resumes the same code. `npx @north-light/sdk whoami` shows who you are logged in as. With no browser (a remote shell or a sandbox) the command prints "Couldn't open a browser here." instead: open the link on any device where you can sign in.
91
+
92
+ The login is saved outside your project, in `~/.config/northlight/credentials.json` (mode `0600`). Never commit, paste or print it.
93
+
94
+ Not yet checked on production: this whole step, including the confirm page and its wording (not yet checked on production). The command, its output and its files are checked against a fake directory in the package's tests.
95
+
96
+ If `login` cannot reach `account.northlight.ai`, or you would rather use the browser, skip to [Register in the browser instead](#register-in-the-browser-instead).
97
+
98
+ ## 4. Register the app
99
+
100
+ ```bash
101
+ npx @north-light/sdk register --scope sms:send
102
+ ```
103
+
104
+ Run it in the folder `init` made. It registers the app with the public key from `local/app-key.json`, the name from `package.json`, the origin from `local/app.env` and the redirect URI `<origin>/auth/callback`, then adds `sms:send` to what the app asks for. A new app starts asking for `crtr:llm` and `crtr:act`. The server in this guide also asks for `sms:send`, so it needs the `--scope`. Take scope strings from `npx @north-light/sdk apps asks` or from `providers.tools`. Do not invent them. See [[northlight-apps/concepts/scopes-and-grants]].
105
+
106
+ You should see:
107
+
108
+ ```text
109
+ Registered my-northlight-app (client ID s53r6e24zmin). It is in testing: only you and your testers can sign in.
110
+ Origin: http://localhost:3000
111
+ Redirect URI: http://localhost:3000/auth/callback
112
+ Public key: Jkk-BxTZ6D4QoeKjLIa3hxuKI1oZvSlgemePh5tZgYw (from local/app-key.json)
113
+ Asks for: crtr:llm crtr:act sms:send
114
+ Wrote CROUTER_CLIENT_ID to local/app.env.
115
+ Console: https://account.northlight.ai/console/apps/s53r6e24zmin
116
+ Next: npm i && npm start, then open http://localhost:3000/auth/login
117
+ ```
118
+
119
+ `local/app.env` now has all four settings. The name, client ID and key ID are examples. Change the defaults with `--name`, `--logo`, `--justification` and `--redirect-uri`; the default logo is a placeholder, `https://sdk.docs.northlight.ai/app-logo.png`. `register` refuses when `CROUTER_CLIENT_ID` is already set. A login can register at most 10 apps a day.
120
+
121
+ Not yet checked on production: this step, including that the directory accepts the default logo (not yet checked on production). Its output and its effect on `local/app.env` are checked against a fake directory in the package's tests.
122
+
123
+ ## 5. Run it
124
+
125
+ ```bash
126
+ npm i
127
+ node --env-file=local/app.env --import tsx server.ts
128
+ ```
129
+
130
+ (`npm start` runs the same command.) You should see `Listening on http://localhost:3000`.
131
+
132
+ Open `http://localhost:3000/auth/login`. Your browser goes to `account.northlight.ai`. Sign in if asked, read the consent page, and choose Allow. You land back on `http://localhost:3000/` with "Signed in" and your Northlight user id. Press **Start a run and send me a text**. The page answers "Done", shows the run's status `idle`, the run's reply (`pong`) and the text's `message_id`, and your phone receives "Hello from my first Northlight app." from Northlight's number.
133
+
134
+ Checked on production, as the account that owns the app: the server starts (with `npm start` too), `/` shows the sign-in link, and `/auth/login` answers `303` to `https://account.northlight.ai/authorize?client_id=…&scope=openid+profile+crtr%3Allm+crtr%3Aact+sms%3Asend…&code_challenge_method=S256…`. The consent page listed the four permissions and the monthly cap, **Allow** returned to `http://localhost:3000/` with "Signed in" and the user id, and the button page answered "Done" with status `idle` and a `message_id` for the text. In the console the key then read "Last used just now". Not yet checked on production: that the text reached the phone (the `message_id` is the provider's answer, not a delivery receipt), and the `426` case. The `pong` reply is read through `runs.reply` and has not been run on production since this example changed. Signing in as a second person needs a second account and is not covered here.
135
+
136
+ If `/` shows "One step left", `CROUTER_CLIENT_ID` is empty: run `npx @north-light/sdk register` (or, for an app you registered in the browser, `env --client-id`) and restart. If a call fails with `426 runtime_version_unsupported`, your SDK is newer than the Northlight runtimes. Install the previous version with `npm i --save-exact @north-light/sdk@<version>` and tell support@northlight.ai.
137
+
138
+ ## 6. What next
139
+
140
+ - Only you can sign in until you add testers: see [[northlight-apps/concepts/testing-mode]].
141
+ - Keep each person's sign-in across restarts and processes: see [[northlight-apps/concepts/connections]].
142
+ - See what `server.ts` does and where each piece runs: [[northlight-apps/how-it-fits]].
143
+ - Change the app later with `npx @north-light/sdk apps …`: see [[northlight-apps/guides/manage-your-app]].
144
+
145
+ ## Register in the browser instead
146
+
147
+ Use this when `login` is not possible, or you prefer the console. It replaces steps 3 and 4.
148
+
149
+ ### Register the app in the console
57
150
 
58
151
  Open `https://account.northlight.ai/console/new`. The page is titled **Register an app**. Fill in:
59
152
 
@@ -64,7 +157,7 @@ Open `https://account.northlight.ai/console/new`. The page is titled **Register
64
157
  | **Logo URL** | Required. A square image shown beside your app's name. |
65
158
  | **Why your app needs access** | One sentence. It is quoted on the consent page as your words. |
66
159
  | **Redirect URIs** | `http://localhost:3000/auth/callback`, one URL per line. Sign-in returns only to a URI listed here, matched exactly. `localhost` and `127.0.0.1` are different URIs, and the port counts. |
67
- | **Public key (optional)** | The JSON line `init` printed. |
160
+ | **Public key (optional)** | The JSON line `init` printed (the public half of `local/app-key.json`). |
68
161
 
69
162
  Choose **Register app**.
70
163
 
@@ -72,15 +165,15 @@ You should see: a page titled "*your app's name* is registered" with a **Client
72
165
 
73
166
  Checked on production: these field labels, registering with a pasted key, and the app page opening in testing with that key active and `crtr:llm` and `crtr:act` listed. The "Next: add your app's public key" message, which shows when the key is left out, is not yet checked on production.
74
167
 
75
- ## 4. Add the permission to text
168
+ ### Add the permission to text
76
169
 
77
170
  On the app's page, open the **What it asks for** tab and choose **Add permissions**. Choose **Texting**, tick `sms:send` ("Send you text messages from Northlight's number"), and choose **Done**. Then choose **Save** under the list: **Done** only closes the dialog, and the change is not stored until you save.
78
171
 
79
- You should see: `sms:send` under what your app asks for after **Save** says "Saved", next to `crtr:llm` and `crtr:act`. The server in this guide asks for all three, so without `sms:send` the consent page does not offer it and the text in step 6 is refused. Take scope strings from this list. Do not invent them. See [[northlight-apps/concepts/scopes-and-grants]].
172
+ You should see: `sms:send` under what your app asks for after **Save** says "Saved", next to `crtr:llm` and `crtr:act`. The server in this guide asks for all three, so without `sms:send` the consent page does not offer it and the text in step 5 is refused. Take scope strings from this list. Do not invent them. See [[northlight-apps/concepts/scopes-and-grants]].
80
173
 
81
- Checked on production: after **Save** and a reload, the tab lists `crtr:llm` and `crtr:act` under Agents and `sms:send` under Texting, and the consent page in step 6 offered the text permission.
174
+ Checked on production: after **Save** and a reload, the tab lists `crtr:llm` and `crtr:act` under Agents and `sms:send` under Texting, and the consent page in step 5 offered the text permission.
82
175
 
83
- ## 5. Write the client ID into the env file
176
+ ### Write the client ID into the env file
84
177
 
85
178
  ```bash
86
179
  npx @north-light/sdk env --client-id <client id>
@@ -100,27 +193,6 @@ Key file: local/app-key.json (set CROUTER_KEY_FILE to change it).
100
193
 
101
194
  Checked on production: this output, and that the command works in a directory with no `local/` yet. `--origin` is not yet checked on production.
102
195
 
103
- ## 6. Run it
104
-
105
- ```bash
106
- npm i
107
- node --env-file=local/app.env --import tsx server.ts
108
- ```
109
-
110
- (`npm start` runs the same command.) You should see `Listening on http://localhost:3000`.
111
-
112
- Open `http://localhost:3000/auth/login`. Your browser goes to `account.northlight.ai`. Sign in if asked, read the consent page, and choose Allow. You land back on `http://localhost:3000/` with "Signed in" and your Northlight user id. Press **Start a run and send me a text**. The page answers "Done", shows the run's status `idle`, the run's reply (`pong`) and the text's `message_id`, and your phone receives "Hello from my first Northlight app." from Northlight's number.
113
-
114
- Checked on production, as the account that owns the app: the server starts (with `npm start` too), `/` shows the sign-in link, and `/auth/login` answers `303` to `https://account.northlight.ai/authorize?client_id=…&scope=openid+profile+crtr%3Allm+crtr%3Aact+sms%3Asend…&code_challenge_method=S256…`. The consent page listed the four permissions and the monthly cap, **Allow** returned to `http://localhost:3000/` with "Signed in" and the user id, and the button page answered "Done" with status `idle` and a `message_id` for the text. In the console the key then read "Last used just now". Not yet checked on production: that the text reached the phone (the `message_id` is the provider's answer, not a delivery receipt), and the `426` case. The `pong` reply is read through `runs.reply` and has not been run on production since this example changed. Signing in as a second person needs a second account and is not covered here.
115
-
116
- If `/` shows "One step left", `CROUTER_CLIENT_ID` is empty: repeat step 5 and restart. If a call fails with `426 runtime_version_unsupported`, your SDK is newer than the Northlight runtimes. Install the previous version with `npm i --save-exact @north-light/sdk@<version>` and tell support@northlight.ai.
117
-
118
- ## 7. What next
119
-
120
- - Only you can sign in until you add testers: see [[northlight-apps/concepts/testing-mode]].
121
- - Keep each person's sign-in across restarts and processes: see [[northlight-apps/concepts/connections]].
122
- - See what `server.ts` does and where each piece runs: [[northlight-apps/how-it-fits]].
123
-
124
196
  ## The server `init` writes
125
197
 
126
198
  This is `examples/apps/quickstart-server.ts`. The SDK reads no environment: the server passes the values in.
@@ -55,6 +55,8 @@ export interface ProviderResponseContext {
55
55
  run_id: string | null;
56
56
  sub: string;
57
57
  grantee: string;
58
+ /** The outbound token's payer: the principal the directory charges for this call. */
59
+ payer: string;
58
60
  /** Whose month the receipt adds to: the calling app, or `app:terminal` for the person (whose outbound identity is their sub). */
59
61
  ledger_grantee: string;
60
62
  jti: string;
@@ -1 +1 @@
1
- var oe=Object.defineProperty;var m=(e,t)=>oe(e,"name",{value:t,configurable:!0});import{validateDeclaredResult as I}from"../../api/command-manifest/result.js";import{callerAllows as x,requireGroup as ne}from"../../daemon/api/handlers/providers.js";import{grantFor as ie,terminalGrant as se}from"../grants/mirror.js";import{assertUnderCap as z,UNCAPPED_GRANTEE as ae}from"../usage/ledger.js";import{listInstalledPlugins as de}from"../resolver.js";import{ProviderError as l}from"./errors.js";import{resolveProviderRef as ue}from"./roots.js";import{ensureProviderBundle as le,refreshProviderBundle as pe,resolveProviderBundle as B,unavailableProviderError as ce}from"./bundles.js";import{prepareFileCall as fe,resignForRetry as Q,resolveFileResult as V}from"./files.js";import{consumeApproval as me,requireProviderApproval as ve}from"./approval.js";import{outboundToken as ge}from"./mint.js";import{discardFile as he}from"./token-cache.js";import{registerProviderCall as we}from"./inflight.js";import{buildRequestPlan as be}from"../command-plugins/transport/http-plan.js";import{sendProviderRequest as ye,ProviderTransportError as _e}from"./request.js";import{acceptProviderReceipt as je}from"./receipts.js";import{writeProviderAudit as Ae}from"./audit.js";import{selectDevice as ke,sendDeviceCall as qe,deviceFailure as Pe,deviceUnreachable as K,DEFAULT_DEVICE_REQUEST_MS as $e}from"./device.js";import{isLibsSearch as W,wantsBundling as Ee,callLibsSearch as Re,bundleExec as Se,mapLibOutcome as Fe,reportLibCalls as Te}from"./device-libs.js";import{envelopeForProviderCaller as Ce}from"../peer/envelope.js";function X(e){const t=[],a=e.roots.length===1?e.roots[0].name:void 0,c=m((r,n)=>{const d=[...n,r.name];if(r.kind==="branch"){for(const _ of r.children)c(_,d);return}const p=d[0]===a?d.slice(1).join(" "):d.join(" ");t.push({tool:p,leaf:r})},"walk");for(const r of e.roots)c(r,[]);return t}m(X,"providerLeaves");function Y(e,t,a){if(e.kind!=="branch")return null;const c=[];for(const i of e.children)if(i.kind==="branch"){const h=Y(i,t,a);h&&c.push(h)}else if("group"in i&&i.group&&x(a,`${t}:${i.group}`)){const{kind:h,name:v,description:y,whenToUse:P,tier:o,summary:A,params:s,output:w,effects:k,group:E}=i;c.push({kind:h,name:v,description:y,whenToUse:P,...o===void 0?{}:{tier:o},summary:A,params:s,output:w,effects:k,group:E})}if(c.length===0)return null;const{kind:r,name:n,description:d,whenToUse:p,tier:_,summary:g,model:j,rootEntry:q}=e;return{kind:r,name:n,description:d,whenToUse:p,..._===void 0?{}:{tier:_},summary:g,...j===void 0?{}:{model:j},...q===void 0?{}:{rootEntry:q},children:c}}m(Y,"providerHelpBranch");async function ar(e,t){const a=await B(t),c=X(a.commands),r=de("user").find(n=>n.name===t)?.manifest.description??t;return{provider:t,status:"available",major:a.major,display_name:r,transport:a.transport.kind,groups:a.provider.groups.filter(n=>x(e,`${t}:${n.group}`)).map(n=>({group:n.group,leaves:c.filter(({leaf:d})=>d.group===n.group).map(({tool:d,leaf:p})=>({tool:d,description:p.description,summary:p.summary,params:p.params,output:p.output}))})),commands:a.commands.roots.flatMap(n=>{const d=Y(n,t,e);return d?[d]:[]})}}m(ar,"providerTools");function De(e){return je(e.headers["crtr-receipt"],e)}m(De,"onProviderResponse");function Z(e){if(!(e.class==="person"||!e.sub||!e.grantee))return{grantee:e.grantee,spend_cap:ie(e.grantee,e.sub)?.spend_cap??null}}m(Z,"capTarget");function Ne(e){if(e.class==="person"){const t=se()?.sub;if(!t)throw new l("grant_revoked","This machine has no active Northlight grant for your terminal. Run `crtr sys join` again from /account/machines.");return{sub:t,grantee:t}}if(!e.sub||!e.grantee)throw new l("grant_revoked","the caller has no active grant");return{sub:e.sub,grantee:e.grantee}}m(Ne,"identity");function Oe(e,t){return[`${e.id}:${t.group}`]}m(Oe,"callScope");function Me(e,t){const a=X(e.commands).find(c=>c.tool===t);if(!a)throw new l("not_found",`tool "${t}" is not in provider "${e.id}"`,{param:"tool"});if(!a.leaf.group)throw new l("not_found",`tool "${t}" has no group`,{param:"tool"});return a.leaf}m(Me,"leafFor");function L(e){if(!e||typeof e!="object"||Array.isArray(e))return{};const t=e.error;return!t||typeof t!="object"||Array.isArray(t)?{}:t}m(L,"errorBody");function Ue(e){const t=Array.isArray(e)?e[0]:e;if(!t)return;const a=Number(t);if(Number.isFinite(a)&&a>=0)return a;const c=Date.parse(t);return Number.isFinite(c)?Math.max(0,Math.ceil((c-Date.now())/1e3)):void 0}m(Ue,"retryAfter");function N(e,t,a,c=!1){const r=L(t),n=typeof r.code=="string"?r.code:void 0,d=typeof r.message=="string"?r.message:`provider answered HTTP ${e}`,p=typeof r.field=="string"?r.field:void 0,g={origin:"provider",details:{code:n??null,message:d,...p?{field:p}:{},...typeof r.next=="string"?{next:r.next}:{},...r.received!==void 0?{received:r.received}:{},status:e},...p?{param:p}:{}};return e===429?new l("provider_rate_limited",d,{...g,retry_after_s:Ue(a["retry-after"])}):e===401||e>=500?new l("provider_unavailable",d,g):e===403&&n==="provider_refused"?new l("provider_refused",d,g):e===403&&n==="approval_required"?new l("provider_error",c?"provider asked for approval again":"provider approval question is invalid",{...g,status:e}):new l("provider_error",d,{...g,status:e})}m(N,"mapProviderResponse");function ee(e,t){return JSON.stringify({major:e.major,endpoint:e.transport.kind==="http"?e.transport.endpoint:null,baseUrl:e.commands.baseUrl??null,rest:t.rest,params:t.params,output:t.output})}m(ee,"approvalShape");async function dr(e,t,a){const c=ue(t.provider);if(c===null)throw new l("not_found",`provider "${t.provider}" is not available`,{param:"provider"});const r={...t,provider:c},n=new AbortController,d=m(()=>n.abort(a.signal.reason),"abort");a.signal.aborted?d():a.signal.addEventListener("abort",d,{once:!0});let p=a.requestId;const _=we({request_id:p,run_id:e.run_id,node_id:e.node_id,controller:n});let g,j,q,i,h,v,y,P="allowed";try{try{await le(r.provider)}catch{throw ce(r.provider)}let o=await B(r.provider,r.version),A=!1,s,w,k,E=!1,J=!1,O,H=0;for(;;){const b=Me(o,r.tool);if(q=`${o.id}:${b.group}`,ne(e,o.id,b.group),z(Z(e)),o.transport.kind!=="machine"&&r.machine!==void 0)throw new l("invalid_argument",`provider "${o.id}" is not served by a device, so it takes no machine`,{param:"machine"});o.transport.kind==="machine"&&O===void 0&&(O=await ke(r.machine,void 0,{newest:W(o.major,r.tool)}),H=Date.now()+(o.commands.timeouts?.requestMs??$e));let M=null;if(r.approval&&!E){const u=me(e,r,ee(o,b));k=u.option,M=u.prepared,y=r.approval,p=u.requestId,E=!0}const R={params:b.params.filter(u=>"type"in u&&u.type==="file").map(u=>u.name),output:b.output.find(u=>u.type==="file")?.name??null},G=JSON.stringify(R);if(w!==void 0&&w!==G)throw new l("provider_bad_response","the refreshed manifest changed the tool file contract",{origin:"provider",details:{status:409,message:"manifest_stale changed the tool file contract"}});w=G,M&&(s=await Q(e,R,r.arguments,M,{signal:n.signal})),s??=await fe(e,R,r.arguments,{signal:n.signal});const{sub:U,grantee:S}=Ne(e);if(z(Z(e)),o.transport.kind==="machine"){const u=await Ie(e,o,r,b,s,{sub:U,requestId:p,deadlineAt:H,signal:n.signal,machine:O});return v=u.responder,g=u.cleanup,{status:200,body:u.result}}const F=await ge({audience:o.transport.endpoint,sub:U,grantee:S,initiating_app:S,scope:Oe(o,b)},{signal:n.signal});i=F.jti;const re={registration:{name:o.id,endpoint:o.transport.endpoint},...o.commands.baseUrl?{baseUrl:o.commands.baseUrl}:{},...o.commands.timeouts?{timeouts:o.commands.timeouts}:{},params:b.params,rest:b.rest,output:b.output,commandPath:[o.id,...r.tool.split(" ")]},te=be(re,s.arguments,void 0,{authorization:`Bearer ${F.access_token}`,requestId:p,fileMode:"pass-through",extraHeaders:{...e.run_id?{"Crtr-Run-Id":e.run_id}:{},...k?{"Crtr-Approval":k}:{},...s.headers}});j??=Date.now()+(o.commands.timeouts?.requestMs??3e4);let f;try{if(f=await ye(te,{connectMs:o.commands.timeouts?.connectMs,requestMs:o.commands.timeouts?.requestMs,deadlineAt:j,signal:n.signal}),n.signal.aborted)throw n.signal.reason??new Error("provider call aborted")}catch(u){throw n.signal.aborted?n.signal.reason??u:u instanceof _e?new l("provider_unavailable",u.message,{origin:"provider",details:{status:null,code:u.kind}}):u}if(h??=De({provider:o.id,tool:r.tool,request_id:p,run_id:e.run_id,sub:U,grantee:S,ledger_grantee:e.class==="person"?ae:S,jti:F.jti,status:f.status,headers:f.headers}),v=f.headers["crtr-responder"],f.status===401&&he(F.key),f.status>=200&&f.status<300){const u=f.body;if(!u||typeof u!="object"||Array.isArray(u))throw new l("provider_bad_response","provider result is not a JSON object",{origin:"provider",details:{status:f.status,message:"provider result is not a JSON object"}});let C;try{C=await V(e,s,u,{signal:n.signal})}catch($){throw $ instanceof l&&$.code==="provider_error"?new l("provider_bad_response",$.message,{origin:"provider",details:{status:f.status,message:$.message}}):$}g=C.cleanup;const D=I(b.output,C.result);if(D)throw new l("provider_bad_response",`provider result has an invalid output field "${D.field}"`,{origin:"provider",param:D.field,details:{status:f.status,...D}});if(n.signal.aborted)throw n.signal.reason??new Error("provider call aborted");return{status:200,body:C.result}}const T=L(f.body);if(f.status===403&&T.code==="approval_required"&&!k&&!J){J=!0;const u=await ve(e,r,p,T.question,n.signal,ee(o,b),s);if(y=u.questionId,k=u.option,s=await Q(e,R,r.arguments,s,{signal:n.signal}),j=void 0,n.signal.aborted)throw new l("user_denied","the approval wait was canceled");continue}if(f.status===403&&T.code==="approval_required"&&k)throw N(f.status,f.body,f.headers,!0);if(f.status!==401&&T.manifest_stale===!0&&!A){A=!0,await pe(o.id,{conditional:!1,...r.version===void 0?{}:{version:r.version}}),o=await B(o.id,r.version);continue}throw N(f.status,f.body,f.headers,!!k)}}catch(o){throw o instanceof l&&o.question_id&&(y=o.question_id),v===void 0&&o instanceof l&&typeof o.responder=="string"&&(v=o.responder),P=`refused:${o instanceof l?o.code:"server_error"}`,g?.(),o}finally{try{Ae({request_id:p,caller:e,provider:r.provider,tool:r.tool,scope_checked:q,outcome:P,jti:i,receipt_id:h,responder:v,question_id:y})}finally{_()}a.signal.removeEventListener("abort",d)}}m(dr,"callProvider");async function Ie(e,t,a,c,r,n){const{machine:d}=n,p=Ce(e,n.sub),_={machine:d,envelope:p,signal:n.signal,deadlineAt:n.deadlineAt,mapFailure:N};if(W(t.major,a.tool)){const s=await Re(_,r.arguments,t.major),w=I(c.output,s);if(w)throw new l("provider_bad_response",`the libraries result has an invalid output field "${w.field}"`,{origin:"provider",param:w.field,details:{status:200,...w}});return{result:s,responder:void 0,cleanup:void 0}}let g=r.arguments,j=null,q="";if(Ee(t.major,a.tool,r.arguments)){const s=await Se(_,r.arguments,t.major);if(n.deadlineAt-Date.now()<=0)throw K(d,"timeout","");s!==null&&(g=s.arguments,j=s.services,q=s.release)}const i=await qe(d,{tool:a.tool,arguments:g,version:t.major,requestId:n.requestId,runId:e.run_id,deadlineAt:n.deadlineAt,upload:r.upload===null?null:{id:r.upload.upload_id,link:r.upload.upload_link}},p,n.signal);if(n.signal.aborted)throw n.signal.reason??new Error("provider call aborted");if("unreachable"in i)throw K(d,i.unreachable.reason,i.unreachable.message);const h=i.headers["crtr-responder"],v=L(i.body);if(i.status<200||i.status>=300){const s=Pe(d,i.status,typeof v.code=="string"?v.code:void 0,typeof v.message=="string"?v.message:void 0,i.body),w=s===null&&i.status>=500&&typeof v.code=="string"?new l("provider_error",typeof v.message=="string"?v.message:`the device answered HTTP ${i.status}`,{origin:"runtime",status:i.status,details:{code:v.code,status:i.status}}):null;throw Object.assign(s??w??N(i.status,i.body,i.headers),{responder:h})}const y=i.body&&typeof i.body=="object"&&!Array.isArray(i.body)?i.body.result:void 0;if(!y||typeof y!="object"||Array.isArray(y))throw Object.assign(new l("provider_bad_response","the device result is not a JSON object",{origin:"provider",details:{status:i.status,message:"the device result is not a JSON object"}}),{responder:h});let P=y;if(j!==null){const s=Fe(P,j);if(Te(d,p,q,s.calls),"error"in s)throw Object.assign(s.error,{responder:h});P=s.result}let o;try{o=await V(e,r,P,{signal:n.signal})}catch(s){throw s instanceof l&&s.code==="provider_error"?Object.assign(new l("provider_bad_response",s.message,{origin:"provider",details:{status:i.status,message:s.message}}),{responder:h}):s}const A=I(c.output,o.result);if(A)throw o.cleanup?.(),Object.assign(new l("provider_bad_response",`the device result has an invalid output field "${A.field}"`,{origin:"provider",param:A.field,details:{status:i.status,...A}}),{responder:h});return{result:o.result,responder:h,cleanup:o.cleanup}}m(Ie,"callDevice");export{dr as callProvider,N as mapProviderResponse,De as onProviderResponse,X as providerLeaves,ar as providerTools};
1
+ var re=Object.defineProperty;var v=(e,t)=>re(e,"name",{value:t,configurable:!0});import{validateDeclaredResult as I}from"../../api/command-manifest/result.js";import{callerAllows as x,requireGroup as te}from"../../daemon/api/handlers/providers.js";import{terminalGrant as oe}from"../grants/mirror.js";import{UNCAPPED_GRANTEE as ie}from"../usage/ledger.js";import{listInstalledPlugins as ne}from"../resolver.js";import{ProviderError as l}from"./errors.js";import{resolveProviderRef as se}from"./roots.js";import{ensureProviderBundle as ae,refreshProviderBundle as de,resolveProviderBundle as B,unavailableProviderError as ue}from"./bundles.js";import{prepareFileCall as le,resignForRetry as z,resolveFileResult as Q}from"./files.js";import{consumeApproval as pe,requireProviderApproval as ce}from"./approval.js";import{outboundToken as fe}from"./mint.js";import{discardFile as me}from"./token-cache.js";import{registerProviderCall as ve}from"./inflight.js";import{buildRequestPlan as ge}from"../command-plugins/transport/http-plan.js";import{sendProviderRequest as he,ProviderTransportError as we}from"./request.js";import{acceptProviderReceipt as be}from"./receipts.js";import{writeProviderAudit as ye}from"./audit.js";import{selectDevice as _e,sendDeviceCall as je,deviceFailure as Ae,deviceUnreachable as V,DEFAULT_DEVICE_REQUEST_MS as ke}from"./device.js";import{isLibsSearch as K,wantsBundling as qe,callLibsSearch as Pe,bundleExec as $e,mapLibOutcome as Ee,reportLibCalls as Re}from"./device-libs.js";import{envelopeForProviderCaller as Se}from"../peer/envelope.js";function W(e){const t=[],a=e.roots.length===1?e.roots[0].name:void 0,c=v((r,i)=>{const d=[...i,r.name];if(r.kind==="branch"){for(const _ of r.children)c(_,d);return}const p=d[0]===a?d.slice(1).join(" "):d.join(" ");t.push({tool:p,leaf:r})},"walk");for(const r of e.roots)c(r,[]);return t}v(W,"providerLeaves");function X(e,t,a){if(e.kind!=="branch")return null;const c=[];for(const n of e.children)if(n.kind==="branch"){const h=X(n,t,a);h&&c.push(h)}else if("group"in n&&n.group&&x(a,`${t}:${n.group}`)){const{kind:h,name:m,description:y,whenToUse:P,tier:o,summary:A,params:s,output:w,effects:k,group:R}=n;c.push({kind:h,name:m,description:y,whenToUse:P,...o===void 0?{}:{tier:o},summary:A,params:s,output:w,effects:k,group:R})}if(c.length===0)return null;const{kind:r,name:i,description:d,whenToUse:p,tier:_,summary:g,model:j,rootEntry:q}=e;return{kind:r,name:i,description:d,whenToUse:p,..._===void 0?{}:{tier:_},summary:g,...j===void 0?{}:{model:j},...q===void 0?{}:{rootEntry:q},children:c}}v(X,"providerHelpBranch");async function ir(e,t){const a=await B(t),c=W(a.commands),r=ne("user").find(i=>i.name===t)?.manifest.description??t;return{provider:t,status:"available",major:a.major,display_name:r,transport:a.transport.kind,groups:a.provider.groups.filter(i=>x(e,`${t}:${i.group}`)).map(i=>({group:i.group,leaves:c.filter(({leaf:d})=>d.group===i.group).map(({tool:d,leaf:p})=>({tool:d,description:p.description,summary:p.summary,params:p.params,output:p.output}))})),commands:a.commands.roots.flatMap(i=>{const d=X(i,t,e);return d?[d]:[]})}}v(ir,"providerTools");function De(e){return be(e.headers["crtr-receipt"],e)}v(De,"onProviderResponse");function Fe(e){if(e.class==="person"){const t=oe()?.sub;if(!t)throw new l("grant_revoked","This machine has no active Northlight grant for your terminal. Run `crtr sys join` again from /account/machines.");return{sub:t,grantee:t}}if(!e.sub||!e.grantee)throw new l("grant_revoked","the caller has no active grant");return{sub:e.sub,grantee:e.grantee}}v(Fe,"identity");function Ne(e,t){return[`${e.id}:${t.group}`]}v(Ne,"callScope");function Oe(e,t){const a=W(e.commands).find(c=>c.tool===t);if(!a)throw new l("not_found",`tool "${t}" is not in provider "${e.id}"`,{param:"tool"});if(!a.leaf.group)throw new l("not_found",`tool "${t}" has no group`,{param:"tool"});return a.leaf}v(Oe,"leafFor");function L(e){if(!e||typeof e!="object"||Array.isArray(e))return{};const t=e.error;return!t||typeof t!="object"||Array.isArray(t)?{}:t}v(L,"errorBody");function Te(e){const t=Array.isArray(e)?e[0]:e;if(!t)return;const a=Number(t);if(Number.isFinite(a)&&a>=0)return a;const c=Date.parse(t);return Number.isFinite(c)?Math.max(0,Math.ceil((c-Date.now())/1e3)):void 0}v(Te,"retryAfter");function T(e,t,a,c=!1){const r=L(t),i=typeof r.code=="string"?r.code:void 0,d=typeof r.message=="string"?r.message:`provider answered HTTP ${e}`,p=typeof r.field=="string"?r.field:void 0,g={origin:"provider",details:{code:i??null,message:d,...p?{field:p}:{},...typeof r.next=="string"?{next:r.next}:{},...r.received!==void 0?{received:r.received}:{},status:e},...p?{param:p}:{}};return e===429?new l("provider_rate_limited",d,{...g,retry_after_s:Te(a["retry-after"])}):e===401||e>=500?new l("provider_unavailable",d,g):e===403&&i==="provider_refused"?new l("provider_refused",d,g):e===403&&i==="approval_required"?new l("provider_error",c?"provider asked for approval again":"provider approval question is invalid",{...g,status:e}):new l("provider_error",d,{...g,status:e})}v(T,"mapProviderResponse");function Y(e,t){return JSON.stringify({major:e.major,endpoint:e.transport.kind==="http"?e.transport.endpoint:null,baseUrl:e.commands.baseUrl??null,rest:t.rest,params:t.params,output:t.output})}v(Y,"approvalShape");async function nr(e,t,a){const c=se(t.provider);if(c===null)throw new l("not_found",`provider "${t.provider}" is not available`,{param:"provider"});const r={...t,provider:c},i=new AbortController,d=v(()=>i.abort(a.signal.reason),"abort");a.signal.aborted?d():a.signal.addEventListener("abort",d,{once:!0});let p=a.requestId;const _=ve({request_id:p,run_id:e.run_id,node_id:e.node_id,controller:i});let g,j,q,n,h,m,y,P="allowed";try{try{await ae(r.provider)}catch{throw ue(r.provider)}let o=await B(r.provider,r.version),A=!1,s,w,k,R=!1,J=!1,C,H=0;for(;;){const b=Oe(o,r.tool);if(q=`${o.id}:${b.group}`,te(e,o.id,b.group),o.transport.kind!=="machine"&&r.machine!==void 0)throw new l("invalid_argument",`provider "${o.id}" is not served by a device, so it takes no machine`,{param:"machine"});o.transport.kind==="machine"&&C===void 0&&(C=await _e(r.machine,void 0,{newest:K(o.major,r.tool)}),H=Date.now()+(o.commands.timeouts?.requestMs??ke));let M=null;if(r.approval&&!R){const u=pe(e,r,Y(o,b));k=u.option,M=u.prepared,y=r.approval,p=u.requestId,R=!0}const S={params:b.params.filter(u=>"type"in u&&u.type==="file").map(u=>u.name),output:b.output.find(u=>u.type==="file")?.name??null},G=JSON.stringify(S);if(w!==void 0&&w!==G)throw new l("provider_bad_response","the refreshed manifest changed the tool file contract",{origin:"provider",details:{status:409,message:"manifest_stale changed the tool file contract"}});w=G,M&&(s=await z(e,S,r.arguments,M,{signal:i.signal})),s??=await le(e,S,r.arguments,{signal:i.signal});const{sub:U,grantee:D}=Fe(e);if(o.transport.kind==="machine"){const u=await Ce(e,o,r,b,s,{sub:U,requestId:p,deadlineAt:H,signal:i.signal,machine:C});return m=u.responder,g=u.cleanup,{status:200,body:u.result}}const $=await fe({audience:o.transport.endpoint,sub:U,grantee:D,initiating_app:D,scope:Ne(o,b)},{signal:i.signal});n=$.jti;const Z={registration:{name:o.id,endpoint:o.transport.endpoint},...o.commands.baseUrl?{baseUrl:o.commands.baseUrl}:{},...o.commands.timeouts?{timeouts:o.commands.timeouts}:{},params:b.params,rest:b.rest,output:b.output,commandPath:[o.id,...r.tool.split(" ")]},ee=ge(Z,s.arguments,void 0,{authorization:`Bearer ${$.access_token}`,requestId:p,fileMode:"pass-through",extraHeaders:{...e.run_id?{"Crtr-Run-Id":e.run_id}:{},...k?{"Crtr-Approval":k}:{},...s.headers}});j??=Date.now()+(o.commands.timeouts?.requestMs??3e4);let f;try{if(f=await he(ee,{connectMs:o.commands.timeouts?.connectMs,requestMs:o.commands.timeouts?.requestMs,deadlineAt:j,signal:i.signal}),i.signal.aborted)throw i.signal.reason??new Error("provider call aborted")}catch(u){throw i.signal.aborted?i.signal.reason??u:u instanceof we?new l("provider_unavailable",u.message,{origin:"provider",details:{status:null,code:u.kind}}):u}if(h??=De({provider:o.id,tool:r.tool,request_id:p,run_id:e.run_id,sub:U,grantee:D,payer:$.payer,ledger_grantee:e.class==="person"?ie:D,jti:$.jti,status:f.status,headers:f.headers}),m=f.headers["crtr-responder"],f.status===401&&me($.key),f.status>=200&&f.status<300){const u=f.body;if(!u||typeof u!="object"||Array.isArray(u))throw new l("provider_bad_response","provider result is not a JSON object",{origin:"provider",details:{status:f.status,message:"provider result is not a JSON object"}});let N;try{N=await Q(e,s,u,{signal:i.signal})}catch(E){throw E instanceof l&&E.code==="provider_error"?new l("provider_bad_response",E.message,{origin:"provider",details:{status:f.status,message:E.message}}):E}g=N.cleanup;const O=I(b.output,N.result);if(O)throw new l("provider_bad_response",`provider result has an invalid output field "${O.field}"`,{origin:"provider",param:O.field,details:{status:f.status,...O}});if(i.signal.aborted)throw i.signal.reason??new Error("provider call aborted");return{status:200,body:N.result}}const F=L(f.body);if(f.status===403&&F.code==="approval_required"&&!k&&!J){J=!0;const u=await ce(e,r,p,F.question,i.signal,Y(o,b),s);if(y=u.questionId,k=u.option,s=await z(e,S,r.arguments,s,{signal:i.signal}),j=void 0,i.signal.aborted)throw new l("user_denied","the approval wait was canceled");continue}if(f.status===403&&F.code==="approval_required"&&k)throw T(f.status,f.body,f.headers,!0);if(f.status!==401&&F.manifest_stale===!0&&!A){A=!0,await de(o.id,{conditional:!1,...r.version===void 0?{}:{version:r.version}}),o=await B(o.id,r.version);continue}throw T(f.status,f.body,f.headers,!!k)}}catch(o){throw o instanceof l&&o.question_id&&(y=o.question_id),m===void 0&&o instanceof l&&typeof o.responder=="string"&&(m=o.responder),P=`refused:${o instanceof l?o.code:"server_error"}`,g?.(),o}finally{try{ye({request_id:p,caller:e,provider:r.provider,tool:r.tool,scope_checked:q,outcome:P,jti:n,receipt_id:h,responder:m,question_id:y})}finally{_()}a.signal.removeEventListener("abort",d)}}v(nr,"callProvider");async function Ce(e,t,a,c,r,i){const{machine:d}=i,p=Se(e,i.sub),_={machine:d,envelope:p,signal:i.signal,deadlineAt:i.deadlineAt,mapFailure:T};if(K(t.major,a.tool)){const s=await Pe(_,r.arguments,t.major),w=I(c.output,s);if(w)throw new l("provider_bad_response",`the libraries result has an invalid output field "${w.field}"`,{origin:"provider",param:w.field,details:{status:200,...w}});return{result:s,responder:void 0,cleanup:void 0}}let g=r.arguments,j=null,q="";if(qe(t.major,a.tool,r.arguments)){const s=await $e(_,r.arguments,t.major);if(i.deadlineAt-Date.now()<=0)throw V(d,"timeout","");s!==null&&(g=s.arguments,j=s.services,q=s.release)}const n=await je(d,{tool:a.tool,arguments:g,version:t.major,requestId:i.requestId,runId:e.run_id,deadlineAt:i.deadlineAt,upload:r.upload===null?null:{id:r.upload.upload_id,link:r.upload.upload_link}},p,i.signal);if(i.signal.aborted)throw i.signal.reason??new Error("provider call aborted");if("unreachable"in n)throw V(d,n.unreachable.reason,n.unreachable.message);const h=n.headers["crtr-responder"],m=L(n.body);if(n.status<200||n.status>=300){const s=Ae(d,n.status,typeof m.code=="string"?m.code:void 0,typeof m.message=="string"?m.message:void 0,n.body),w=s===null&&n.status>=500&&typeof m.code=="string"?new l("provider_error",typeof m.message=="string"?m.message:`the device answered HTTP ${n.status}`,{origin:"runtime",status:n.status,details:{code:m.code,status:n.status}}):null;throw Object.assign(s??w??T(n.status,n.body,n.headers),{responder:h})}const y=n.body&&typeof n.body=="object"&&!Array.isArray(n.body)?n.body.result:void 0;if(!y||typeof y!="object"||Array.isArray(y))throw Object.assign(new l("provider_bad_response","the device result is not a JSON object",{origin:"provider",details:{status:n.status,message:"the device result is not a JSON object"}}),{responder:h});let P=y;if(j!==null){const s=Ee(P,j);if(Re(d,p,q,s.calls),"error"in s)throw Object.assign(s.error,{responder:h});P=s.result}let o;try{o=await Q(e,r,P,{signal:i.signal})}catch(s){throw s instanceof l&&s.code==="provider_error"?Object.assign(new l("provider_bad_response",s.message,{origin:"provider",details:{status:n.status,message:s.message}}),{responder:h}):s}const A=I(c.output,o.result);if(A)throw o.cleanup?.(),Object.assign(new l("provider_bad_response",`the device result has an invalid output field "${A.field}"`,{origin:"provider",param:A.field,details:{status:n.status,...A}}),{responder:h});return{result:o.result,responder:h,cleanup:o.cleanup}}v(Ce,"callDevice");export{nr as callProvider,T as mapProviderResponse,De as onProviderResponse,W as providerLeaves,ir as providerTools};
@@ -1 +1 @@
1
- var j=Object.defineProperty;var a=(r,e)=>j(r,"name",{value:e,configurable:!0});import{createHash as T}from"node:crypto";import{readConfig as M}from"../config.js";import{emitEvent as $}from"../events/emit.js";import{grantSyncStateFor as E}from"../grants/mirror.js";import{assertion as A,machineRevokedMessage as N,requestGrantSync as O,runtimeClient as P}from"../grants/sync.js";import{isUnsandboxedMachine as I}from"../runtime/unsandboxed.js";import{ProviderError as h}from"./errors.js";import{cacheKey as F,cachedToken as G,discardEpoch as L,storeToken as U}from"./token-cache.js";import{discardFile as ae}from"./token-cache.js";const C=1e4;function D(r){const e=r.issuer??M("user").runtime?.issuer;if(!e)throw new h("not_found","provider calls need a directory-connected runtime");return e.replace(/\/$/,"")}a(D,"issuerOf");function l(r,e,t){return new h(r,e,{origin:"directory",details:t})}a(l,"directoryError");function J(r,e,t,i){const n=typeof e.error=="string"?e.error:null,o=typeof e.error_description=="string"?e.error_description:void 0,c={directory_status:r,...n?{directory_error:n}:{},...o?{directory_description:o}:{}},u=o?`: ${o}`:"";switch(n){case"invalid_client":return I()?{error:l("machine_revoked",N(i),c),grantRefusal:!1}:{error:l("server_error",`the directory refused the outbound token request (${n})${u}`,c),grantRefusal:!1};case"invalid_request":return{error:l("server_error",`the directory refused the outbound token request (${n})${u}`,c),grantRefusal:!1};case"invalid_target":return{error:l("not_found",`the directory knows no provider at ${t.audience}${u}`,c),grantRefusal:!1};case"invalid_grant":return{error:l("grant_revoked",`the directory has no active grant for ${t.grantee}${u}`,c),grantRefusal:!0};case"provider_not_connected":return{error:l("provider_not_connected",o??"The user has not connected this provider.",c),grantRefusal:!1};case"invalid_scope":{const s=typeof e.scope=="string"&&e.scope?e.scope:t.scope.length===1?t.scope[0]:void 0,g=s?`missing scope ${s}`:`the grant does not cover ${t.scope.join(" ")}`;return{error:new h("scope_missing",g,{origin:"directory",details:c,...s?{scope:s}:{}}),grantRefusal:!0}}default:return{error:l("server_error",`the directory token endpoint answered ${r}${n?` ${n}`:""}${u}`,c),grantRefusal:!1}}}a(J,"refusal");function B(r){try{return JSON.parse(Buffer.from(r.split(".")[1]??"","base64url").toString("utf8"))}catch{return{}}}a(B,"claims");function H(r){const e=r.grant_rev,t=r.grant_status;return typeof e!="number"||!Number.isInteger(e)||e<0||typeof t!="string"?null:{rev:e,status:t}}a(H,"refusedGrantState");function K(r,e){const t=a(i=>i.status==="active"?0:1,"phase");return r.rev>e.rev||r.rev===e.rev&&t(r)>t(e)}a(K,"newer");function z(r,e,t){if(!e)return;const i=E(r.grantee,r.sub)??{rev:0,status:"removed"};if(!K(e,i))return;const n=t.syncGrants??O;Promise.resolve().then(()=>n({fresh:!0})).catch(()=>{})}a(z,"syncIfNewer");async function Q(r,e={}){const t=D(e),i=`${t}/token`,n=P(),o=new URLSearchParams({grant_type:"client_credentials",client_id:n.client_id,client_assertion_type:"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",client_assertion:A(n,i),audience:r.audience,sub:r.sub,grantee:r.grantee,scope:[...new Set(r.scope)].sort().join(" "),initiating_app:r.initiating_app}),c=AbortSignal.timeout(C),u=e.signal?AbortSignal.any([e.signal,c]):c;let s,g;try{s=await(e.fetch??fetch)(i,{method:"POST",body:o,signal:u,headers:{Accept:"application/json"}}),g=await s.text()}catch(d){throw e.signal?.aborted?e.signal.reason:new h("server_error",`the directory token endpoint could not be reached: ${d instanceof Error?d.message:String(d)}`,{origin:"directory"})}let f=null;try{f=g?JSON.parse(g):null}catch{f=null}const p=f&&typeof f=="object"&&!Array.isArray(f)?f:{};if(!s.ok){const{error:d,grantRefusal:R}=J(s.status,p,r,t);throw d.code==="machine_revoked"&&$({level:"error",event:"daemon.grants.machine_revoked",fields:{audience:r.audience}}),$({level:"info",event:"daemon.providers.mint_refused",fields:{audience:r.audience,grantee:r.grantee,code:d.code,directory_error:typeof p.error=="string"?p.error:null,status:s.status}}),R&&z(r,H(p),e),d}const m=p.access_token;if(typeof m!="string"||!m)throw new h("server_error","the directory token response has no access_token",{origin:"directory"});const y=B(m),S=Math.floor((e.now?.()??Date.now())/1e3),_=p.expires_in,v=typeof _=="number"&&Number.isFinite(_)&&_>0?S+Math.floor(_):null,w=typeof y.exp=="number"?y.exp:null,b=v!==null&&w!==null?Math.min(v,w):v??w;if(b===null)throw new h("server_error","the directory token response names no expiry",{origin:"directory"});const x=typeof y.jti=="string"&&y.jti?y.jti:T("sha256").update(m).digest("hex");return{access_token:m,jti:x,exp:b}}a(Q,"mintOutboundToken");const k=new Map;async function oe(r,e={}){const t=F(r),i=e.now??Date.now,n=G(t,i());if(n)return{...n,key:t};let o=k.get(t);if(!o){const u=L(t);o=Q(r,{...e,signal:void 0}).then(s=>(U(t,s,u),s)).finally(()=>{k.delete(t)}),o.catch(()=>{}),k.set(t,o)}return{...e.signal?await V(o,e.signal):await o,key:t}}a(oe,"outboundToken");function V(r,e){return e.aborted?Promise.reject(e.reason):new Promise((t,i)=>{const n=a(()=>i(e.reason),"onAbort");e.addEventListener("abort",n,{once:!0}),r.then(t,i).finally(()=>e.removeEventListener("abort",n))})}a(V,"abortable");export{C as MINT_TIMEOUT_MS,ae as discardFile,Q as mintOutboundToken,oe as outboundToken};
1
+ var T=Object.defineProperty;var a=(r,e)=>T(r,"name",{value:e,configurable:!0});import{createHash as M}from"node:crypto";import{readConfig as E}from"../config.js";import{emitEvent as $}from"../events/emit.js";import{grantSyncStateFor as A}from"../grants/mirror.js";import{assertion as N,machineRevokedMessage as O,requestGrantSync as P,runtimeClient as I}from"../grants/sync.js";import{isUnsandboxedMachine as F}from"../runtime/unsandboxed.js";import{ProviderError as h}from"./errors.js";import{cacheKey as G,cachedToken as L,discardEpoch as U,storeToken as C}from"./token-cache.js";import{discardFile as ce}from"./token-cache.js";const D=1e4;function J(r){const e=r.issuer??E("user").runtime?.issuer;if(!e)throw new h("not_found","provider calls need a directory-connected runtime");return e.replace(/\/$/,"")}a(J,"issuerOf");function f(r,e,t){return new h(r,e,{origin:"directory",details:t})}a(f,"directoryError");function B(r,e,t,i){const n=typeof e.error=="string"?e.error:null,o=typeof e.error_description=="string"?e.error_description:void 0,c={directory_status:r,...n?{directory_error:n}:{},...o?{directory_description:o}:{}},u=o?`: ${o}`:"";switch(n){case"invalid_client":return F()?{error:f("machine_revoked",O(i),c),grantRefusal:!1}:{error:f("server_error",`the directory refused the outbound token request (${n})${u}`,c),grantRefusal:!1};case"invalid_request":return{error:f("server_error",`the directory refused the outbound token request (${n})${u}`,c),grantRefusal:!1};case"invalid_target":return{error:f("not_found",`the directory knows no provider at ${t.audience}${u}`,c),grantRefusal:!1};case"invalid_grant":return{error:f("grant_revoked",`the directory has no active grant for ${t.grantee}${u}`,c),grantRefusal:!0};case"provider_not_connected":return{error:f("provider_not_connected",o??"The user has not connected this provider.",c),grantRefusal:!1};case"invalid_scope":{const s=typeof e.scope=="string"&&e.scope?e.scope:t.scope.length===1?t.scope[0]:void 0,m=s?`missing scope ${s}`:`the grant does not cover ${t.scope.join(" ")}`;return{error:new h("scope_missing",m,{origin:"directory",details:c,...s?{scope:s}:{}}),grantRefusal:!0}}default:return{error:f("server_error",`the directory token endpoint answered ${r}${n?` ${n}`:""}${u}`,c),grantRefusal:!1}}}a(B,"refusal");function H(r){try{return JSON.parse(Buffer.from(r.split(".")[1]??"","base64url").toString("utf8"))}catch{return{}}}a(H,"claims");function K(r){const e=r.grant_rev,t=r.grant_status;return typeof e!="number"||!Number.isInteger(e)||e<0||typeof t!="string"?null:{rev:e,status:t}}a(K,"refusedGrantState");function z(r,e){const t=a(i=>i.status==="active"?0:1,"phase");return r.rev>e.rev||r.rev===e.rev&&t(r)>t(e)}a(z,"newer");function Q(r,e,t){if(!e)return;const i=A(r.grantee,r.sub)??{rev:0,status:"removed"};if(!z(e,i))return;const n=t.syncGrants??P;Promise.resolve().then(()=>n({fresh:!0})).catch(()=>{})}a(Q,"syncIfNewer");async function V(r,e={}){const t=J(e),i=`${t}/token`,n=I(),o=new URLSearchParams({grant_type:"client_credentials",client_id:n.client_id,client_assertion_type:"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",client_assertion:N(n,i),audience:r.audience,sub:r.sub,grantee:r.grantee,scope:[...new Set(r.scope)].sort().join(" "),initiating_app:r.initiating_app}),c=AbortSignal.timeout(D),u=e.signal?AbortSignal.any([e.signal,c]):c;let s,m;try{s=await(e.fetch??fetch)(i,{method:"POST",body:o,signal:u,headers:{Accept:"application/json"}}),m=await s.text()}catch(l){throw e.signal?.aborted?e.signal.reason:new h("server_error",`the directory token endpoint could not be reached: ${l instanceof Error?l.message:String(l)}`,{origin:"directory"})}let p=null;try{p=m?JSON.parse(m):null}catch{p=null}const g=p&&typeof p=="object"&&!Array.isArray(p)?p:{};if(!s.ok){const{error:l,grantRefusal:j}=B(s.status,g,r,t);throw l.code==="machine_revoked"&&$({level:"error",event:"daemon.grants.machine_revoked",fields:{audience:r.audience}}),$({level:"info",event:"daemon.providers.mint_refused",fields:{audience:r.audience,grantee:r.grantee,code:l.code,directory_error:typeof g.error=="string"?g.error:null,status:s.status}}),j&&Q(r,K(g),e),l}const y=g.access_token;if(typeof y!="string"||!y)throw new h("server_error","the directory token response has no access_token",{origin:"directory"});const d=H(y),S=Math.floor((e.now?.()??Date.now())/1e3),_=g.expires_in,v=typeof _=="number"&&Number.isFinite(_)&&_>0?S+Math.floor(_):null,w=typeof d.exp=="number"?d.exp:null,b=v!==null&&w!==null?Math.min(v,w):v??w;if(b===null)throw new h("server_error","the directory token response names no expiry",{origin:"directory"});const x=typeof d.jti=="string"&&d.jti?d.jti:M("sha256").update(y).digest("hex"),R=typeof d.payer=="string"&&d.payer?d.payer:r.sub;return{access_token:y,jti:x,exp:b,payer:R}}a(V,"mintOutboundToken");const k=new Map;async function ie(r,e={}){const t=G(r),i=e.now??Date.now,n=L(t,i());if(n)return{...n,key:t};let o=k.get(t);if(!o){const u=U(t);o=V(r,{...e,signal:void 0}).then(s=>(C(t,s,u),s)).finally(()=>{k.delete(t)}),o.catch(()=>{}),k.set(t,o)}return{...e.signal?await W(o,e.signal):await o,key:t}}a(ie,"outboundToken");function W(r,e){return e.aborted?Promise.reject(e.reason):new Promise((t,i)=>{const n=a(()=>i(e.reason),"onAbort");e.addEventListener("abort",n,{once:!0}),r.then(t,i).finally(()=>e.removeEventListener("abort",n))})}a(W,"abortable");export{D as MINT_TIMEOUT_MS,ce as discardFile,V as mintOutboundToken,ie as outboundToken};
@@ -6,6 +6,8 @@ export declare function acceptProviderReceipt(value: string | string[] | undefin
6
6
  sub: string;
7
7
  grantee: string;
8
8
  run_id: string | null;
9
+ /** The outbound token's `payer` claim: the principal the directory charges (an app, or the user). */
10
+ payer: string;
9
11
  /** Whose month the receipt adds to when it differs from the receipt's grantee (the person's calls count under `app:terminal`). */
10
12
  ledger_grantee?: string;
11
13
  }): string | undefined;
@@ -1,2 +1,2 @@
1
- var p=Object.defineProperty;var _=(t,r)=>p(t,"name",{value:r,configurable:!0});import{prepared as o}from"../sqlite-statements.js";import{withCanvasWrite as a}from"../canvas/db.js";import{addUsage as g}from"../usage/ledger.js";function E(t,r){const s=Array.isArray(t)?t[0]:t;if(!s||s.length>16384)return;let i;try{i=JSON.parse(s)}catch{return}if(!i||typeof i!="object"||Array.isArray(i))return;const e=i;if(!(typeof e.receipt_id!="string"||!e.receipt_id||e.receipt_id.length>256||e.provider!==`app:${r.provider}`||e.tool!==r.tool||e.request_id!==r.request_id||e.sub!==r.sub||e.grantee!==r.grantee||e.payer!==r.sub||(e.run_id??null)!==r.run_id||typeof e.unit!="string"||!e.unit||typeof e.units!="number"||!Number.isFinite(e.units)||e.units<0||typeof e.time!="string"||!Number.isFinite(Date.parse(e.time))||typeof e.usd_equivalent!="number"||!Number.isFinite(e.usd_equivalent)||e.usd_equivalent<0))return a(n=>{if(o(n,`INSERT OR IGNORE INTO provider_receipts
2
- (provider, receipt_id, grantee, run_id, usd_equivalent, received_at) VALUES (?, ?, ?, ?, ?, ?)`).run(r.provider,e.receipt_id,r.grantee,r.run_id,e.usd_equivalent,new Date().toISOString()).changes&&(g(n,{grantee:r.ledger_grantee??r.grantee,payer:"user",source:`provider:${r.provider}`,served_model:"",input_tokens:0,output_tokens:0,cached_tokens:0,usd_equivalent:e.usd_equivalent}),r.run_id)){const d=o(n,"SELECT usage_json FROM nodes WHERE node_id = ? AND run_id = ?").get(r.run_id,r.run_id);if(d){const u=d.usage_json?JSON.parse(d.usage_json):{};u.cost_usd=(typeof u.cost_usd=="number"?u.cost_usd:0)+e.usd_equivalent,o(n,"UPDATE nodes SET usage_json = ? WHERE node_id = ?").run(JSON.stringify(u),r.run_id)}}}),e.receipt_id}_(E,"acceptProviderReceipt");export{E as acceptProviderReceipt};
1
+ var _=Object.defineProperty;var p=(t,r)=>_(t,"name",{value:r,configurable:!0});import{prepared as o}from"../sqlite-statements.js";import{withCanvasWrite as a}from"../canvas/db.js";import{addUsage as g}from"../usage/ledger.js";function E(t,r){const u=Array.isArray(t)?t[0]:t;if(!u||u.length>16384)return;let i;try{i=JSON.parse(u)}catch{return}if(!i||typeof i!="object"||Array.isArray(i))return;const e=i;if(!(typeof e.receipt_id!="string"||!e.receipt_id||e.receipt_id.length>256||e.provider!==`app:${r.provider}`||e.tool!==r.tool||e.request_id!==r.request_id||e.sub!==r.sub||e.grantee!==r.grantee||e.payer!==r.payer||(e.run_id??null)!==r.run_id||typeof e.unit!="string"||!e.unit||typeof e.units!="number"||!Number.isFinite(e.units)||e.units<0||typeof e.time!="string"||!Number.isFinite(Date.parse(e.time))||typeof e.usd_equivalent!="number"||!Number.isFinite(e.usd_equivalent)||e.usd_equivalent<0))return a(n=>{if(o(n,`INSERT OR IGNORE INTO provider_receipts
2
+ (provider, receipt_id, grantee, run_id, usd_equivalent, received_at) VALUES (?, ?, ?, ?, ?, ?)`).run(r.provider,e.receipt_id,r.grantee,r.run_id,e.usd_equivalent,new Date().toISOString()).changes&&(g(n,{grantee:r.ledger_grantee??r.grantee,payer:r.payer.startsWith("app:")?"app":"user",source:`provider:${r.provider}`,served_model:"",input_tokens:0,output_tokens:0,cached_tokens:0,usd_equivalent:e.usd_equivalent}),r.run_id)){const d=o(n,"SELECT usage_json FROM nodes WHERE node_id = ? AND run_id = ?").get(r.run_id,r.run_id);if(d){const s=d.usage_json?JSON.parse(d.usage_json):{};s.cost_usd=(typeof s.cost_usd=="number"?s.cost_usd:0)+e.usd_equivalent,o(n,"UPDATE nodes SET usage_json = ? WHERE node_id = ?").run(JSON.stringify(s),r.run_id)}}}),e.receipt_id}p(E,"acceptProviderReceipt");export{E as acceptProviderReceipt};
@@ -11,11 +11,13 @@ export interface OutboundTokenRequest {
11
11
  /** `<provider>:<group>` strings; order does not matter. */
12
12
  scope: readonly string[];
13
13
  }
14
- /** A minted token. `exp` is epoch seconds (the JWT's own claim). */
14
+ /** A minted token. `exp` is epoch seconds (the JWT's own claim). `payer` is the principal the directory
15
+ * charges for calls under it (its `payer` claim; `sub` when the claim is absent). */
15
16
  export interface CachedToken {
16
17
  access_token: string;
17
18
  jti: string;
18
19
  exp: number;
20
+ payer: string;
19
21
  }
20
22
  export declare function tokenCacheDir(): string;
21
23
  /** JSON of `[aud, sub, grantee, initiating_app, sorted scopes]`. */