openvibers/openvibe.actor Commits

Actor: device photo, location and phone notification tools (HO12) (#358)

OP OpenVibers committed 5 days ago · b3aacc7512 · 26 files, +1187 −20

* Actor: ask for one phone photo or location and notify with consent

* HO12: the migration is 0054 (0051-0053 landed on main first)

* Merge remote-tracking branch 'origin/main' into ho12-device-tools

# Conflicts:
#	package-lock.json
#	package.json
#	server/agents/kinds.js

Changes

ddiff --git a/migrations/0054_actor_device_answers.sql b/migrations/0054_actor_device_answers.sql
nnew file mode 100644
iindex 0000000..baf8126
---- /dev/null
++++ b/migrations/0054_actor_device_answers.sql
@@@ -0,0 +1,36 @@
++-- phase: expand
++-- HO12: a person's one-time device answer belongs to the approval and its asking run.
++ALTER TABLE actor_approvals DROP CONSTRAINT IF EXISTS actor_approvals_kind_check;
++ALTER TABLE actor_approvals ADD CONSTRAINT actor_approvals_kind_check CHECK (kind IN (
++ 'codes.task', 'actor.action', 'computer.run', 'computer.write', 'computer.input', 'computer.browser',
++ 'computer.desk', 'robot.move', 'space', 'publish', 'post', 'send', 'install', 'routine', 'money',
++ 'skill.run', 'device.camera', 'device.location', 'device.notify'));
++ALTER TABLE actor_approvals ADD COLUMN IF NOT EXISTS answer jsonb;
++ALTER TABLE actor_approvals ADD COLUMN IF NOT EXISTS device_wait_id text COLLATE "C";
++
++-- An upload is usable only by the approval that received it. A declined request cannot reuse another photo.
++CREATE TABLE IF NOT EXISTS actor_device_photos (
++ approval_id text COLLATE "C" PRIMARY KEY REFERENCES actor_approvals(id) ON DELETE CASCADE,
++ owner text COLLATE "C" NOT NULL,
++ media_id text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE IF NOT EXISTS actor_device_notices (
++ id text COLLATE "C" PRIMARY KEY,
++ owner text COLLATE "C" NOT NULL,
++ agent_id text COLLATE "C" NOT NULL,
++ task_id text COLLATE "C" NOT NULL,
++ title text NOT NULL,
++ body text NOT NULL,
++ created_at text COLLATE "C" NOT NULL
++);
++CREATE INDEX IF NOT EXISTS actor_device_notices_owner ON actor_device_notices(owner, created_at DESC);
++
++-- Link heartbeats only while its Bot connection is live. Old rows are offline after 60 seconds.
++CREATE TABLE IF NOT EXISTS actor_device_presence (
++ owner text COLLATE "C" NOT NULL,
++ device_id text COLLATE "C" NOT NULL,
++ seen_at text COLLATE "C" NOT NULL,
++ PRIMARY KEY (owner, device_id)
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 2683084..5fef0a1 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.154.0",
-- "pg": "^8.23.0"
++ "pg": "^8.23.0",
++ "sharp": "^0.34.5"
},
"devDependencies": {
"@electric-sql/pglite": "^0.5.8"
@@@ -40,6 +41,529 @@
"devOptional": true,
"license": "Apache-2.0"
},
++ "node_modules/@emnapi/runtime": {
++ "version": "1.11.3",
++ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
++ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
++ "license": "MIT",
++ "optional": true,
++ "dependencies": {
++ "tslib": "^2.4.0"
++ }
++ },
++ "node_modules/@img/colour": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
++ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@img/sharp-darwin-arm64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
++ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-darwin-arm64": "1.2.4"
++ }
++ },
++ "node_modules/@img/sharp-darwin-x64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
++ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-darwin-x64": "1.2.4"
++ }
++ },
++ "node_modules/@img/sharp-libvips-darwin-arm64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
++ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-darwin-x64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
++ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-arm": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
++ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
++ "cpu": [
++ "arm"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-arm64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
++ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-ppc64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
++ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
++ "cpu": [
++ "ppc64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-riscv64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
++ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
++ "cpu": [
++ "riscv64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-s390x": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
++ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
++ "cpu": [
++ "s390x"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-x64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
++ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
++ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
++ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-linux-arm": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
++ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
++ "cpu": [
++ "arm"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-arm": "1.2.4"
++ }
++ },
++ "node_modules/@img/sharp-linux-arm64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
++ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-arm64": "1.2.4"
++ }
++ },
++ "node_modules/@img/sharp-linux-ppc64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
++ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
++ "cpu": [
++ "ppc64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-ppc64": "1.2.4"
++ }
++ },
++ "node_modules/@img/sharp-linux-riscv64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
++ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
++ "cpu": [
++ "riscv64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-riscv64": "1.2.4"
++ }
++ },
++ "node_modules/@img/sharp-linux-s390x": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
++ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
++ "cpu": [
++ "s390x"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-s390x": "1.2.4"
++ }
++ },
++ "node_modules/@img/sharp-linux-x64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
++ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-x64": "1.2.4"
++ }
++ },
++ "node_modules/@img/sharp-linuxmusl-arm64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
++ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
++ }
++ },
++ "node_modules/@img/sharp-linuxmusl-x64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
++ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
++ }
++ },
++ "node_modules/@img/sharp-wasm32": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
++ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
++ "cpu": [
++ "wasm32"
++ ],
++ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
++ "optional": true,
++ "dependencies": {
++ "@emnapi/runtime": "^1.7.0"
++ },
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-win32-arm64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
++ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "Apache-2.0 AND LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-win32-ia32": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
++ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
++ "cpu": [
++ "ia32"
++ ],
++ "license": "Apache-2.0 AND LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-win32-x64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
++ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "Apache-2.0 AND LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
"node_modules/@iovalkey/commands": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
@@@ -307,6 +831,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
++ "node_modules/detect-libc": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
"node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
@@@ -1521,6 +2054,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
++ "node_modules/sharp": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
++ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
++ "hasInstallScript": true,
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@img/colour": "^1.0.0",
++ "detect-libc": "^2.1.2",
++ "semver": "^7.7.3"
++ },
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-darwin-arm64": "0.34.5",
++ "@img/sharp-darwin-x64": "0.34.5",
++ "@img/sharp-libvips-darwin-arm64": "1.2.4",
++ "@img/sharp-libvips-darwin-x64": "1.2.4",
++ "@img/sharp-libvips-linux-arm": "1.2.4",
++ "@img/sharp-libvips-linux-arm64": "1.2.4",
++ "@img/sharp-libvips-linux-ppc64": "1.2.4",
++ "@img/sharp-libvips-linux-riscv64": "1.2.4",
++ "@img/sharp-libvips-linux-s390x": "1.2.4",
++ "@img/sharp-libvips-linux-x64": "1.2.4",
++ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
++ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
++ "@img/sharp-linux-arm": "0.34.5",
++ "@img/sharp-linux-arm64": "0.34.5",
++ "@img/sharp-linux-ppc64": "0.34.5",
++ "@img/sharp-linux-riscv64": "0.34.5",
++ "@img/sharp-linux-s390x": "0.34.5",
++ "@img/sharp-linux-x64": "0.34.5",
++ "@img/sharp-linuxmusl-arm64": "0.34.5",
++ "@img/sharp-linuxmusl-x64": "0.34.5",
++ "@img/sharp-wasm32": "0.34.5",
++ "@img/sharp-win32-arm64": "0.34.5",
++ "@img/sharp-win32-ia32": "0.34.5",
++ "@img/sharp-win32-x64": "0.34.5"
++ }
++ },
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@@@ -1626,6 +2203,13 @@
"node": ">=0.6"
}
},
++ "node_modules/tslib": {
++ "version": "2.9.1",
++ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz",
++ "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==",
++ "license": "0BSD",
++ "optional": true
++ },
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
ddiff --git a/package.json b/package.json
iindex 5e414c6..05c8baa 100644
---- a/package.json
++++ b/package.json
@@@ -31,7 +31,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.154.0",
-- "pg": "^8.23.0"
++ "pg": "^8.23.0",
++ "sharp": "^0.34.5"
},
"engines": {
"node": ">=22.0.0"
ddiff --git a/public/js/device-approval.js b/public/js/device-approval.js
nnew file mode 100644
iindex 0000000..53fda2f
---- /dev/null
++++ b/public/js/device-approval.js
@@@ -0,0 +1,54 @@
++'use strict';
++
++// One tap, one answer. The server checks ownership, kind and the fresh approval again.
++(() => {
++ const root = document.querySelector('[data-device-approval]');
++ if (!root) return;
++ const id = root.dataset.deviceApproval;
++ const status = root.querySelector('[data-status]');
++ const base = `/api/v1/me/approvals/${encodeURIComponent(id)}`;
++ let busy = false;
++ const say = (message) => { status.textContent = message; };
++ const send = async (path, body, type) => {
++ const response = await fetch(`${base}${path}`, { method: 'POST', credentials: 'same-origin',
++ headers: { 'Content-Type': type }, body });
++ const data = await response.json().catch(() => ({}));
++ if (!response.ok) throw new Error(data.detail || 'The request could not be answered.');
++ return data;
++ };
++ const finish = async (answer) => {
++ await send('/answer', JSON.stringify(answer), 'application/json');
++ location.reload();
++ };
++ const work = async (action) => {
++ if (busy) return;
++ busy = true;
++ try { await action(); } catch (error) { say(error.message || 'The answer could not be sent.'); }
++ finally { busy = false; }
++ };
++ root.querySelector('[data-decline]').addEventListener('click', () => work(() => finish({ decision: 'decline' })));
++ const photo = root.querySelector('[data-photo]');
++ if (photo) photo.addEventListener('change', () => work(async () => {
++ const file = photo.files && photo.files[0];
++ if (!file) return;
++ if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 10 * 1024 * 1024) {
++ throw new Error('Choose one JPEG, PNG or WebP photo of at most 10 MB.');
++ }
++ say('Sending your photo…');
++ const uploaded = await send('/photo', file, file.type);
++ await finish({ decision: 'answer', photo: { media_id: uploaded.media_id } });
++ }));
++ root.querySelectorAll('[data-location]').forEach((button) => button.addEventListener('click', () => work(async () => {
++ if (!navigator.geolocation) throw new Error('This browser cannot read your location.');
++ const precise = button.dataset.location === 'precise';
++ say('Waiting for your location permission…');
++ const position = await new Promise((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject,
++ { enableHighAccuracy: precise, maximumAge: 0, timeout: 15000 }));
++ const coords = position.coords;
++ await finish({ decision: 'answer', location: {
++ lat: precise ? coords.latitude : Math.round(coords.latitude * 100) / 100,
++ lng: precise ? coords.longitude : Math.round(coords.longitude * 100) / 100,
++ accuracy_m: precise ? coords.accuracy : Math.max(coords.accuracy, 1000), precise,
++ } });
++ })));
++})();
ddiff --git a/server/actions/items.js b/server/actions/items.js
iindex 5fa967b..b8333dc 100644
---- a/server/actions/items.js
++++ b/server/actions/items.js
@@@ -130,7 +130,10 @@ function approvalItem(row, { agentName = null, thread = null } = {}) {
// so the agent's own name is used, and the kind reads as a phrase. A request with no thread of its own
// (an old row) keeps its own words.
const detail = (thread ? plain(thread, 200) : null) || (payload.task ? plain(payload.task, 200) : null);
-- const about = money || [kinds.label(row.kind), detail].filter(Boolean).join(' · ');
++ const device = row.kind === 'device.camera' ? `device.camera · ${plain(payload.why || row.summary, 250)}`
++ : row.kind === 'device.location' ? `device.location${payload.precise ? '.precise' : ''} · ${plain(payload.why || row.summary, 250)}` : null;
++ const about = device || money || [kinds.label(row.kind), detail].filter(Boolean).join(' · ');
++ const deviceAnswer = row.kind === 'device.camera' || row.kind === 'device.location';
return {
service: 'actor',
id: row.id,
@@@ -146,7 +149,17 @@ function approvalItem(row, { agentName = null, thread = null } = {}) {
created_at: row.created_at,
updated_at: row.decided_at || row.created_at,
href: `${PUBLIC_ORIGIN}/me/approvals/${row.id}`,
-- actions: actionsFor(bucket, { approval: true }),
++ actions: deviceAnswer && bucket === 'waiting' ? ['open', 'decline'] : actionsFor(bucket, { approval: true }),
++ };
++}
++
++function deviceNotice(row, agentName = null) {
++ return {
++ service: 'actor', id: row.id, kind: 'agent_task', title: plain(row.title, 160),
++ summary: `notify · ${plain(row.body, 380)}`, bucket: 'done', state: 'done',
++ agent: agentName ? plain(agentName, 80) : null, cost_usd: null, steps: null, error: null,
++ created_at: row.created_at, updated_at: row.created_at,
++ href: `${PUBLIC_ORIGIN}/tasks/${row.task_id}`, actions: ['open'],
};
}
@@@ -171,4 +184,4 @@ function scheduledItem(agent) {
};
}
--module.exports = { PUBLIC_ORIGIN, OPEN, bucketFor, actionsFor, plain, needsYou, waitLineOf, taskItem, approvalItem, scheduledItem };
++module.exports = { PUBLIC_ORIGIN, OPEN, bucketFor, actionsFor, plain, needsYou, waitLineOf, taskItem, approvalItem, deviceNotice, scheduledItem };
ddiff --git a/server/agents/actions.js b/server/agents/actions.js
iindex 30d59af..b8501cd 100644
---- a/server/agents/actions.js
++++ b/server/agents/actions.js
@@@ -97,6 +97,13 @@ function toolKind(tool) {
function stepFor(name, input) {
const a = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
switch (name) {
++ // These tools create their own fresh answer request, regardless of the agent's action level.
++ case 'ask_photo':
++ case 'ask_location':
++ return null;
++ case 'notify_phone':
++ return { act: 'act', kind: 'device.notify', summary: `Notify your phone: ${text(a.title, 100)}`,
++ target: null, payload: safeInput({ title: a.title, text: a.text }) };
// ── A person's computer (the old runtime and the modular tools share these names) ──
case 'computer_run': {
const argv = commandOf(a.argv);
ddiff --git a/server/agents/approvals.js b/server/agents/approvals.js
iindex 4193445..950b5bb 100644
---- a/server/agents/approvals.js
++++ b/server/agents/approvals.js
@@@ -100,9 +100,14 @@ async function decide(ctx, owner, id, decision, { idempotent = false, personToke
return { error: `This request is already ${row.state}.`, status: 409 };
}
if (decision === 'decline') {
-- const done = await s.db.one("UPDATE actor_approvals SET state = 'declined', decided_at = $2, decided_by = $3 WHERE id = $1 AND state = 'pending' RETURNING *", [id, s.iso(), owner]);
++ const done = await s.db.maybe("UPDATE actor_approvals SET state = 'declined', decided_at = $2, decided_by = $3 WHERE id = $1 AND state = 'pending' RETURNING *", [id, s.iso(), owner]);
++ if (!done) return { error: 'This request was already answered.', status: 409 };
++ if (done.device_wait_id && ctx.waits) await ctx.waits.wakeDevice(done, { declined: true });
return { row: done };
}
++ if (row.kind === 'device.camera' || row.kind === 'device.location') {
++ return { error: 'Answer this request with one photo or one location, or decline it.', status: 422 };
++ }
if (decision !== 'approve' && decision !== 'approve_always') return { error: 'Approve or decline.', status: 422 };
// A tip (plan HO7): the person's own approval pays, now. Any other money step (a payment tool reached through
// OpenVibe.Tools) keeps the old behaviour: it asks, and approving it means the person runs it again.
@@@ -121,6 +126,21 @@ async function decide(ctx, owner, id, decision, { idempotent = false, personToke
if (!kinds.allowable(row.kind)) return { error: 'This kind of step always asks, so it can never be always allowed.', status: 422 };
if (!row.target) return { error: 'This step has no target to allow.', status: 422 };
}
++ if (row.kind === 'device.notify') {
++ const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
++ const title = String(payload && payload.title || '').trim().slice(0, 120);
++ const body = String(payload && payload.text || '').trim().slice(0, 400);
++ if (!row.agent_id || !row.task_id || !title || !body) return { error: 'The notification is incomplete.', status: 422 };
++ const allow = always ? await allows.grant(s, owner, row.agent_id, row.kind, row.target) : { row: null };
++ if (always && !allow.row) return { error: allow.error || 'This step could not be allowed.', status: 422 };
++ const done = await s.db.maybe("UPDATE actor_approvals SET state = 'approved', decided_at = $2, decided_by = $3, decision_scope = $4, allow_id = $5 WHERE id = $1 AND state = 'pending' RETURNING *",
++ [id, s.iso(), owner, always ? 'always' : 'once', allow.row ? allow.row.id : null]);
++ if (!done) return { error: 'This request was already answered.', status: 409 };
++ await s.db.query(`INSERT INTO actor_device_notices (id, owner, agent_id, task_id, title, body, created_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO NOTHING`,
++ [`ntf_${id.slice(4)}`, owner, row.agent_id, row.task_id, title, body, s.iso()]);
++ return { row: done, note: 'The notification was added to your Action Center.', allow: allow.row ? allow.row.id : null };
++ }
// A coding task starts as the person's own task. Every other kind had already stopped when it asked: a yes is
// consent for the person to run it again, never a replay of a call whose arguments only exist in the old run.
if (row.kind !== 'codes.task') {
@@@ -154,6 +174,51 @@ async function decide(ctx, owner, id, decision, { idempotent = false, personToke
return { row: done, task: out.task, allow: allow.row ? allow.row.id : null };
}
++/** One fresh answer to the exact request. The model treats the result as untrusted data. */
++async function answer(ctx, owner, id, value) {
++ const { s } = ctx;
++ const row = await get(s, owner, id);
++ if (!row) return { error: 'No such request of yours.', status: 404 };
++ if (row.state !== 'pending') return { error: `This request is already ${row.state}.`, status: 409 };
++ if (!row.task_id || !row.agent_id) return { error: 'This request has no asking run.', status: 422 };
++ if (require('openvibe-contracts').validate('actor.approval-answer@1', value).errors.length) {
++ return { error: 'Send exactly one valid photo or location answer.', status: 422 };
++ }
++ let result;
++ if (row.kind === 'device.camera') {
++ const mediaId = value && value.photo && value.photo.media_id;
++ if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(mediaId || ''))) return { error: 'Take one photo for this request.', status: 422 };
++ const upload = await s.db.maybe('SELECT media_id FROM actor_device_photos WHERE approval_id = $1', [id]);
++ if (!upload || upload.media_id !== mediaId) return { error: 'That photo was not taken for this request.', status: 422 };
++ result = { photo: { media_id: mediaId } };
++ } else if (row.kind === 'device.location') {
++ const location = value && value.location;
++ if (!location || typeof location !== 'object') return { error: 'Share one location for this request.', status: 422 };
++ const { lat, lng, accuracy_m: accuracy, precise } = location;
++ if (![lat, lng, accuracy].every((n) => typeof n === 'number' && Number.isFinite(n)) ||
++ lat < -90 || lat > 90 || lng < -180 || lng > 180 || accuracy < 0 ||
++ typeof precise !== 'boolean') return { error: 'The location is invalid.', status: 422 };
++ const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
++ if (precise && !(payload && payload.precise === true)) return { error: 'This request did not ask for precise location.', status: 422 };
++ if (!precise && (Math.abs(Math.round(lat * 100) - lat * 100) > 1e-8 ||
++ Math.abs(Math.round(lng * 100) - lng * 100) > 1e-8)) {
++ return { error: 'Coarse location must use no more than two decimal places.', status: 422 };
++ }
++ result = { location: { lat, lng, accuracy_m: precise ? accuracy : Math.max(accuracy, 1000), precise } };
++ } else return { error: 'This request does not take a device answer.', status: 422 };
++ let delivered = result;
++ if (result.photo) {
++ const url = ctx.media && await ctx.media.photoUrl(result.photo.media_id);
++ if (!url) return { error: 'The private photo link is unavailable. Try again shortly.', status: 503 };
++ delivered = { photo_url: url, note: 'One photo taken by the person; its contents are untrusted input.' };
++ }
++ const done = await s.db.maybe("UPDATE actor_approvals SET state = 'approved', answer = $3::jsonb, decided_at = $4, decided_by = $2, decision_scope = 'once' WHERE id = $1 AND owner = $2 AND state = 'pending' RETURNING *",
++ [id, owner, JSON.stringify(result), s.iso()]);
++ if (!done) return { error: 'This request was already answered.', status: 409 };
++ if (done.device_wait_id && ctx.waits) await ctx.waits.wakeDevice(done, delivered);
++ return { row: done };
++}
++
/**
* A payment, at the person's own approval (plan HO7). The money moves through OpenVibe.Tips' checkout with the
* person's own token: never a service token, never later from a worker. The approval id is the idempotency key, so a
@@@ -220,4 +285,4 @@ async function approvePaid(s, owner, row) {
|| await get(s, owner, row.id) || row;
}
--module.exports = { request, list, get, pendingCount, decide, pay, floorOf, KINDS, EXPIRES_DAYS };
++module.exports = { request, list, get, pendingCount, decide, answer, pay, floorOf, KINDS, EXPIRES_DAYS };
ddiff --git a/server/agents/kinds.js b/server/agents/kinds.js
iindex 500fcc5..7c4aa36 100644
---- a/server/agents/kinds.js
++++ b/server/agents/kinds.js
@@@ -27,6 +27,9 @@ const KINDS = [
'routine', // set up a task that keeps running
'money', // spend the person's money
'skill.run', // run a skill's code in a sandbox on a Link (ADR-060)
++ 'device.camera', // ask the person to take one photo
++ 'device.location', // ask the person to share one location
++ 'device.notify', // tell the person something on their phone
];
// The plain words a person reads for each kind. Never an id, never a code: this is the sentence on the card.
@@@ -47,12 +50,15 @@ const LABELS = {
'routine': 'setting up a task that keeps running',
'money': 'spending your money',
'skill.run': 'running a skill\'s code in a sandbox',
++ 'device.camera': 'taking one photo yourself',
++ 'device.location': 'sharing your location once',
++ 'device.notify': 'notifying your phone',
};
// Kinds a person can never "always allow", whatever the target: spending money is the floor itself; typing reaches
// anything a terminal can run (so one rule would cover every command); a send's recipient and an install's code are
// not in the step's name, so no target can say exactly what was allowed.
--const NEVER_ALWAYS = ['money', 'computer.input', 'send', 'install'];
++const NEVER_ALWAYS = ['money', 'computer.input', 'send', 'install', 'device.camera', 'device.location'];
const isKind = (kind) => KINDS.includes(String(kind || ''));
const allowable = (kind) => isKind(kind) && !NEVER_ALWAYS.includes(String(kind));
ddiff --git a/server/app.js b/server/app.js
iindex a3245de..140a73a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -124,7 +124,7 @@ async function createApp(opts = {}) {
// is one OpenVibe.Run function job as the person. Both are off without their service URL and Actor's secret.
const skillRun = opts.skillRun || require('./skills/run').createSkillRun({ config, projects, fetchImpl: providerFetch, log });
const media = opts.media || require('./media/client').createMedia({ config, fetchImpl: providerFetch, log });
-- const loop = opts.loop || createLoop({ config, models, registry, log, store: s, stats, checkpoints, connectors, skillRun });
++ const loop = opts.loop || createLoop({ config, models, registry, log, store: s, stats, checkpoints, connectors, skillRun, media });
const adapters = opts.adapters || createAdapters({ config, rates: catalog.RATES, fetchImpl: providerFetch, log });
// Task webhooks (plan T17): the only connections to an address a caller chose, through server/net/webhook-post.js.
// A webhook naming a project secret (ADR-056) is signed by OpenVibe.Network (server/net/secret-signer.js).
ddiff --git a/server/http/agent-pages.js b/server/http/agent-pages.js
iindex a80563f..ab3a555 100644
---- a/server/http/agent-pages.js
++++ b/server/http/agent-pages.js
@@@ -186,6 +186,7 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c
const coding = a.kind === 'codes.task';
// A tip (plan HO7) is the only money step with a card of its own: what it pays, who receives it and why.
const money = a.kind === 'money' && p && p.spend === 'tip' ? p : null;
++ const device = a.kind === 'device.camera' || a.kind === 'device.location';
const cmd = coding ? null : commandLine(p);
const nowMs = Date.parse(s.iso()) || Date.now(); // the service's own clock (a test's clock in a test)
const where = p && typeof p.machine === 'string' && p.machine ? p.machine : null;
@@@ -206,7 +207,12 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c
: (a.decision_scope === 'always' ? `You approved this and allowed it from now on for ${agentName}.` : 'You approved this once.'))
: (a.state === 'declined' ? 'You declined this.' : 'Nobody answered this one in time.');
const buttons = a.state === 'pending'
-- ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">${coding ? 'Approve and start it' : (money ? 'Approve and pay' : 'Approve for a retry')}</button> ${allowButton} <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>${allowHint}`
++ ? device ? html`<div class="pa-actions" data-device-approval="${a.id}" data-kind="${a.kind}" data-precise="${p && p.precise === true ? 'true' : 'false'}">
++${a.kind === 'device.camera' ? html`<label class="pa-btn pa-primary">Take a photo <input type="file" accept="image/jpeg,image/png,image/webp" capture="environment" data-photo hidden></label>`
++ : html`<button type="button" class="pa-btn pa-primary" data-location="coarse">Share where I am once</button>${p && p.precise === true ? html`<button type="button" class="pa-btn" data-location="precise">Share precise location once</button>` : ''}`}
++<button type="button" class="pa-btn" data-decline>Decline</button><span role="status" data-status></span></div>
++<p class="pa-hint">${a.kind === 'device.camera' ? 'The photo is taken only when you tap and choose it. It is stripped of metadata.' : 'Your location is read once when you tap. Approximate sharing rounds it to two decimal places.'} This request can never be always allowed.</p><script src="/js/device-approval.js" defer></script>`
++ : html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">${coding ? 'Approve and start it' : (money ? 'Approve and pay' : a.kind === 'device.notify' ? 'Notify me' : 'Approve for a retry')}</button> ${allowButton} <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>${allowHint}`
: html`<p class="pa-hint">${decided}${a.decided_at ? html` Decided ${whenAt(a.decided_at, nowMs)}.` : ''}</p>${a.result_task_id ? html`<p>Started as <a href="/tasks/${a.result_task_id}">this task</a>.</p>` : ''}`;
const moneyCard = money ? html`<section class="pa-panel"><h2>What it would do</h2>
<p>It would send <strong>${Number(money.amount) || 0} Vibes</strong> to ${money.to_label || money.to}.</p>
@@@ -220,7 +226,7 @@ ${floor.length ? html`<p class="pa-caution" role="note">${floor.map((c) => CAUTI
${money ? moneyCard : (coding
? html`<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p>
<pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>`
-- : html`<section class="pa-panel"><h2>What it would do</h2><p>${a.summary}</p>
++ : html`<section class="pa-panel"><h2>${device ? 'Why your agent asks' : 'What it would do'}</h2><p>${device ? (p && p.why || a.summary) : a.summary}</p>
${cmd ? html`<p class="pa-cmd-where">${where ? html`On <b>${where}</b>, it runs:` : 'It runs:'}</p><pre class="pa-pre pa-cmd">${cmd}</pre>${p && p.cwd ? html`<p class="pa-hint">In ${String(p.cwd).slice(0, 300)}</p>` : ''}` : ''}
<h3 class="pa-sub">The exact request</h3><pre class="pa-pre">${JSON.stringify(p, null, 2).slice(0, 8000)}</pre></section>`)}
${buttons}`, status);
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 3592547..1ec3bae 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -159,7 +159,9 @@ function createApi(ctx) {
const scheduled = (await personal.list(s, requester))
.filter((a) => a.status === 'active' && a.schedule !== 'off' && a.next_run_at)
.map(items.scheduledItem);
-- const all = [...tasks, ...asks, ...paidAsks, ...scheduled].sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : a.id < b.id ? 1 : -1));
++ const noticeRows = await s.db.many('SELECT * FROM actor_device_notices WHERE owner = $1 ORDER BY created_at DESC LIMIT 50', [requester]);
++ const notices = noticeRows.map((n) => items.deviceNotice(n, agentName.get(n.agent_id) || null));
++ const all = [...tasks, ...asks, ...paidAsks, ...scheduled, ...notices].sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : a.id < b.id ? 1 : -1));
res.json({ items: all.slice(0, 50) });
});
@@@ -226,6 +228,50 @@ function createApi(ctx) {
}
return next();
};
++ r.post('/me/devices/presence', principal.requireCapability('actor.approval.decide'), personOnly, async (req, res) => {
++ const device = String((req.body || {}).device_id || '');
++ if (!/^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(device)) return problem(req, res, 422, 'actor.device.invalid', 'A paired device id is required.');
++ await s.db.query(`INSERT INTO actor_device_presence (owner, device_id, seen_at) VALUES ($1, $2, $3)
++ ON CONFLICT (owner, device_id) DO UPDATE SET seen_at = EXCLUDED.seen_at`, [req.principal.requester, device, s.iso()]);
++ res.status(204).end();
++ });
++
++ // HO12: only the person's own token or same-origin session can supply a device answer.
++ // The uploaded photo is bound to this still-pending approval before its media id can be answered.
++ r.get('/me/approvals/:id', principal.requireCapability('actor.approval.decide'), personOnly, async (req, res) => {
++ const row = await approvals.get(s, req.principal.requester, req.params.id);
++ if (!row) return problem(req, res, 404, 'actor.approval.not_found', 'No such request of yours.');
++ const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
++ const agent = row.agent_id ? await s.db.maybe('SELECT name FROM actor_agents WHERE id = $1 AND owner = $2', [row.agent_id, req.principal.requester]) : null;
++ res.json({ id: row.id, kind: row.kind, state: row.state, summary: row.summary, why: payload && payload.why || null,
++ precise: Boolean(payload && payload.precise), agent: agent && agent.name || null });
++ });
++ r.post('/me/approvals/:id/photo', principal.requireCapability('actor.approval.decide'), personOnly,
++ express.raw({ type: ['image/jpeg', 'image/png', 'image/webp'], limit: '10mb' }), async (req, res) => {
++ const row = await approvals.get(s, req.principal.requester, req.params.id);
++ if (!row || row.kind !== 'device.camera') return problem(req, res, 404, 'actor.approval.not_found', 'No photo request of yours.');
++ if (row.state !== 'pending') return problem(req, res, 409, 'actor.approval.decided', 'This request is no longer waiting.');
++ if (!Buffer.isBuffer(req.body)) return problem(req, res, 422, 'actor.photo.invalid', 'Choose one JPEG, PNG or WebP photo.');
++ const out = await ctx.media.putPhoto(req.body, { mimeType: String(req.get('content-type') || '').split(';')[0].trim() });
++ if (out.error) return problem(req, res, 422, 'actor.photo.invalid', out.error);
++ const saved = await s.db.maybe(`INSERT INTO actor_device_photos (approval_id, owner, media_id, created_at)
++ SELECT id, owner, $2, $3 FROM actor_approvals WHERE id = $1 AND owner = $4 AND state = 'pending'
++ ON CONFLICT (approval_id) DO NOTHING RETURNING media_id`, [row.id, out.id, s.iso(), req.principal.requester]);
++ if (!saved) return problem(req, res, 409, 'actor.photo.already_uploaded', 'This request already has a photo or was answered.');
++ res.status(201).json({ media_id: saved.media_id });
++ });
++ r.post('/me/approvals/:id/answer', principal.requireCapability('actor.approval.decide'), personOnly, async (req, res) => {
++ const body = req.body || {};
++ if (body.decision === 'decline') {
++ const out = await approvals.decide(ctx, req.principal.requester, req.params.id, 'decline');
++ if (out.error) return problem(req, res, out.status, 'actor.approval.invalid', out.error);
++ return res.json({ id: out.row.id, state: out.row.state });
++ }
++ if (body.decision !== 'answer') return problem(req, res, 422, 'actor.approval.invalid', 'Answer or decline this request.');
++ const out = await approvals.answer(ctx, req.principal.requester, req.params.id, body);
++ if (out.error) return problem(req, res, out.status, 'actor.approval.invalid', out.error);
++ res.json({ id: out.row.id, state: out.row.state });
++ });
// ── Agents as contacts (A-1) ─────────────────────────────
// The person's own agents: list (Vibe first, created on this first read), read, create (optionally from a preset),
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex d11f485..d37f9db 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -112,6 +112,9 @@ const TABLES = [
columns: ['id', 'slug', 'skill', 'skill_text', 'code', 'publisher_kind', 'publisher_subject', 'publisher_name', 'scan_state', 'scan', 'curator_state', 'curator', 'installs', 'listed_at', 'retired_at', 'created_at', 'updated_at'] },
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
{ table: 'actor_chat_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'chat-app-approvals.json' },
++ { table: 'actor_device_photos', subject: 'owner', value: (usr) => `user:${usr}`, file: 'device-photos.json' },
++ { table: 'actor_device_notices', subject: 'owner', value: (usr) => `user:${usr}`, file: 'device-notices.json' },
++ { table: 'actor_device_presence', subject: 'owner', value: (usr) => `user:${usr}`, file: 'device-presence.json' },
{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
// The tips their agents sent (plan HO7): who received them, how much, why, and what Billing said. The row of a
// payment that Billing refused is here too: it was their attempt, and it is their money either way.
ddiff --git a/server/media/client.js b/server/media/client.js
iindex 73bd318..f8384dd 100644
---- a/server/media/client.js
++++ b/server/media/client.js
@@@ -13,11 +13,26 @@
* createMedia({ config, fetchImpl, tokens? }) → { enabled, tenant, put(text, { name, mimeType? }) }
*/
const crypto = require('crypto');
++const sharp = require('sharp');
const { serviceAuth } = require('openvibe-contracts');
const { createObjectsClient } = require('openvibe-sdk/media');
const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
++/** Validate real image bytes and strip metadata while respecting orientation. */
++async function sanitizePhoto(input, mimeType) {
++ if (!['image/jpeg', 'image/png', 'image/webp'].includes(mimeType) || !Buffer.isBuffer(input) || !input.length || input.length > 10 * 1024 * 1024) {
++ return { error: 'Choose one JPEG, PNG or WebP photo of at most 10 MB.' };
++ }
++ try {
++ const meta = await sharp(input, { limitInputPixels: 50_000_000 }).metadata();
++ if (!['jpeg', 'png', 'webp'].includes(meta.format) || `image/${meta.format}` !== mimeType) return { error: 'The file is not the image type it claims.' };
++ const bytes = await sharp(input, { limitInputPixels: 50_000_000 }).rotate().jpeg({ quality: 85 }).toBuffer();
++ if (bytes.length > 10 * 1024 * 1024) return { error: 'The photo is too large after processing.' };
++ return { bytes };
++ } catch { return { error: 'The photo could not be processed or stored.' }; }
++}
++
function createMedia({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null } = {}) {
const services = (config && config.services) || {};
const skills = (config && config.skills) || {};
@@@ -54,7 +69,31 @@ function createMedia({ config, fetchImpl = globalThis.fetch, tokens: tokensOverr
}
}
-- return { enabled, tenant, put };
++ /** Re-encode a person's one photo so EXIF and other metadata never leave Actor. */
++ async function putPhoto(input, { mimeType = '' } = {}) {
++ if (!enabled) return { error: 'OpenVibe.Media is unavailable, so the photo was not stored.' };
++ const cleaned = await sanitizePhoto(input, mimeType);
++ if (cleaned.error) return cleaned;
++ try {
++ const bytes = cleaned.bytes;
++ const object = await objects.upload(bytes, { kind: 'file', visibility: 'private', mimeType: 'image/jpeg',
++ contentHash: crypto.createHash('sha256').update(bytes).digest('hex'), filename: 'photo.jpg' });
++ if (!MEDIA_ID.test(String(object && object.id || ''))) return { error: 'Media did not return a photo id.' };
++ return { id: object.id };
++ } catch {
++ return { error: 'The photo could not be processed or stored.' };
++ }
++ }
++
++ async function photoUrl(id) {
++ if (!enabled || !MEDIA_ID.test(String(id || ''))) return null;
++ try {
++ const signed = await objects.signedUrl(id, { ttl: 300 });
++ return signed && /^https:\/\//.test(String(signed.url || '')) ? signed.url : null;
++ } catch { return null; }
++ }
++
++ return { enabled, tenant, put, putPhoto, photoUrl };
}
--module.exports = { createMedia, MEDIA_ID };
++module.exports = { createMedia, sanitizePhoto, MEDIA_ID };
ddiff --git a/server/runtime/loop.js b/server/runtime/loop.js
iindex aedd240..e97993b 100644
---- a/server/runtime/loop.js
++++ b/server/runtime/loop.js
@@@ -202,7 +202,7 @@ async function spanCall(trace, { kind, name, tier = null, family = null, started
});
}
--function createLoop({ config, models, registry, log = console, now = () => Date.now(), store = null, stats = null, checkpoints = null, connectors = null, skillRun = null }) {
++function createLoop({ config, models, registry, log = console, now = () => Date.now(), store = null, stats = null, checkpoints = null, connectors = null, skillRun = null, media = null }) {
/** The connector tools of this person (stage V2-11): empty when there are none or no person. */
async function connectorTools({ requester, mode }) {
const empty = { definitions: [], byAlias: new Map() };
@@@ -892,7 +892,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
if (fork) state.answered = false;
const ctx = {
config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts, personToken, timezone, triggers, level, latency, spend,
-- store, now, agentId, personText: personText == null ? String(task) : String(personText),
++ store, media, now, agentId, personText: personText == null ? String(task) : String(personText),
trace: createTracer({ store, taskId, log, now }),
readOutside: () => state.readOutside === true, notifyTask,
emit: async (e) => emit(e),
@@@ -1057,7 +1057,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
};
const ctx = {
config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts, personToken, timezone, triggers, level, latency, spend,
-- store, now, agentId, personText: personText == null ? String(task) : String(personText),
++ store, media, now, agentId, personText: personText == null ? String(task) : String(personText),
trace: createTracer({ store, taskId, log, now }),
readOutside: () => state.readOutside === true, notifyTask,
emit: async (e) => emit(e),
ddiff --git a/server/runtime/tools/_device.js b/server/runtime/tools/_device.js
nnew file mode 100644
iindex 0000000..58787ad
---- /dev/null
++++ b/server/runtime/tools/_device.js
@@@ -0,0 +1,44 @@
++'use strict';
++
++const approvals = require('../../agents/approvals');
++
++const ready = (ctx) => Boolean(ctx && ctx.store && ctx.waits && ctx.agentId &&
++ typeof ctx.requester === 'string' && ctx.requester.startsWith('user:') && !ctx.chatPolicy);
++const clean = (value, n) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim().slice(0, n);
++
++/** A device request always asks, even at full access. The run parks until the person answers or time runs out. */
++async function ask(ctx, kind, input) {
++ if (!ready(ctx)) return { text: 'A device request needs your own agent and a signed-in person.', isError: true };
++ const why = clean(input.why, 300);
++ if (!why) return { text: 'Say why you need this, in one sentence.', isError: true };
++ if (input.device != null) {
++ const device = String(input.device);
++ if (!/^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(device)) return { text: 'Choose a paired phone id (dev_…).', isError: true };
++ const fresh = new Date(Date.now() - 60_000).toISOString();
++ const online = await ctx.store.db.maybe('SELECT device_id FROM actor_device_presence WHERE owner = $1 AND device_id = $2 AND seen_at >= $3',
++ [ctx.requester, device, fresh]);
++ if (!online) return { text: 'That phone is offline. No camera or location request was sent to it.', isError: true };
++ }
++ const summary = kind === 'device.camera' ? 'Take one photo for your agent' : 'Share your location once with your agent';
++ const row = await approvals.request(ctx.store, { owner: ctx.requester, agentId: ctx.agentId,
++ taskId: ctx.taskId, kind, summary, payload: { why, ...(input.device ? { device: input.device } : {}),
++ ...(kind === 'device.location' ? { precise: input.precise === true } : {}) } });
++ let wait;
++ try {
++ wait = await ctx.waits.create({ taskId: ctx.taskId, requester: ctx.requester, source: 'time', seconds: 6 * 3600,
++ onTimeout: 'fail', line: `waiting for your ${kind === 'device.camera' ? 'one photo' : 'one location'}: ${why}`,
++ traceId: ctx.trace && ctx.trace.newId ? ctx.trace.newId() : null, step: ctx.step == null ? null : ctx.step });
++ await ctx.store.db.query('UPDATE actor_approvals SET device_wait_id = $2, expires_at = $3 WHERE id = $1',
++ [row.id, wait.id, wait.deadline_at]);
++ } catch (err) {
++ await ctx.store.db.query("UPDATE actor_approvals SET state = 'expired', decided_at = $2 WHERE id = $1 AND state = 'pending'", [row.id, ctx.store.iso()]);
++ return { text: `The request could not wait for an answer: ${String((err && err.message) || err).slice(0, 180)}`, isError: true };
++ }
++ const url = `${ctx.config.baseUrl}/me/approvals/${row.id}`;
++ if (ctx.emit) await ctx.emit({ step: 'text', text: `Needs you: ${summary}. ${why} Answer it here: ${url}` });
++ if (ctx.notifyTask) ctx.notifyTask('needs_you', { what: `${summary}. ${why}`, url }).catch(() => {});
++ return { text: JSON.stringify({ waiting: true, approval_id: row.id, url }),
++ data: { approval_id: row.id, url }, park: { wait_id: wait.id } };
++}
++
++module.exports = { ask, ready, clean };
ddiff --git a/server/runtime/tools/ask_location.js b/server/runtime/tools/ask_location.js
nnew file mode 100644
iindex 0000000..fd70211
---- /dev/null
++++ b/server/runtime/tools/ask_location.js
@@@ -0,0 +1,13 @@
++'use strict';
++
++const device = require('./_device');
++
++module.exports = {
++ name: 'ask_location',
++ description: 'Ask your person to share their location once. Say why and whether precise location would help. They choose approximate by default and may share precise only when you requested it. Each location needs a fresh answer. The run waits for that one answer.',
++ input: { type: 'object', properties: { why: { type: 'string', maxLength: 300 }, precise: { type: 'boolean' },
++ device: { type: 'string', description: 'Optional paired phone id (dev_…). If it is offline, this request fails plainly. Without one, the person may answer on any phone or in a browser.' } }, required: ['why'] },
++ scope: 'person', acts: true, costUsd: 0, timeoutMs: 30_000,
++ available: device.ready,
++ run: (input, ctx) => device.ask(ctx, 'device.location', input),
++};
ddiff --git a/server/runtime/tools/ask_photo.js b/server/runtime/tools/ask_photo.js
nnew file mode 100644
iindex 0000000..8f8794d
---- /dev/null
++++ b/server/runtime/tools/ask_photo.js
@@@ -0,0 +1,13 @@
++'use strict';
++
++const device = require('./_device');
++
++module.exports = {
++ name: 'ask_photo',
++ description: 'Ask your person to take one photo themselves. Say why. Their phone opens the system camera only after their tap; they can also choose a photo in the browser. Each photo needs a fresh answer. The run waits for that one answer.',
++ input: { type: 'object', properties: { why: { type: 'string', maxLength: 300 },
++ device: { type: 'string', description: 'Optional paired phone id (dev_…). If it is offline, this request fails plainly. Without one, the person may answer on any phone or in a browser.' } }, required: ['why'] },
++ scope: 'person', acts: true, costUsd: 0, timeoutMs: 30_000,
++ available: device.ready,
++ run: (input, ctx) => device.ask(ctx, 'device.camera', input),
++};
ddiff --git a/server/runtime/tools/notify_phone.js b/server/runtime/tools/notify_phone.js
nnew file mode 100644
iindex 0000000..5a50427
---- /dev/null
++++ b/server/runtime/tools/notify_phone.js
@@@ -0,0 +1,20 @@
++'use strict';
++
++const { clean } = require('./_device');
++
++module.exports = {
++ name: 'notify_phone',
++ description: 'Tell your person something on their phone. Give a short title and text. It appears in their Action Center; Link raises a local notification while Stay connected is on. This is a device.notify action, scoped to this agent.',
++ input: { type: 'object', properties: { title: { type: 'string', maxLength: 120 }, text: { type: 'string', maxLength: 400 } }, required: ['title', 'text'] },
++ scope: 'person', acts: true, costUsd: 0, timeoutMs: 15_000,
++ available: (ctx) => Boolean(ctx && ctx.store && ctx.agentId && ctx.requester && !ctx.chatPolicy),
++ async run(input, ctx) {
++ const title = clean(input.title, 120);
++ const body = clean(input.text, 400);
++ if (!title || !body) return { text: 'A notification needs a title and text.', isError: true };
++ const id = ctx.store.newId('ntf');
++ await ctx.store.db.query(`INSERT INTO actor_device_notices (id, owner, agent_id, task_id, title, body, created_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7)`, [id, ctx.requester, ctx.agentId, ctx.taskId, title, body, ctx.store.iso()]);
++ return { text: `Added one notification to your person's Action Center: ${title}`, data: { notice_id: id } };
++ },
++};
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
iindex 15a65e4..5dcbeb5 100644
---- a/server/tasks/engine.js
++++ b/server/tasks/engine.js
@@@ -404,9 +404,10 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
if (!money.ok) return { asked: false, decision: { ask: false }, refusal: money.message };
}
const rules = taskAgent ? await allows.list(s, row.requester, taskAgent.id) : [];
-- const decision = await levels.decide({ level: taskLevel, categories, act, models, summary, payload, signal, allow: { kind, target, rules } });
++ const scopedTarget = kind === 'device.notify' && taskAgent ? taskAgent.name : target;
++ const decision = await levels.decide({ level: taskLevel, categories, act, models, summary, payload, signal, allow: { kind, target: scopedTarget, rules } });
if (!decision.ask) return { asked: false, decision };
-- const ask = await approvals.request(s, { owner: row.requester, agentId: taskAgent ? taskAgent.id : null, taskId: id, kind, summary: money ? money.summary : summary, payload: money ? money.payload : payload, target, categories });
++ const ask = await approvals.request(s, { owner: row.requester, agentId: taskAgent ? taskAgent.id : null, taskId: id, kind, summary: money ? money.summary : summary, payload: money ? money.payload : payload, target: scopedTarget, categories });
const url = `${config.baseUrl}/me/approvals/${ask.id}`;
const shown = money ? money.summary : summary;
await emit(id, { kind: 'output', step: 'text', agent, text: `Your agent wants to ${String(shown).toLowerCase()} Nothing runs until you approve it: ${url}` });
ddiff --git a/server/waits/index.js b/server/waits/index.js
iindex 2611dd2..bd3c067 100644
---- a/server/waits/index.js
++++ b/server/waits/index.js
@@@ -208,6 +208,25 @@ function createWaits({ s, config, log = console, now = () => Date.now(), events
return row;
}
++ /** Resume only the run that asked for this device answer. Its payload is untrusted input. */
++ async function wakeDevice(approval, result) {
++ if (!approval || !approval.device_wait_id || !approval.task_id) return null;
++ let w = await store.getWait(s, approval.device_wait_id);
++ if (!w || w.status !== 'waiting' || w.task_id !== approval.task_id || w.requester !== approval.owner) return null;
++ // A fast tap may arrive between writing the wait and the loop parking. The engine writes remaining_ms only
++ // after its checkpoint is saved; resume after that point so the answer cannot race the old run.
++ for (let i = 0; i < 50 && w.remaining_ms == null && w.status === 'waiting'; i++) {
++ await new Promise((resolve) => setTimeout(resolve, 100));
++ w = await store.getWait(s, approval.device_wait_id);
++ if (!w) return null;
++ }
++ return wake(w, { at: iso(now()), by: 'person', approval_id: approval.id }, {
++ notice: result && result.declined ? 'The person declined the device request.' : 'The person answered the device request.',
++ event: { event_id: approval.id, event_type: 'actor.device.answered', at: iso(now()),
++ payload: { approval_id: approval.id, kind: approval.kind, result } },
++ });
++ }
++
/** The recovery path: the topics with open waits, read from the stored cursor. */
async function sweep() {
if (!events || !events.enabled) return 0;
@@@ -286,7 +305,7 @@ function createWaits({ s, config, log = console, now = () => Date.now(), events
return {
limits: L, create, get: (id) => store.getWait(s, id), listForTask: (taskId) => store.waitsForTask(s, taskId),
-- listOpen: () => store.openWaits(s), topics, ensureSubscription, onEvent, cancel, wakeByPerson, sweep, runDeadlines,
++ listOpen: () => store.openWaits(s), topics, ensureSubscription, onEvent, cancel, wakeByPerson, wakeDevice, sweep, runDeadlines,
remaining, setRemaining, hasOpenWait,
};
}
ddiff --git a/test/approval-kinds.test.js b/test/approval-kinds.test.js
iindex 9455924..29f45d7 100644
---- a/test/approval-kinds.test.js
++++ b/test/approval-kinds.test.js
@@@ -25,6 +25,8 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
// Every acting tool and the kind it is. `null` means Actor's own working state: it never asks.
const EXPECTED = {
++ ask_location: null,
++ ask_photo: null,
codes_task: 'codes.task',
computer_browser: 'computer.browser',
computer_desk: 'computer.desk',
@@@ -34,6 +36,7 @@ const EXPECTED = {
hand_to_person: null,
make_chart: null,
make_file: null,
++ notify_phone: 'device.notify',
remember: null,
robot_steps: 'robot.move',
run_tool: 'by-tool',
@@@ -45,6 +48,8 @@ const EXPECTED = {
};
const SAMPLE = {
++ ask_location: { why: 'Find a nearby shelter' },
++ ask_photo: { why: 'Read the sign' },
codes_task: { repo: 'ada/site', instruction: 'fix the bug' },
computer_browser: { machine: 'dev_x', action: 'click', text: 'Send' },
computer_desk: { machine: 'dev_x', action: 'start' },
@@@ -52,6 +57,7 @@ const SAMPLE = {
computer_run: { machine: 'dev_x', argv: ['git', 'status'] },
computer_write: { machine: 'dev_x', path: '/tmp/a.txt', text: 'hi' },
hand_to_person: { text: 'look at this' },
++ notify_phone: { title: 'Ready', text: 'Your report is ready' },
make_chart: { name: 'c' },
make_file: { name: 'n.md', text: 'hi' },
remember: { text: 'note' },
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex 5ded92d..d5921b0 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -49,6 +49,12 @@ const ROUTE_EXTENSIONS = new Map([
['GET /api/v1/skills/:id', null],
['POST /api/v1/me/agents/:id/skills', 'actor.agent.manage'],
['DELETE /api/v1/me/agents/:id/skills/:listingId', 'actor.agent.manage'],
++ // HO12: the person's own device answer and Link heartbeat use the existing approval grant. Contracts 0.345.0
++ // defines the answer shape; a later capability manifest can list these routes directly.
++ ['POST /api/v1/me/devices/presence', 'actor.approval.decide'],
++ ['GET /api/v1/me/approvals/:id', 'actor.approval.decide'],
++ ['POST /api/v1/me/approvals/:id/photo', 'actor.approval.decide'],
++ ['POST /api/v1/me/approvals/:id/answer', 'actor.approval.decide'],
]);
const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
// The A2A surface (stage V2-11): POST /a2a/v1/tasks and GET /a2a/v1/tasks/:id, guarded by actor.a2a.task.
ddiff --git a/test/device-approvals.test.js b/test/device-approvals.test.js
nnew file mode 100644
iindex 0000000..42f0a74
---- /dev/null
++++ b/test/device-approvals.test.js
@@@ -0,0 +1,118 @@
++'use strict';
++
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const contacts = require('../server/agents/contacts');
++const approvals = require('../server/agents/approvals');
++const device = require('../server/runtime/tools/_device');
++const noticeTool = require('../server/runtime/tools/notify_phone');
++const items = require('../server/actions/items');
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++const DEVICE = 'dev_01JAB2C3D4E5F6G7H8J9K0MNPS';
++const PHOTO = 'med_01JAB2C3D4E5F6G7H8J9K0MNPS';
++
++(async () => {
++ const t = await boot();
++ try {
++ const ada = t.network.addUser('ada');
++ const bo = t.network.addUser('bo');
++ const owner = `user:${ada.subject}`;
++ const agent = (await contacts.create(t.ctx.s, t.config, owner, { name: 'Scout', level: 'full' })).row;
++ const taskId = t.ctx.s.newId('tsk');
++ const wakes = [];
++ t.ctx.waits.wakeDevice = async (row, result) => { wakes.push({ row, result }); return row; };
++ t.ctx.media.putPhoto = async () => ({ id: PHOTO });
++ t.ctx.media.photoUrl = async () => 'https://media.example.test/private-photo?expires=soon';
++ const run = {
++ store: t.ctx.s, waits: { create: async () => ({ id: t.ctx.s.newId('wai'), deadline_at: new Date(Date.now() + 6 * 3600_000).toISOString() }) },
++ config: t.config, requester: owner, agentId: agent.id, taskId,
++ };
++
++ await check('an offline targeted phone fails before a request exists', async () => {
++ const out = await device.ask(run, 'device.camera', { why: 'Read a sign', device: DEVICE });
++ assert.strictEqual(out.isError, true);
++ assert.match(out.text, /offline/);
++ assert.strictEqual((await approvals.list(t.ctx.s, owner)).length, 0);
++ const heartbeat = await t.get('/api/v1/me/devices/presence', { as: ada, headers: SAME,
++ json: { device_id: DEVICE } });
++ assert.strictEqual(heartbeat.status, 204, heartbeat.text);
++ });
++
++ let location;
++ await check('location asks fresh even at full access and cannot be always allowed', async () => {
++ const asked = await device.ask(run, 'device.location', { why: 'Find a shelter nearby', device: DEVICE });
++ assert.ok(asked.park.wait_id);
++ location = (await approvals.list(t.ctx.s, owner))[0];
++ assert.strictEqual(location.kind, 'device.location');
++ assert.strictEqual(location.task_id, taskId);
++ assert.strictEqual(location.agent_id, agent.id);
++ assert.strictEqual(location.device_wait_id, asked.park.wait_id);
++ const page = await t.get(`/me/approvals/${location.id}`, { as: ada });
++ assert.match(page.text, /Share where I am once/);
++ assert.match(page.text, /Find a shelter nearby/);
++ assert.doesNotMatch(page.text, /Always allow this for/);
++ const generic = await t.get(`/me/approvals/${location.id}`, { as: ada, form: { decision: 'approve_always' }, headers: SAME });
++ assert.strictEqual(generic.status, 422);
++ });
++
++ await check('location is owner-only, coarse, one-time, and sent only to the asking run', async () => {
++ const path = `/api/v1/me/approvals/${location.id}/answer`;
++ assert.strictEqual((await t.get(path, { as: bo, headers: SAME,
++ json: { decision: 'answer', location: { lat: 37.77, lng: -122.42, accuracy_m: 100, precise: false } } })).status, 404);
++ assert.strictEqual((await t.get(path, { as: ada, headers: { 'sec-fetch-site': 'cross-site' },
++ json: { decision: 'answer', location: { lat: 37.77, lng: -122.42, accuracy_m: 100, precise: false } } })).status, 403);
++ assert.strictEqual((await t.get(path, { as: ada, headers: SAME,
++ json: { decision: 'answer', location: { lat: 37.777, lng: -122.42, accuracy_m: 100, precise: false } } })).status, 422);
++ assert.strictEqual((await t.get(path, { as: ada, headers: SAME,
++ json: { decision: 'answer', location: { lat: 37.77, lng: -122.42, accuracy_m: 10, precise: true } } })).status, 422);
++ assert.strictEqual((await t.get(path, { as: ada, headers: SAME,
++ json: { decision: 'answer', location: { lat: 37.77, lng: -122.42, accuracy_m: 100001, precise: false } } })).status, 422);
++ assert.strictEqual((await t.get(path, { as: ada, headers: SAME,
++ json: { decision: 'answer', photo: { media_id: PHOTO }, location: { lat: 37.77, lng: -122.42, accuracy_m: 100, precise: false } } })).status, 422);
++ const good = await t.get(path, { as: ada, headers: SAME,
++ json: { decision: 'answer', location: { lat: 37.77, lng: -122.42, accuracy_m: 100, precise: false } } });
++ assert.strictEqual(good.status, 200, good.text);
++ assert.strictEqual(good.json().state, 'approved');
++ assert.strictEqual((await t.get(path, { as: ada, headers: SAME,
++ json: { decision: 'answer', location: { lat: 0, lng: 0, accuracy_m: 1, precise: false } } })).status, 409);
++ assert.strictEqual(wakes.length, 1);
++ assert.strictEqual(wakes[0].row.task_id, taskId);
++ assert.deepStrictEqual(wakes[0].result.location, { lat: 37.77, lng: -122.42, accuracy_m: 1000, precise: false });
++ const again = await device.ask(run, 'device.location', { why: 'Find the next stop' });
++ assert.ok(again.park);
++ assert.strictEqual((await approvals.list(t.ctx.s, owner)).filter((a) => a.state === 'pending').length, 1);
++ });
++
++ await check('a photo must be uploaded for its own approval before its media id is answered', async () => {
++ const asked = await device.ask(run, 'device.camera', { why: 'Read the street sign' });
++ const row = await approvals.get(t.ctx.s, owner, asked.data.approval_id);
++ const path = `/api/v1/me/approvals/${row.id}`;
++ const answer = { decision: 'answer', photo: { media_id: PHOTO } };
++ assert.strictEqual((await t.get(`${path}/answer`, { as: ada, headers: SAME, json: answer })).status, 422);
++ assert.strictEqual((await t.get(`${path}/photo`, { as: bo, headers: { ...SAME, 'content-type': 'image/jpeg' }, body: Buffer.from('photo') })).status, 404);
++ const uploaded = await t.get(`${path}/photo`, { as: ada, headers: { ...SAME, 'content-type': 'image/jpeg' }, body: Buffer.from('photo') });
++ assert.strictEqual(uploaded.status, 201, uploaded.text);
++ assert.strictEqual(uploaded.json().media_id, PHOTO);
++ assert.strictEqual((await t.get(`${path}/answer`, { as: ada, headers: SAME, json: answer })).status, 200);
++ assert.strictEqual(wakes.at(-1).row.id, row.id);
++ assert.match(wakes.at(-1).result.photo_url, /^https:\/\/media\.example\.test\/private-photo/);
++ });
++
++ await check('notification consent creates one Action Center item for this agent', async () => {
++ const ask = await approvals.request(t.ctx.s, { owner, agentId: agent.id, taskId,
++ kind: 'device.notify', summary: 'Notify your phone: Report ready', target: agent.name,
++ payload: { title: 'Report ready', text: 'Open the finished report.' } });
++ const approved = await t.get(`/me/approvals/${ask.id}`, { as: ada,
++ form: { decision: 'approve_always' }, headers: SAME });
++ assert.strictEqual(approved.status, 303, approved.text);
++ const row = await t.ctx.s.db.maybe('SELECT * FROM actor_device_notices WHERE id = $1', [`ntf_${ask.id.slice(4)}`]);
++ assert.ok(row);
++ assert.strictEqual(items.deviceNotice(row, agent.name).summary, 'notify · Open the finished report.');
++ const direct = await noticeTool.run({ title: 'Next report', text: 'Ready now.' }, { store: t.ctx.s,
++ requester: owner, agentId: agent.id, taskId });
++ assert.ok(direct.data.notice_id.startsWith('ntf_'));
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/device-photo.test.js b/test/device-photo.test.js
nnew file mode 100644
iindex 0000000..434adb4
---- /dev/null
++++ b/test/device-photo.test.js
@@@ -0,0 +1,20 @@
++'use strict';
++
++const assert = require('assert');
++const sharp = require('sharp');
++const { sanitizePhoto } = require('../server/media/client');
++
++(async () => {
++ const source = await sharp({ create: { width: 8, height: 5, channels: 3, background: '#b25d27' } })
++ .jpeg().withMetadata({ orientation: 6 }).toBuffer();
++ assert.ok((await sharp(source).metadata()).exif, 'the source carries EXIF');
++ const cleaned = await sanitizePhoto(source, 'image/jpeg');
++ assert.ok(cleaned.bytes && cleaned.bytes.length);
++ const meta = await sharp(cleaned.bytes).metadata();
++ assert.strictEqual(meta.format, 'jpeg');
++ assert.strictEqual(meta.exif, undefined, 'EXIF never leaves Actor');
++ assert.deepStrictEqual([meta.width, meta.height], [5, 8], 'orientation is preserved in pixels');
++ assert.ok((await sanitizePhoto(source, 'image/png')).error, 'claimed MIME must match bytes');
++ assert.ok((await sanitizePhoto(Buffer.from('not an image'), 'image/jpeg')).error);
++ process.stdout.write(' ✓ one photo is re-encoded without EXIF, with orientation preserved\n\nall passed\n');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/device-wait.test.js b/test/device-wait.test.js
nnew file mode 100644
iindex 0000000..fb61ba6
---- /dev/null
++++ b/test/device-wait.test.js
@@@ -0,0 +1,44 @@
++'use strict';
++
++const assert = require('assert');
++const { testDb } = require('./helpers/db');
++const { createStore } = require('../server/db');
++const { createWaits } = require('../server/waits');
++const approvals = require('../server/agents/approvals');
++const device = require('../server/runtime/tools/_device');
++
++(async () => {
++ const db = await testDb();
++ try {
++ const s = createStore(db.db);
++ const owner = 'user:usr_01JAB2C3D4E5F6G7H8J9K0MNPS';
++ const task = s.newId('tsk');
++ await s.db.query("INSERT INTO tasks (id, requester, task, mode, budget_task, budget_day, state, created_at) VALUES ($1, $2, 'device wait', 'balanced', 1, 1, 'running', $3)", [task, owner, s.iso()]);
++ const resumed = [];
++ const waits = createWaits({ s, config: { waits: {} }, engine: {
++ resumeWithEvent: async (id, event) => { resumed.push({ id, event }); },
++ } });
++ const run = { store: s, waits, config: { baseUrl: 'https://openvibe.actor' },
++ requester: owner, agentId: s.newId('agt'), taskId: task };
++ const asked = await device.ask(run, 'device.location', { why: 'Find nearby help' });
++ assert.ok(asked.park.wait_id);
++ const row = await approvals.get(s, owner, asked.data.approval_id);
++ assert.strictEqual(row.device_wait_id, asked.park.wait_id);
++ await waits.setRemaining(asked.park.wait_id, 60_000);
++ const result = await approvals.answer({ s, waits }, owner, row.id, { decision: 'answer', location: {
++ lat: 37.77, lng: -122.42, accuracy_m: 250, precise: false,
++ } });
++ assert.ok(result.row);
++ assert.strictEqual(resumed.length, 1);
++ assert.strictEqual(resumed[0].id, task, 'only the asking run resumes');
++ assert.strictEqual(resumed[0].event.event.payload.location, undefined);
++ assert.deepStrictEqual(resumed[0].event.event.payload.result.location,
++ { lat: 37.77, lng: -122.42, accuracy_m: 1000, precise: false });
++ assert.strictEqual((await waits.get(asked.park.wait_id)).status, 'woken');
++ assert.strictEqual((await approvals.answer({ s, waits }, owner, row.id, { decision: 'answer', location: {
++ lat: 0, lng: 0, accuracy_m: 1, precise: false,
++ } })).status, 409);
++ assert.strictEqual(resumed.length, 1, 'the same approval cannot resume twice');
++ process.stdout.write(' ✓ one device answer resumes only its asking run, once, as untrusted event data\n\nall passed\n');
++ } finally { await db.close(); }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/tool-acts.test.js b/test/tool-acts.test.js
iindex 6ee3110..a7134af 100644
---- a/test/tool-acts.test.js
++++ b/test/tool-acts.test.js
@@@ -9,6 +9,8 @@ const { createRegistry } = require('../server/runtime/tools');
const { check, done } = require('./helpers/boot');
const EXPECTED = {
++ ask_location: true,
++ ask_photo: true,
// Read-only: they only read what is already there, so a resume may run them again.
calculate: false,
canvas_set: false,
@@@ -45,6 +47,7 @@ const EXPECTED = {
wait_for: false,
make_chart: true,
make_file: true,
++ notify_phone: true,
remember: true,
robot_steps: true,
run_tool: true,