Skip to content

Commit 13cab51

Browse files
sahrizviclaude
andauthored
chore(release): v0.12.1 (#1338)
* fix(workspace): close the v0.12.1 release-review findings on the pin/identity seam Five-persona review of v0.12.0..main. #1320 (IDE pin) and #1330 (identity every turn) changed `resolveBindingOutcome` from opposite sides and were never reviewed together; every item here is on that seam. - A pin served from the offline grace window is marked `stale`, so the identity section says "last known" for it as it already did for a cached link. `pinValidation` is bounded like the other caches. - Identity's memo is keyed on the credential digest as well as the tenant and host — two accounts on one tenant no longer share an entry (the pin cache in `state.ts` already did this). - Under a pin, identity's deadline fallback never reaches for the project's own cached link — the workspace the pin exists to override. - A pinned session is described as pinned by the IDE extension, with the caveat that warehouse tool routing still follows the project's own link (#1337); the unknown copy no longer promises that retrying helps. - The persistent `shell` tool strips the same host markers as `bash` (`ALTIMATE_CODE_SERVE`, the pin trio, headless, non-interactive) via a shared `stripHostMarkers`, so a nested `serve` cannot inherit a pin. - `pin.ts` states the extension contract: a pin is fixed for the life of the process; a panel switch means relaunching `serve`. - Docs: the three pin variables and `ALTIMATE_CODE_SERVE` in cli.md, and a note on the identity line and the pin under "Workspaces (pilot)". Tests: pinned-session copy, pin-aware fallback, same-tenant credential switch, grace-path stale, host-marker stripping. Each guard was deleted once to confirm its test fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 * fix(workspace): pin ids are decimal digits; the identity cap fits every shape Two defects the v0.12.1 adversarial tests found. - `readPin` took any string `Number()` parses — "1e3", "0x10", "1.0" — as an id. The extension never writes those; only decimal digits (with surrounding whitespace) are a pin now, the rest fail closed as before. - `MAX_SECTION_CHARS` (1,000) was below the pinned-and-stale identity copy with a budget-sized label (1,238), so `render` failed closed and dropped the name — and for the plain stale shape (1,078) too. Raised to 1,500; a test renders every shape with the worst-case label and checks the name survives. Adds `test/skill/release-v0.12.1-adversarial.test.ts`: hostile pin environments, root traversal (including the documented symlink bypass), host-marker stripping by exact name, and the identity copy across pin × stale × unbound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 * release: v0.12.1 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 * test: the #937 env-plumbing guard checks stripHostMarkers behaviourally It grepped bash.ts for the literal `delete mergedEnv["ALTIMATE_NON_INTERACTIVE"]`, which moved into the shared `stripHostMarkers` in this release. The contract it protected — the non-interactive marker is stripped, auto-answer is kept — is now asserted on the function. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 * fix(workspace): address the bot and multi-model review on the v0.12.1 release PR - `stripHostMarkers` deletes every spelling of a marker on Windows, where environment names are case-insensitive, and the exact name elsewhere. The nested `altimate_change` markers around its call site are gone. - `shell.ts` exposes `shellChildEnv` so the persistent shell's child environment is tested on values, not on this file's source text. - `readPin` treats a whitespace-only id, name or root as a broken pin. - Identity keeps the resolver behind the deadline even when no complete account is configured: the resolver's own credential read is looser than `accountScope` and can still reach the network. - The adversarial test file no longer mutates `XDG_STATE_HOME`; the preload already isolates state and nothing here reads it. - Docs: `ALTIMATE_CODE_SERVE` is set by `serve` itself, not only by the extension; ordinary and pinned sessions described separately. - CHANGELOG narrows the credential-scoping claim to the layer this release fixes: the resolver's own five-minute caches are still keyed by tenant and host (tracked separately). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 * docs(changelog): point the resolver-scope caveat at #1339 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 * test: keep the source guard for ALTIMATE_AUTO_ANSWER beside the behavioural one Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 * fix(workspace): identity renders unknown without the resolver when no complete account is configured Nothing can verify a link without a credential, and the resolver's looser credential read would otherwise reach the network from that path with no memo, no single-flight and a synchronous git probe. Test asserts the resolver is not called. Also: the shell child-env test now exercises the default `process.env` base the production call site relies on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 * test: read PATH or Path in the shell child-env test (Windows) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 327eb9f commit 13cab51

13 files changed

Lines changed: 701 additions & 61 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.12.1] - 2026-09-21
9+
10+
Two workspace-pilot additions that landed right after 0.12.0, plus the fixes their joint review turned up. Numbered as a patch because everything under **Added** is behind `ALTIMATE_WORKSPACE=1`; the only changes that reach every user are two reworded strings under **Changed**. **Heads-up for support (pilot):** a session launched from the VS Code / Cursor extension now follows the workspace picked in the extension's panel for skills and memory — but warehouse tool routing still follows the project's own link (#1337), and the agent says so when asked.
11+
12+
### Added
13+
14+
- **The agent states which Altimate Workspace the project is linked to, every turn.** Not only when it is routing warehouse tools: an unlinked project, a link that cannot be verified right now, and a freshly created workspace each get a definite answer ("linked to … id N", "none is linked — here is how to link one", "could not be confirmed"). When the link is served from cache because the server could not be asked, the agent says "last known", and a "none is linked" answered from the five-minute cache says "as of the last check". The instruction is scoped to a genuine identity question ("this/current/active workspace") so an unrelated Databricks conversation gets no linking pitch, and the model is told not to confuse the Altimate Workspace with a Databricks workspace or an IDE workspace folder in either direction. The workspace name is presented as a label chosen by the workspace owner, not an instruction (#1335 tracks structural isolation). Resolved at most once per 30 s per account and project, with a 1.5 s deadline so a slow server never stalls a turn. (#1330, closes #1331)
15+
- **The IDE extension's workspace selection governs the session it launches.** `altimate-code serve` started by the VS Code / Cursor extension reads `ALTIMATE_PINNED_WORKSPACE_{ID,NAME,ROOT}`; a valid pin outranks the project's stored binding for skills and memory, is validated against the workspaces the signed-in account can see (it selects among them, it grants nothing), is scoped to the folder it was launched for, and is never written to disk. A partial or malformed pin, or one naming a workspace the account cannot see, fails closed rather than falling back to the project's link. A pin is fixed for the life of the process; the extension relaunches `serve` when the selection changes. (#1320)
16+
17+
### Fixed
18+
19+
- **Routing and identity can no longer contradict each other about the link.** The routing section used to say "this project is bound to workspace X" from a snapshot taken at tool resolution; it now says which workspace *serves* the tools, and only the identity section states the link. (#1330)
20+
- **A pinned session is described as pinned, with the routing caveat**, and a pin served from the offline grace window is marked "last known" like a cached link. Identity's per-turn memo is keyed on the credential, not only the tenant, so two accounts on one tenant never share that memo; the resolver's own five-minute caches underneath it are still keyed by tenant and host, so a same-tenant account switch can still be answered from the previous account's cached link for up to five minutes (#1339, deferred). Under a pin the deadline fallback never reaches for the project's own cached link. Found in this release's review.
21+
- **The persistent `shell` tool strips the same host markers as `bash`** (`ALTIMATE_CODE_SERVE`, the pin variables, headless and non-interactive), so a nested `altimate-code serve` started from it cannot inherit a pin it was never given. Found in this release's review.
22+
- **Pin ids are decimal digits only** — `Number()` also accepted `1e3` and `0x10` — and the identity section's size cap now fits its longest shape with a maximum-length name instead of dropping the name. Found by this release's adversarial tests.
23+
24+
### Changed
25+
26+
- **Two strings no longer use "workspace" for something other than the Altimate Workspace** (for every user, not only the pilot): the Databricks credential prompt says `<databricks-workspace-host>`, and the dbt nothing-built validator says "this project is configured to require artifacts". (#1330)
27+
28+
### Known limitations (pilot)
29+
30+
- Warehouse tool routing does not yet honour the IDE pin (#1337); skills, memory and the identity line do.
31+
- Switching the pinned workspace does not pull that workspace's skills and memory until the next sync cycle (#1320 notes).
32+
833
## [0.12.0] - 2026-09-18
934

1035
The workspace pilot grows a management surface: the agent knows which workspace it is linked to, the sidebar shows what has and has not synced, a `/workspace` menu handles refresh/sync/unlink, and a locally written skill can be published to the workspace. Everything under **Added** is pilot-only (`ALTIMATE_WORKSPACE=1`); nothing changes for other users. **Heads-up for support:** `upgrade` and `uninstall` now refuse when they cannot tell how the binary was installed, instead of guessing — see the first entry under **Fixed**.

‎docs/docs/usage/cli.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ Workspace features are off unless `ALTIMATE_WORKSPACE=1` is set. With it:
5353
- `altimate-code link` links the current project to a workspace (or creates one). The sidebar then names the workspace and shows how many memories are not yet synced and when skills last synced.
5454
- `/workspace` in the TUI opens a menu: **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Unlink** detaches the project.
5555
- `altimate-code skill publish <name>` uploads a project skill to the linked workspace; see [Skills](../configure/skills.md#cli-commands).
56+
- In an ordinary session the agent is told every turn which workspace the project is linked to — or that none is, or that the link could not be verified just now. In an extension-pinned session it is told the pinned workspace instead, and that it differs from the project's own link. Either way "which workspace am I in?" has an answer, and the agent is told not to confuse it with a Databricks workspace or an IDE workspace folder.
57+
- When `altimate-code serve` is launched by the VS Code / Cursor extension, the workspace selected in the extension's panel governs that session's skills and memory, taking priority over whatever the project is linked to on the backend, and is scoped to the folder it was launched for. Warehouse tool routing still follows the project's own link for now. A pin is fixed for the life of the `serve` process, so the extension relaunches `serve` when the selection changes; nothing updates a running one.
5658

5759
## Global Flags
5860

@@ -96,6 +98,8 @@ Configuration can be controlled via environment variables:
9698
| `ALTIMATE_CLI_DISABLE_MODELS_FETCH` | Don't fetch models from models.dev |
9799
| `ALTIMATE_WORKSPACE` | Opt into the workspace pilot (`1`). Off by default; nothing about workspaces is active without it |
98100
| `ALTIMATE_INTEGRATIONS` | Set to `local` to keep warehouse tools local rather than routing them through a bound workspace's engine |
101+
| `ALTIMATE_CODE_SERVE` | Set to `1` by `altimate-code serve` itself, whether the IDE extension or you launched it. Marks that process as the extension's host — so it is the one that reads the pin variables — and is stripped from every child the bash and shell tools start |
102+
| `ALTIMATE_PINNED_WORKSPACE_ID` / `_NAME` / `_ROOT` | Set together by the IDE extension on `serve`: the workspace selected in its panel and the folder it applies to. All three or none — a partial pin is refused rather than ignored. Read only when `ALTIMATE_CODE_SERVE` is set; never persisted; stripped from child processes |
99103

100104
### Server & Security
101105

‎packages/opencode/src/altimate/workspace/identity.ts‎

Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,22 @@
2222
// meant nagging about linking mid-conversation about an unrelated Databricks topic, or
2323
// pedantically re-qualifying every casual mention of one. Neither is this feature's
2424
// job — resolving "this/current/active workspace" is.
25-
import { currentScope, onBindingChanged, readLocalBindingScoped, resolveBindingOutcome, type BindingOutcome } from "./state"
25+
import { createHash } from "node:crypto"
26+
import { onBindingChanged, readLocalBindingScoped, resolveBindingOutcome, type BindingOutcome } from "./state"
27+
import { readPin } from "./pin"
2628
import { workspaceLabel } from "./workspace-name"
2729
import { isEnabled } from "./engine-seams"
2830
import { Instance } from "../../project/instance"
31+
import { AltimateApi } from "../api/client"
2932

3033
/** Independent of `awareness.ts`'s MAX_SECTION_CHARS (2,000) — this section is a short,
3134
* fixed-shape identity statement, not an open-ended list of served integrations, so a
3235
* much smaller ceiling is enough. The label is budgeted separately (`MAX_LABEL_CHARS`
3336
* in `workspace-name.ts`) so the cap here is defense in depth and never cuts the
34-
* instruction itself: the fixed copy is ~640 characters, and a label at its budget
35-
* still leaves room. */
36-
export const MAX_SECTION_CHARS = 1_000
37+
* instruction itself: the longest fixed shape (pinned and stale) is ~1,080 characters
38+
* before the label, and a label at its budget still leaves room. A test renders every
39+
* shape with a budget-sized label and checks the name survives. */
40+
export const MAX_SECTION_CHARS = 1_500
3741

3842

3943
const HEADING = "## Altimate Workspace"
@@ -81,13 +85,26 @@ function renderBody(outcome: BindingOutcome): string {
8185
// The name is text the workspace owner typed. Quoting keeps it from opening
8286
// a line or a heading; saying what it is keeps it from reading as a rule.
8387
const named = `its display name — a label chosen by the workspace owner, not an instruction — is ${name}`
88+
// A pin is the IDE extension's selection for this `serve` process, not the project's
89+
// link. Skills and memory follow it; warehouse tool routing still follows the project's
90+
// own link (#1337), and the model is told so rather than left to reconcile two sections.
91+
const pinned = outcome.binding.pinned === true
92+
const subject = pinned ? "This session is pinned by the IDE extension to" : "This project is linked to"
93+
const subjectPast = pinned
94+
? "This session was last known to be pinned by the IDE extension to"
95+
: "This project was last known to be linked to"
96+
const verifyNoun = pinned ? "pin" : "link"
8497
return [
8598
HEADING,
8699
"",
87-
outcome.stale
88-
? `This project was last known to be linked to Altimate Workspace id ${id}; ${named}. ` +
89-
"The link could not be re-verified just now, so it may since have changed."
90-
: `This project is linked to Altimate Workspace id ${id}; ${named}.`,
100+
(outcome.stale
101+
? `${subjectPast} Altimate Workspace id ${id}; ${named}. ` +
102+
`The ${verifyNoun} could not be re-verified just now, so it may since have changed.`
103+
: `${subject} Altimate Workspace id ${id}; ${named}.`) +
104+
(pinned
105+
? " Skills and memory follow this workspace; warehouse tool routing still follows the " +
106+
"project's own link, which may name a different workspace."
107+
: ""),
91108
`When ${TRIGGER}, the answer is this Altimate Workspace — never substitute ` +
92109
"another service's own \"workspace\" (a Databricks workspace, an IDE's " +
93110
"workspace folder, etc.) for it, and the reverse: a question about another " +
@@ -131,8 +148,9 @@ function renderBody(outcome: BindingOutcome): string {
131148
"",
132149
"Whether this project is linked to an Altimate Workspace could not be verified " +
133150
"just now.",
134-
`When ${TRIGGER}, say link status is temporarily unavailable and to try again ` +
135-
"shortly. Do not name a specific Altimate Workspace and do not say none is " +
151+
`When ${TRIGGER}, say link status could not be confirmed, and that if this persists ` +
152+
"across turns the user should check the workspace selected in the IDE extension or " +
153+
"run `altimate-code link`. Do not name a specific Altimate Workspace and do not say none is " +
136154
"linked.",
137155
"Outside such a question, other services' own \"workspace\" concepts (e.g. a " +
138156
"Databricks workspace) are unaffected and can be discussed normally.",
@@ -235,7 +253,7 @@ function resolve(key: string, directory: string): Promise<BindingOutcome> {
235253
const task = identityInternals
236254
.resolveBindingOutcome(directory)
237255
.then(async (outcome): Promise<BindingOutcome> => {
238-
const after = await currentScope()
256+
const after = await accountScope().catch(() => null)
239257
if (!after || keyFor(after, directory) !== key) return { status: "unknown" }
240258
if (seen === generation) remember(key, outcome)
241259
return outcome
@@ -244,7 +262,7 @@ function resolve(key: string, directory: string): Promise<BindingOutcome> {
244262
// Same account check as the settled path, for symmetry: an unknown filed
245263
// under another account's key asserts nothing, but should not exist.
246264
const outcome: BindingOutcome = { status: "unknown" }
247-
const after = await currentScope().catch(() => null)
265+
const after = await accountScope().catch(() => null)
248266
if (after && keyFor(after, directory) === key && seen === generation) remember(key, outcome, FAILURE_MEMO_MS)
249267
return outcome
250268
})
@@ -255,8 +273,22 @@ function resolve(key: string, directory: string): Promise<BindingOutcome> {
255273
return task
256274
}
257275

258-
function keyFor(scope: { tenant: string; apiUrl: string }, directory: string): string {
259-
return `${scope.tenant}|${scope.apiUrl}|${directory}`
276+
/** The account this step runs as: tenant, host, AND a short digest of the credential.
277+
* Keyed on the credential, not only the tenant, for the reason `state.ts`'s pin
278+
* validation gives: two accounts on one tenant would otherwise share an entry, and a
279+
* switch mid-process would let the new principal inherit the previous one's answer.
280+
* Only the digest is kept, never the key. Null when no credentials are configured. */
281+
type AccountScope = { tenant: string; apiUrl: string; account: string }
282+
async function accountScope(): Promise<AccountScope | null> {
283+
if (!(await AltimateApi.isConfigured())) return null
284+
const c = await AltimateApi.getCredentials()
285+
if (!c.altimateInstanceName || !c.altimateUrl || !c.altimateApiKey) return null
286+
const account = createHash("sha256").update(c.altimateApiKey).digest("hex").slice(0, 16)
287+
return { tenant: c.altimateInstanceName, apiUrl: c.altimateUrl, account }
288+
}
289+
290+
function keyFor(scope: AccountScope, directory: string): string {
291+
return `${scope.tenant}|${scope.apiUrl}|${scope.account}|${directory}`
260292
}
261293

262294
/** What to render when the resolve has not settled inside the deadline: the last
@@ -274,12 +306,16 @@ function keyFor(scope: { tenant: string; apiUrl: string }, directory: string): s
274306
async function lastKnown(key: string, directory: string): Promise<BindingOutcome> {
275307
// The account must still be the one the key names before ANY last-known
276308
// answer is used — the expired memo entry as much as the local cache.
277-
const scope = await currentScope().catch(() => null)
309+
const scope = await accountScope().catch(() => null)
278310
if (!scope || keyFor(scope, directory) !== key) return { status: "unknown" }
279311
const previous = memo.get(key)?.outcome
280312
if (previous?.status === "bound") return { ...previous, stale: true }
313+
// Under a pin the project's own cached link is not the answer — the pin outranks it
314+
// in the resolver, and an invalid pin fails closed — so a cold memo renders unknown
315+
// rather than the workspace the pin exists to override.
316+
if (readPin().kind !== "absent") return { status: "unknown" }
281317
const local = await readLocalBindingScoped(directory).catch(() => ({ binding: null, scope: null }))
282-
if (local.binding && local.scope && key === `${local.scope}|${directory}`) {
318+
if (local.binding && local.scope === `${scope.tenant}|${scope.apiUrl}`) {
283319
return { status: "bound", binding: local.binding, stale: true }
284320
}
285321
return { status: "unknown" }
@@ -306,20 +342,22 @@ export async function systemSection(): Promise<string> {
306342
if (!isEnabled()) return ""
307343
try {
308344
const directory = Instance.directory
309-
const scope = await currentScope()
310-
// No account to ask with: nothing to memoise under, and the resolver answers
311-
// from the local cache alone without touching the network.
312-
if (!scope) return render(await resolveBindingOutcome(directory))
313-
const key = keyFor(scope, directory)
314-
const hit = memo.get(key)
315-
if (fresh(hit)) return render(hit!.outcome)
345+
const scope = await accountScope()
316346
const timers: ReturnType<typeof setTimeout>[] = []
317347
const after = (ms: number, value: () => BindingOutcome | Promise<BindingOutcome>) =>
318348
new Promise<BindingOutcome>((done) => {
319349
const t = setTimeout(() => done(value()), ms)
320350
t.unref?.()
321351
timers.push(t)
322352
})
353+
// No complete account: nothing can verify a link, and the resolver's own credential
354+
// read is looser (a file with an empty key still names a tenant and host, and would
355+
// reach the network from here with no memo, no single-flight and a synchronous
356+
// `git remote` probe). Say so and do not invoke it.
357+
if (!scope) return render({ status: "unknown" })
358+
const key = keyFor(scope, directory)
359+
const hit = memo.get(key)
360+
if (fresh(hit)) return render(hit!.outcome)
323361
// The fallback is itself raced against a small budget, so the wait is
324362
// bounded by RESOLVE_DEADLINE_MS + FALLBACK_BUDGET_MS, not by the disk.
325363
const deadline = after(RESOLVE_DEADLINE_MS, () =>

‎packages/opencode/src/altimate/workspace/pin.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@
2121
// session look like a half-populated pin, and the fail-closed rule below would then break
2222
// `--workspace` outright. The two mechanisms are kept apart deliberately, and `readPin` additionally
2323
// stands down outside `serve`.
24+
//
25+
// CONTRACT FOR THE EXTENSION: the pin is fixed for the life of the `serve` process. It is read
26+
// from the environment on every call, but nothing here — no route, no IPC, no file watch —
27+
// updates that environment after spawn. When the user picks a different datamate in the panel
28+
// the extension MUST kill and relaunch `serve` with the new values; a running process keeps
29+
// serving the old pin indefinitely otherwise, and `PIN_VALIDATION_TTL_MS` only re-checks that
30+
// the SAME datamate is still visible, it cannot notice that the selection changed. For the same
31+
// reason a pin change cannot fire `onBindingChanged` and the per-process caches downstream
32+
// (identity's memo, the pin validation memo) need no invalidation path: a new pin is a new
33+
// process, which starts with both empty.
2434
import { realpathSync } from "node:fs"
2535
import path from "node:path"
2636
import { Filesystem } from "@/util/filesystem"
@@ -115,11 +125,13 @@ export function readPin(env: NodeJS.ProcessEnv = process.env): PinState {
115125

116126
// Partial or empty is invalid, never "good enough". The extension sets all three or none;
117127
// anything else means something rewrote the environment and we no longer know what was intended.
118-
if (!rawId || !name || !root) {
128+
if (!rawId?.trim() || !name?.trim() || !root?.trim()) {
119129
return { kind: "invalid", reason: "pin is partially set or empty" }
120130
}
121131

122-
const datamateId = Number(rawId)
132+
// Decimal digits only. `Number()` also accepts "1e3", "0x10" and "1.0", and an id in any of
133+
// those spellings means something other than the extension wrote the environment.
134+
const datamateId = /^\d+$/.test(rawId.trim()) ? Number(rawId.trim()) : NaN
123135
if (!Number.isSafeInteger(datamateId) || datamateId <= 0) {
124136
return { kind: "invalid", reason: `datamate id ${JSON.stringify(rawId)} is not a positive integer` }
125137
}

0 commit comments

Comments
 (0)