From 6f301d09e18cf5e6ee3019fd5a90aa0a3a00fda5 Mon Sep 17 00:00:00 2001 From: alex-w-99 Date: Wed, 15 Jul 2026 21:07:33 +0000 Subject: [PATCH 1/8] chore: require Inkbox SDK 0.5.0 --- CHANGELOG.md | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c858ee..3f12b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Initial release. +- Requires `@inkbox/sdk` 0.5.0 or newer. - 56 `inkbox_*` tools across email, SMS/MMS, iMessage, calls, contacts, notes, contact rules, access grants, encrypted vault, and diagnostics. 24 are enabled by default; the rest are opt-in via the `tools.enable` plugin option diff --git a/package.json b/package.json index 40b06a0..ac8465a 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "opencode": ">=1.15.0 <1.19.0" }, "dependencies": { - "@inkbox/sdk": ">=0.4.24 <1.0.0", + "@inkbox/sdk": ">=0.5.0 <1.0.0", "@opencode-ai/sdk": ">=1.17.18 <1.19.0", "ws": "^8.21.0", "zod": "4.1.8" From e078df0a23ba12cd75f5daad483c6170aefa738f Mon Sep 17 00:00:00 2001 From: alex-w-99 Date: Sat, 18 Jul 2026 01:58:52 +0000 Subject: [PATCH 2/8] feat: align organization-wide contacts --- README.md | 6 +- skills/inkbox-contact-lookup/SKILL.md | 18 ++-- skills/inkbox-identity-access/SKILL.md | 21 ++-- skills/inkbox-notes-memory/SKILL.md | 2 + skills/inkbox-troubleshooting/SKILL.md | 2 +- src/client.ts | 6 +- src/tools/access.ts | 95 +--------------- src/tools/contacts.ts | 16 +-- tests/contract/tool-vocabulary.test.ts | 7 +- tests/unit/access.test.ts | 143 +------------------------ 10 files changed, 40 insertions(+), 276 deletions(-) diff --git a/README.md b/README.md index 8c8ae33..097e0c6 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ groups are enabled. ## Tools -27 tools are enabled by default; 24 more are opt-in (see +27 tools are enabled by default; 21 more are opt-in (see [Enabling more tools](#enabling-more-tools)). Names are stable — treat renames as breaking. @@ -124,10 +124,12 @@ as breaking. | `contacts` | `inkbox_lookup_contact`, `inkbox_get_contact`, `inkbox_list_contacts`, `inkbox_create_contact`, `inkbox_update_contact`, `inkbox_delete_contact` | — | | `notes` | `inkbox_list_notes`, `inkbox_get_note`, `inkbox_create_note` | `inkbox_update_note`, `inkbox_delete_note` | | `contact-rules` | `inkbox_list_mail_contact_rules`, `inkbox_list_phone_contact_rules`, `inkbox_list_imessage_contact_rules` (read-only: rule changes are console-only) | — | -| `access` | — | `inkbox_list_contact_access`, `inkbox_grant_contact_access`, `inkbox_revoke_contact_access`, `inkbox_list_note_access`, `inkbox_grant_note_access`, `inkbox_revoke_note_access` | +| `access` | — | `inkbox_list_note_access`, `inkbox_grant_note_access`, `inkbox_revoke_note_access` | | `vault` | — | `inkbox_credentials_list`, and by exact name only: `inkbox_credentials_get_login`, `inkbox_credentials_get_api_key`, `inkbox_credentials_get_ssh_key`, `inkbox_totp_code` | | `diagnostics` | `inkbox_whoami`, `inkbox_doctor` | — | +Contacts and generated contact facts are organization-wide. Contact `notes` remain user-managed profile text, separate from source-grounded generated facts. Correspondence still follows the configured identity's channel permissions. Unified correspondence and generated-fact tools are deferred until the installed SDK exposes those resources; this plugin does not use raw API calls as a substitute. + ### Enabling more tools opencode sends every registered tool's spec to the model on each turn, so the diff --git a/skills/inkbox-contact-lookup/SKILL.md b/skills/inkbox-contact-lookup/SKILL.md index 734622e..8623eaa 100644 --- a/skills/inkbox-contact-lookup/SKILL.md +++ b/skills/inkbox-contact-lookup/SKILL.md @@ -1,11 +1,11 @@ --- name: inkbox-contact-lookup -description: Use when the user asks "who is X", "what's the email for Y", "find a contact named Z", "save this contact", or any request that needs contact context. The agent can read and write Inkbox contacts visible to the configured identity; contact access grants, contact rules, and vCard flows are separate surfaces. +description: Use when the user asks "who is X", "what's the email for Y", "find a contact named Z", "save this contact", or any request that needs organization-wide contact context. --- # Inkbox contact lookup -Inkbox contacts are an address-book scoped to the configured identity. When the user asks about a person, wants to save or edit a contact, or needs an address or number resolved, use the contact tools below. +Inkbox contacts are an organization-wide address book. When the user asks about a person, wants to save or edit a contact, or needs an address or number resolved, use the contact tools below. ## Required tools @@ -18,14 +18,14 @@ All six are enabled by default: - `inkbox_update_contact` — change an existing contact after you know its UUID - `inkbox_delete_contact` — delete a contact only after the target is explicit and confirmed -There is no vCard export/import tool. Contact access tools, contact rule tools, and `inkbox_place_call` are opt-in — the user must enable them in your .opencode/plugins/inkbox.ts wrapper and restart opencode: +There is no vCard export/import tool. Contact rule tools and `inkbox_place_call` are opt-in — the user must enable them in your .opencode/plugins/inkbox.ts wrapper and restart opencode: ```ts // in your .opencode/plugins/inkbox.ts wrapper: -InkboxPlugin(input, { "tools": { "enable": ["inkbox_place_call", "access", "contact-rules"] } }) +InkboxPlugin(input, { "tools": { "enable": ["inkbox_place_call", "contact-rules"] } }) ``` -Use access and rule tools only when the user explicitly asks to manage sharing or allow/block rules. `inkbox_doctor` reports which tools are enabled or disabled. +Use rule tools only when the user explicitly asks to manage allow/block rules. `inkbox_doctor` reports which tools are enabled or disabled. ## Workflow @@ -36,11 +36,13 @@ Use access and rule tools only when the user explicitly asks to manage sharing o 5. **Delete cautiously.** If the user asks to delete a contact, confirm the exact target when there is any ambiguity, then call `inkbox_delete_contact` with the UUID. 6. **Ask when the target is ambiguous.** If lookup returns multiple plausible contacts, ask which contact the user means before sending, calling, updating, or deleting. -## Access semantics +## Contact memory semantics -- Contact tools operate only on contacts visible/writable to the configured identity. +- Active contacts and generated contact facts are organization-wide. - Contacts created through `inkbox_create_contact` are Inkbox address-book records, not local notes or session memory. -- Sharing contacts across Inkbox identities is grant management — that requires the opt-in access tools, and only when the user explicitly asks for it. +- Contact `notes` are user-managed profile text. Generated facts are separate, source-grounded memory; do not copy or overwrite them through the `notes` field. +- Correspondence remains limited to the configured identity's authorized email, text, iMessage, and call history. +- The installed SDK does not expose unified contact correspondence or generated-fact reads, so this plugin does not register those tools. Do not reconstruct them with raw requests. ## What this skill does NOT cover diff --git a/skills/inkbox-identity-access/SKILL.md b/skills/inkbox-identity-access/SKILL.md index 1445eea..2f9be9c 100644 --- a/skills/inkbox-identity-access/SKILL.md +++ b/skills/inkbox-identity-access/SKILL.md @@ -1,11 +1,11 @@ --- name: inkbox-identity-access -description: Use when the user asks which Inkbox agent identities can see a contact or note, or asks to grant/revoke cross-identity access to contacts or notes. +description: Use when the user asks which Inkbox agent identities can see a note, or asks to grant/revoke cross-identity note access. Contacts and generated contact facts are organization-wide. --- # Inkbox identity access -Use this skill when managing per-identity visibility for Inkbox contacts and notes. +Use this skill when managing per-identity visibility for Inkbox notes. Contacts and generated contact facts do not have per-identity grants. ## Enabling the tools @@ -16,20 +16,15 @@ All identity-access tools are opt-in. Enable the `access` group in your .opencod InkboxPlugin(input, { "tools": { "enable": ["access"] } }) ``` -This enables `inkbox_list_contact_access`, `inkbox_grant_contact_access`, `inkbox_revoke_contact_access`, `inkbox_list_note_access`, `inkbox_grant_note_access`, and `inkbox_revoke_note_access`. Run `inkbox_doctor` to confirm which tools are currently enabled. +This enables `inkbox_list_note_access`, `inkbox_grant_note_access`, and `inkbox_revoke_note_access`. Run `inkbox_doctor` to confirm which tools are currently enabled. ## Workflow -1. Resolve the contact or note id first. If the user names a person or note, use `inkbox_lookup_contact`, `inkbox_list_contacts`, `inkbox_get_note`, or `inkbox_list_notes`. -2. List current access with `inkbox_list_contact_access` / `inkbox_list_note_access` before changing it when possible. -3. For contacts: - - Grant a specific identity with `identityId`. - - Use `wildcard: true` only when the user wants every active identity to see the contact; it replaces the specific grants. Pass either `identityId` or `wildcard`, never both. - - Revoke by `identityId`. -4. For notes: - - Grant and revoke only by explicit `identityId`; notes do not support wildcard grants. -5. If the user gives an agent handle instead of an identity UUID: `inkbox_whoami` returns the current identity's own id and handle, and access listings may already contain the other identity's id. If neither resolves the handle, explain that you need the identity id. +1. Resolve the note id first with `inkbox_get_note` or `inkbox_list_notes`. +2. List current access with `inkbox_list_note_access` before changing it when possible. +3. Grant and revoke only by explicit `identityId`; notes do not support wildcard grants. +4. If the user gives an agent handle instead of an identity UUID: `inkbox_whoami` returns the current identity's own id and handle, and note access listings may already contain the other identity's id. If neither resolves the handle, explain that you need the identity id. ## Safety -Access changes affect what other Inkbox agent identities can see. Confirm the target identity and object before granting broad or wildcard contact access. +Note access changes affect what other Inkbox agent identities can see. Confirm the target identity and note before changing access. diff --git a/skills/inkbox-notes-memory/SKILL.md b/skills/inkbox-notes-memory/SKILL.md index 3db1e29..bac59a7 100644 --- a/skills/inkbox-notes-memory/SKILL.md +++ b/skills/inkbox-notes-memory/SKILL.md @@ -33,6 +33,8 @@ InkboxPlugin(input, { "tools": { "enable": ["inkbox_update_note", "inkbox_delete 2. **Do not store contact details as notes.** If the user asks to save a person, phone number, email, address-book entry, or "my contact", use the contact workflow: lookup first, then create or update an Inkbox contact. + Contact `notes`, generated contact facts, and Inkbox notes are distinct. Generated facts are source-grounded organization-wide memory and must not be copied into either user-managed notes surface. + 3. **Search before editing.** For "update the note about X", call `inkbox_list_notes` with a focused query, then `inkbox_get_note` if needed before using `inkbox_update_note`. 4. **Be explicit about opt-in tools.** If update/delete is not enabled, say that the note was found but the tool is disabled, and point the user at the config snippet above. diff --git a/skills/inkbox-troubleshooting/SKILL.md b/skills/inkbox-troubleshooting/SKILL.md index 1dc2945..dbd8635 100644 --- a/skills/inkbox-troubleshooting/SKILL.md +++ b/skills/inkbox-troubleshooting/SKILL.md @@ -16,7 +16,7 @@ Start with `inkbox_doctor` (default-enabled). It reports config presence, API re | `Inkbox plugin is not configured` | Set `INKBOX_API_KEY` + `INKBOX_IDENTITY` in the environment launching opencode, or pass `apiKey`/`identity` as plugin options in your .opencode/plugins/inkbox.ts wrapper (`~/.inkbox/config` is also read, at lowest precedence). Restart opencode after changes. | | Tool not found / not available | The tool is opt-in and not enabled. Add its exact name (or its group) to `tools.enable` in your `.opencode/plugins/inkbox.ts` wrapper, then restart: `InkboxPlugin(input, { tools: { enable: ["inkbox_..."] } })`. Check `inkbox_doctor` for the enabled/disabled list. | | `whoami failed: 401 Unauthorized` | API key is wrong, revoked, or has a typo. Ask the user to mint or paste a fresh key. | -| `API key is not agent-scoped` | Outbound may work, but access-scoped reads (contacts, notes, vault) can behave differently. Prefer an agent-scoped key bound to the configured identity. | +| `API key is not agent-scoped` | Outbound may work, but channel, note, and vault reads can behave differently. Prefer an agent-scoped key bound to the configured identity. | | `404` on identity resolution | The configured `identity` handle does not exist under this key or does not match the key's bound identity. | | `sender_sms_pending` | The Inkbox phone number is still propagating to carriers. Retry later and verify `smsStatus`. | | `recipient_not_opted_in` | Ask the recipient to text `START` to the agent's Inkbox number, then retry. | diff --git a/src/client.ts b/src/client.ts index 8da9507..9fd1f51 100644 --- a/src/client.ts +++ b/src/client.ts @@ -67,8 +67,8 @@ export function createInkboxRuntime(source: ConfigSource, logger?: PluginLogger) const identityHandle = cfg.identity; const promise = (async () => { // Confirm the key shape before we go any further. Agent-scoped is the - // expected mode; admin-scoped works for outbound but we surface a - // warning since access-scoped reads assume the agent-scoped pattern. + // expected mode; admin-scoped works for outbound but channel and + // note/vault reads may not use the configured identity's scope. try { const info = await inkbox.whoami(); if (info.authType === "api_key") { @@ -78,7 +78,7 @@ export function createInkboxRuntime(source: ConfigSource, logger?: PluginLogger) sub === AUTH_SUBTYPE_API_KEY_AGENT_SCOPED_UNCLAIMED; if (!isAgentScoped) { logger?.warn?.( - `Inkbox plugin: API key is not agent-scoped (subtype=${sub}). Outbound tools will work but access-scoped reads (contacts, notes, vault) may behave differently.`, + `Inkbox plugin: API key is not agent-scoped (subtype=${sub}). Outbound tools will work but channel, note, and vault reads may behave differently.`, ); } } else { diff --git a/src/tools/access.ts b/src/tools/access.ts index 538da8e..b7f7e02 100644 --- a/src/tools/access.ts +++ b/src/tools/access.ts @@ -3,24 +3,6 @@ import { runTool } from "../errors.js"; import { formatJson, formatWithHeader } from "../format.js"; import type { RegisteredTool, ToolDeps } from "./types.js"; -const listContactAccessArgs = { - contactId: z.string().describe("Contact UUID."), -}; - -const grantContactAccessArgs = { - contactId: z.string().describe("Contact UUID."), - identityId: z.string().describe("Agent identity UUID to grant.").optional(), - wildcard: z - .boolean() - .describe("Set true to replace specific grants with wildcard access.") - .optional(), -}; - -const revokeContactAccessArgs = { - contactId: z.string().describe("Contact UUID."), - identityId: z.string().describe("Agent identity UUID to revoke."), -}; - const listNoteAccessArgs = { noteId: z.string().describe("Note UUID."), }; @@ -35,89 +17,14 @@ const revokeNoteAccessArgs = { identityId: z.string().describe("Agent identity UUID to revoke."), }; -type ListContactAccessArgs = z.infer>; -type GrantContactAccessArgs = z.infer>; -type RevokeContactAccessArgs = z.infer>; type ListNoteAccessArgs = z.infer>; type GrantNoteAccessArgs = z.infer>; type RevokeNoteAccessArgs = z.infer>; -function hasString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -// Access grants control which identities can see a contact or a note. -// Contacts support wildcard grants (every active identity sees the contact); -// note grants are strictly per-identity. +// Notes retain per-identity grants; contacts are organization-wide. export function accessTools(deps: ToolDeps): RegisteredTool[] { const { runtime } = deps; return [ - { - name: "inkbox_list_contact_access", - group: "access", - defaultEnabled: false, - definition: { - description: - "List which Inkbox identities can see a contact. Use before granting or revoking cross-identity contact access.", - args: listContactAccessArgs, - async execute(args: ListContactAccessArgs, _ctx) { - return runTool(async () => { - const inkbox = await runtime.getClient(); - const grants = await inkbox.contacts.access.list(args.contactId); - return formatWithHeader(`Returned ${grants.length} contact access grant(s).`, grants); - }); - }, - }, - }, - { - name: "inkbox_grant_contact_access", - group: "access", - defaultEnabled: false, - definition: { - description: - "Grant an Inkbox identity access to a contact, or grant wildcard access so every active identity can see it.", - args: grantContactAccessArgs, - async execute(args: GrantContactAccessArgs, _ctx) { - return runTool(async () => { - const identityId = hasString(args.identityId) ? args.identityId.trim() : undefined; - if (args.wildcard === true && identityId) { - throw new Error("Pass either identityId or wildcard=true, not both."); - } - if (args.wildcard !== true && !identityId) { - throw new Error("identityId is required unless wildcard=true."); - } - const inkbox = await runtime.getClient(); - const grant = await inkbox.contacts.access.grant(args.contactId, { - identityId, - wildcard: args.wildcard === true, - }); - return { - title: `Granted access to contact ${args.contactId}`, - output: formatWithHeader("Granted contact access.", grant), - }; - }); - }, - }, - }, - { - name: "inkbox_revoke_contact_access", - group: "access", - defaultEnabled: false, - definition: { - description: "Revoke one Inkbox identity's access to a contact.", - args: revokeContactAccessArgs, - async execute(args: RevokeContactAccessArgs, _ctx) { - return runTool(async () => { - const inkbox = await runtime.getClient(); - await inkbox.contacts.access.revoke(args.contactId, args.identityId); - return { - title: `Revoked access to contact ${args.contactId}`, - output: `Revoked identity ${args.identityId} access to contact ${args.contactId}.`, - }; - }); - }, - }, - }, { name: "inkbox_list_note_access", group: "access", diff --git a/src/tools/contacts.ts b/src/tools/contacts.ts index 54d1503..15b5ca2 100644 --- a/src/tools/contacts.ts +++ b/src/tools/contacts.ts @@ -40,7 +40,10 @@ const createContactArgs = { familyName: z.string().describe("Family/last name.").optional(), companyName: z.string().describe("Company or organization.").optional(), jobTitle: z.string().describe("Job title.").optional(), - notes: z.string().describe("Free-form contact notes.").optional(), + notes: z + .string() + .describe("User-managed contact notes, separate from generated contact facts.") + .optional(), emails: z.array(contactEmailSchema).describe("Email addresses.").optional(), phones: z.array(contactPhoneSchema).describe("Phone numbers.").optional(), }; @@ -128,10 +131,8 @@ function buildContactWritePayload(args: ContactWriteArgs): Record { @@ -174,8 +175,7 @@ export function contactTools(deps: ToolDeps): RegisteredTool[] { group: "contacts", defaultEnabled: true, definition: { - description: - "List contacts this identity has access to. Optional free-text search via `q`; results scoped by per-identity grants.", + description: "List organization-wide contacts. Optional free-text search via `q`.", args: listContactsArgs, async execute(args: ListContactsArgs, _ctx) { return runTool(async () => { diff --git a/tests/contract/tool-vocabulary.test.ts b/tests/contract/tool-vocabulary.test.ts index c209521..a36f88b 100644 --- a/tests/contract/tool-vocabulary.test.ts +++ b/tests/contract/tool-vocabulary.test.ts @@ -75,9 +75,6 @@ const OPT_IN = [ "inkbox_place_call", "inkbox_update_note", "inkbox_delete_note", - "inkbox_list_contact_access", - "inkbox_grant_contact_access", - "inkbox_revoke_contact_access", "inkbox_list_note_access", "inkbox_grant_note_access", "inkbox_revoke_note_access", @@ -111,10 +108,10 @@ const GROUPS = [ describe("tool vocabulary", () => { const all = buildAllTools(stubDeps()); - it("ships exactly the expected 51 tools", () => { + it("ships exactly the expected 48 tools", () => { const names = all.map((t) => t.name).sort(); expect(names).toEqual([...DEFAULT_ENABLED, ...OPT_IN].sort()); - expect(names).toHaveLength(51); + expect(names).toHaveLength(48); }); it("has no duplicate tool names", () => { diff --git a/tests/unit/access.test.ts b/tests/unit/access.test.ts index ff5075c..60e5ce4 100644 --- a/tests/unit/access.test.ts +++ b/tests/unit/access.test.ts @@ -30,20 +30,6 @@ function makeCtx() { function makeClient() { return { - contacts: { - access: { - list: vi.fn(async () => [ - { id: "grant-1", contactId: "contact-1", identityId: "identity-1", wildcard: false }, - ]), - grant: vi.fn(async () => ({ - id: "grant-2", - contactId: "contact-1", - identityId: "identity-2", - wildcard: false, - })), - revoke: vi.fn(async () => undefined), - }, - }, notes: { access: { list: vi.fn(async () => [ @@ -68,12 +54,9 @@ function outputText(result: unknown): string { } describe("accessTools", () => { - it("registers the six access tools in the access group", () => { + it("registers the three note access tools in the access group", () => { const tools = accessTools(makeDeps(makeClient())); expect(tools.map((t) => t.name)).toEqual([ - "inkbox_list_contact_access", - "inkbox_grant_contact_access", - "inkbox_revoke_contact_access", "inkbox_list_note_access", "inkbox_grant_note_access", "inkbox_revoke_note_access", @@ -91,130 +74,6 @@ describe("accessTools", () => { } }); - describe("inkbox_list_contact_access", () => { - it("lists grants for the contact and reports the count", async () => { - const client = makeClient(); - const tool = findTool(accessTools(makeDeps(client)), "inkbox_list_contact_access"); - const result = await tool.definition.execute({ contactId: "contact-1" }, makeCtx()); - expect(client.contacts.access.list).toHaveBeenCalledWith("contact-1"); - const text = outputText(result); - expect(text).toContain("Returned 1 contact access grant(s)."); - expect(text).toContain('"identityId": "identity-1"'); - }); - - it("declares a schema that requires contactId", () => { - const tool = findTool(accessTools(makeDeps(makeClient())), "inkbox_list_contact_access"); - const schema = z.object(tool.definition.args); - expect(schema.safeParse({ contactId: "contact-1" }).success).toBe(true); - expect(schema.safeParse({}).success).toBe(false); - expect(schema.safeParse({ contactId: 42 }).success).toBe(false); - }); - }); - - describe("inkbox_grant_contact_access", () => { - it("grants a specific identity access to the contact", async () => { - const client = makeClient(); - const tool = findTool(accessTools(makeDeps(client)), "inkbox_grant_contact_access"); - const result = await tool.definition.execute( - { contactId: "contact-1", identityId: "identity-2" }, - makeCtx(), - ); - expect(client.contacts.access.grant).toHaveBeenCalledWith("contact-1", { - identityId: "identity-2", - wildcard: false, - }); - const text = outputText(result); - expect(text).toContain("Granted contact access."); - expect(text).toContain('"identityId": "identity-2"'); - }); - - it("grants wildcard access without an identityId", async () => { - const client = makeClient(); - const tool = findTool(accessTools(makeDeps(client)), "inkbox_grant_contact_access"); - await tool.definition.execute({ contactId: "contact-1", wildcard: true }, makeCtx()); - expect(client.contacts.access.grant).toHaveBeenCalledWith("contact-1", { - identityId: undefined, - wildcard: true, - }); - }); - - it("trims surrounding whitespace from identityId before granting", async () => { - const client = makeClient(); - const tool = findTool(accessTools(makeDeps(client)), "inkbox_grant_contact_access"); - await tool.definition.execute( - { contactId: "contact-1", identityId: " identity-2 " }, - makeCtx(), - ); - expect(client.contacts.access.grant).toHaveBeenCalledWith("contact-1", { - identityId: "identity-2", - wildcard: false, - }); - }); - - it("rejects passing both identityId and wildcard=true", async () => { - const client = makeClient(); - const tool = findTool(accessTools(makeDeps(client)), "inkbox_grant_contact_access"); - await expect( - tool.definition.execute( - { contactId: "contact-1", identityId: "identity-2", wildcard: true }, - makeCtx(), - ), - ).rejects.toThrow("Pass either identityId or wildcard=true, not both."); - expect(client.contacts.access.grant).not.toHaveBeenCalled(); - }); - - it("rejects when neither identityId nor wildcard=true is given", async () => { - const client = makeClient(); - const tool = findTool(accessTools(makeDeps(client)), "inkbox_grant_contact_access"); - await expect(tool.definition.execute({ contactId: "contact-1" }, makeCtx())).rejects.toThrow( - "identityId is required unless wildcard=true.", - ); - expect(client.contacts.access.grant).not.toHaveBeenCalled(); - }); - - it("treats a whitespace-only identityId as missing", async () => { - const client = makeClient(); - const tool = findTool(accessTools(makeDeps(client)), "inkbox_grant_contact_access"); - await expect( - tool.definition.execute({ contactId: "contact-1", identityId: " " }, makeCtx()), - ).rejects.toThrow("identityId is required unless wildcard=true."); - expect(client.contacts.access.grant).not.toHaveBeenCalled(); - }); - - it("declares a schema with optional identityId and boolean wildcard", () => { - const tool = findTool(accessTools(makeDeps(makeClient())), "inkbox_grant_contact_access"); - const schema = z.object(tool.definition.args); - expect(schema.safeParse({ contactId: "c", identityId: "i" }).success).toBe(true); - expect(schema.safeParse({ contactId: "c", wildcard: true }).success).toBe(true); - expect(schema.safeParse({ contactId: "c" }).success).toBe(true); - expect(schema.safeParse({}).success).toBe(false); - expect(schema.safeParse({ contactId: "c", wildcard: "yes" }).success).toBe(false); - }); - }); - - describe("inkbox_revoke_contact_access", () => { - it("revokes the identity's access and summarizes it", async () => { - const client = makeClient(); - const tool = findTool(accessTools(makeDeps(client)), "inkbox_revoke_contact_access"); - const result = await tool.definition.execute( - { contactId: "contact-1", identityId: "identity-1" }, - makeCtx(), - ); - expect(client.contacts.access.revoke).toHaveBeenCalledWith("contact-1", "identity-1"); - expect(outputText(result)).toContain( - "Revoked identity identity-1 access to contact contact-1.", - ); - }); - - it("declares a schema that requires contactId and identityId", () => { - const tool = findTool(accessTools(makeDeps(makeClient())), "inkbox_revoke_contact_access"); - const schema = z.object(tool.definition.args); - expect(schema.safeParse({ contactId: "c", identityId: "i" }).success).toBe(true); - expect(schema.safeParse({ contactId: "c" }).success).toBe(false); - expect(schema.safeParse({ identityId: "i" }).success).toBe(false); - }); - }); - describe("inkbox_list_note_access", () => { it("lists grants for the note and reports the count", async () => { const client = makeClient(); From e76bb7dc759916d206cd44adb390782fec534b00 Mon Sep 17 00:00:00 2001 From: alex-w-99 Date: Sun, 19 Jul 2026 23:36:29 +0000 Subject: [PATCH 3/8] ci: test prerelease SDK source --- .github/workflows/tests.yml | 41 +++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9fe6e75..2dac652 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,11 +10,21 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: inkbox-ai/inkbox + ref: b2bb11a45c9f4dc89f3bcfbadbd4de5d66d0e9e5 + path: .ci/inkbox - uses: actions/setup-node@v7 with: node-version: 22 cache: npm - - run: npm ci + - run: | + npm ci --prefix .ci/inkbox/sdk/typescript + npm run build --prefix .ci/inkbox/sdk/typescript + npm install --no-save --package-lock=false \ + ./.ci/inkbox/sdk/typescript \ + @opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18 - run: npm run lint - run: npm run typecheck - run: npm test @@ -26,12 +36,25 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: inkbox-ai/inkbox + ref: b2bb11a45c9f4dc89f3bcfbadbd4de5d66d0e9e5 + path: .ci/inkbox - uses: actions/setup-node@v7 with: node-version: 22 cache: npm - - run: npm ci - - run: npm install -D @opencode-ai/plugin@latest + - run: | + npm ci --prefix .ci/inkbox/sdk/typescript + npm run build --prefix .ci/inkbox/sdk/typescript + npm install --no-save --package-lock=false \ + ./.ci/inkbox/sdk/typescript \ + @opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18 + - run: | + npm install --no-save --package-lock=false \ + ./.ci/inkbox/sdk/typescript \ + @opencode-ai/sdk@latest @opencode-ai/plugin@latest - run: npm run typecheck - run: npx vitest run tests/contract @@ -39,10 +62,20 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: inkbox-ai/inkbox + ref: b2bb11a45c9f4dc89f3bcfbadbd4de5d66d0e9e5 + path: .ci/inkbox - uses: actions/setup-node@v7 with: node-version: 22 cache: npm - - run: npm ci + - run: | + npm ci --prefix .ci/inkbox/sdk/typescript + npm run build --prefix .ci/inkbox/sdk/typescript + npm install --no-save --package-lock=false \ + ./.ci/inkbox/sdk/typescript \ + @opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18 - run: npm install -g opencode-ai@latest - run: bash scripts/smoke-loader.sh From 9a18cddeb5ce9551c084eb841ca3e7895e4351a1 Mon Sep 17 00:00:00 2001 From: alex-w-99 Date: Mon, 20 Jul 2026 02:49:37 +0000 Subject: [PATCH 4/8] ci: seed loader smoke with prerelease SDK --- .github/workflows/tests.yml | 2 +- scripts/smoke-loader.sh | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2dac652..17b29e2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -78,4 +78,4 @@ jobs: ./.ci/inkbox/sdk/typescript \ @opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18 - run: npm install -g opencode-ai@latest - - run: bash scripts/smoke-loader.sh + - run: INKBOX_SDK_PATH="$PWD/.ci/inkbox/sdk/typescript" bash scripts/smoke-loader.sh diff --git a/scripts/smoke-loader.sh b/scripts/smoke-loader.sh index eae38c0..d12e67d 100644 --- a/scripts/smoke-loader.sh +++ b/scripts/smoke-loader.sh @@ -25,7 +25,11 @@ echo "==> creating throwaway project in $WORKDIR" cd "$WORKDIR" git init -q . npm init -y >/dev/null -npm install --silent "$TARBALL" +if [ -n "${INKBOX_SDK_PATH:-}" ]; then + npm install --silent "$INKBOX_SDK_PATH" "$TARBALL" +else + npm install --silent "$TARBALL" +fi mkdir -p .opencode/plugins # The wrapper loads the packaged plugin exactly as opencode would, then drops From c16137f108e39f2f14d9b96f442bd2cf201e3b68 Mon Sep 17 00:00:00 2001 From: alex-w-99 Date: Mon, 20 Jul 2026 04:43:01 +0000 Subject: [PATCH 5/8] Keep contact access identity-scoped --- README.md | 6 +- skills/inkbox-contact-lookup/SKILL.md | 18 ++-- skills/inkbox-identity-access/SKILL.md | 21 ++-- skills/inkbox-notes-memory/SKILL.md | 2 - skills/inkbox-troubleshooting/SKILL.md | 2 +- src/client.ts | 6 +- src/tools/access.ts | 95 +++++++++++++++- src/tools/contacts.ts | 16 +-- tests/contract/tool-vocabulary.test.ts | 7 +- tests/unit/access.test.ts | 143 ++++++++++++++++++++++++- 10 files changed, 276 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 097e0c6..8c8ae33 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ groups are enabled. ## Tools -27 tools are enabled by default; 21 more are opt-in (see +27 tools are enabled by default; 24 more are opt-in (see [Enabling more tools](#enabling-more-tools)). Names are stable — treat renames as breaking. @@ -124,12 +124,10 @@ as breaking. | `contacts` | `inkbox_lookup_contact`, `inkbox_get_contact`, `inkbox_list_contacts`, `inkbox_create_contact`, `inkbox_update_contact`, `inkbox_delete_contact` | — | | `notes` | `inkbox_list_notes`, `inkbox_get_note`, `inkbox_create_note` | `inkbox_update_note`, `inkbox_delete_note` | | `contact-rules` | `inkbox_list_mail_contact_rules`, `inkbox_list_phone_contact_rules`, `inkbox_list_imessage_contact_rules` (read-only: rule changes are console-only) | — | -| `access` | — | `inkbox_list_note_access`, `inkbox_grant_note_access`, `inkbox_revoke_note_access` | +| `access` | — | `inkbox_list_contact_access`, `inkbox_grant_contact_access`, `inkbox_revoke_contact_access`, `inkbox_list_note_access`, `inkbox_grant_note_access`, `inkbox_revoke_note_access` | | `vault` | — | `inkbox_credentials_list`, and by exact name only: `inkbox_credentials_get_login`, `inkbox_credentials_get_api_key`, `inkbox_credentials_get_ssh_key`, `inkbox_totp_code` | | `diagnostics` | `inkbox_whoami`, `inkbox_doctor` | — | -Contacts and generated contact facts are organization-wide. Contact `notes` remain user-managed profile text, separate from source-grounded generated facts. Correspondence still follows the configured identity's channel permissions. Unified correspondence and generated-fact tools are deferred until the installed SDK exposes those resources; this plugin does not use raw API calls as a substitute. - ### Enabling more tools opencode sends every registered tool's spec to the model on each turn, so the diff --git a/skills/inkbox-contact-lookup/SKILL.md b/skills/inkbox-contact-lookup/SKILL.md index 8623eaa..734622e 100644 --- a/skills/inkbox-contact-lookup/SKILL.md +++ b/skills/inkbox-contact-lookup/SKILL.md @@ -1,11 +1,11 @@ --- name: inkbox-contact-lookup -description: Use when the user asks "who is X", "what's the email for Y", "find a contact named Z", "save this contact", or any request that needs organization-wide contact context. +description: Use when the user asks "who is X", "what's the email for Y", "find a contact named Z", "save this contact", or any request that needs contact context. The agent can read and write Inkbox contacts visible to the configured identity; contact access grants, contact rules, and vCard flows are separate surfaces. --- # Inkbox contact lookup -Inkbox contacts are an organization-wide address book. When the user asks about a person, wants to save or edit a contact, or needs an address or number resolved, use the contact tools below. +Inkbox contacts are an address-book scoped to the configured identity. When the user asks about a person, wants to save or edit a contact, or needs an address or number resolved, use the contact tools below. ## Required tools @@ -18,14 +18,14 @@ All six are enabled by default: - `inkbox_update_contact` — change an existing contact after you know its UUID - `inkbox_delete_contact` — delete a contact only after the target is explicit and confirmed -There is no vCard export/import tool. Contact rule tools and `inkbox_place_call` are opt-in — the user must enable them in your .opencode/plugins/inkbox.ts wrapper and restart opencode: +There is no vCard export/import tool. Contact access tools, contact rule tools, and `inkbox_place_call` are opt-in — the user must enable them in your .opencode/plugins/inkbox.ts wrapper and restart opencode: ```ts // in your .opencode/plugins/inkbox.ts wrapper: -InkboxPlugin(input, { "tools": { "enable": ["inkbox_place_call", "contact-rules"] } }) +InkboxPlugin(input, { "tools": { "enable": ["inkbox_place_call", "access", "contact-rules"] } }) ``` -Use rule tools only when the user explicitly asks to manage allow/block rules. `inkbox_doctor` reports which tools are enabled or disabled. +Use access and rule tools only when the user explicitly asks to manage sharing or allow/block rules. `inkbox_doctor` reports which tools are enabled or disabled. ## Workflow @@ -36,13 +36,11 @@ Use rule tools only when the user explicitly asks to manage allow/block rules. ` 5. **Delete cautiously.** If the user asks to delete a contact, confirm the exact target when there is any ambiguity, then call `inkbox_delete_contact` with the UUID. 6. **Ask when the target is ambiguous.** If lookup returns multiple plausible contacts, ask which contact the user means before sending, calling, updating, or deleting. -## Contact memory semantics +## Access semantics -- Active contacts and generated contact facts are organization-wide. +- Contact tools operate only on contacts visible/writable to the configured identity. - Contacts created through `inkbox_create_contact` are Inkbox address-book records, not local notes or session memory. -- Contact `notes` are user-managed profile text. Generated facts are separate, source-grounded memory; do not copy or overwrite them through the `notes` field. -- Correspondence remains limited to the configured identity's authorized email, text, iMessage, and call history. -- The installed SDK does not expose unified contact correspondence or generated-fact reads, so this plugin does not register those tools. Do not reconstruct them with raw requests. +- Sharing contacts across Inkbox identities is grant management — that requires the opt-in access tools, and only when the user explicitly asks for it. ## What this skill does NOT cover diff --git a/skills/inkbox-identity-access/SKILL.md b/skills/inkbox-identity-access/SKILL.md index 2f9be9c..1445eea 100644 --- a/skills/inkbox-identity-access/SKILL.md +++ b/skills/inkbox-identity-access/SKILL.md @@ -1,11 +1,11 @@ --- name: inkbox-identity-access -description: Use when the user asks which Inkbox agent identities can see a note, or asks to grant/revoke cross-identity note access. Contacts and generated contact facts are organization-wide. +description: Use when the user asks which Inkbox agent identities can see a contact or note, or asks to grant/revoke cross-identity access to contacts or notes. --- # Inkbox identity access -Use this skill when managing per-identity visibility for Inkbox notes. Contacts and generated contact facts do not have per-identity grants. +Use this skill when managing per-identity visibility for Inkbox contacts and notes. ## Enabling the tools @@ -16,15 +16,20 @@ All identity-access tools are opt-in. Enable the `access` group in your .opencod InkboxPlugin(input, { "tools": { "enable": ["access"] } }) ``` -This enables `inkbox_list_note_access`, `inkbox_grant_note_access`, and `inkbox_revoke_note_access`. Run `inkbox_doctor` to confirm which tools are currently enabled. +This enables `inkbox_list_contact_access`, `inkbox_grant_contact_access`, `inkbox_revoke_contact_access`, `inkbox_list_note_access`, `inkbox_grant_note_access`, and `inkbox_revoke_note_access`. Run `inkbox_doctor` to confirm which tools are currently enabled. ## Workflow -1. Resolve the note id first with `inkbox_get_note` or `inkbox_list_notes`. -2. List current access with `inkbox_list_note_access` before changing it when possible. -3. Grant and revoke only by explicit `identityId`; notes do not support wildcard grants. -4. If the user gives an agent handle instead of an identity UUID: `inkbox_whoami` returns the current identity's own id and handle, and note access listings may already contain the other identity's id. If neither resolves the handle, explain that you need the identity id. +1. Resolve the contact or note id first. If the user names a person or note, use `inkbox_lookup_contact`, `inkbox_list_contacts`, `inkbox_get_note`, or `inkbox_list_notes`. +2. List current access with `inkbox_list_contact_access` / `inkbox_list_note_access` before changing it when possible. +3. For contacts: + - Grant a specific identity with `identityId`. + - Use `wildcard: true` only when the user wants every active identity to see the contact; it replaces the specific grants. Pass either `identityId` or `wildcard`, never both. + - Revoke by `identityId`. +4. For notes: + - Grant and revoke only by explicit `identityId`; notes do not support wildcard grants. +5. If the user gives an agent handle instead of an identity UUID: `inkbox_whoami` returns the current identity's own id and handle, and access listings may already contain the other identity's id. If neither resolves the handle, explain that you need the identity id. ## Safety -Note access changes affect what other Inkbox agent identities can see. Confirm the target identity and note before changing access. +Access changes affect what other Inkbox agent identities can see. Confirm the target identity and object before granting broad or wildcard contact access. diff --git a/skills/inkbox-notes-memory/SKILL.md b/skills/inkbox-notes-memory/SKILL.md index bac59a7..3db1e29 100644 --- a/skills/inkbox-notes-memory/SKILL.md +++ b/skills/inkbox-notes-memory/SKILL.md @@ -33,8 +33,6 @@ InkboxPlugin(input, { "tools": { "enable": ["inkbox_update_note", "inkbox_delete 2. **Do not store contact details as notes.** If the user asks to save a person, phone number, email, address-book entry, or "my contact", use the contact workflow: lookup first, then create or update an Inkbox contact. - Contact `notes`, generated contact facts, and Inkbox notes are distinct. Generated facts are source-grounded organization-wide memory and must not be copied into either user-managed notes surface. - 3. **Search before editing.** For "update the note about X", call `inkbox_list_notes` with a focused query, then `inkbox_get_note` if needed before using `inkbox_update_note`. 4. **Be explicit about opt-in tools.** If update/delete is not enabled, say that the note was found but the tool is disabled, and point the user at the config snippet above. diff --git a/skills/inkbox-troubleshooting/SKILL.md b/skills/inkbox-troubleshooting/SKILL.md index dbd8635..1dc2945 100644 --- a/skills/inkbox-troubleshooting/SKILL.md +++ b/skills/inkbox-troubleshooting/SKILL.md @@ -16,7 +16,7 @@ Start with `inkbox_doctor` (default-enabled). It reports config presence, API re | `Inkbox plugin is not configured` | Set `INKBOX_API_KEY` + `INKBOX_IDENTITY` in the environment launching opencode, or pass `apiKey`/`identity` as plugin options in your .opencode/plugins/inkbox.ts wrapper (`~/.inkbox/config` is also read, at lowest precedence). Restart opencode after changes. | | Tool not found / not available | The tool is opt-in and not enabled. Add its exact name (or its group) to `tools.enable` in your `.opencode/plugins/inkbox.ts` wrapper, then restart: `InkboxPlugin(input, { tools: { enable: ["inkbox_..."] } })`. Check `inkbox_doctor` for the enabled/disabled list. | | `whoami failed: 401 Unauthorized` | API key is wrong, revoked, or has a typo. Ask the user to mint or paste a fresh key. | -| `API key is not agent-scoped` | Outbound may work, but channel, note, and vault reads can behave differently. Prefer an agent-scoped key bound to the configured identity. | +| `API key is not agent-scoped` | Outbound may work, but access-scoped reads (contacts, notes, vault) can behave differently. Prefer an agent-scoped key bound to the configured identity. | | `404` on identity resolution | The configured `identity` handle does not exist under this key or does not match the key's bound identity. | | `sender_sms_pending` | The Inkbox phone number is still propagating to carriers. Retry later and verify `smsStatus`. | | `recipient_not_opted_in` | Ask the recipient to text `START` to the agent's Inkbox number, then retry. | diff --git a/src/client.ts b/src/client.ts index 9fd1f51..8da9507 100644 --- a/src/client.ts +++ b/src/client.ts @@ -67,8 +67,8 @@ export function createInkboxRuntime(source: ConfigSource, logger?: PluginLogger) const identityHandle = cfg.identity; const promise = (async () => { // Confirm the key shape before we go any further. Agent-scoped is the - // expected mode; admin-scoped works for outbound but channel and - // note/vault reads may not use the configured identity's scope. + // expected mode; admin-scoped works for outbound but we surface a + // warning since access-scoped reads assume the agent-scoped pattern. try { const info = await inkbox.whoami(); if (info.authType === "api_key") { @@ -78,7 +78,7 @@ export function createInkboxRuntime(source: ConfigSource, logger?: PluginLogger) sub === AUTH_SUBTYPE_API_KEY_AGENT_SCOPED_UNCLAIMED; if (!isAgentScoped) { logger?.warn?.( - `Inkbox plugin: API key is not agent-scoped (subtype=${sub}). Outbound tools will work but channel, note, and vault reads may behave differently.`, + `Inkbox plugin: API key is not agent-scoped (subtype=${sub}). Outbound tools will work but access-scoped reads (contacts, notes, vault) may behave differently.`, ); } } else { diff --git a/src/tools/access.ts b/src/tools/access.ts index b7f7e02..538da8e 100644 --- a/src/tools/access.ts +++ b/src/tools/access.ts @@ -3,6 +3,24 @@ import { runTool } from "../errors.js"; import { formatJson, formatWithHeader } from "../format.js"; import type { RegisteredTool, ToolDeps } from "./types.js"; +const listContactAccessArgs = { + contactId: z.string().describe("Contact UUID."), +}; + +const grantContactAccessArgs = { + contactId: z.string().describe("Contact UUID."), + identityId: z.string().describe("Agent identity UUID to grant.").optional(), + wildcard: z + .boolean() + .describe("Set true to replace specific grants with wildcard access.") + .optional(), +}; + +const revokeContactAccessArgs = { + contactId: z.string().describe("Contact UUID."), + identityId: z.string().describe("Agent identity UUID to revoke."), +}; + const listNoteAccessArgs = { noteId: z.string().describe("Note UUID."), }; @@ -17,14 +35,89 @@ const revokeNoteAccessArgs = { identityId: z.string().describe("Agent identity UUID to revoke."), }; +type ListContactAccessArgs = z.infer>; +type GrantContactAccessArgs = z.infer>; +type RevokeContactAccessArgs = z.infer>; type ListNoteAccessArgs = z.infer>; type GrantNoteAccessArgs = z.infer>; type RevokeNoteAccessArgs = z.infer>; -// Notes retain per-identity grants; contacts are organization-wide. +function hasString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +// Access grants control which identities can see a contact or a note. +// Contacts support wildcard grants (every active identity sees the contact); +// note grants are strictly per-identity. export function accessTools(deps: ToolDeps): RegisteredTool[] { const { runtime } = deps; return [ + { + name: "inkbox_list_contact_access", + group: "access", + defaultEnabled: false, + definition: { + description: + "List which Inkbox identities can see a contact. Use before granting or revoking cross-identity contact access.", + args: listContactAccessArgs, + async execute(args: ListContactAccessArgs, _ctx) { + return runTool(async () => { + const inkbox = await runtime.getClient(); + const grants = await inkbox.contacts.access.list(args.contactId); + return formatWithHeader(`Returned ${grants.length} contact access grant(s).`, grants); + }); + }, + }, + }, + { + name: "inkbox_grant_contact_access", + group: "access", + defaultEnabled: false, + definition: { + description: + "Grant an Inkbox identity access to a contact, or grant wildcard access so every active identity can see it.", + args: grantContactAccessArgs, + async execute(args: GrantContactAccessArgs, _ctx) { + return runTool(async () => { + const identityId = hasString(args.identityId) ? args.identityId.trim() : undefined; + if (args.wildcard === true && identityId) { + throw new Error("Pass either identityId or wildcard=true, not both."); + } + if (args.wildcard !== true && !identityId) { + throw new Error("identityId is required unless wildcard=true."); + } + const inkbox = await runtime.getClient(); + const grant = await inkbox.contacts.access.grant(args.contactId, { + identityId, + wildcard: args.wildcard === true, + }); + return { + title: `Granted access to contact ${args.contactId}`, + output: formatWithHeader("Granted contact access.", grant), + }; + }); + }, + }, + }, + { + name: "inkbox_revoke_contact_access", + group: "access", + defaultEnabled: false, + definition: { + description: "Revoke one Inkbox identity's access to a contact.", + args: revokeContactAccessArgs, + async execute(args: RevokeContactAccessArgs, _ctx) { + return runTool(async () => { + const inkbox = await runtime.getClient(); + await inkbox.contacts.access.revoke(args.contactId, args.identityId); + return { + title: `Revoked access to contact ${args.contactId}`, + output: `Revoked identity ${args.identityId} access to contact ${args.contactId}.`, + }; + }); + }, + }, + }, { name: "inkbox_list_note_access", group: "access", diff --git a/src/tools/contacts.ts b/src/tools/contacts.ts index 15b5ca2..54d1503 100644 --- a/src/tools/contacts.ts +++ b/src/tools/contacts.ts @@ -40,10 +40,7 @@ const createContactArgs = { familyName: z.string().describe("Family/last name.").optional(), companyName: z.string().describe("Company or organization.").optional(), jobTitle: z.string().describe("Job title.").optional(), - notes: z - .string() - .describe("User-managed contact notes, separate from generated contact facts.") - .optional(), + notes: z.string().describe("Free-form contact notes.").optional(), emails: z.array(contactEmailSchema).describe("Email addresses.").optional(), phones: z.array(contactPhoneSchema).describe("Phone numbers.").optional(), }; @@ -131,8 +128,10 @@ function buildContactWritePayload(args: ContactWriteArgs): Record { @@ -175,7 +174,8 @@ export function contactTools(deps: ToolDeps): RegisteredTool[] { group: "contacts", defaultEnabled: true, definition: { - description: "List organization-wide contacts. Optional free-text search via `q`.", + description: + "List contacts this identity has access to. Optional free-text search via `q`; results scoped by per-identity grants.", args: listContactsArgs, async execute(args: ListContactsArgs, _ctx) { return runTool(async () => { diff --git a/tests/contract/tool-vocabulary.test.ts b/tests/contract/tool-vocabulary.test.ts index a36f88b..c209521 100644 --- a/tests/contract/tool-vocabulary.test.ts +++ b/tests/contract/tool-vocabulary.test.ts @@ -75,6 +75,9 @@ const OPT_IN = [ "inkbox_place_call", "inkbox_update_note", "inkbox_delete_note", + "inkbox_list_contact_access", + "inkbox_grant_contact_access", + "inkbox_revoke_contact_access", "inkbox_list_note_access", "inkbox_grant_note_access", "inkbox_revoke_note_access", @@ -108,10 +111,10 @@ const GROUPS = [ describe("tool vocabulary", () => { const all = buildAllTools(stubDeps()); - it("ships exactly the expected 48 tools", () => { + it("ships exactly the expected 51 tools", () => { const names = all.map((t) => t.name).sort(); expect(names).toEqual([...DEFAULT_ENABLED, ...OPT_IN].sort()); - expect(names).toHaveLength(48); + expect(names).toHaveLength(51); }); it("has no duplicate tool names", () => { diff --git a/tests/unit/access.test.ts b/tests/unit/access.test.ts index 60e5ce4..ff5075c 100644 --- a/tests/unit/access.test.ts +++ b/tests/unit/access.test.ts @@ -30,6 +30,20 @@ function makeCtx() { function makeClient() { return { + contacts: { + access: { + list: vi.fn(async () => [ + { id: "grant-1", contactId: "contact-1", identityId: "identity-1", wildcard: false }, + ]), + grant: vi.fn(async () => ({ + id: "grant-2", + contactId: "contact-1", + identityId: "identity-2", + wildcard: false, + })), + revoke: vi.fn(async () => undefined), + }, + }, notes: { access: { list: vi.fn(async () => [ @@ -54,9 +68,12 @@ function outputText(result: unknown): string { } describe("accessTools", () => { - it("registers the three note access tools in the access group", () => { + it("registers the six access tools in the access group", () => { const tools = accessTools(makeDeps(makeClient())); expect(tools.map((t) => t.name)).toEqual([ + "inkbox_list_contact_access", + "inkbox_grant_contact_access", + "inkbox_revoke_contact_access", "inkbox_list_note_access", "inkbox_grant_note_access", "inkbox_revoke_note_access", @@ -74,6 +91,130 @@ describe("accessTools", () => { } }); + describe("inkbox_list_contact_access", () => { + it("lists grants for the contact and reports the count", async () => { + const client = makeClient(); + const tool = findTool(accessTools(makeDeps(client)), "inkbox_list_contact_access"); + const result = await tool.definition.execute({ contactId: "contact-1" }, makeCtx()); + expect(client.contacts.access.list).toHaveBeenCalledWith("contact-1"); + const text = outputText(result); + expect(text).toContain("Returned 1 contact access grant(s)."); + expect(text).toContain('"identityId": "identity-1"'); + }); + + it("declares a schema that requires contactId", () => { + const tool = findTool(accessTools(makeDeps(makeClient())), "inkbox_list_contact_access"); + const schema = z.object(tool.definition.args); + expect(schema.safeParse({ contactId: "contact-1" }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ contactId: 42 }).success).toBe(false); + }); + }); + + describe("inkbox_grant_contact_access", () => { + it("grants a specific identity access to the contact", async () => { + const client = makeClient(); + const tool = findTool(accessTools(makeDeps(client)), "inkbox_grant_contact_access"); + const result = await tool.definition.execute( + { contactId: "contact-1", identityId: "identity-2" }, + makeCtx(), + ); + expect(client.contacts.access.grant).toHaveBeenCalledWith("contact-1", { + identityId: "identity-2", + wildcard: false, + }); + const text = outputText(result); + expect(text).toContain("Granted contact access."); + expect(text).toContain('"identityId": "identity-2"'); + }); + + it("grants wildcard access without an identityId", async () => { + const client = makeClient(); + const tool = findTool(accessTools(makeDeps(client)), "inkbox_grant_contact_access"); + await tool.definition.execute({ contactId: "contact-1", wildcard: true }, makeCtx()); + expect(client.contacts.access.grant).toHaveBeenCalledWith("contact-1", { + identityId: undefined, + wildcard: true, + }); + }); + + it("trims surrounding whitespace from identityId before granting", async () => { + const client = makeClient(); + const tool = findTool(accessTools(makeDeps(client)), "inkbox_grant_contact_access"); + await tool.definition.execute( + { contactId: "contact-1", identityId: " identity-2 " }, + makeCtx(), + ); + expect(client.contacts.access.grant).toHaveBeenCalledWith("contact-1", { + identityId: "identity-2", + wildcard: false, + }); + }); + + it("rejects passing both identityId and wildcard=true", async () => { + const client = makeClient(); + const tool = findTool(accessTools(makeDeps(client)), "inkbox_grant_contact_access"); + await expect( + tool.definition.execute( + { contactId: "contact-1", identityId: "identity-2", wildcard: true }, + makeCtx(), + ), + ).rejects.toThrow("Pass either identityId or wildcard=true, not both."); + expect(client.contacts.access.grant).not.toHaveBeenCalled(); + }); + + it("rejects when neither identityId nor wildcard=true is given", async () => { + const client = makeClient(); + const tool = findTool(accessTools(makeDeps(client)), "inkbox_grant_contact_access"); + await expect(tool.definition.execute({ contactId: "contact-1" }, makeCtx())).rejects.toThrow( + "identityId is required unless wildcard=true.", + ); + expect(client.contacts.access.grant).not.toHaveBeenCalled(); + }); + + it("treats a whitespace-only identityId as missing", async () => { + const client = makeClient(); + const tool = findTool(accessTools(makeDeps(client)), "inkbox_grant_contact_access"); + await expect( + tool.definition.execute({ contactId: "contact-1", identityId: " " }, makeCtx()), + ).rejects.toThrow("identityId is required unless wildcard=true."); + expect(client.contacts.access.grant).not.toHaveBeenCalled(); + }); + + it("declares a schema with optional identityId and boolean wildcard", () => { + const tool = findTool(accessTools(makeDeps(makeClient())), "inkbox_grant_contact_access"); + const schema = z.object(tool.definition.args); + expect(schema.safeParse({ contactId: "c", identityId: "i" }).success).toBe(true); + expect(schema.safeParse({ contactId: "c", wildcard: true }).success).toBe(true); + expect(schema.safeParse({ contactId: "c" }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ contactId: "c", wildcard: "yes" }).success).toBe(false); + }); + }); + + describe("inkbox_revoke_contact_access", () => { + it("revokes the identity's access and summarizes it", async () => { + const client = makeClient(); + const tool = findTool(accessTools(makeDeps(client)), "inkbox_revoke_contact_access"); + const result = await tool.definition.execute( + { contactId: "contact-1", identityId: "identity-1" }, + makeCtx(), + ); + expect(client.contacts.access.revoke).toHaveBeenCalledWith("contact-1", "identity-1"); + expect(outputText(result)).toContain( + "Revoked identity identity-1 access to contact contact-1.", + ); + }); + + it("declares a schema that requires contactId and identityId", () => { + const tool = findTool(accessTools(makeDeps(makeClient())), "inkbox_revoke_contact_access"); + const schema = z.object(tool.definition.args); + expect(schema.safeParse({ contactId: "c", identityId: "i" }).success).toBe(true); + expect(schema.safeParse({ contactId: "c" }).success).toBe(false); + expect(schema.safeParse({ identityId: "i" }).success).toBe(false); + }); + }); + describe("inkbox_list_note_access", () => { it("lists grants for the note and reports the count", async () => { const client = makeClient(); From 6a01215b7331cd02f0c74259c3c9d535407f9beb Mon Sep 17 00:00:00 2001 From: alex-w-99 Date: Mon, 20 Jul 2026 04:44:19 +0000 Subject: [PATCH 6/8] Pin validation to updated SDK source --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 17b29e2..a87f59e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v7 with: repository: inkbox-ai/inkbox - ref: b2bb11a45c9f4dc89f3bcfbadbd4de5d66d0e9e5 + ref: bcff3526d700c2be6546e7ebdbff60073a6ec7e0 path: .ci/inkbox - uses: actions/setup-node@v7 with: @@ -39,7 +39,7 @@ jobs: - uses: actions/checkout@v7 with: repository: inkbox-ai/inkbox - ref: b2bb11a45c9f4dc89f3bcfbadbd4de5d66d0e9e5 + ref: bcff3526d700c2be6546e7ebdbff60073a6ec7e0 path: .ci/inkbox - uses: actions/setup-node@v7 with: @@ -65,7 +65,7 @@ jobs: - uses: actions/checkout@v7 with: repository: inkbox-ai/inkbox - ref: b2bb11a45c9f4dc89f3bcfbadbd4de5d66d0e9e5 + ref: bcff3526d700c2be6546e7ebdbff60073a6ec7e0 path: .ci/inkbox - uses: actions/setup-node@v7 with: From 7643cc62ff0b40fbda2f4e29b3e7ba6255ccbc39 Mon Sep 17 00:00:00 2001 From: alex-w-99 Date: Mon, 20 Jul 2026 06:41:09 +0000 Subject: [PATCH 7/8] Pin tests to corrected SDK head --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a87f59e..48236a4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v7 with: repository: inkbox-ai/inkbox - ref: bcff3526d700c2be6546e7ebdbff60073a6ec7e0 + ref: 8f4b37c56cd153a2acc6cfabe27dbbeb43510f00 path: .ci/inkbox - uses: actions/setup-node@v7 with: @@ -39,7 +39,7 @@ jobs: - uses: actions/checkout@v7 with: repository: inkbox-ai/inkbox - ref: bcff3526d700c2be6546e7ebdbff60073a6ec7e0 + ref: 8f4b37c56cd153a2acc6cfabe27dbbeb43510f00 path: .ci/inkbox - uses: actions/setup-node@v7 with: @@ -65,7 +65,7 @@ jobs: - uses: actions/checkout@v7 with: repository: inkbox-ai/inkbox - ref: bcff3526d700c2be6546e7ebdbff60073a6ec7e0 + ref: 8f4b37c56cd153a2acc6cfabe27dbbeb43510f00 path: .ci/inkbox - uses: actions/setup-node@v7 with: From 23c9f147c3b46a273a5fa6f3ef7394c384022b82 Mon Sep 17 00:00:00 2001 From: alex-w-99 Date: Mon, 20 Jul 2026 07:05:08 +0000 Subject: [PATCH 8/8] Update SDK lockfile --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index e6f58dc..6b57bc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@inkbox/sdk": ">=0.4.24 <1.0.0", + "@inkbox/sdk": ">=0.5.0 <1.0.0", "@opencode-ai/sdk": ">=1.17.18 <1.19.0", "ws": "^8.21.0", "zod": "4.1.8" @@ -605,9 +605,9 @@ } }, "node_modules/@inkbox/sdk": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/@inkbox/sdk/-/sdk-0.4.24.tgz", - "integrity": "sha512-x5u4aXnGuB063Q91B5NVDKVLUQs+Atab4KQn7PZShC1jZ/apPsbLydJRDQfzZv7MVWtVa2QA/ybqG7TsVgd6aw==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@inkbox/sdk/-/sdk-0.5.0.tgz", + "integrity": "sha512-F2jgPLzUSNH81MUa49HidpZuTNzBK+Ovk/7VfuV0ECOukYeDuVdApT6Sr2Yt8bug28T4BAc+yGyQZmiOh13ZzA==", "license": "MIT", "dependencies": { "@peculiar/x509": "^2.0.0",